Redis缓存穿透、击穿与雪崩,本质上是三种由不同场景触发的高并发缓存失效问题。它们都会导致大量请求直接打到数据库,造成数据库压力骤增甚至宕机。要解决这些问题,不能只停留在理论层面,必须结合业务场景,从架构设计、编码实现和运维监控三个维度同时下手。下面直接拆解每种问题的成因,并给出可落地的解决方案。

缓存穿透:查询不存在的数据

缓存穿透是指用户持续请求一个数据库和缓存中都不存在的数据。因为缓存没有命中,每次请求都会绕过缓存直接查询数据库,而数据库也查不到,自然也不会回写缓存。如果有人恶意利用这个漏洞,用大量不存在的数据发起请求,数据库很快就会被拖垮。这个问题在电商秒杀、用户黑产扫描等场景中非常常见。

解决缓存穿透,核心思路是“拦住无效请求”,不让它落到数据库。最直接的方案是缓存空对象。当数据库查询结果为空时,依然把这条空结果写入缓存,并设置一个较短的过期时间,比如30秒到5分钟。这样后续相同请求就能命中缓存,不会打到数据库。代码逻辑大致如下:

// 伪代码示例:缓存空对象
String value = redis.get(key);
if (value != null) {
    return value;
}
if (redis.exists(key)) {
    // 命中空对象标记,直接返回空
    return null;
}
value = db.query(key);
if (value == null) {
    // 缓存空对象,设置较短过期时间
    redis.set(key, "", 180);
    return null;
}
redis.set(key, value, 3600);
return value;

缓存空对象虽然简单,但有两个缺点:一是会占用额外的内存空间,如果被恶意构造大量不同key,依然可能撑爆缓存;二是空值缓存和真实数据缓存时间不一致,可能导致短暂的数据不一致。为了弥补这些缺陷,可以引入布隆过滤器。布隆过滤器是一种概率型数据结构,它判断一个元素“一定不存在”是100%准确的,判断“可能存在”则有一定误判率。把所有真实存在的数据key提前加载到布隆过滤器中,请求进来时先经过布隆过滤器判断,如果判断key不存在,直接返回空,不再查询缓存和数据库。这样就能在源头拦截绝大多数无效请求。

// 伪代码示例:布隆过滤器拦截
if (!bloomFilter.mightContain(key)) {
    return null;
}
// 后续正常走缓存-数据库逻辑

布隆过滤器的难点在于初始化数据量巨大时的加载效率,以及数据新增时需要同步更新过滤器。实际项目中,可以结合两种方案:对热点不存在数据用布隆过滤器拦截,对非热点数据用空对象缓存兜底。此外,还可以在网关层增加请求合法性校验,比如对ID格式、数值范围做基础过滤,把明显非法的请求直接拒绝掉,进一步减轻后端压力。

缓存击穿:热点数据过期瞬间

缓存击穿是指某个被超高并发访问的热点key,在缓存过期的瞬间,大量请求同时涌入数据库,就像在缓存这层屏障上凿开了一个洞。这种情况通常发生在秒杀商品详情、热搜话题、明星动态等场景。热点key过期的那一刻,如果没有保护措施,数据库连接数会被瞬间打满,导致整个服务雪崩。

解决缓存击穿的核心是“避免大量线程同时重建缓存”。最经典的方案是互斥锁。当缓存失效时,不是所有请求都去查数据库,而是让其中一个请求获取锁,由它去查询数据库并回写缓存,其他请求则等待或返回旧值。用Redis的SETNX命令可以轻松实现分布式互斥锁:

// 伪代码示例:互斥锁防止缓存击穿
String value = redis.get(key);
if (value != null) {
    return value;
}
// 尝试获取锁,设置锁超时防止死锁
boolean locked = redis.setnx("lock:" + key, "1", 10);
if (locked) {
    try {
        value = db.query(key);
        redis.set(key, value, 3600);
    } finally {
        redis.del("lock:" + key);
    }
} else {
    // 未获取锁,短暂休眠后重试或返回默认值
    Thread.sleep(50);
    return redis.get(key);
}
return value;

这个方案在高并发下依然会有少量请求等待,但数据库压力被大幅削减。需要注意的是锁的超时时间必须合理设置,过短可能导致锁提前释放、重复查询数据库,过长则会让等待线程堆积。另一个更激进的方案是“永不过期”。对于极端热点的key,不设置过期时间,而是采用异步更新的方式:后台线程定时或通过消息通知,主动刷新缓存。这样热点key永远不会因为过期而失效,从根源上杜绝了击穿的可能。但这种方案对运维和监控要求较高,需要确保异步更新机制的稳定运行。

