外观
缓存穿透、击穿、雪崩一次讲透:分层防护方案与线上避坑实录
一次大促预热把一个没有防护的缓存集群打出了原形:活动页配置被脚本用随机 ID 疯狂请求,数据库连接池瞬间打满;紧接着首页几个热点 key 同时到点过期,重建流量又叠了一层;最后有人手滑把 Redis 主库重启,所有请求同时涌向数据库,连锁把上游服务拖垮。穿透、击穿、雪崩三个问题在同一天轮番上演。这三类问题的根源各不相同,但都指向同一个结构缺陷:缓存层失效的瞬间,没有东西挡在数据库前面。这篇文章按请求链路把防护拆成三道防线,每一道给出可选方案、代码与踩坑点。
三个问题一张表分清:先诊断再开药
很多文章把三个问题混在一起讲,实际处理时第一步必须先分清现象属于哪一类,药方完全不同:
| 问题 | 触发条件 | 典型现象 | 防护主方向 |
|---|---|---|---|
| 缓存穿透 | 请求的数据在缓存和数据库中都不存在 | 无效请求持续打到数据库,命中率异常低 | 拦截不存在数据的请求 |
| 缓存击穿 | 某个热点 key 到期失效的瞬间高并发涌入 | 单 key 过期瞬间数据库出现毛刺尖峰 | 控制 key 重建的并发 |
| 缓存雪崩 | 大量 key 同时失效,或 Redis 整体不可用 | 数据库压力整体陡增甚至被打挂 | 错开失效时间 + 分层兜底 |
判断依据可以看监控:命中率骤降但数据库慢查询集中在少数主键查询,多半是穿透;QPS 曲线出现与 key 过期周期对齐的周期性毛刺,是击穿;整库压力陡增且伴随 Redis 不可用或大量 key 同时过期,是雪崩。诊断对了,后面的防护才不会白做。
分层防护总览:三道防线各拦一段
一个健壮的缓存体系应该像漏斗,让恶意流量和异常流量逐层被消耗掉:第一道防线在入口,用参数校验和布隆过滤器把「明知道不存在」的请求拦在缓存之前;第二道防线在缓存层,用空值缓存兜住穿透漏网之鱼,用互斥重建和逻辑过期控制击穿并发;第三道防线在架构层,用 TTL 抖动、多级缓存、限流熔断和高可用部署抵御雪崩。三层不是互斥关系,生产系统通常三层同时启用,成本从低到高依次叠加。
第一道防线:穿透治理的三级拦截
穿透的本质是「数据库里也没有的数据」被反复查询,常见于恶意扫描和业务漏洞。第一级是入口参数校验:ID 为负数、格式非法的请求直接拒绝,成本最低。第二级是空值缓存:查库确认不存在后,把一个约定的空值占位写进缓存并给一个较短的 TTL,让同一个无效 key 的后续请求被缓存挡住。第三级是布隆过滤器,适合 key 规模大且命中率要求高的场景,以 RedisBloom 模块为例:
bash
# 按预期容量 1 亿与 0.1% 误判率创建过滤器
BF.RESERVE user:bloom 0.001 100000000
# 数据加载阶段:把全量合法用户 ID 注册进去
BF.ADD user:bloom 10086
# 查询前校验:不存在的 ID 直接返回,不碰缓存也不碰数据库
BF.EXISTS user:bloom 999999布隆过滤器的空间效率极高,一亿 ID 只需要百兆级内存,代价是存在一定误判率——它只会把不存在的误判为存在(漏拦),不会把存在的误判为不存在(错杀),所以正确性有保障,拦截率取决于误判率参数。
第二道防线:击穿治理的互斥重建与逻辑过期
击穿发生在热点 key 失效的瞬间:成百上千个请求同时发现缓存失效,全部涌向数据库重建同一份数据。治理思路有两条。思路一是互斥重建:只放一个请求去回源,其余请求短暂等待后读新缓存。下面是一个 Go 的完整示例,把单飞限流与空值缓存组合在一起:
go
// 互斥重建 + 空值缓存:同一进程内同 key 并发只放一个请求回源
func GetProduct(ctx context.Context, rdb *redis.Client, id int64) (*Product, error) {
key := fmt.Sprintf("product:%d", id)
if val, err := rdb.Get(ctx, key).Result(); err == nil {
if val == "{}" { // 约定空值占位,命中即返回不存在
return nil, ErrNotFound
}
return decodeProduct(val), nil
}
v, err, _ := flight.Do(key, func() (any, error) {
p, err := loadProductFromDB(id)
if errors.Is(err, ErrNotFound) {
// 空值缓存 60 秒,挡住反复打到数据库的无效请求
rdb.Set(ctx, key, "{}", 60*time.Second)
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
// TTL 加随机抖动,避免同批 key 同时失效(顺带防雪崩)
ttl := 30*time.Minute + time.Duration(rand.Intn(300))*time.Second
rdb.Set(ctx, key, encodeProduct(p), ttl)
return p, nil
})
if err != nil {
return nil, err
}
return v.(*Product), nil
}单飞只解决了单进程内的并发,跨实例的并发要用分布式锁兜底:SET lock:key unique NX EX 10 抢锁,抢到的回源重建,没抢到的自旋几十毫秒后重读缓存;释放锁必须用 Lua 脚本先比对持有者标记再删除,避免误删别人的锁。思路二是逻辑过期:热点 key 物理上不设 TTL,把过期时间存在 value 里,读到「已逻辑过期」的数据时先返回旧值,再异步起任务重建。它的好处是读路径完全无阻塞,代价是重建完成前返回的是旧数据,存在脏读窗口。
第三道防线:雪崩治理的四件套
雪崩分两种成因,防护手段也不同。针对「大量 key 同时失效」:一是 TTL 随机抖动,示例代码里的 rand.Intn(300) 就是让同批 key 的过期时间错开最多五分钟;二是多级缓存,进程内缓存(如 Caffeine)挡在最前面,Redis 抖动时本地还能扛住读流量;三是建立过期监控,把「未来 N 分钟内将过期的热点 key 数量」做成指标告警,提前预热。针对「Redis 整体不可用」:一是限流熔断降级,在数据库前设置并发闸门,超限请求走降级逻辑返回默认值或兜底页,宁可降级不可崩溃;二是高可用部署,哨兵或集群模式保证单节点故障能自动切换。这四件套里,限流降级是最后一堵墙,一定要在活动前压测出阈值并演练降级开关的真实生效路径。
避坑总结
- 布隆过滤器不支持删除元素。数据会被物理删除的业务(如注销用户)用计数布隆或布谷鸟过滤器,否则过滤器越用越「脏」,误判率持续上升;扩容与重建要设计成可执行的运维流程。
- 互斥锁必须带过期时间且释放时校验持有者。没设 EX 的锁在持锁进程崩溃后会造成死锁;不校验 value 就 DEL,可能把别人刚抢到的锁删掉,两个问题都要在代码评审里盯住。
- 空值缓存的 TTL 要短,并考虑数据新增时的清理。数据从「不存在」变为「存在」后,残留的空值占位会让新数据在 TTL 内读不到;要么 TTL 控制在可接受窗口内,要么在写入路径主动删除占位。
- 逻辑过期不适合强一致场景。异步重建期间返回旧值是设计使然,库存、余额这类强一致读不要用;它适合首页配置、商品详情这类容忍秒级陈旧的场景。
- 防护体系必须演练。限流阈值是压出来的不是算出来的,降级开关要真的拨过一次才算数;建议每季度用预发环境做一次「穿透 + 击穿 + 主库切换」的组合演练。
写在最后
三大问题的防护没有银弹,本质是在「数据库安全、数据新鲜度、响应延迟」三者之间做工程取舍:互斥重建牺牲一点等待时间换取数据库安全,逻辑过期牺牲一点新鲜度换取读路径无阻塞,TTL 抖动用几行代码换来失效时间错峰。把三道防线的代码沉淀成团队公共组件,新服务接入时默认带上,比每次事故后亡羊补牢便宜得多。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。