缓存模式与穿透、击穿、雪崩
缓存是 Redis 最普遍的用途,也是事故最高发的领域。本章先讲清读写模式(数据在缓存与数据库之间怎么流动),再逐个拆解三大经典问题——穿透、击穿、雪崩,最后直面最缠人的"双写一致性"。
1. 三种缓存读写模式
1.1 Cache-Aside(旁路缓存,事实标准)
应用自己管理缓存与数据库:
读: 查缓存 --命中--> 返回
\--未命中--> 查数据库 -> 写入缓存(带 TTL) -> 返回
写: 更新数据库 -> 删除缓存 (注意: 是删除, 不是更新)def get_user(uid):
data = redis.get(f"cache:user:{uid}")
if data is not None:
return json.loads(data)
user = db.query("SELECT ... WHERE id = %s", uid)
redis.set(f"cache:user:{uid}", json.dumps(user), ex=600)
return user
def update_user(uid, fields):
db.execute("UPDATE ... WHERE id = %s", uid)
redis.delete(f"cache:user:{uid}") # 删缓存, 下次读时重建写时为什么删缓存而不是更新缓存?1)更新的值可能被并发写覆盖出错,删除天然幂等;2)缓存的可能是多表拼装结果,更新成本高;3)写多读少的字段更新了也未必被读,浪费。
1.2 Read/Write-Through(读写穿透)
应用只和"缓存服务"交互,由缓存层负责与数据库同步。应用代码更干净,但需要一个能挂钩数据库的缓存中间层,Redis 本身不提供,通常由公司内部平台或框架(如带 CacheLoader 的组件)实现。
1.3 Write-Behind(异步写回)
写只写缓存,由异步任务批量刷数据库。写性能极高(合并大量写),代价是缓存宕机会丢数据、一致性最弱。适合点赞数、浏览量这类容忍丢失的高频计数,不适合交易数据。
| 模式 | 读路径 | 写路径 | 一致性 | 复杂度 |
|---|---|---|---|---|
| Cache-Aside | 应用查缓存/DB | 写 DB, 删缓存 | 较好 | 低(推荐默认) |
| Read/Write-Through | 只查缓存层 | 缓存层同步写 DB | 较好 | 需中间层 |
| Write-Behind | 只查缓存层 | 只写缓存, 异步刷 DB | 弱 | 高 |
2. 缓存穿透:查"根本不存在"的数据
请求 key=user:-1 (恶意构造/已删除的 ID)
-> 缓存无 (不存在的东西当然没缓存)
-> 打到数据库, 也查不到
-> 没有结果可缓存, 下一次同样请求原样再来
=> 缓存形同虚设, 数据库被海量无效请求打穿2.1 解法一:缓存空值
# 第一次查: 缓存里没有, 只能去查数据库(也查不到)
GET cache:user:404
# 把"确认不存在"这件事也缓存起来, 空字符串占位, TTL 要短
SET cache:user:404 "" EX 60
# 之后同样的请求: 拿到空串就直接返回"不存在", 不再打数据库
GET cache:user:404
# 注意区分: EXISTS 是 1(有这个 key), 值才是空的
EXISTS cache:user:404
TTL cache:user:404查到空值直接返回"不存在"。简单有效,但海量随机不存在的 key 会占内存,且 TTL 内新插入的数据会被误判——所以空值 TTL 要短(30~120 秒)。
2.2 解法二:布隆过滤器
把全量存在的 ID 预先放进布隆过滤器,请求先问过滤器:
布隆过滤器: m 位的 bit 数组 + k 个哈希函数
写入 x: k 个哈希各算一个位置, 全部置 1
查询 y: k 个位置都是 1?
├─ 有任何一位是 0 -> y 一定不存在 (直接拒绝, 不打 DB)
└─ 全是 1 -> y 可能存在 (有误判率, 放行去查)
特性: 不存在的判断 100% 准; 存在的判断有小概率误判; 不支持删除用 RedisBloom 模块(redis-stack 自带):
127.0.0.1:6379> BF.RESERVE bf:users 0.01 1000000 # 误判率 1%, 容量 100 万
OK
127.0.0.1:6379> BF.ADD bf:users 1001
(integer) 1
127.0.0.1:6379> BF.EXISTS bf:users 1001
(integer) 1
127.0.0.1:6379> BF.EXISTS bf:users 999999999
(integer) 0 # 一定不存在, 直接返回, 不查库没有模块时可以用 Bitmap 手搓,或在应用内用 Guava BloomFilter。此外,接口层的参数校验(ID 格式、范围)能挡掉大部分低级恶意流量,永远是第一道防线。
3. 缓存击穿:热点 key 过期的瞬间
热点 key (如首页 banner) 每秒 1 万次读
t0: TTL 到期, 缓存消失
t0+1ms: 1 万个并发请求同时未命中 -> 1 万个请求同时打向数据库重建
=> 数据库瞬间被打爆; 且 1 万次重建是重复劳动3.1 解法一:互斥锁重建
只允许一个请求去重建,其余等待:
def get_hot(key):
data = redis.get(key)
if data is not None:
return data
# 抢重建锁 (就是上一章的分布式锁)
if redis.set(f"lock:rebuild:{key}", uuid, nx=True, ex=10):
try:
data = db.load()
redis.set(key, data, ex=600)
finally:
release_lock(f"lock:rebuild:{key}", uuid) # Lua 校验释放
return data
else:
time.sleep(0.05) # 没抢到: 短暂等待后重读缓存
return get_hot(key)代价:重建期间其他请求在等待(可能堆积线程)。
3.2 解法二:逻辑过期
key 不设 TTL,把过期时间放进 value 里:
value = { data: {...}, expire_at: 1722310600 }
读到后检查 expire_at:
├─ 未过期 -> 直接用
└─ 已过期 -> 返回旧数据 + 异步触发一个任务去重建(带互斥)
=> 永远不会有"缓存消失"的瞬间, 请求永不阻塞代价:过期后到重建完成之间,读到的是旧数据。适合"热点榜单、活动页"这类允许分钟级陈旧的场景。两种解法的取舍就是一致性 vs 可用性。
4. 缓存雪崩:大面积同时失效
两种成因,两套药方:
成因一:大批 key 同一时刻过期(比如凌晨批量预热时统一设了 3600 秒)。
ttl = 3600 + random.randint(0, 600) # TTL 加随机抖动, 摊平过期时刻
redis.set(key, value, ex=ttl)效果就是同一批预热的 key 各自散落在不同时刻过期:
# 同一批预热的缓存, 基准 3600 秒 + 各自不同的随机抖动
SET cache:sku:1 v1 EX 3611
SET cache:sku:2 v2 EX 3754
SET cache:sku:3 v3 EX 3902
# 三个 key 的过期时刻被拉开了几百秒, 不会同一秒集体失效
TTL cache:sku:1
TTL cache:sku:2
TTL cache:sku:3成因二:Redis 实例整体宕机,所有请求穿到数据库。
- 事前:主从 + 哨兵/Cluster 高可用(第 14~16 章);
- 事中:应用层限流与熔断(数据库前面加信号量/线程池隔离,超载直接快速失败降级);
- 兜底:多级缓存(进程内缓存扛一部分)、静态化兜底页。
三大问题速查表:
| 问题 | 本质 | 首选解法 |
|---|---|---|
| 穿透 | 查不存在的数据,缓存永远无法命中 | 布隆过滤器 + 空值缓存 + 参数校验 |
| 击穿 | 单个热点 key 过期瞬间并发重建 | 互斥锁重建 或 逻辑过期 |
| 雪崩 | 大量 key 同时失效 / 实例宕机 | TTL 随机抖动 + 高可用 + 限流熔断 |
5. 双写一致性与延迟双删
Cache-Aside 的"先更新库,再删缓存"仍有不一致窗口:
经典竞态 (概率低但存在):
读请求 R: 缓存未命中 -> 查 DB 得到旧值 v1 -> (卡了一下)
写请求 W: 更新 DB 为 v2 -> 删除缓存 (此时缓存本来就空, 删了个寂寞)
读请求 R: 恢复执行 -> 把旧值 v1 写进缓存
=> 缓存里是 v1, 数据库是 v2, 直到 TTL 到期都不一致延迟双删:写请求删完缓存后,隔一小段时间再删一次,把上述竞态写进去的脏值清掉:
def update_user(uid, fields):
redis.delete(f"cache:user:{uid}") # 第一次删
db.execute("UPDATE ...", fields)
time.sleep_async(0.5) # 睡过"读请求回填"的窗口(异步做)
redis.delete(f"cache:user:{uid}") # 第二次删延迟时间取"一次读请求的 P99 耗时 + 余量",通常几百毫秒。更彻底的方案是订阅数据库 binlog(Canal/Debezium),由独立服务在数据变更后删缓存,将删除与业务代码解耦,还能重试。
只要用缓存,缓存与数据库之间就不存在无成本的强一致(除非引入分布式事务,那缓存就失去意义了)。工程目标是:不一致窗口足够短 + TTL 兜底。所有缓存必须设 TTL——它是一切一致性方案失灵后的最后防线。
小结
- 默认用 Cache-Aside:读时回填,写时"更新库 + 删缓存"
- 穿透防"不存在"(布隆过滤器/空值),击穿防"热点过期"(互斥锁/逻辑过期),雪崩防"集体失效"(TTL 抖动/高可用/熔断)
- 双写不一致的兜底三板斧:延迟双删、binlog 驱动删除、必设 TTL
- 下一章:运维与性能优化 →
- 用伪代码写出完整的 Cache-Aside 读写函数,加入空值缓存与 TTL 随机抖动。
- 模拟击穿:删掉一个 key 后用脚本并发 100 个读请求,统计打到"数据库"(可用一个计数器模拟)的次数;加上互斥锁重建后再测一次。
- 画出延迟双删的时序图,标注它能清掉哪个窗口的脏数据,以及它仍然无法覆盖的场景(提示:第二次删除之后 DB 主从延迟)。