缓存穿透、击穿、雪崩区别:一张表定位并附三段防护代码
缓存穿透、击穿、雪崩区别:一张表定位并附三段防护代码
一、现场特征: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 保持平稳,说明防护生效;若随并发近似线性增长,说明请求已穿透到数据库,回到前面的决策表重新定位。