Learn
Redis/18-cache-patterns

缓存模式与穿透、击穿、雪崩

缓存是 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 各自散落在不同时刻过期:

TTL 随机抖动,摊平过期时刻
# 同一批预热的缓存, 基准 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
  • 下一章:运维与性能优化 →
🎯练习
  1. 用伪代码写出完整的 Cache-Aside 读写函数,加入空值缓存与 TTL 随机抖动。
  2. 模拟击穿:删掉一个 key 后用脚本并发 100 个读请求,统计打到"数据库"(可用一个计数器模拟)的次数;加上互斥锁重建后再测一次。
  3. 画出延迟双删的时序图,标注它能清掉哪个窗口的脏数据,以及它仍然无法覆盖的场景(提示:第二次删除之后 DB 主从延迟)。