我给一批缓存数据统一设了一小时过期、平时缓存命中率高得很数据库压力很小,可每隔整整一小时数据库就会突然被海量请求瞬间打垮、CPU 飙满响应超时,排查很久才惊觉那批缓存是同一时刻批量写进去的、于是又在同一时刻集体失效、把流量齐刷刷地全砸向了数据库
这是一次让我把"缓存过期时间"这件事,从"设个固定 TTL 就行",重新理解成"一批 key 同时失效会制造一个流量尖峰、把数据库瞬间打垮"的事故。我给一批缓存数据统一设了一小时过期,平时缓存命中率高得很、数据库压力很小。可每隔整整一小时,数据库就会突然被海量请求瞬间打垮、CPU 飙满、响应超时。我排查了很久才惊觉:那批缓存是同一时刻批量写进去的,于是又在同一时刻集体失效,把流量齐刷刷地全砸向了数据库。这篇就把这次"缓存雪崩、整点打垮 DB"的事故,从头到尾复盘一遍。
故障现场:每隔整一小时,数据库准时被打垮一次
我的系统在缓存里存了一大批热点数据(比如商品信息、配置项),为了让它们定期刷新,我给每条都设了 TTL = 1 小时。平时跑得非常好:绝大多数请求都命中缓存、根本不碰数据库,DB 的 QPS 低得可怜,监控一片祥和。我对这套缓存很满意。
可诡异的事情开始周期性发生:每隔大约一小时,数据库就会突然遭遇一波海量查询,QPS 瞬间冲到平时的几十倍,CPU 打满、连接池被占爆、大量请求超时,持续几秒到十几秒后才慢慢恢复——然后下一个整点,再来一次。我先怀疑是有定时任务在整点跑批,查了一圈没有;又怀疑是有人整点刷接口,也不是。直到我把"数据库被打垮的时刻"和"缓存的写入时间"对在一起看,才恍然大悟——那批缓存数据,是系统启动时(或某次批量预热时)在几乎同一时刻一次性写进去的;我给它们设了完全相同的 1 小时 TTL;于是一小时后,它们又在几乎同一时刻集体过期失效。在那个瞬间,所有原本命中缓存的请求,一下子全部扑空、同时穿透到数据库去查、再回写缓存——海量请求在同一刹那砸向 DB,这就是"缓存雪崩",DB 被这个尖峰瞬间打垮。
# 我的缓存: 一批 key 统一设相同 TTL, 又是同一时刻批量写入的
def warm_up_cache(items):
for item in items:
cache.set(f"item:{item.id}", item, ttl=3600) # ★ 全部 TTL=3600 秒
# 这批 key 在"同一时刻"写入 + "相同 TTL"
# → 一小时后"同一时刻"集体过期
# 一小时后那个瞬间发生了什么:
# t=3600s: 成千上万个 key 同时失效
# → 所有请求同时缓存未命中
# → 全部同时穿透到 DB 查询(还没人来得及回写缓存)
# → DB 瞬间被几十倍流量打垮: CPU 满、连接爆、超时
# → 这就是【缓存雪崩】: 大量 key 同时失效, 流量齐刷刷砸向后端
# 现象: 每隔整一小时(TTL 周期), DB 准时被打垮一次
问题被钉死在这个认知错位上:我以为"给每个 key 设一小时过期"是让它们各自独立地、平滑地过期,但因为它们是同一时刻写入的、又是完全相同的 TTL,它们的过期时刻就被对齐到了同一个瞬间;于是"过期"不再是分散的、细水长流的,而是集中爆发的——成千上万个 key 在同一刹那一起失效,把它们各自挡在缓存后面的流量,瞬间汇成一股洪流砸向数据库。缓存平时的价值,是"挡在数据库前面的一道墙";而这道墙,因为所有砖块都在同一秒一起垮塌,反而在那一刻把积蓄的全部冲击力,一次性释放给了数据库。我没意识到"统一 TTL + 同时写入"会制造出这种"同步失效"的尖峰。我以为我建了一道结实的墙,却没想到这墙被设定成了在同一秒钟集体消失。
第一件事:想明白"同时写入 + 统一过期"会制造同步失效的尖峰
把这次事故彻底想清楚,关键是理解缓存雪崩的本质,是"大量缓存在同一时间集中失效"(或缓存服务整体宕机),导致原本被缓存挡住的海量请求,在同一瞬间一起穿透到后端数据库,把 DB 打垮。而"同一时刻批量写入"加上"完全相同的过期时间",正是制造这种"同步失效"的最典型配方——它把本该分散在时间轴上的过期事件,人为地对齐、堆叠到了同一个点。
这和"缓存击穿"(单个热点 key 过期、大量请求争抢去重建它)不同,雪崩是大面积的同步失效;但它们的共性是:当一个"缓冲层"(缓存)在某一刻突然、大面积地失去保护作用时,它平时拦下的全部压力,会被瞬间、集中地转嫁给它身后那个脆弱的、本来被保护着的后端。缓存的意义在于"削峰填谷、把压力摊平";而同步失效恰恰相反,它把压力重新聚集成了一个尖峰。根本原因在于"同步性":大量本应独立、错开的事件(每个 key 的过期),因为共享了同一个触发条件(同时写入 + 同一 TTL),被绑成了一个"同时发生"的集体事件,其瞬时冲击远超后端的承受力。关键认知是:大量个体若在同一时刻做同一件事,其叠加的瞬时冲击,会远大于它们错开来时的平均负载;系统设计要警惕这种"人为制造的同步性"。
# 正解1: 给 TTL 加随机抖动, 打散过期时刻, 避免同步失效
import random
def warm_up_cache(items):
for item in items:
# 基础 1 小时 + 0~600 秒随机抖动 → 过期时刻分散在一个区间里
jitter = random.randint(0, 600)
cache.set(f"item:{item.id}", item, ttl=3600 + jitter)
# 现在这批 key 的过期时刻散布在 [3600, 4200] 秒, 不再挤在同一秒
# → 即使到期, 也是陆陆续续过期、零散回源, DB 压力平摊, 不再被尖峰打垮
# 正解2: 热点数据"逻辑过期"/后台异步刷新, 不让请求线程去同步重建
# 缓存里存"值 + 逻辑过期时间"; 读到逻辑过期就触发【后台】异步刷新,
# 当前请求先返回旧值 → 用户无感, DB 也不会被同步回源洪流冲垮
# 正解3: 多级缓存 + 限流降级兜底
# 本地缓存(进程内) + 分布式缓存(Redis)分担; 万一仍大面积失效,
# 对回源加互斥锁/单飞(singleflight)只让一个请求重建, 其余等待;
# 并对 DB 入口限流, 实在扛不住就降级返回兜底数据, 保护 DB 不被打死
想通这一层,我才明白自己错在哪:我只想着"给缓存设个过期时间好定期刷新",却完全没考虑"这一批缓存会不会在同一时刻一起过期"——而"同时写入 + 统一 TTL"恰恰保证了它们会。我把成千上万个 key 的过期时刻,无意中全部对齐到了同一秒,等于给数据库埋了一个每小时准时引爆一次的炸弹。根治之道,是打破这种同步性:给 TTL 加随机抖动把过期打散、用后台异步刷新避免请求线程同步回源、再用单飞和限流降级兜底。不是不能设过期时间,而是绝不能让大量缓存的过期时刻,挤在同一个瞬间。
第二件事:正解——打散过期(TTL 抖动)+ 异步刷新 + 单飞限流兜底
找到根因,正解就清晰了:从三个层面防缓存雪崩——其一,给 TTL 加随机抖动,把大量 key 的过期时刻打散到一个区间,杜绝"同步失效";其二,对热点数据用逻辑过期/后台异步刷新,不让用户请求线程去同步回源;其三,即便仍发生大面积失效,用单飞(singleflight)让同一 key 只有一个请求去重建、其余等待结果,并对 DB 入口限流、扛不住就降级返回兜底数据。
// 正解1: TTL 加随机抖动, 打散过期时刻
func setCache(key string, val any) {
base := 60 * time.Minute
jitter := time.Duration(rand.Intn(600)) * time.Second // 0~10min 抖动
cache.Set(key, val, base+jitter) // 过期时刻分散, 不再挤在同一秒
}
// 正解2: 单飞(singleflight) —— 大量请求同时缓存未命中时,
// 同一 key 只放一个请求去查 DB 重建, 其余请求共享它的结果
var g singleflight.Group
func getWithSingleflight(key string) (any, error) {
if v, ok := cache.Get(key); ok {
return v, nil
}
// 同一 key 并发只执行一次 fn, 其余等待复用结果 → DB 只被查一次
v, err, _ := g.Do(key, func() (any, error) {
data, err := db.Query(key) // 只有一个请求真正打到 DB
if err != nil {
return nil, err
}
setCache(key, data) // 回写(带抖动 TTL)
return data, nil
})
return v, err
}
// 正解3: DB 入口限流 + 降级兜底, 实在扛不住时保护 DB 不被打死
// sem 控制同时打到 DB 的并发上限; 拿不到令牌就返回降级数据/旧值
这套做法的精髓,是从"制造同步"转向"主动打散同步",并为缓冲层失效的瞬间准备好多道兜底。TTL 抖动让过期事件回归"分散、错峰"的本来面目,是治本;异步刷新让缓存"软过期"、用户永远拿到旧值而非等待回源;单飞把"N 个请求重建同一 key"压成"1 个请求重建";限流降级则是最后一道保命符——宁可少数请求拿到降级数据,也不让数据库被打死、引发全站雪崩。不是消灭过期,而是绝不让大量过期挤在同一刻,并为万一的失效准备好层层缓冲。
【防缓存雪崩, 我现在认死的几条】
1. 缓存雪崩 = 大量 key 同时失效(或缓存宕机), 请求齐刷刷砸向 DB
2. "同时写入 + 统一 TTL" = 同步失效的配方, 必出整点尖峰
3. 治本: 给 TTL 加随机抖动, 把过期时刻打散到一个区间
4. 热点数据: 逻辑过期 + 后台异步刷新, 不让请求线程同步回源
5. 单飞(singleflight): 同一 key 只放一个请求重建, 其余等结果
6. DB 入口限流 + 降级兜底: 缓冲层塌了也别让 DB 被打死
7. 多级缓存(本地+分布式)分担; 缓存宕机要有兜底, 别让 DB 裸奔
第三件事:其他"大量个体同步行动制造冲击尖峰"的同类坑
顺着"大量个体在同一时刻做同一件事、叠加出远超平均的瞬时冲击"这条线,我把同类的坑都排查了一遍:
第一个,整点对齐的定时任务扎堆。一堆 cron 都设"每小时 0 分",整点一到全部同时启动、抢同一批资源,瞬间打爆。要加抖动错开。
第二个,客户端重试不加退避抖动,形成重试风暴。一批客户端同时失败、又同时按固定间隔重试,把已经吃力的服务再砸一波。要指数退避 + 随机抖动。
第三个,令牌/会话统一时刻批量过期。一批用户的 token 同一时刻发放、同一 TTL,于是同一时刻集体失效、同时来重新登录/刷新,认证服务被冲。
第四个,连接池统一空闲超时,同时被回收又同时重建。大量连接同时到达空闲上限被关、下一波请求又同时新建连接,造成抖动。
第四件事:缓存雪崩 vs 击穿 vs 穿透——一张对照表
我把三个最容易混淆的缓存故障摆在一起对比,核心看"是什么失效、怎么打垮 DB、怎么防":
| 故障 | 是什么 | 怎么打垮 DB | 主要对策 |
|---|---|---|---|
| 缓存雪崩 | 大量 key 同时失效/缓存宕机 | 海量请求同一刻齐穿透 | TTL 抖动打散 + 多级缓存 + 限流降级 |
| 缓存击穿 | 单个热点 key 过期 | 大量请求争抢重建同一 key | 单飞/互斥锁 + 逻辑过期异步刷新 |
| 缓存穿透 | 查根本不存在的 key | 每次都绕过缓存查 DB | 缓存空值 + 布隆过滤器拦截 |
看清这张表,对症下药就有谱了:雪崩治"同步失效"——打散 TTL、多级缓存、限流降级;击穿治"单热点重建争抢"——单飞加锁、逻辑过期;穿透治"查不存在的 key"——缓存空值、布隆过滤器。我这次踩坑,是典型的缓存雪崩:统一 TTL + 同时写入,让一大批 key 在整点集体失效。三者成因不同,别张冠李戴用错对策。
第五件事:我曾经对缓存过期想当然的几个误区
这次事故也把我对缓存过期的一堆"想当然"照了个底朝天:
| 我以为 | 实际上 |
|---|---|
| 给每个 key 设 1 小时过期就是各自独立过期 | 同时写入+统一 TTL 会让它们同一刻集体过期 |
| 缓存命中率高就说明缓存设计没问题 | 平时很好, 但同步失效的那一刻会瞬间打垮 DB |
| 过期是分散、平滑发生的 | 对齐了触发条件, 过期会聚集成尖峰 |
| DB 每隔整点被打垮是有人整点跑批 | 是缓存 TTL 周期到了、大批 key 同步失效 |
| 缓存挡住了流量, 后端就一直安全 | 缓冲层一旦大面积失效, 积蓄的压力会瞬间转嫁后端 |
这些误区的根子是同一个:我只看到了缓存平时"削峰"的好处,却没意识到当大量缓存因为共享同一个触发条件而同步失效时,这个"缓冲层"会在一瞬间把它平时拦下的全部压力,集中地、放大地砸向它本应保护的后端。缓存削峰的前提,是失效本身是分散的;一旦失效被人为地对齐、聚集,缓存非但不削峰,反而制造了一个更陡的尖峰。把"分散发生的好处"误当成"任何情况下都成立",而忽略了同步性会把分散变成聚集、把平稳变成冲击,是这类雪崩事故的共同根源。
第六件事:设缓存、排查"DB 周期性被打垮"时,我现在的自检习惯
现在每当我设计缓存、或排查"数据库每隔一段时间准时被打垮一次",我都会先按这张图问自己:
这张图的精髓,是"DB 周期性被打垮先对齐缓存 TTL 周期;大量 key 同时失效就是雪崩,治本是给 TTL 加随机抖动打散过期"。设计就给批量缓存的 TTL 加随机抖动、热点用逻辑过期异步刷新、配单飞和限流降级、排查就把 DB 被打垮的时刻和缓存写入时间/TTL 周期对齐看是不是同步失效。这套习惯,让我从"给缓存随手设个统一过期"变成了"先想这批缓存会不会在同一刻一起过期"——核心始终是:缓存雪崩的本质是大量缓存在同一时间集中失效(或缓存服务整体宕机),导致原本被缓存挡住的海量请求在同一瞬间一起穿透到后端数据库把 DB 打垮;而"同一时刻批量写入"加上"完全相同的过期时间"正是制造这种同步失效的最典型配方,它把本该分散在时间轴上的过期事件人为地对齐堆叠到了同一个点;它和缓存击穿(单个热点 key 过期、大量请求争抢重建)不同——雪崩是大面积的同步失效,但共性是当一个缓冲层在某一刻突然大面积失去保护作用时它平时拦下的全部压力会被瞬间集中地转嫁给身后脆弱的后端;根本原因在于同步性——大量本应独立错开的事件因为共享了同一个触发条件被绑成了同时发生的集体事件,其瞬时冲击远超后端承受力;正解是从制造同步转向主动打散同步——给 TTL 加随机抖动把过期时刻分散到一个区间杜绝同步失效(治本)、对热点数据用逻辑过期和后台异步刷新不让用户请求线程同步回源、用单飞让同一 key 只放一个请求去重建其余等待结果、并对 DB 入口限流扛不住就降级返回兜底数据、再加多级缓存分担;一句话,大量个体若在同一时刻做同一件事其叠加的瞬时冲击会远大于它们错开来时的平均负载,系统设计要警惕这种人为制造的同步性。
我立下的几条规矩
这场"缓存雪崩、整点打垮 DB"的事故,换来了我设计缓存时,刻进骨子里的几条铁律:
- 缓存雪崩 = 大量 key 同时失效(或缓存宕机),请求齐刷刷砸向 DB。
- "同时写入 + 统一 TTL" 是同步失效的配方,必出整点尖峰。
- 治本:给 TTL 加随机抖动,把过期时刻打散到一个区间。
- 热点数据用逻辑过期 + 后台异步刷新,不让请求线程同步回源。
- 单飞:同一 key 只放一个请求去重建,其余等结果,别 N 个一起查 DB。
- DB 入口限流 + 降级兜底:缓冲层塌了也别让 DB 被打死。
- 警惕一切"人为制造的同步性":整点 cron、固定重试间隔、统一过期。
附:我现在防缓存雪崩的"抖动TTL + 单飞 + 限流降级"骨架
这是我现在做缓存固定套的骨架——把这次踩坑的教训(TTL 抖动打散、单飞重建、限流降级兜底)固化成一套封装,让"缓存雪崩打垮 DB"那种坑再不会埋进系统:
type SafeCache struct {
cache Cache
db DB
sf singleflight.Group
sem chan struct{} // DB 入口并发上限(限流)
}
func NewSafeCache(c Cache, db DB, dbConcurrency int) *SafeCache {
return &SafeCache{cache: c, db: db, sem: make(chan struct{}, dbConcurrency)}
}
func (s *SafeCache) Get(key string) (any, error) {
// 1) 命中直接返回
if v, ok := s.cache.Get(key); ok {
return v, nil
}
// 2) 未命中: 单飞, 同一 key 只放一个请求去重建, 其余等结果
v, err, _ := s.sf.Do(key, func() (any, error) {
// 3) DB 入口限流: 拿不到令牌就降级, 绝不让 DB 被洪流冲垮
select {
case s.sem <- struct{}{}:
defer func() { <-s.sem }()
default:
return s.fallback(key) // 降级: 返回旧值/兜底数据
}
data, err := s.db.Query(key)
if err != nil {
return s.fallback(key) // DB 出错也降级, 不抛给用户
}
// 4) 回写时 TTL 加随机抖动, 打散过期, 杜绝下一轮同步失效
ttl := 60*time.Minute + time.Duration(rand.Intn(600))*time.Second
s.cache.Set(key, data, ttl)
return data, nil
})
return v, err
}
// 批量预热同理: 每个 key 各自带抖动 TTL, 绝不统一过期
func (s *SafeCache) WarmUp(items []Item) {
for _, it := range items {
ttl := 60*time.Minute + time.Duration(rand.Intn(600))*time.Second
s.cache.Set(it.Key, it.Val, ttl) // 过期时刻分散到 [60, 70] 分钟
}
}
这套骨架把我这次的教训钉死在了结构里:无论回写还是批量预热,TTL 一律加随机抖动把过期打散(治本);未命中时单飞让同一 key 只一个请求重建;DB 入口限流控制同时回源的并发、拿不到令牌就降级返回旧值/兜底;DB 出错也降级不抛给用户。这样,即便某一刻仍有较多缓存到期,过期是错峰的、回源是受控的、DB 是被限流和降级层层护住的,而不再是当初那个"一整批缓存在整点齐刷刷失效、把 DB 一秒打垮"的局面。把"警惕同步性、用抖动把集中的冲击重新摊回分散"这个道理,沉淀成缓存的固定骨架,这是我对这次"每整点准时崩溃的数据库"最实在的交代——毕竟,缓存是用来替数据库挡刀的,绝不能让它在同一秒集体撤防、反把数据库推到刀口上。
写在最后
回头看,这场由"统一 TTL"引发的"缓存雪崩"事故,真正教给我的,远不止"给过期时间加个随机数"这一个技巧。它让我对"大量个体各自看似无害的行为,一旦因为共享了同一个触发条件而在同一时刻'整齐划一'地一起发生,其叠加起来的瞬时冲击,会远远超过它们各自错开、细水长流时的平均水平;而我们设计系统时,常常只盯着'平均负载'和'单个行为的代价',却忽略了'同步性'这个会把分散的涓流瞬间汇成洪峰的放大器",有了一次刻骨的体会。我栽跟头,是因为我无意中给成千上万个缓存,设定了一个"同时诞生、同时死亡"的命运——我只想着"给每个 key 设一小时过期",以为它们会各自独立、平滑地过期;我没意识到,因为它们是同一时刻批量写入的、又用了完全相同的 TTL,它们的过期时刻被精确地对齐到了同一秒;于是"过期"这件本该分散发生的事,被我无意中编排成了一场"集体谢幕"——成千上万道挡在数据库前的墙,在同一刹那一起消失,把它们平时拦下的全部洪流,一次性灌向了毫无防备的后端。这让我领悟到一个关于"同步性与瞬时冲击"的深刻认知:一个系统能否扛住压力,不仅取决于"总量"和"平均速率",更取决于压力是"分散到达"还是"集中到达"——同样的总请求量,平摊在一段时间里后端游刃有余,挤在一瞬间则可能直接压垮;而"同步性"正是把"分散"变成"集中"的元凶:当大量独立的个体共享了同一个触发条件(同一时刻初始化、同一个 TTL、同一个整点、同一个固定重试间隔),它们就会从"各自错开"坍缩成"同时发生",制造出一个总量没变、但瞬时冲击被急剧放大的尖峰;更隐蔽的是,这种同步性往往是我们为了"整齐、统一、好管理"而无意中引入的——统一的配置、对齐的时间、相同的参数,恰恰是同步灾难的温床。这给了我一种看待"一切'大量个体共享同一触发条件'之事"时的清醒:每当我给大量个体设定同一个时间、同一个周期、同一个参数时,要追问"它们会不会因此在同一时刻一起行动?如果会,这个同时叠加的瞬时冲击,后端扛得住吗?我是不是该主动给它们加点随机抖动、把它们错开"——主动打散人为的同步性,用随机抖动让本应独立的事件回归分散错峰,绝不让大量个体被对齐到同一个引爆点;"警惕同步性、用抖动把集中的冲击重新摊回分散",是防住缓存雪崩、也是设计一切高并发系统的关键。认清缓存雪崩是同步失效、统一 TTL+同时写入是其配方、要给 TTL 加随机抖动打散——这,是我用一次"每整点准时被打垮的数据库"事故,换来的、关于架构、也关于如何警惕同步性的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次给一批缓存设过期时间时,先停一秒想想"它们会不会在同一刻一起过期?要不要给 TTL 加个随机抖动?",那我对着那个"每隔一小时准时 CPU 飙满"的数据库排查的大半天,就值了。
—— 别看了 · 2026