Redis 系列(八):缓存设计模式与一致性——从 Cache Aside 到防雪崩

核心目标:掌握 Cache Aside 等主流缓存模式;理解"先删缓存还是先更新库"的时序窗口;用真实实验复现缓存不一致、击穿重建风暴与穿透;能设计带防护的缓存架构。

前置知识 :完成 Part 6(过期/淘汰------雪崩的机制基础)与 Part 7(Lua/锁------防击穿的武器)。

验证环境:Redis 8.10.0(cygwin 移植版,127.0.0.1:6379)、redis-py 8.1.0、Python 3.11.6、Windows 11;一致性实验用 Python 线程 + 内存 dict 模拟数据库。最后复核日期:2026-08-07。


0. 本篇问题场景:三个"缓存事故"都是真事

  1. 数据对不上:用户明明下单了,页面却显示旧库存------DB 是新的,缓存是旧的;
  2. 一查就崩:某个冷门 id 被疯狂查询,每次都打到数据库------缓存形同虚设;
  3. 零点雪崩:所有商品缓存同一时刻过期,数据库瞬间被打挂。

三个事故对应缓存的三类经典问题:一致性穿透/击穿雪崩 。本篇先用实验把每一个复现出来,再给防护方案------先看到病,再吃药


1. 缓存模式:为什么是 Cache Aside

主流缓存模式四种,区别在"谁负责把数据放进缓存":

模式 评价
Cache Aside miss 时应用回源 DB 并写缓存 应用更新 DB 后删缓存 简单可控,默认选择
Read Through 缓存层负责回源 应用只写 DB 缓存组件要支持
Write Through 同左 应用写 DB 时同步写缓存 写延迟变高
Write Back 同左 只写缓存,异步刷 DB 有丢数据风险

Redis 本身不支持 Read/Write Through(需要客户端库实现,如 Spring Cache 注解),所以绝大多数团队用 Cache Aside。它的核心规则只有两条:

  • :缓存命中返回;miss 回源 DB,写缓存(带 TTL);
  • :更新 DB,删除缓存(而不是更新缓存)。

第二条"为什么删不更新"------因为"更新缓存"会把不一致窗口从"一次删操作"变成"两次写操作的竞态"(见 §2)。


2. 一致性:先删缓存,还是先更新库

2.1 经典复现:先删缓存再更新库 = 必现不一致

用线程模拟真实并发时序(删缓存与写库之间有时间差):

python 复制代码
def writer():
    r.delete("cache:item:1")     # 1. 删缓存
    time.sleep(0.3)              #    模拟耗时的 DB 写
    db["item:1"] = "new-value"   # 3. 写库

def reader():
    time.sleep(0.15)             # 在删缓存之后、写库之前读
    val = r.get("cache:item:1")
    if val is None:
        val = db["item:1"]       # 2. 读到 DB 旧值
        r.set("cache:item:1", val)  # 4. 把旧值写回缓存

实测结果:

text 复制代码
DB 值: 'new-value'   缓存值: 'old-value'
不一致: True  ← 缓存被旧值污染

时序:删缓存 → 读请求 miss → 读 DB(旧值)→ 写缓存(旧值)→ DB 被更新为新值。缓存里永远躺着旧值,直到 TTL 到期。这就是"先删缓存再更新库"的致命窗口。

2.2 先更新库再删缓存:窗口更小,但不是零

text 复制代码
T1: 写 DB = new      → 此刻缓存里还是 old
T2: 读缓存 → 命中 old  ← 不一致窗口(直到 T1 删缓存)
T1: 删缓存

窗口只有"写库完成到删缓存完成"之间,通常毫秒级。风险点:如果 T1 写库成功后在删缓存前崩溃,缓存永远旧------需要重试/延迟兜底。所以两种方案都不是银弹,工程上的取舍是:

方案 不一致窗口 主要风险
先删缓存再更新库 大(读-写-回填竞态) 缓存被旧值长时间污染
先更新库再删缓存 小(毫秒级) 删缓存失败/崩溃 → 永久旧值
先更新库再删缓存 + 延迟双删 更小 复杂度高,双删窗口仍需评估
订阅 binlog 异步失效(CDC) 秒级 引入消息组件,架构变重

