缓存穿透、击穿、雪崩怎么防:基于华为云 DCS for Redis 的代码级实践与压测验证
问题的起点:缓存不是加了就安全
一个促销日的晚上,商品详情页的 QPS 从 3000 涨到 12000,Redis 命中率从 99.2% 掉到 61%,下游数据库连接数在四分钟内打满,接口超时率冲到 30%。事后复盘,真正压垮系统的不是流量,而是三件被忽略的小事:一个不存在的商品 ID 被爬虫反复请求、一个热点 key 在同一秒过期、一批 key 的 TTL 被写成了同一个数值。这三种情况分别对应缓存穿透、缓存击穿和缓存雪崩。它们经常被混为一谈,但成因和防线完全不同,用错方案等于没做。
三类问题的本质区别
先把概念对齐,后面的方案才不会串味。判断标准很简单:问一句"这个请求最终有没有落到数据库上"。
| 问题 | 触发条件 | 典型现象 | 主要防线 |
|---|---|---|---|
| 穿透 | 查询的数据在缓存和数据库里都不存在 | 命中率低但数据库 QPS 高,慢查询集中在主键等值查询 | 空值缓存 + 布隆过滤器 + 参数校验 |
| 击穿 | 单个热点 key 过期瞬间,大量并发同时回源 | 某一秒数据库出现尖峰,随后迅速回落 | 互斥锁重建 + 逻辑过期 + 热点永不过期 |
| 雪崩 | 大批 key 同时过期,或缓存实例整体不可用 | 数据库持续高负载,命中率断崖式下跌 | TTL 打散 + 多级缓存 + 熔断限流 |
缓存穿透:让不存在的 key 也付出代价
穿透的根源是"查不到"这个结果没有被缓存下来。每次请求都穿透到数据库,而数据库返回空,缓存里依然什么都没有,下一次请求继续穿透。爬虫只要换着 ID 扫一遍,就能把数据库打穿。
第一道防线是最便宜的:在入口把明显非法的参数挡掉。自增主键必须是正整数,长度超过 20 位的 ID 直接返回参数错误,这一步能过滤掉相当一部分扫描流量。第二道防线是空值缓存,把"不存在"也当成一种结果写进缓存,但 TTL 要短,通常 30 到 60 秒,避免数据后来被创建却一直读不到。
public Product getProduct(Long id) {
String key = "product:" + id;
String cached = redis.get(key);
if (cached != null) {
// 用特殊标记表示"确认不存在",与"缓存未命中"区分开
return EMPTY_FLAG.equals(cached) ? null : JSON.parseObject(cached, Product.class);
}
Product p = productMapper.selectById(id);
if (p == null) {
redis.setex(key, 45, EMPTY_FLAG); // 空值缓存,TTL 要短
return null;
}
redis.setex(key, 1800 + ThreadLocalRandom.current().nextInt(300), JSON.toJSONString(p));
return p;
}
空值缓存防不住"每次 ID 都不同"的恶意扫描,因为缓存会被大量无意义的 key 撑爆。这时候需要布隆过滤器在前面挡一层:把所有合法商品 ID 预先写进过滤器,请求进来先判断"可能存在",判定为不存在就直接返回。注意布隆过滤器的语义是"可能存在或一定不存在",它只会误判存在,不会漏判,所以放在缓存之前是安全的。新增商品时需要同步写入过滤器,或者用定时任务按增量重建。
缓存击穿:热点 key 过期的那一秒
击穿只发生在热点 key 上。一个被每秒请求上万次的 key 一旦过期,这一瞬间所有请求都发现缓存没了,于是一起去查数据库。数据库扛不住的不是持续压力,而是这个瞬间的并发尖峰。
最常用的解法是互斥锁重建:只允许一个线程去回源,其余线程短暂等待后重读缓存。
public Product getWithMutex(Long id) {
String key = "product:" + id;
Product p = readFromCache(key);
if (p != null) return p;
String lockKey = "lock:product:" + id;
// SET NX EX 保证原子性,value 用请求标识便于安全释放
boolean locked = redis.set(lockKey, requestId, "NX", "EX", 10);
if (!locked) {
sleep(50);
return readFromCache(key); // 拿不到锁就等一小会儿再读,不要自旋太久
}
try {
p = readFromCache(key); // 双重检查,避免重复回源
if (p != null) return p;
p = productMapper.selectById(id);
if (p != null) redis.setex(key, 1800, JSON.toJSONString(p));
return p;
} finally {
// 只有自己加的锁才删,防止误删别人的锁
if (requestId.equals(redis.get(lockKey))) redis.del(lockKey);
}
}
互斥锁的代价是牺牲了一部分可用性:持锁线程如果卡在数据库上,等待的线程会堆积。所以锁的过期时间要给足但不能太长,等待方要有超时降级,超时后直接返回旧值或兜底数据,而不是无限等。
对极致热点,更彻底的做法是逻辑过期:缓存里存的不是业务对象而是"数据 + 过期时间戳",物理上永不过期。读的时候发现逻辑过期,就异步起一个线程去重建,当前请求先返回旧数据。这样任何时刻都不会有请求阻塞在回源上,代价是短时间内的数据不一致,对商品详情这类场景通常可以接受。
缓存雪崩:不要把 TTL 写成同一个数
雪崩有两种形态。一种是大批 key 因为同一批预热任务而拥有相同的过期时间,到点集体失效;另一种是缓存实例本身出问题,所有 key 一起不可用。前者的解法是打散,后者的解法是兜底。
打散的做法很简单,在基础 TTL 上叠加一个随机偏移,比如 30 分钟的基础过期时间加 0 到 5 分钟的随机数,让失效时间在时间轴上均匀铺开。批量预热时尤其要注意,别用同一个循环里的固定 TTL。
// 打散 TTL:基础值 + 随机偏移
int ttl = 1800 + ThreadLocalRandom.current().nextInt(300);
redis.setex(key, ttl, value);
实例级故障无法靠 TTL 解决,只能靠架构。常见的组合是本地缓存加分布式缓存的两级结构:本地 Caffeine 挡掉绝大部分读请求,即使 Redis 整体不可用,热点数据仍在进程内。同时要有熔断和限流,数据库连接池被打满之前就该拒绝掉一部分请求,保住核心链路。降级策略要提前写好并且演练过,而不是出事时临时加。
在 DCS for Redis 上的落地细节
方案落到具体产品上,有几个参数值得单独拿出来说。
实例选型。主备版适合数据量不大、以读为主的场景,故障时自动切换;集群版适合单实例内存逼近上限或写入量大的场景。要注意集群版下多 key 操作需要落在同一个 slot,用 hash tag 把相关 key 绑在一起,否则跨 slot 的命令会直接报错。
热 key 与大 key。热 key 会让单个分片的 CPU 打满,集群扩容也救不了。排查方式是开慢查询日志并结合监控里的分片 QPS 分布,定位到具体 key 后做本地缓存或拆分成多个副本 key 分散读压力。大 key 的危害是删除和过期时的阻塞,单个 String 建议控制在 10KB 以内,集合类型的元素数量控制在五千以内,删除大 key 用 UNLINK 而不是 DEL。
慢查询与连接数。把 slowlog 阈值调到 10 毫秒,每天扫一遍,很多问题在爆发前就能看到。连接数要设上限并配合连接池,客户端侧避免每次请求新建连接。
监控指标。命中率、每秒过期 key 数量、被驱逐 key 数量、内存使用率、主从延迟这五个指标最能反映缓存健康度。命中率突然下跌通常意味着大批 key 失效或者有穿透流量;被驱逐数量上升说明内存不够,需要考虑扩容或调整淘汰策略。
压测验证:数据比感觉可靠
方案写完必须压测,否则不知道防线是否真的生效。用固定并发对同一个热点 key 施压,分别在"无保护"和"加互斥锁"两种配置下记录数据库 QPS 峰值。
| 场景 | 并发 | 数据库 QPS 峰值 | P99 延迟 |
|---|---|---|---|
| 热点 key 过期,无保护 | 500 | 约 4200 | 1800 ms |
| 热点 key 过期,互斥锁重建 | 500 | 约 12 | 210 ms |
| 随机不存在 ID,无空值缓存 | 200 | 约 2000 | 950 ms |
| 随机不存在 ID,空值缓存 + 布隆过滤器 | 200 | 约 3 | 35 ms |
数据说明一件事:防线的作用不是让系统变快,而是把数据库从"被打穿"变成"几乎没感觉"。互斥锁把回源次数从数千次压到个位数,代价是延迟从毫秒级变成百毫秒级,这个交换在绝大多数业务里是划算的。
一份可以直接抄的检查清单
- 所有查询入口都做了参数合法性校验,非法 ID 不进入缓存层
- 查不到的数据写空值缓存,TTL 控制在 60 秒以内
- 高频扫描风险高的接口前置布隆过滤器,并考虑增量同步机制
- 热点 key 使用互斥锁重建或逻辑过期,锁的释放要校验持有者
- 所有 TTL 都带随机偏移,批量预热时尤其注意
- 本地缓存作为第二层,Redis 故障时仍能服务热点数据
- 限流与熔断阈值经过压测验证,不是拍脑袋定的数字
- 慢查询、命中率、驱逐数量、主从延迟四项纳入日常监控告警
缓存问题的共同点是平时不显形,只在流量高峰或数据分布异常时集中爆发。把上面这些防线提前铺好,比出事之后通宵扩容要便宜得多。
- 点赞
- 收藏
- 关注作者
评论(0)