1. 起因

线上一个查询接口在某天流量陡增时数据库 CPU 直接打满,排查下来是缓存这一层没扛住,暴露出了穿透、击穿、雪崩三个经典问题里的两个。这三个概念平时都知道,真正踩过一遍才记得住区别和对应的解法,记一下。

2. 缓存穿透:查一个根本不存在的 key

1
2
3
4
5
6
def get_user(user_id):
data = redis.get(f"user:{user_id}")
if data is None:
data = db.query_user(user_id) # 缓存没有就查数据库
redis.set(f"user:{user_id}", data, ex=3600)
return data

问题出在如果 user_id 对应的数据在数据库里根本不存在,db.query_user 每次都返回空,这个空结果不会被写进缓存(或者写了也马上被判断为无效),于是每一次请求都会穿透缓存直接打到数据库上。正常情况下这种请求很少见,但一旦被恶意构造大量不存在的 id 来请求,或者爬虫扫了一堆无效 id,数据库瞬间就被压垮。

解法是空结果也要缓存,哪怕值是一个哨兵值:

1
2
3
4
5
6
7
8
9
10
11
def get_user(user_id):
data = redis.get(f"user:{user_id}")
if data is not None:
return None if data == "NULL" else data

data = db.query_user(user_id)
if data is None:
redis.set(f"user:{user_id}", "NULL", ex=60) # 空值也缓存,过期时间短一些
else:
redis.set(f"user:{user_id}", data, ex=3600)
return data

如果担心恶意 id 的种类特别多导致缓存里塞满哨兵值,也可以在前面加一层布隆过滤器,提前判断 id 是否可能存在,根本不存在的直接拦掉,不用走到 Redis 这一层。

3. 缓存击穿:单个热点 key 过期瞬间被并发打爆

这次线上的问题就是这个。一个热点商品详情的缓存 key 在某个时间点过期,恰好那个时间点并发量很高,几百个请求同时发现缓存里没数据,全部一股脑去查数据库、然后各自往 Redis 里写:

1
2
3
4
5
6
def get_product(product_id):
data = redis.get(f"product:{product_id}")
if data is None:
data = db.query_product(product_id) # 瞬间被几百个请求同时执行
redis.set(f"product:{product_id}", data, ex=1800)
return data

这跟穿透的区别是:击穿针对的是存在但缓存恰好失效的热点 key,穿透针对的是根本不存在的 key。解法是给查库+回写这一段加锁,保证同一时刻只有一个请求去查数据库,其他请求要么等,要么先返回旧值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def get_product(product_id):
data = redis.get(f"product:{product_id}")
if data is not None:
return data

lock_key = f"lock:product:{product_id}"
if redis.set(lock_key, "1", nx=True, ex=10): # 抢锁成功的去查库
try:
data = db.query_product(product_id)
redis.set(f"product:{product_id}", data, ex=1800)
finally:
redis.delete(lock_key)
return data
else:
time.sleep(0.05)
return get_product(product_id) # 没抢到锁的稍等重试

对于访问量特别大、绝对不能容忍数据库压力的热点 key,更简单的做法是干脆不设过期时间,改成后台定时任务主动刷新,业务代码只读缓存,永远不会有”缓存失效瞬间”这个状态。

4. 缓存雪崩:大量 key 在同一时间集体过期

雪崩和击穿的机制类似,区别是雪崩是大量不同的 key 集中在同一时间点过期,而不是单个热点 key。最常见的诱因是批量写缓存的时候统一用了同一个固定的过期时间:

1
2
for item in items:
redis.set(f"item:{item.id}", item.data, ex=3600) # 所有 key 都是一小时后过期

一小时之后这一整批 key 会在几乎同一时刻失效,如果这批 key 数量大、访问频繁,效果就是短时间内大量请求同时穿到数据库,跟单个热点 key 击穿的破坏力不是一个量级。解法很直接,过期时间加一个随机抖动,把集中失效打散开:

1
2
3
4
5
import random

for item in items:
ttl = 3600 + random.randint(0, 600) # 加 0~10 分钟的随机抖动
redis.set(f"item:{item.id}", item.data, ex=ttl)

另外 Redis 实例本身挂掉、或者主从切换导致的大规模缓存失效也叫雪崩,这一层的应对更多是架构层面的事情:做好哨兵/集群的高可用、给数据库前面加限流熔断,真扛不住的时候宁可降级返回默认数据也不能让数据库被打死。

5. 小结

三个问题的判断标准可以简化成:查的 key 存不存在(穿透 vs 击穿/雪崩)、失效的是一个 key 还是一批 key(击穿 vs 雪崩)。应对手段本质上都是”别让大量请求在同一时刻绕过缓存直接打数据库”,具体用空值缓存、加锁、随机 TTL 还是布隆过滤器,看场景搭配着用,线上遇到问题优先看监控里数据库 QPS 和缓存命中率的曲线,基本能一眼定位是哪一种。