Redis 缓存三大难题:穿透、击穿与雪崩
在生产环境中,缓存虽然能极大提升系统性能,但一旦设计不当,反而会成为系统的瓶颈。作为后端开发,我们最常遇到的就是缓存穿透、缓存雪崩和缓存击穿这三个'经典'问题。它们看似相似,背后的成因和解决思路却各有不同。
缓存穿透
所谓缓存穿透,是指查询一个一定不存在的数据。由于缓存层不命中时会回源数据库,如果存储层也查不到数据且出于容错考虑不写入缓存,那么每次请求都会直接打到数据库上。在流量高峰期,这会导致数据库瞬间压力过大甚至宕机。更糟糕的是,如果有恶意攻击者利用不存在的 Key 频繁请求,这就成了安全漏洞。
解决这个问题,业界最通用的方案是布隆过滤器(Bloom Filter)。它可以将所有可能存在的数据哈希到一个足够大的 bitmap 中,如果一个 Key 不在 bitmap 里,那它肯定不存在,从而直接拦截掉对底层存储的查询。当然,布隆过滤器有空间开销和误判率的问题,对于简单的场景,还有一个更粗暴但有效的方法:即使查询返回空结果,也将其缓存起来,只是设置一个很短的过期时间(比如不超过 5 分钟),这样既能防止重复查询,又不会长期占用缓存资源。
缓存雪崩
缓存雪崩通常发生在大量缓存键在同一时刻失效的时候。比如我们统一设置了相同的过期时间,导致某一时间点所有缓存同时过期,所有的请求瞬间全部转发到数据库,造成 DB 瞬时压力过重,甚至引发服务雪崩。
应对雪崩的核心在于'分散'。大多数系统设计者会考虑用加锁或队列来保证写操作的串行化,但这在高并发下可能成为新的瓶颈。更轻量级的做法是在原有的失效时间基础上增加一个随机值,比如在原有 TTL 上加 1-5 分钟的随机偏移。这样一来,每个缓存的过期时间就不再集中,很难再触发集体失效的事件,从而平滑了数据库的压力曲线。
缓存击穿
如果说雪崩是'面'上的问题,那缓存击穿就是'点'上的危机。针对某些设置了过期时间的热点 Key,如果在某个时间点恰好过期,而此时又有超高并发的访问请求过来,这些请求发现缓存失效后,会同时去加载数据库并回设缓存。这种瞬间的大并发可能会直接把后端数据库压垮。
处理击穿的关键在于互斥锁(Mutex Lock)。当缓存失效时,不要立即去加载数据库,而是先尝试用一个带成功操作返回值的命令(比如 Redis 的 SETNX)去设置一个互斥锁 Key。只有拿到锁的线程才允许去查库并重建缓存,其他线程则重试获取缓存即可。
下面是一个基于 Java 的实现示例,展示了如何用 SETNX 实现互斥重建:
public String get(String key) {
// 先从缓存获取
String value = redis.get(key);
if (value == null) {
// 代表缓存值过期或未命中
// 构造互斥锁 Key,设置 3 分钟超时,防止 del 操作失败导致死锁
String keyNx = key.concat(":nx");
// 尝试设置锁,返回 1 表示设置成功
if (redis.setnx(keyNx, 1, 3 * 60) == 1) {
try {
// 只有拿到锁的线程才能查库
value = db.get(key);
// 将数据回设到缓存,注意这里需要设置合理的过期时间
redis.set(key, value, expire_secs);
} {
redis.del(keyNx);
}
} {
Thread.sleep();
get(key);
}
}
value;
}

