3. 缓存失效与穿透:雪崩、穿透、击穿的定义、原因与基础解决方案
缓存失效与穿透问题是分布式缓存系统中最高频的故障场景。三者虽然名称相近,但成因、影响范围与治理策略截然不同。本章从定义、根因与基础方案三个维度进行系统梳理,为后续的索引选择与连接池调优奠定问题认知基础。
3.1 三种故障的定义与核心区别
| 故障类型 | 定义 | 影响范围 | 典型特征 |
|---|---|---|---|
| 缓存雪崩 | 大量缓存键在同一时间或极短时间内集中失效,导致请求全部穿透到数据库 | 全局性 | 数据库瞬时QPS飙升,可能引发级联宕机 |
| 缓存穿透 | 请求查询一个根本不存在的数据,缓存与数据库均无命中,每次请求都直达数据库 | 单键/局部 | 恶意攻击或非法参数导致缓存形同虚设 |
| 缓存击穿 | 一个热点键在失效瞬间,大量并发请求同时涌入数据库重建缓存 | 单键热点 | 高并发下单个key失效引发瞬时压力 |
记忆口诀:雪崩是“群体失效”,穿透是“查无此人”,击穿是“热点倒下”。
3.2 缓存雪崩:原因与基础方案
3.2.1 根因分析
- 统一过期时间:所有缓存键设置了相同的TTL,到期时刻同时失效。
- 缓存节点宕机:Redis集群中大量节点同时不可用,导致缓存层整体失效。
- 缓存服务重启:重启后缓存为空,大量请求瞬间穿透至数据库。
3.2.2 基础解决方案
- 过期时间随机化:在基础TTL上增加随机偏移量(如±10%~±30%),避免集体失效。
- 多级缓存架构:本地缓存(Caffeine/Guava)作为第一级,Redis作为第二级,数据库作为第三级。
- 缓存预热与懒加载:系统启动时主动加载热点数据,避免冷启动穿透。
- 限流与熔断:对数据库访问层实施限流(如Sentinel/Hystrix),保护后端。
// 过期时间随机化示例(Java)
int baseTTL = 3600; // 1小时
int randomOffset = ThreadLocalRandom.current().nextInt(-600, 600); // ±10分钟
int finalTTL = baseTTL + randomOffset;
redisTemplate.opsForValue().set(key, value, finalTTL, TimeUnit.SECONDS);
实践建议:随机化范围建议控制在基础TTL的10%~30%之间。范围过小无法打散失效时间,范围过大会导致部分数据过早失效,增加数据库压力。
3.3 缓存穿透:原因与基础方案
3.3.1 根因分析
- 恶意攻击:攻击者构造大量不存在的ID(如负数、超大值、SQL注入特征)发起请求。
- 业务漏洞:查询参数未做合法性校验,非法参数直接穿透缓存。
- 数据未预热:合法但冷门的数据未被缓存,且数据库中也无记录。
3.3.2 基础解决方案
- 空值缓存:对查询结果为null的key,在缓存中写入一个短TTL的空值占位符(如30~60秒)。
- 布隆过滤器:在缓存层之前加一层布隆过滤器,快速判断key是否可能存在。
- 参数校验:在入口层对请求参数进行格式、范围、类型校验,拦截非法请求。
- 接口限流:对同一IP或同一用户实施访问频率限制。
// 空值缓存示例(Java)
ValueOperations<String, Object> ops = redisTemplate.opsForValue();
Object cacheResult = ops.get(key);
if (cacheResult != null) {
return cacheResult; // 缓存命中
}
// 查询数据库
Object dbResult = userDao.findById(key);
if (dbResult == null) {
// 空值缓存,TTL设为60秒
ops.set(key, NULL_PLACEHOLDER, 60, TimeUnit.SECONDS);
return null;
}
ops.set(key, dbResult, 3600, TimeUnit.SECONDS);
return dbResult;
注意:空值缓存会占用Redis内存,需设置合理的TTL并监控空值缓存占比。布隆过滤器存在误判率(假阳性),需根据业务容忍度调整位数组大小与哈希函数数量。
3.4 缓存击穿:原因与基础方案
3.4.1 根因分析
- 热点key失效:单个高频访问的key(如秒杀商品、热搜榜单)在失效瞬间,大量并发请求同时发现缓存未命中。
- 重建成本高:数据库查询或计算逻辑耗时较长,导致请求堆积。
3.4.2 基础解决方案
- 互斥锁:只允许一个线程去数据库重建缓存,其他线程等待或降级。
- 逻辑过期:缓存永不过期,但数据中携带逻辑过期时间,异步线程负责更新。
- 热点key永不过期:对极端热点数据设置极长TTL或永不过期,通过后台任务主动更新。
// 互斥锁重建缓存示例(Java)
String lockKey = "lock:hotkey:" + key;
String lockValue = UUID.randomUUID().toString();
Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, 3, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
// 重建缓存
Object dbResult = expensiveQuery(key);
redisTemplate.opsForValue().set(key, dbResult, 3600, TimeUnit.SECONDS);
return dbResult;
} finally {
// 释放锁(使用Lua脚本保证原子性)
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
redisTemplate.execute(new DefaultRedisScript<>(luaScript, Long.class),
Collections.singletonList(lockKey), lockValue);
}
} else {
// 等待或降级
Thread.sleep(50);
return redisTemplate.opsForValue().get(key);
}
3.5 三种故障的治理策略对比
| 治理维度 | 缓存雪崩 | 缓存穿透 | 缓存击穿 |
|---|---|---|---|
| 核心防御 | 过期时间随机化 | 空值缓存 / 布隆过滤器 | 互斥锁 / 逻辑过期 |
| 辅助手段 | 多级缓存、限流熔断 | 参数校验、接口限流 | 热点key永不过期 |
| 对Redis影响 | 低(仅调整TTL) | 中(空值占用内存) | 低(锁开销可控) |
| 对数据库影响 | 降低峰值压力 | 消除无效查询 | 减少并发重建 |
| 实施复杂度 | 低 | 中 | 中高 |
最佳实践:生产环境中三种故障往往同时存在。建议按“穿透→击穿→雪崩”的优先级进行治理:先拦截非法请求(穿透),再保护热点key(击穿),最后通过随机化与多级缓存兜底(雪崩)。