核心目标:掌握 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. 本篇问题场景:三个"缓存事故"都是真事
- 数据对不上:用户明明下单了,页面却显示旧库存------DB 是新的,缓存是旧的;
- 一查就崩:某个冷门 id 被疯狂查询,每次都打到数据库------缓存形同虚设;
- 零点雪崩:所有商品缓存同一时刻过期,数据库瞬间被打挂。
三个事故对应缓存的三类经典问题:一致性 、穿透/击穿 、雪崩 。本篇先用实验把每一个复现出来,再给防护方案------先看到病,再吃药。
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 打垮数据库。
防护(按成本递增):
- 参数校验:非法的 id(负数、超长)直接拒绝;
- 空值缓存:查询结果为空也缓存一个占位值(短 TTL);
- 布隆过滤器:写 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 洗库)。防护:
- 过期时间加随机抖动 :
TTL = base + random(0, 300),打散集中失效; - 多级缓存:本地缓存(进程内)兜 Redis,Redis 失效不直接打 DB;
- 限流降级:回源侧限流,超限返回旧数据或降级文案;
- 热点预加载:活动前预热缓存,错峰重建。
抖动是最便宜的一招,生产缓存写入务必带 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 pytest32 passed。
7. 常见误区
- "删缓存改成更新缓存更好"------更糟:两次写操作的竞态窗口更大(§1)。
- "延迟双删能根治不一致"------只是把窗口挪走,双删本身也有新窗口与复杂度(§2.2)。
- "布隆过滤器能当缓存用" ------它只回答"可能存在",误判率 > 0,且不能删除;它是防穿透的哨兵不是存储。
- "击穿和雪崩是一回事"------击穿是单热点瞬时,雪崩是批量同时(§3.2/3.3)。
- "缓存一致性可以做到强一致"------分布式环境下只能"最终一致 + 窗口可控",强一致场景别用缓存。
8. 本篇小结
回到开篇三个事故:
- 数据对不上:缓存一致性没有银弹------默认"先更新库再删缓存 + 重试 + TTL 兜底",接受秒级最终一致;
- 一查就崩:穿透用空值缓存/布隆过滤器,击穿用互斥锁/逻辑过期;
- 零点雪崩:过期打散 + 多级缓存 + 限流降级。
下一篇 Part 9:分布式锁与限流 把并发治理工具化:分布式锁的正确实现与 Redlock 争议、令牌桶/滑动窗口限流,以及"锁 + 幂等 + 限流"三层防护在 shop-lab 的落地。
9. 官方资料
- Cache Aside 模式:https://docs.microsoft.com/en-us/azure/architecture/patterns/cache-aside
- Redis SET 命令(NX/EX):https://redis.io/docs/latest/commands/set/
- Bloom filter 模块:https://redis.io/docs/latest/develop/data-types/probabilistic/bloom-filter/
- Keyspace notifications(失效通知):https://redis.io/docs/latest/develop/notifications/