缓存穿透、击穿、雪崩区别:一张表定位并附三段防护代码

缓存穿透、击穿、雪崩区别:一张表定位并附三段防护代码

一、现场特征:Redis 正常,MySQL 连接数却被打满

常见形态是 Redis 的 QPS 与命中率没有明显波动,MySQL 的 Threads_connected 却在短时间内从几十跳到 max_connections,SHOW FULL PROCESSLIST 里大量 Sending data 的慢查询集中在同一张业务表。接口侧 P99 RT 明显升高(示例观测:从 20ms 量级升到 2s 以上),上游网关开始超时;日志里能看到下面这类报错(示例):

redis.clients.jedis.exceptions.JedisConnectionException: Could not get a resource from the pool
HikariPool-1 - Connection is not available, request timed out after 30000ms.

先定方向,用 redis-cli INFO stats 看 keyspace_hits 与 keyspace_misses 的变化。这两个都是全局计数,只能说明整体命中率是缓降还是断崖,判断不了单个 key 的情况,后者要另找证据:

  • 全局 misses 持续激增、hits 基本不变,命中率趋近 0 → 大概率是穿透;
  • 全局命中率正常,但同一 key 在数秒内被反复查询,应用层重建日志集中 → 击穿;
  • 同一时间窗内大批 key 同时 miss、命中率断崖下跌 → 雪崩。

排查顺序固定为:单 key 访问分布(SCAN 配合 OBJECT FREQ 抽样、--hotkeys、SLOWLOG)→ MySQL 慢日志 → 应用层重建缓存日志的时间戳。MONITOR 会显著增加实例负载,并可能把命令中的敏感数据打印出来,只在短时排障时使用,用完立即关闭。不建议一上来就扩容数据库。

二、一张表分清穿透 / 击穿 / 雪崩

三类场景的判别只看两个变量:请求查的是不是同一个 key,以及这批 key 是不是在同一时刻失效。

维度 穿透 击穿 雪崩
触发条件 查库中不存在的数据,如 id=-1、随机 UUID 单个热点 key 到期或被 LRU 淘汰 大批 key 同一时刻过期,或 Redis 整体不可用
缓存命中 永不命中,miss 率接近 100% 平时高命中,过期瞬间同一 key 集中 miss 命中率整体断崖下跌
MySQL 压力形态 持续高位,与 miss 数近似线性增长 尖刺,数秒内并发打同一行,出现行锁等待 全面高位,连接数被打满
日志与监控特征 INFO stats 里 keyspace_misses 持续涨,命中率趋近 0 --hotkeys、SLOWLOG 或应用层重建日志中同一 key 在数秒内反复出现 同一时间点大量 key 消失,或客户端集中报连接超时
处置手段 空值缓存或布隆过滤器 单飞 singleflight 或互斥锁重建 TTL 随机化加多级缓存

定位时跑这几条:

redis-cli INFO stats | grep keyspace   # 全局命中率:缓降还是断崖
redis-cli --hotkeys                     # 需 maxmemory-policy 为 allkeys-lfu
redis-cli SLOWLOG GET 20                # 慢命令样本,单 key 重复需结合应用层重建日志
mysql -e "SHOW GLOBAL STATUS LIKE 'Threads_connected';"

命中率持续走低、MySQL 连接数缓慢爬升,先怀疑穿透;命中率正常但 RT 出现秒级尖刺,是击穿;命中率与连接数在同一分钟同时恶化,按雪崩处理。三者可以叠加,同一个接口既做空值缓存又做 TTL 随机化不冲突。

三、代码一:空值缓存 + 短 TTL 防穿透,以及布隆过滤器的取舍

查询结果为 null 时仍写缓存,value 用固定占位符 "__NULL__",TTL 取 60 秒,反序列化时遇到占位符直接返回 null。空值 TTL 建议 30~120 秒:太长,新增数据在 TTL 内查不到;太短,防穿透效果打折。写入用 SET key value EX 60 NX,避免覆盖并发写入的真实值。

示例(Java + Spring Data Redis 2.1 及以上,ValueOperations.setIfAbsent(K key, V value, Duration timeout) 在该版本起提供):

String key = "user:" + id;
String v = redis.opsForValue().get(key);
if (v != null) return "__NULL__".equals(v) ? null : parse(v);
User u = db.query(id);
if (u == null) {
    redis.opsForValue().setIfAbsent(key, "__NULL__", Duration.ofSeconds(60));
    return null;
}
redis.opsForValue().set(key, toJson(u), Duration.ofSeconds(300));
return u;

布隆过滤器与空值缓存取舍:

维度 布隆过滤器 空值缓存
内存占用 位数组,可控 每个不存在 key 一份占位值
能否删除元素 标准布隆不支持删除 可删 key
误判表现 判存在可能误判,判不存在一定准确 无假阳性,但漏防新增
需要预热 需要,全量数据入过滤器 不需要
新增数据响应 需同步写入过滤器 首次查询后写缓存,TTL 内可查

白名单/黑名单这类集合稳定的场景用布隆过滤器,数据频繁增删用空值缓存。标准布隆过滤器不支持删除;计数布隆(Counting Bloom Filter)把每个位扩成计数器来支持删除,代价是内存成倍增加,且计数器位宽固定、长期高频插入会溢出,实现层面需要做饱和或重建处理。

