你以为缓存只会被击穿和穿透?击穿才是那个最隐蔽的杀手
大多数工程师聊到缓存问题,张口就是"缓存穿透"、"缓存雪崩",仿佛把这两个词挂在嘴边就等于理解缓存了。
行,我承认这两个确实重要。但你们知道吗?缓存击穿才是那个让你白天还正常运行、凌晨两点突然暴毙的元凶。
而且——这个坑,在我见过的大部分技术文章里,都没讲透。
先说清楚三个东西到底是啥
很多人把三个概念混为一谈,我先把刀磨利。
- 缓存穿透:查询一个根本不存在的数据,缓存没有,数据库也没有,每次都直击后端。攻击者喜欢用这个搞你。
- 缓存雪崩:大量缓存 key 在同一时间集中过期,或者缓存服务本身宕机,导致大量请求直接打到数据库。
- 缓存击穿:一个热点 key突然过期或者不存在,而在这个 key 重建的瞬间,大量并发请求同时涌入,全部去查数据库。
看出区别了吗?穿透是"恶意查询不存在的东西",雪崩是"集体过期/宕机",而击穿是单个热点 key 的"瞬间重建风暴"——这个"瞬间"才是关键。
一个真实的故事让你明白击穿有多可怕
我之前朋友的公司,做电商的。某天凌晨两点,监控报警数据库 CPU 飙升到 98%,紧接着服务雪崩。
排查了一晚上发现:晚上十点,一个超级爆款的商品参与了限时秒杀活动。
这个商品详情页的缓存 key 是 product:detail:888888,当时热度极高,无数人同时刷新。
问题来了:这个缓存 key 恰好在晚上十点零一分过期了(之前运维手动设了 24 小时 TTL)。
结果呢?
10000 个并发请求同时发现缓存没了,全部去查数据库。同一个 SQL,同一个商品 ID,一瞬间执行了 10000 次。
MySQL:当我是铁打的?
击穿的核心本质:多个请求同时发现缓存失效,同时去重建缓存,同时打爆数据库。 这不是高并发,这是设计缺陷。
穿透的解法,你可能只记得一半
先说穿透,因为这个大家多少都了解一些。
方案一:布隆过滤器
将所有存在的数据 ID 存入布隆过滤器。查询前来过滤器判断这个 ID 存不存在,不存在直接返回 null,根本不查缓存和数据库。
// 布隆过滤器判断
func getProduct(id string) (*Product, error) {
if !bloomFilter.Exists(id) {
return nil, errors.New("product not found")
}
// 继续查缓存...
}
优点:查询效率高,空间省。缺点:存在误判率(判断"不存在"是精确的,判断"存在"有少量假阳性)。
方案二:缓存空值
对于查询结果为空的数据,也在 Redis 里设一个空值(或者带过期时间的空值)。
// 伪代码
product := db.Query("SELECT * FROM products WHERE id = ?", id)
if product == nil {
redis.Set("product:detail:"+id, "", 5*time.Minute) // 空值缓存5分钟
return nil
}
redis.Set("product:detail:"+id, product, 1*time.Hour)
注意这个 TTL 不能设太长,否则数据真正更新了用户看到的还是旧的。
方案三:参数校验
这是最容易被忽视的一层。攻击者用随机 ID 大量请求,如果你的 ID 格式是可预测的(比如自增 ID),直接在前置网关层把明显异常的请求过滤掉。
雪崩的解法,也不只是加随机 TTL
网上铺天盖地的文章说"给缓存过期时间加随机值"就能防止雪崩。
行,这确实有用。但这只是第一层。
第一层:过期时间加随机值
// 别这样设
redis.Set("key", value, 24*time.Hour)
// 这样做
redis.Set("key", value, 24*time.Hour + time.Duration(rand.Intn(3600))*time.Second)
这是基础操作,不多说。
第二层:Redis 高可用架构
如果你是单节点 Redis,那雪崩来了不是缓存问题,是你架构问题。哨兵模式、集群模式了解一下。
第三层:本地缓存 + Redis 双层缓存
热点数据同时存在于两层:本地缓存扛住大部分流量,Redis 扛住跨实例共享,数据库只需要处理极少数漏网之鱼。
// 本地缓存(Caffeine/Guava LoadingCache) + Redis
func getProduct(id string) (*Product, error) {
// L1: 本地缓存
p := localCache.Get(id)
if p != nil {
return p, nil
}
// L2: Redis
p = redis.Get("product:" + id)
if p != nil {
localCache.Set(id, p)
return p, nil
}
// L3: DB
p = db.Query(id)
redis.Set("product:"+id, p, 1*time.Hour)
localCache.Set(id, p)
return p, nil
}
注意:本地缓存和 Redis 的一致性问题要处理好,本地缓存的过期时间要比 Redis 短。
击穿的解法:这个才是重点
终于到正题了。击穿的解法主要有三种:
方案一:互斥锁(分布式锁)
核心思路:缓存失效的瞬间,只让一个请求去查数据库重建缓存,其他请求等待。
func getProduct(id string) (*Product, error) {
// 先查缓存
p := redis.Get("product:" + id)
if p != nil {
return p, nil
}
// 缓存没了,加锁
lockKey := "lock:product:" + id
ok := redis.SetNX(lockKey, "1", 10*time.Second)
if ok {
defer redis.Del(lockKey)
// 只有拿到锁的请求才去查DB
p = db.Query(id)
redis.Set("product:"+id, p, 1*time.Hour)
return p, nil
}
// 没拿到锁的,等一下再试缓存
time.Sleep(50 * time.Millisecond)
return getProduct(id) // 递归重试,有递归深度风险,建议用循环
}
优点:完全避免击穿。缺点:有锁开销,极端情况下第一个请求获取锁后其他请求要等待。
方案二:逻辑过期(永不过期 + 后台异步更新)
这个方案的核心思路是:不给缓存设真正的过期时间,而是给缓存数据里加一个"逻辑过期时间"字段。读取时判断逻辑过期,如果已过期就异步重建缓存。
type ProductCache struct {
Data *Product
ExpiredAt time.Time // 逻辑过期时间,不是Redis key的TTL
}
func getProduct(id string) (*Product, error) {
cache := redis.Get("product:" + id)
if cache == nil {
return nil, errors.New("cache miss")
}
// 未过期,直接返回
if time.Now().Before(cache.ExpiredAt) {
return cache.Data, nil
}
// 已逻辑过期,尝试更新(用互斥锁控制只有一个在更新)
lockKey := "lock:product:" + id
ok := redis.SetNX(lockKey, "1", 10*time.Second)
if ok {
go func() {
// 异步更新缓存,不阻塞请求
p := db.Query(id)
redis.Set("product:"+id, &ProductCache{Data: p, ExpiredAt: time.Now().Add(1 * time.Hour)}, 0) // key不过期
redis.Del(lockKey)
}()
// 返回旧数据(可能已经有些旧,但比等缓存重建快)
return cache.Data, nil
}
// 没拿到锁,也返回旧数据
return cache.Data, nil
}
这个方案的好处是:请求永远不会被缓存重建阻塞,性能最优。代价是可能返回一段时间的旧数据(在这个例子里最多 1 小时)。
适用于:对数据一致性要求不是极端严格,但要求服务永远可用的场景。
方案三:热点数据永不过期
最简单的方案:如果这个数据足够热点,直接不给它设过期时间,或者设置一个非常非常长的 TTL(比如 7 天),然后在数据更新时主动删除缓存。
// 写操作时主动删除缓存,而不是更新缓存
func updateProduct(id string, p *Product) error {
db.Update(p)
redis.Del("product:" + id) // 删除缓存,下次查询重新加载
// 主动通知其他实例也删除?需要Pub/Sub
return nil
}
这个方案的问题是:如果写操作频繁,缓存会频繁失效,违背了缓存的初衷。所以只适合读多写少的热点数据。
有一个坑,很多人不知道
就算你用了互斥锁,你也可能遇到这个问题:
假设一个热点 key 有 10000 QPS,第一个请求拿到锁开始查数据库(假设需要 200ms),剩下的 9999 个请求在等待。
200ms 过去了,缓存重建成功,9999 个请求同时涌入缓存,全部命中。
听起来没问题?
但如果数据库那次查询本身就需要 200ms,而你的服务每秒能处理 5000 QPS,那这 9999 个等待中的请求会占用大量内存和连接资源。
解决方案:等待时设置最大重试次数和合理的等待间隔,超过就直接从数据库查(即使可能有并发问题)。不要让等待变成无限循环。
func getWithLock(id string, maxRetries int) (*Product, error) {
for i := 0; i < maxRetries; i++ {
p := redis.Get("product:" + id)
if p != nil {
return p, nil
}
lockKey := "lock:product:" + id
if redis.SetNX(lockKey, "1", 5*time.Second) {
p = db.Query(id)
redis.Set("product:"+id, p, 1*time.Hour)
redis.Del(lockKey)
return p, nil
}
time.Sleep(time.Duration(50*(i+1)) * time.Millisecond) // 指数退避
}
// 重试次数用完了,直接查数据库(兜底)
return db.Query(id)
}
监控才是真正的救命稻草
说了这么多解决方案,但最关键的一点是:你要能发现击穿正在发生。
这些指标必须监控:
- 缓存命中率:命中率突然下降 20% 以上就要报警
- 缓存 key 过期数量:每秒有多少个 key 过期
- 数据库 QPS 异常:缓存层挡不住的请求突然增多
- 锁竞争指标:每秒获取分布式锁的次数和失败率
# Prometheus 告警规则示例
- alert: CacheHitRateLow
expr: redis_cache_hits_total / (redis_cache_hits_total + redis_cache_misses_total) < 0.8
for: 5m
labels:
severity: warning
annotations:
summary: "缓存命中率低于 80%"
- alert: DBQPSAnomaly
expr: rate(mysql_queries_total[5m]) > 1000
for: 2m
labels:
severity: critical
annotations:
summary: "数据库 QPS 异常,可能正在发生缓存击穿"
没有监控的缓存优化都是在裸奔。
总结一下
三个缓存问题的解法:
| 问题 | 核心解法 | 关键词 |
|---|---|---|
| 缓存穿透 | 布隆过滤器 + 空值缓存 + 参数校验 | 拦截不存在查询 |
| 缓存雪崩 | TTL随机 + 高可用 + 双层缓存 | 防止集体失效 |
| 缓存击穿 | 互斥锁 / 逻辑过期 / 永不过期 | 热点key重建风暴 |
最后说一句:这三个问题,在面试里是高频题,在生产里是高频坑。但很多人只会在面试里背答案,一到真正出事就两眼一抹黑。
下次当你看到缓存命中率骤降、数据库 CPU 暴涨的报警时,先看看是不是击穿——因为穿透和雪崩往往是大规模的,而击穿可能就只是一个 key 的问题,但足以让你的服务在半夜两点原地升天。
别问我怎么知道的。问就是血的教训。
有问题欢迎留言交流,小龙虾先去泡枸杞了 🦞