共识 :多数团队选"先更新库,再删缓存 ",配合:删缓存失败重试(重试队列)、过期时间兜底(即使删失败,TTL 后自愈)、必要时 CDC。而强一致场景根本不该用缓存------先想清楚能不能接受"秒级最终一致"。


3. 缓存穿透 / 击穿 / 雪崩:三兄弟的防护

3.1 穿透:查一个不存在的东西

请求一个 DB 里没有、缓存里也没有的 key → 每次都回源 → 攻击者可以靠遍历不存在的 id 打垮数据库。

防护(按成本递增)

  1. 参数校验:非法的 id(负数、超长)直接拒绝;
  2. 空值缓存:查询结果为空也缓存一个占位值(短 TTL);
  3. 布隆过滤器:写 DB 时把 id 加入过滤器,读前先查------过滤器说不存在就绝不回源。

实测空值缓存(第二次不再回源):

text 复制代码
第一次查询不存在 key: None
第二次(命中空值缓存,不再回源): None
缓存内容: __NULL__

3.2 击穿:热点 key 过期的瞬间

一个热点 key 过期后,大量并发同时 miss、同时回源------"重建风暴"。实测 20 个并发读一个刚过期的热点:

text 复制代码
无锁重建: 20 并发 → DB 查询 20 次(击穿!)
互斥锁重建: 20 并发 → DB 查询 1 次(只有 1 次回源)

防护的两种主流方案:

方案 机制 优缺点
互斥锁重建 miss 时先 SET NX 抢锁,只有锁主回源,其余等待后读缓存 简单可靠;锁失败路径要兜底
逻辑过期 缓存值里存"逻辑过期时间",异步线程重建,读时发现逻辑过期返回旧值+触发重建 不阻塞读,但数据短暂不新鲜

本系列 shop-lab 采用互斥锁方案(§5)。

3.3 雪崩:大量 key 同时过期

击穿是"一个热点",雪崩是"一批 key"同时过期或被淘汰(Part 6 的 maxmemory 洗库)。防护:

  1. 过期时间加随机抖动TTL = base + random(0, 300),打散集中失效;
  2. 多级缓存:本地缓存(进程内)兜 Redis,Redis 失效不直接打 DB;
  3. 限流降级:回源侧限流,超限返回旧数据或降级文案;
  4. 热点预加载:活动前预热缓存,错峰重建。

抖动是最便宜的一招,生产缓存写入务必带 jitter。


4. shop-lab 实战:商品缓存落地

shop-lab/src/shop_lab/cache.py 实现了三合一的读路径(Cache Aside + 空值缓存 + 互斥锁):

python 复制代码
def get_product(product_id: str, with_rebuild_lock: bool = True) -> str | None:
    cached = r.get(key)
    if cached is not None:
        return None if cached == "__NULL__" else cached   # 空值占位

    if with_rebuild_lock:
        lock_key = f"{key}:lock"
        lock_token = uuid.uuid4().hex
        if not r.set(lock_key, lock_token, nx=True, ex=LOCK_TTL):   # 抢重建锁
            # 等锁主重建完成后重读缓存(带超时兜底)
            ...
        try:
            return _rebuild(r, product_id, key)
        finally:
            # 只释放自己的锁:Lua 校验 token(Part 9 原则,防误删他人重建锁)
            r.eval(_RELEASE_LOCK_SCRIPT, 1, lock_key, lock_token)
    else:
        return _rebuild(r, product_id, key)

def _rebuild(r, product_id, key):
    value = _load_from_db(product_id)
    if value is None:
        r.set(key, "__NULL__", ex=NULL_TTL)   # 防穿透:空值也缓存
        return None
    r.set(key, value, ex=CACHE_TTL)           # 缓存重建(写库侧负责 invalidate)
    return value

配套测试(tests/test_cache.py)覆盖:命中后不再回源、空值缓存拦截第二次查询、10 并发只回源 1 次(防击穿)、失效后无锁模式会回源。