还有一种逻辑过期方案,在缓存value中存储一个逻辑过期时间字段。读取缓存时,如果发现逻辑时间已过期,先返回旧值(保证可用性),同时异步开启一个线程去更新缓存。这种方案能保证请求始终快速返回,不会因为锁等待而阻塞,适合对一致性要求不那么极致的场景。具体选择哪种方案,取决于业务对数据实时性和系统可用性的权衡。

缓存雪崩:大面积缓存同时失效

缓存雪崩是指某一时刻大量缓存key同时过期,或者Redis集群整体宕机,导致所有请求瞬间压向数据库。与击穿针对单个热点key不同,雪崩是更大范围的灾难性失效。常见诱因包括:为大量key设置了相同的过期时间,导致它们在同一个时间窗口集体失效;Redis服务器重启或网络故障,整个缓存层不可用。

解决雪崩的第一要务是“打散过期时间”。在设置key的过期时间时,加上一个随机值,避免大量key在同一时刻过期。比如原本统一设置1小时过期,可以改为3600秒加上一个0到600秒的随机偏移量。这样即使有大量key需要缓存,它们的过期时间也会分散在一个时间区间内,不会形成集中失效的洪峰。

// 伪代码示例:过期时间加随机值
int baseExpire = 3600;
int randomOffset = new Random().nextInt(600);
redis.set(key, value, baseExpire + randomOffset);

第二层防护是构建多级缓存架构。在应用本地增加一层短生命周期的本地缓存,如Caffeine或Guava Cache。当Redis大面积失效时,本地缓存还能顶住一部分请求,为数据库恢复争取时间。多级缓存虽然增加了架构复杂度,但在高并发系统中是必要的冗余设计。第三层防护是限流和降级。当检测到数据库压力骤增时,通过Hystrix、Sentinel等组件对请求进行限流,直接返回兜底数据或友好提示,保证核心链路不被冲垮。同时可以触发降级策略,比如暂时关闭次要功能,释放服务器资源给核心业务。

对于Redis集群本身的高可用,必须部署哨兵模式或集群模式,确保主节点宕机时能自动故障转移。同时,对Redis的监控要覆盖内存使用率、连接数、命令延迟、命中率等关键指标,设置合理的报警阈值。定期进行容灾演练,验证在缓存层完全不可用时,系统能否通过限流降级机制存活下来。雪崩的应对从来不是单一技术点,而是架构、运维和代码策略的组合拳。

穿透、击穿、雪崩的关联与综合防御

这三种问题虽然触发条件不同,但在真实生产环境中往往交织出现。比如一次缓存雪崩,可能导致大量key同时失效,其中包含热点key,进而引发击穿;而缓存穿透如果被恶意利用,大量不存在的key请求也可能消耗连接资源,间接诱发雪崩。因此,一个健壮的缓存体系必须同时具备针对这三种问题的防御能力。

综合防御策略可以从请求入口开始层层设防:第一层在网关或业务入口做参数校验,过滤明显非法的请求;第二层用布隆过滤器拦截不存在的数据;第三层在缓存读取逻辑中,对空值做缓存处理;第四层对热点key采用互斥锁或逻辑过期保护;第五层对所有key的过期时间加随机偏移;第六层在应用层构建本地缓存作为最后屏障;第七层配备熔断限流机制,确保极端情况下系统不会完全崩溃。每一层都解决一部分问题,组合起来才能形成纵深防御。

另外,从数据层面也可以做一些优化。比如对数据库的查询语句进行优化,确保即使缓存失效,单次查询也不会太慢;对数据库连接池做好隔离,为不同业务分配独立的连接资源,避免一个业务的缓存问题影响全局。在监控层面,需要建立缓存命中率、数据库QPS、请求延迟的实时大盘,当命中率骤降或数据库QPS异常飙升时,能第一时间通知开发人员介入排查。很多缓存灾难本可以在早期通过监控发现并扼杀在萌芽状态。

最后,缓存问题的解决没有一劳永逸的银弹。业务在发展,数据量在增长,访问模式在变化,曾经有效的方案可能在新阶段失效。因此,团队需要定期回顾缓存策略,结合压测结果调整参数,持续优化防御体系。把缓存当成一个需要持续运营的系统组件,而不是配置好就一劳永逸的静态设置,这才是应对缓存三大问题的根本态度。