Redis 缓存穿透、雪崩、击穿

缓存挂了,请求会直接拍到数据库上。这三种情况听着像,处理办法却不是同一套。

本次导航

  • 穿透:查一个库里根本没有的东西
  • 雪崩:大批 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 找出来,上线前先看看哪些值得加这些保护。

五、串起来

一次商品详情请求,可以按这个顺序走:

  1. 本地缓存(有就直接返回)
  2. BF.EXISTS product_id {id},0 就结束
  3. GET product:{id},有值且不是 __nil__ 就返回;是 __nil__ 当不存在
  4. 没有值:抢 lock:product:{id},查 DB,回填 Redis(空也填),再写本地缓存

不是每个接口都要布隆 + 逻辑过期。普通后台查询,空值 + 随机 TTL 就够了。C 端爆款再加锁或逻辑过期。

六、练手

  1. SET product:99999 "__nil__" EX 30,马上 GET,再等 30 秒看 Key 是否消失。
  2. BF.RESERVE product_id 0.01 10000,BF.ADD 几个真 ID,对 99999 做 BF.EXISTS,确认是 0。
  3. 两个窗口同时 SET lock:product:1001 node-x NX EX 5,只有一个 OK。
  4. 窗口 A 抢到锁后不要马上 DEL。等几秒让锁自己过期,窗口 B 再 SET NX 抢到,这时 A 再 DEL,会把 B 的锁删掉。用上面那段 Lua 解,A 删不动 B 的锁。

下期预告: Redis 的 JSON、向量、时序 这些能力。


本次导航结束,欢迎 关注、点赞、转发。

相关推荐
禾小西3 小时前
05丨Redis 内存快照:宕机后,如何实现快速恢复?
数据库·redis·缓存
夕除3 小时前
redis--缓存同步
redis
禾小西4 小时前
06丨Redis 数据同步:主从库如何实现数据一致?
数据库·redis·php
我不会插花弄玉5 小时前
4.string类型【由浅入深-redis】
数据库·redis·缓存
范中勤10 小时前
LLM 动态加载与多用户缓存架构技术文档
redis·langchain·llm·缓存架构·多用户隔离
小小龙学IT11 小时前
Redis 源码深度解析:从常用命令到内部实现
redis·golang·开源
禾小西1 天前
07丨Redis 哨兵机制:主库故障后,如何恢复服务?
java·开发语言·redis
骉马代驾1 天前
代驾系统实战(三):抢单池的 Redis 锁 + RabbitMQ 异步消费怎么做
redis
行百里er1 天前
Redis Streams——有确认、能回溯的消息队列
redis