缓存挂了,请求会直接拍到数据库上。这三种情况听着像,处理办法却不是同一套。
本次导航
- 穿透:查一个库里根本没有的东西
- 雪崩:大批 Key 同一时刻过期
- 击穿:热点 Key 刚好失效,所有人去打 DB
- 重建缓存时怎么加一把互斥锁,避免所有人一起打 DB
发车前提醒:这三种情况经常被混着讲,下面用商品详情的例子把它们分开。
按惯例,启动 Redis 客户端,方便后续敲命令:
bash
docker exec -it redis-demo redis-cli
一、三个词别混
拿商品详情来说:
- 穿透 :有人拿
product:99999来查,这个 ID 库里从来没有,缓存没有,DB 也没有,每次都把数据库空跑一遍。刷接口的人最爱干这事。 - 击穿 :
product:1001是爆款,一直在缓存里。TTL 一到,Key 没了,这一瞬间所有请求去打 DB。热点 Key 失效的那一下,就是击穿。 - 雪崩:今晚 12 点一批活动商品同时写入,TTL 都是 3600 秒,一小时后 Redis 里一片空,DB 被集体问候。Redis 进程挂掉,效果也差不多。
一句话对照:
| 缓存里 | 数据库里 | 典型原因 | |
|---|---|---|---|
| 穿透 | 没有 | 也没有 | 乱 ID、爬虫 |
| 击穿 | 刚才还有,刚过期 | 有,而且很热 | 单个热点 Key 失效 |
| 雪崩 | 一大片同时没 | 有 | TTL 对齐,或 Redis 挂了 |
二、穿透:空结果也要记住
最省事的办法:DB 查不到,也往 Redis 里写一条短命的空标记。
bash
# 查过了,没有这个商品。别让下一次再去烦 MySQL
SET product:99999 "__nil__" EX 30
下次 GET 到 __nil__,直接返回不存在,别再查库。TTL 别太长,商品后来上架了还能被正常缓存覆盖。
空值不能永久留着,否则 Redis 会被垃圾 ID 塞满。30 秒到几分钟都常见。
空标记挡得住偶发的乱 ID。有人拿脚本扫 product:1 到 product:10000000,空值 Key 一样能把内存打爆。这时候上 布隆过滤器 。
布隆过滤器:没有的,就别去查
它不存完整 ID 列表,只占一小块位图。问它"1001 在不在":
- 回答 不在:一定不在,可以跳过 DB
- 回答 在:可能在,也可能误判,还是要查库或查缓存
它就是用误判来换空间。
Redis 8 的官方镜像已经带了 Bloom 模块,redis:8.6.2 里直接就能用。先定容量和误判率,再往里丢存在的 ID:
bash
# 预计 10 万个商品,误判率大约 1%
127.0.0.1:6379> BF.RESERVE product_id 0.01 100000
OK
127.0.0.1:6379> BF.ADD product_id 1001
(integer) 1
127.0.0.1:6379> BF.ADD product_id 1002
(integer) 1
127.0.0.1:6379> BF.EXISTS product_id 1001
(integer) 1
127.0.0.1:6379> BF.EXISTS product_id 99999
(integer) 0
批量灌:
bash
BF.MADD product_id 1003 1004 1005
请求进来先 BF.EXISTS,返回 0,直接 404,连 Redis 里的 product:99999 都不用建。返回 1,再走正常的 GET → 没有再查 DB。
注意几件小事:
0.01再往下压,内存涨得很快,1% 对商品 ID 这种场景够用了- 布隆过滤器不好删。商品下架了,它可能还说"在",后面靠空值缓存兜住就行
- 老版本 Redis 没有这个模块,空值缓存仍然有效,模块不是必须的
商品数据初始化、全量同步的时候,把 DB 里的 ID 灌进过滤器。漏灌了,会误伤成"不存在",比误判成"存在"麻烦,灌数这步别偷懒。
三、雪崩:别让过期时间排成一队
写入缓存时 TTL 不要用同一个整数。
python
import random
ttl = 300 + random.randint(0, 60)
# SET product:1001 '{...}' EX ttl
300 秒到 360 秒之间散开,到期时间就错开了。活动预热这种"同一秒写入一万个 Key",尤其要加随机。
Redis 挂了也会表现为雪崩。那不是调 TTL 能解决的:
- 应用进程里加一层本地缓存,能挡一部分读
- 接口要有限流、降级,DB 扛不住就返回旧数据或兜底页
- 生产 Redis 别裸奔单机,至少做主从,别把所有流量押在一个进程上
过期时间加随机,是雪崩里最便宜、也最该先做的那一步。
四、击穿:热点过期的那一瞬间
爆款 Key 别跟普通商品共用一个 5 分钟 TTL 就完事。常用三招,可以叠着用。
1. 互斥锁:只放一个人去查库
Key 没有时,先抢锁,抢到的人查 DB、回填缓存,其他人稍等再 GET。
bash
127.0.0.1:6379> SET lock:product:1001 node-a NX EX 3
OK
# 第二个人抢,失败
127.0.0.1:6379> SET lock:product:1001 node-b NX EX 3
(nil)
抢到锁之后,释放不能直接 DEL。锁过期了别人可能已经抢到,你再删就把别人的锁干掉了。先核对是不是自己写入的那个值:
lua
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
伪代码:
python
val = redis.get("product:1001")
if val == "__nil__":
return None
if val is not None:
return val
locked = redis.set("lock:product:1001", node_id, nx=True, ex=3)
if locked:
try:
val = db.query(...) # 只有一个人打 DB
if val is None:
redis.set("product:1001", "__nil__", ex=30)
else:
redis.set("product:1001", val, ex=300)
return val
finally:
redis.eval(unlock_lua, 1, "lock:product:1001", node_id)
else:
sleep(0.05)
return redis.get("product:1001")
EX 3 防止查库的人死了锁不释放。同一进程里还可以再加一层本地互斥(Java 里常见的是按 Key 分组的 synchronized 或单飞),少一些 Redis 往返。
这把锁只是为了重建缓存。单实例 Redis,SET NX EX 加上面这段校验删除就够了,犯不着上 Redlock。
2. 逻辑过期:过期了先把旧的吐出去
物理 TTL 不设,或者设得很长。值里面带一个逻辑过期时间:
json
{"data": {"name": "手机壳", "price": 29}, "expire_at": 1789080000}
读的时候发现逻辑过期了,先把旧 JSON 返回给用户,再让拿到锁的那个人去刷新。用户几乎不等 DB,热点也不会在某一秒突然真空。
代价是:短时间内可能读到旧价格。商品名、详情页能接受;库存、余额这种别用。
3. 热点干脆不过期
秒杀品、首页推荐位,缓存可以不设 TTL,改数据时主动 SET 覆盖。流量再大,可以把同一个热点拆成 product:1001:1、product:1001:2 几份,读的时候随机挑一个;应用里再加本地缓存,少打 Redis。
Redis 8.6 可以用 HOTKEYS START / HOTKEYS GET 把访问最凶的 Key 找出来,上线前先看看哪些值得加这些保护。
五、串起来
一次商品详情请求,可以按这个顺序走:
- 本地缓存(有就直接返回)
BF.EXISTS product_id {id},0 就结束GET product:{id},有值且不是__nil__就返回;是__nil__当不存在- 没有值:抢
lock:product:{id},查 DB,回填 Redis(空也填),再写本地缓存
不是每个接口都要布隆 + 逻辑过期。普通后台查询,空值 + 随机 TTL 就够了。C 端爆款再加锁或逻辑过期。
六、练手
SET product:99999 "__nil__" EX 30,马上GET,再等 30 秒看 Key 是否消失。BF.RESERVE product_id 0.01 10000,BF.ADD几个真 ID,对99999做BF.EXISTS,确认是 0。- 两个窗口同时
SET lock:product:1001 node-x NX EX 5,只有一个 OK。 - 窗口 A 抢到锁后不要马上
DEL。等几秒让锁自己过期,窗口 B 再SET NX抢到,这时 A 再DEL,会把 B 的锁删掉。用上面那段 Lua 解,A 删不动 B 的锁。
下期预告: Redis 的 JSON、向量、时序 这些能力。
本次导航结束,欢迎 关注、点赞、转发。