100 万数据的理论参数,按 m = -n·ln p / (ln 2)²、k = (m/n)·ln 2 估算:

数据量 目标误判率 位数组(约) 哈希个数
100 万 0.1% 1.8MB 10
100 万 0.01% 2.4MB 13
100 万 0.001% 3.0MB 17

Redis 侧创建命令:BF.RESERVE key 0.001 1000000(依次为误判率、预计元素数)。表中的位数组和哈希个数是公式估算值,实际占用与参数以 BF.INFO key 返回的结果为准,按实际值核对内存预算。

四、代码二:单飞 / 互斥锁重建防击穿

抢锁用 SET lock:sku:1001 <uuid> NX PX 3000。返回 OK 者查库并回写缓存;失败者 sleep 50ms 后重试读缓存,最多 3 次,仍未命中返回兜底值(默认空对象、降级页)或直接降级。

锁只包住“查库 + 回写”这一个动作,不承担业务互斥。需要留意锁 TTL 与重建耗时的关系:PX 3000 是重建动作的耗时上限,如果查库 + 回写超过 3 秒,锁会先过期,第二个请求就能同时进入重建,互斥失效。所以要监控重建耗时:接近或超过 3 秒,先查慢 SQL 或连接池;如果业务本身就可能在秒级以上,改用带续期的锁(例如 Redisson 的看门狗机制),不要靠加大 TTL 硬扛。

释放时必须用 Lua 比对 uuid 再删除,避免误删他人锁:

if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('DEL', KEYS[1])
end
return 0

同一进程内更轻量的是 singleflight:以 key 为粒度合并并发请求,只有一个 goroutine 查库,其余等待结果。

v, err, _ := group.Do("sku:1001", func() (interface{}, error) {
    return loadFromDB(1001)
})

用 golang.org/x/sync/singleflight 的 Group.Do 即可,代价是只对单实例生效,多实例部署仍需分布式锁。

热点 key 可用逻辑过期:不设物理 TTL,value 内嵌 expireAt 字段。发现逻辑过期时先返回旧值,再由异步线程重建,可把 P99 抖动压到最低;代价是短暂返回旧数据,需按业务容忍度决定是否采用。

五、代码三:TTL 随机化 + 多级缓存防雪崩,缓存全挂时的兜底配置

TTL 随机化把同一批预热数据的失效点打散,避免整点齐刷。基础 30 分钟时:

int baseTtl = 1800; // 秒
int ttl = baseTtl + ThreadLocalRandom.current().nextInt(300); // 1800~2099
redis.opsForValue().set(key, value, Duration.ofSeconds(ttl));

多级缓存用本地 Caffeine 加 Redis。本地 expireAfterWrite 设 5~10 秒,maximumSize 按热点 key 数量估算;本地未命中查 Redis,Redis 未命中再查库并逐级回填。本地 TTL 远小于 Redis TTL,两级不会同时失效。

兜底点 参考配置 达到后行为
Sentinel 读接口 QPS 阈值 = MySQL 安全水位 × 0.6 快速失败,返回默认值
Resilience4j CircuitBreaker failureRateThreshold=50%、slowCallDurationThreshold=500ms、slidingWindowSize 按实例 QPS 设置 熔断,走本地缓存或静态兜底
HikariCP maximumPoolSize = max_connections / 实例数,connectionTimeout=1000ms 请求快速失败,不占满数据库连接

maximumPoolSize 调小后单请求更容易超时,上线前按实例数压测确认总连接数低于 max_connections。QPS 阈值和安全水位取自压测结果,不要直接套用固定值。

六、落地检查清单与适用边界

上线前逐项核对:

  • 所有 get 路径都有 null 兜底,空值以短 TTL(如 60s 级别)写入,避免 NULL 长期驻留。
  • 热点 key 清单单独配置逻辑过期,不走统一的物理 TTL。
  • 物理 TTL 加随机量,例如 ttl = base + random(0, base * 0.1)。
  • 客户端配置熔断与超时:timeout=200ms、maxAttempts=1,不留无限重试。

适用边界:

  • 空值缓存不适合会被高频查询的不存在 key,随机 key 攻击会带来内存放大,此时优先布隆过滤器。
  • 布隆过滤器不支持删除,删除频繁的数据改用计数布隆(Counting Bloom Filter)或 Cuckoo Filter:前者把每位扩为计数器来支持删除,代价是内存成倍增长和计数器溢出处理;后者支持删除元素,但插入时可能需要搬移已有元素,装载率过高会搬移失败。
  • 单飞只在单实例内串行化重建,多实例下只降低单机并发,不能替代分布式锁。

验证方式:用 wrk -t4 -c1000 -d30s 对同一热点 key 压测,同时观察两个来源。

redis-cli --latency
mysql -e "SHOW GLOBAL STATUS LIKE 'Threads_connected';"

判断标准:并发上升时 Threads_connected 保持平稳,说明防护生效;若随并发近似线性增长,说明请求已穿透到数据库,回到前面的决策表重新定位。


相关阅读