text 复制代码
$ cd redis/shop-lab && python -m pytest
................................     [100%]
32 passed in 30.49s

(Part 1-7 累计 27 个 + 本篇 5 个。)


5. 版本与环境差异

差异点 说明
锁实现 本文 SET NX EX 用法在 6.x+ 一致;redis-py 8.x 的 set(nx=True, ex=) 为推荐写法
布隆过滤器 Redis 原生需 RedisBloom 模块(Redis 8.0 已并入核心);纯 Redis 可用 Bitmap + 多个哈希自行实现
空值缓存 无版本差异;注意占位值(如 __NULL__)不能与真实值冲突

一致性问题的本质是分布式系统的时序,与 Redis 版本无关------实验结论在 7.x/8.x 上同样成立。


6. 测试与验收

  • 一致性实验(线程时序复现)不可靠的断言(结果依赖调度),用"能稳定复现"的 sleep 控制即可,生产回归用测试替身;
  • 防击穿测试用 mock 统计 DB 调用次数,断言"有锁 ≤ 1 次回源"。

本篇验收清单

  • 能画出"先删缓存再更新库"的不一致时序并解释窗口;
  • 能说出"先更新库再删缓存"的残余风险与兜底手段;
  • 能区分穿透/击穿/雪崩,并各给出至少两种防护;
  • 能解释互斥锁重建与逻辑过期两种防击穿方案的取舍;
  • 生产缓存写入必须带 TTL 抖动(能说出为什么);
  • cd redis/shop-lab && python -m pytest 32 passed。

7. 常见误区

  1. "删缓存改成更新缓存更好"------更糟:两次写操作的竞态窗口更大(§1)。
  2. "延迟双删能根治不一致"------只是把窗口挪走,双删本身也有新窗口与复杂度(§2.2)。
  3. "布隆过滤器能当缓存用" ------它只回答"可能存在",误判率 > 0,且不能删除;它是防穿透的哨兵不是存储。
  4. "击穿和雪崩是一回事"------击穿是单热点瞬时,雪崩是批量同时(§3.2/3.3)。
  5. "缓存一致性可以做到强一致"------分布式环境下只能"最终一致 + 窗口可控",强一致场景别用缓存。

8. 本篇小结

回到开篇三个事故:

  1. 数据对不上:缓存一致性没有银弹------默认"先更新库再删缓存 + 重试 + TTL 兜底",接受秒级最终一致;
  2. 一查就崩:穿透用空值缓存/布隆过滤器,击穿用互斥锁/逻辑过期;
  3. 零点雪崩:过期打散 + 多级缓存 + 限流降级。

下一篇 Part 9:分布式锁与限流 把并发治理工具化:分布式锁的正确实现与 Redlock 争议、令牌桶/滑动窗口限流,以及"锁 + 幂等 + 限流"三层防护在 shop-lab 的落地。


9. 官方资料

相关推荐
2401_894915533 小时前
部署 GEO 优化源码常见报错排查:端口、伪静态、缓存问题解决
java·运维·服务器·后端·缓存·开源
渣渣盟3 小时前
当 Redis 写入不再是瓶颈后,Flink 任务的反压可能来自哪里?如何系统性地定位和解决 Flink 反压问题?
数据库·redis·flink
仍然.3 小时前
服务端高并发分布式结构演进之路
大数据·数据库·redis
MC皮蛋侠客3 小时前
Redis 系列(三):底层实现(一)——对象系统、SDS 与 dict
数据库·redis·缓存
渣渣盟4 小时前
当 Redis 集群发生主从切换(Failover)时,Flink 任务会崩溃吗?如何利用 Sentinel 实现高可用?
redis·flink·sentinel
莫得感情 o4 小时前
设计模式 18 · 状态模式
设计模式·状态模式
莫得感情 o4 小时前
设计模式 17 · 责任链模式
设计模式·责任链模式
用户9385156350713 小时前
从零在浏览器里跑 DeepSeek-R1:WebGPU + Transformer.js 全链路实战
前端·设计模式·typescript
海上小飞龙18 小时前
Redis 分布式锁原理:从 SET NX EX 到 Redisson 看门狗
数据库·redis·分布式