主题
第 12 章 缓存设计三大问题 + 一致性
学习目标:把生产环境最容易翻车的三个缓存场景——穿透 / 击穿 / 雪崩——彻底吃透。每一个都会经过「现象 → 损害 → 多种解法 → 各自代价」四步剖析;同时把面试常青树「缓存与数据库一致性」讲到滴水不漏:Cache Aside、Read/Write Through、Write Behind 各自适用什么场景,「先删缓存还是先更新 DB」到底该怎么选,延迟双删 / Binlog 订阅又是怎么救场的。学完之后能从容回答「热 Key 怎么处理」「双写一致性怎么保证」「布隆过滤器原理」这一类高频追问。
12.1 缓存的标准模式:Cache Aside(旁路缓存)
在讨论「翻车」之前,先把「正常工作」的样子画清楚。互联网公司 90% 以上的读路径都是 Cache Aside(旁路缓存) 模式。
12.1.1 一句话定义
Cache 是一个「旁观者」,由业务代码自己负责把它和 DB 同步。
┌──────────────┐
│ Client │
└──────┬───────┘
│
┌─────────────┼─────────────┐
│ Read 路径 │ Write 路径 │
▼ ▼ ▼
先查 Cache 更新 DB 删除 Cache
miss 查 DB ┌──────┐ ┌──────┐
回写 Cache │ DB │ │Cache │
└──────┘ └──────┘业务代码同时认识 Cache 和 DB,自己决定先访问谁、出错怎么处理。这就是「旁路(Aside)」的含义——Cache 站在 DB 旁边,但不在 DB 和 Client 的主链路上。
12.1.2 读路径详细流程
┌────────┐ 1. GET k ┌────────┐
│ Client │─────────────▶│ Redis │
└────────┘ └────────┘
▲ │
│ 4. 返回 v │ 2. miss / hit
│ ▼
│ ┌───────────┐ miss ┌────────┐
└───│ 业务代码 │◀───────│ DB │
└───────────┘ 3. 查DB └────────┘
│
└── 3.5 把结果回写 Cache(带过期时间)伪代码:
python
def get_user(uid):
v = cache.get(f"user:{uid}")
if v is not None:
return json.loads(v) # ① 缓存命中,直接返回
v = db.query("SELECT * FROM user WHERE id=?", uid)
if v is not None:
cache.set(f"user:{uid}", json.dumps(v), ex=600) # ② 回写
return v12.1.3 写路径:为什么是「先 DB 后删缓存」
┌────────┐ 1. UPDATE k ┌────────┐
│ Client │─────────────▶ │ DB │
└────────┘ └────────┘
│
│ 2. 删除 Redis 中的 k
▼
┌────────┐
│ Redis │
└────────┘伪代码:
python
def update_user(uid, fields):
db.execute("UPDATE user SET ... WHERE id=?", *fields, uid) # ① 改 DB
cache.delete(f"user:{uid}") # ② 删缓存💡 看起来「不就是更 DB + 删缓存吗」?但这两步的顺序、是否原子藏着一整章的故事,我们留到 12.6 节细讲。
12.1.4 Cache Aside 的优缺点
| 维度 | 评价 |
|---|---|
| 实现简单 | ✅ 业务自己控制,不依赖任何中间件 |
| 故障隔离 | ✅ Cache 挂了可以直接旁路,DB 还能扛 |
| 一致性 | ⚠️ 不是强一致,存在小概率脏读窗口(见 12.6) |
| 缓存穿透/击穿/雪崩 | ❌ Cache Aside 自身不解决,需要额外补丁(本章核心) |
| 写多读少场景 | ❌ 写完总要删缓存,缓存命中率上不去 |
🍱 生活类比:Cache 像「点菜前看的菜单图片」,DB 是「真正的厨房」。客人按图片点 → 厨房做。如果菜品换图片了,服务员要先去厨房改菜(DB)再把旧的菜单图揭掉(删缓存)。但任何一步都不是原子的,可能出现「菜单还是旧图,但厨房已经做新菜」的短暂尴尬——这就是缓存一致性问题的源头。
12.2 三大问题概览:一图看懂区别
┌────────────────────────────────────────────────────────────────────┐
│ │
│ ① 穿透 Penetration ② 击穿 Hotspot Invalid ③ 雪崩 Avalanche │
│ │
│ 查的数据「根本不存在」 单个「热点 Key 过期」 大量 Key「同时过期」 │
│ 缓存永远 miss 瞬间几万 QPS 打 DB 或 Redis 宕机 │
│ │
│ Client Client × 10000 Client × 大量 │
│ ↓ ↓ ↓ │
│ ┌─────┐ miss ↓ ┌─────┐ miss ↓ ┌─────┐ 全 miss │
│ │Cache│──────┐ │Cache│──────┐ │Cache│──────┐ │
│ └─────┘ ▼ └─────┘ ▼ └─────┘ ▼ │
│ ┌────┐ ┌────┐ ┌────┐ │
│ │ DB │ ← 全部打过来 │ DB │ ← 万级 QPS │ DB │ │
│ └────┘ └────┘ 砸死 └────┘ │
│ │
│ 解决:空值缓存/布隆 解决:互斥锁/不过期/ 解决:TTL 抖动/ │
│ /参数校验 多副本 多级缓存/熔断 │
│ │
└────────────────────────────────────────────────────────────────────┘助记口诀:
- 穿透:查的是不存在的东西(黑客探测、爬虫乱试 ID)
- 击穿:查的是存在但缓存刚失效的热点(热搜、爆款商品)
- 雪崩:缓存整体失效(批量过期 / Redis 挂了)
三者的本质都是「缓存挡不住请求 → 全打到 DB → DB 雪崩 → 服务瘫痪」。区别只是「为什么挡不住」。
12.3 缓存穿透(Cache Penetration)
12.3.1 现象与危害
请求查询的数据,DB 里就没有,所以缓存永远不会被填充。每次请求都打到 DB。
最典型的恶意场景:
攻击者写一个脚本:
GET /api/user?id=-1
GET /api/user?id=-2
GET /api/user?id=-3
... 每秒发 1 万次
服务端:
cache.get("user:-1") → miss
db.query("SELECT * FROM user WHERE id=-1") → null
返回 404,但缓存依旧没东西
→ 1 万 QPS 全部打到 DB,DB 直接趴下非恶意场景同样常见:爬虫扫站、错误的客户端逻辑、上线初期 ID 不连续。
12.3.2 方案 A:空值缓存(Null Caching)
DB 查不到时,把「这个 key 真的不存在」这个结论本身缓存起来:
python
def get_user(uid):
v = cache.get(f"user:{uid}")
if v is not None:
return None if v == "__NULL__" else json.loads(v) # 命中 NULL 标记
v = db.query("SELECT * FROM user WHERE id=?", uid)
if v is None:
cache.set(f"user:{uid}", "__NULL__", ex=60) # 短 TTL:60s
else:
cache.set(f"user:{uid}", json.dumps(v), ex=600)
return v ┌────────┐
GET user:-1 ─────▶ │ Redis │
│"__NULL__"│ ← 第二次请求直接命中
└────────┘优点:实现极简,立竿见影 缺点:
- 占用内存:恶意攻击者可以用海量不同 ID 把 Redis 内存撑爆
- 数据延迟:如果某 ID 后来真的在 DB 里被创建,要等空值过期才能看到(短 TTL 缓解)
适用场景:ID 空间小且可控(用户 ID、商品 ID 等业务 ID),偶发的查不到。
12.3.3 方案 B:布隆过滤器(Bloom Filter)
核心思想:用一个超紧凑的数据结构,提前告诉你「这个 key 一定不存在 / 可能存在」,把不存在的请求挡在 Redis 之前。
12.3.3.1 原理图
布隆过滤器 = 一个长度为 m 的位数组(全 0 初始化) + k 个独立 hash 函数
插入元素 "alice":
h1("alice") % m = 3 ┐
h2("alice") % m = 17 │ 把第 3、17、42 位置 1
h3("alice") % m = 42 ┘
位数组(m=64):
┌──┬──┬──┬──┬──┬──┬─┬─┬──┬─┬──┬──┬──┬──┬──┬──┬──┬─┐
│0 │0 │0 │1 │0 │0 │..│..│0 │1 │0 │..│..│..│..│..│1 │..│
└──┴──┴──┴──┴──┴──┴─┴─┴──┴─┴──┴──┴──┴──┴──┴──┴──┴─┘
↑ ↑ ↑
bit[3] bit[17] bit[42]
查询元素 "alice":
h1("alice") % m = 3 → bit[3] = 1 ✓
h2("alice") % m = 17 → bit[17] = 1 ✓
h3("alice") % m = 42 → bit[42] = 1 ✓
→ 三个位都为 1 → 「可能存在」
查询元素 "zoe":
h1("zoe") % m = 7 → bit[7] = 0 ✗
→ 任意一位为 0 → 「一定不存在」(直接返回,不查 DB)12.3.3.2 「可能 / 一定不」的语义
| 查询结果 | 含义 |
|---|---|
| 一定不存在 | 100% 不在集合里。可以放心地拒绝请求。 |
| 可能存在 | 大概率在集合里,但有小概率「假阳性」(恰好被别的 key 把位都占了) |
⚠️ 布隆过滤器没有假阴性(说不存在就一定不存在),但有假阳性(说存在不一定真存在)。 假阳性的代价:少量请求穿过到 DB,DB 查询发现没有,正常返回 null 即可——本来就要兜底。
12.3.3.3 误差率公式
设位数组长度 m,hash 函数数 k,已插入元素数 n。
[ P_{\text{假阳性}} \approx \left(1 - e^{-kn/m}\right)^k ]
最优 k 值(让误差率最小):
[ k_{\text{opt}} = \frac{m}{n} \ln 2 \approx 0.7 \cdot \frac{m}{n} ]
工程上的快速参考表:
| 期望误差率 | 每个元素需要的位数 m/n |
|---|---|
| 1% | 约 9.6 bit |
| 0.1% | 约 14.4 bit |
| 0.01% | 约 19.2 bit |
估算:1 亿个 ID + 1% 误差率 → 1 亿 × 9.6 / 8 ≈ 120 MB,比缓存全量 ID 节省几十倍。
12.3.3.4 用 Redis Bitmap 实现
python
import mmh3
class BloomFilter:
def __init__(self, redis, key, m=2**20, k=3):
self.r, self.key, self.m, self.k = redis, key, m, k
def _bits(self, item):
return [mmh3.hash(item, seed) % self.m for seed in range(self.k)]
def add(self, item):
for b in self._bits(item):
self.r.setbit(self.key, b, 1)
def contains(self, item):
return all(self.r.getbit(self.key, b) for b in self._bits(item))💡 Redis 4.0 起官方有 RedisBloom 模块,提供
BF.ADD/BF.EXISTS,自动管理参数。生产环境推荐直接用模块。
12.3.3.5 工程使用范式
启动时(或定期):
遍历 DB 把所有合法 ID → bf.add()
线上请求:
if not bf.contains(uid):
return 404 # 直接拦截,连 Redis 都不查
else:
走原本的 Cache Aside注意点:
- 布隆过滤器只能加,不能删(删一个位会影响其他元素)。
- 删除场景:用「计数布隆过滤器(Counting Bloom Filter)」每位存计数器;或周期性整体重建。
12.3.4 方案 C:参数校验
最便宜也最容易被忽视的一道防线:明显非法的请求,连缓存都不用过,直接拒绝。
python
def get_user(uid):
if uid is None or uid <= 0 or uid > MAX_USER_ID:
return None # 早 fail,省一次 Redis IO
if not isinstance(uid, int):
return None
...典型规则:
- ID 必须是正整数,不能是
-1/0/ 字符串 - ID 不能超过当前最大注册 ID
- 业务可枚举的字段(性别、状态码)必须在白名单里
12.3.5 三种方案选型对比
| 方案 | 内存代价 | 实现成本 | 拦截率 | 适合场景 |
|---|---|---|---|---|
| 空值缓存 | 中等 | ★ | 100%(命中后) | ID 空间小、攻击 ID 重复度高 |
| 布隆过滤器 | 低 | ★★★ | ~99% | 海量 ID,恶意攻击 ID 高度分散 |
| 参数校验 | 0 | ★ | 30~50% | 必备前置,配合其他方案使用 |
实际生产:三者通常一起上——参数校验拦掉明显非法的,布隆过滤器拦掉根本不存在的,空值缓存兜底剩下零碎请求。
12.4 缓存击穿(Hotspot Invalid)
12.4.1 现象与危害
某个热点 Key 在缓存中突然过期,瞬间几万 QPS 全部打到 DB。
经典案例:双 11 凌晨,「茅台 53 度 500ml」商品 Key 过期了 1 秒:
T = 0.000s cache.get("sku:1001") → "..." (10 万 QPS 都命中)
T = 1.000s sku:1001 过期
T = 1.001s cache.get("sku:1001") → miss
T = 1.001s 10 万个请求同时 miss
T = 1.001s 10 万个 db.query() 同时打到 MySQL
T = 1.002s MySQL CPU 100% → 拒绝连接 → 雪崩注意区别:
- 穿透:DB 也没有
- 击穿:DB 有,但单个热点 Key 过期那一瞬间集中打过去
12.4.2 方案 A:互斥锁(Mutex Lock)
用分布式锁让只有一个请求去查 DB 并回写缓存,其他请求等一会再读缓存即可。
T0 10000 个请求同时 cache.get("sku:1001") → miss
T0 10000 个请求都尝试 SETNX lock:sku:1001 → 只有 1 个成功
T0 那 1 个成功者去 DB 查询 + 回写缓存
T0 其他 9999 个:sleep 50ms → 重新 cache.get
T1 9999 个请求命中缓存(被那 1 个回写过了)→ 返回伪代码:
python
def get_sku(sku_id):
key = f"sku:{sku_id}"
v = cache.get(key)
if v is not None:
return json.loads(v)
lock = f"lock:sku:{sku_id}"
if cache.set(lock, "1", nx=True, ex=10): # 拿到锁
try:
v = db.query(...)
cache.set(key, json.dumps(v), ex=600)
return v
finally:
cache.delete(lock)
else: # 没拿到锁,等一会
time.sleep(0.05)
return get_sku(sku_id) # 递归重试优点:实现简单,几乎不增加内存 缺点:
- 等待的请求需要重试,延迟略增
- 锁的持有者如果宕机,要靠 TTL 兜底(详见第 11 章分布式锁)
12.4.3 方案 B:永不过期 + 异步更新
缓存里不设过期时间,但 value 内嵌「逻辑过期时间」。读到「逻辑已过期」时,由后台线程异步刷新,前台请求继续返回旧值。
缓存值:
{"data": "...", "logical_expire": 1713312345}
读流程:
v = cache.get(key)
if v.logical_expire > now:
return v.data # 还没逻辑过期,正常返回
else:
# 已逻辑过期:尝试拿一把锁
if SETNX lock_key:
spawn_async_thread(refresh) # 异步刷新
return v.data # 仍然返回旧数据T0 v = {"data": old, "logical_expire": T0}
T0 请求到来,发现已过期 → 触发异步刷新 → 立刻返回 old
T0 其他请求继续读到 old(不阻塞)
T1 异步线程查 DB → cache.set(key, {"data": new, "logical_expire": T0+10min})
T2 请求读到 new优点:读路径永远不阻塞,对突发流量友好 缺点:
- 实现稍复杂,要管理后台线程池
- 会读到旧值(短时间内),不适合强一致场景
12.4.4 方案 C:热点 Key 多副本
把单个热点 Key 拆成 N 个副本:
sku:1001:0~sku:1001:9,客户端按user_id % N选一个读。
原本:
10 万 QPS → 单一 Key sku:1001 → 单一 Redis 节点(CPU 打满)
拆分后:
10 万 QPS → 10 个 Key 平摊 → 10 个 Redis 节点(CPU 各 1/10)
客户端:
shard = user_id % 10
cache.get(f"sku:1001:{shard}")优点:把单 Key 的网络/CPU 瓶颈水平拆开 缺点:
- 写时要更新所有副本(写放大 N 倍)
- 仅适合「读极多 / 写极少」的明星热点
12.4.5 三种方案选型对比
| 方案 | 实现复杂度 | 一致性 | 适用场景 |
|---|---|---|---|
| 互斥锁 | ★★ | 较强 | 读多但能容忍少量请求等几十毫秒 |
| 永不过期+异步 | ★★★ | 弱 | 不能阻塞、能容忍秒级旧数据(首页推荐) |
| 多副本 | ★★ | 与原同 | 读极热、写极少(首页 Banner、明星 sku) |
12.5 缓存雪崩(Avalanche)
12.5.1 现象 1:大量 Key 同时过期
最常见的「自爆」原因——上线时一次性预热缓存,TTL 全设成 1 小时:
T0:00 批量 SET 100 万个商品缓存,每个 ex=3600
T1:00 100 万个 Key 同时过期 → 所有读请求 miss → DB 雪崩12.5.2 解决方案:TTL 加随机抖动
python
import random
base_ttl = 3600
jitter = random.randint(0, 300) # 抖动 0~5 分钟
cache.set(key, value, ex=base_ttl + jitter)固定 TTL: 抖动 TTL:
QPS QPS
│ │
│ █ │ ▁▂▃▃▂▁
│ █ │ ▁ ▁
│ █ │ ▁ ▁
│_______█____________→ time │_ _____ → time
T+TTL T+TTL ± jitter
全部过期,瞬时过载 平滑分散,DB 压力恒定关键参数:
base_ttl:业务能接受的最长缓存时间jitter范围:建议 5%~20% 的 base_ttl,太小没效果,太大命中率波动大
12.5.3 现象 2:Redis 整体宕机
更恶劣的场景:Redis 节点挂了,所有读请求都直接打 DB。
┌────────┐
请求 ─▶│ Redis │ ←── 宕机 / 网络分区 / 主从切换中
└────────┘
✗
┌────────┐
│ DB │ ←── 所有流量直接砸过来
└────────┘12.5.4 解决方案组合拳
A. 多级缓存
Client
↓
本地缓存(Caffeine / Guava) ← JVM 进程内,毫秒级
↓ miss
Redis 集群 ← 网络一跳
↓ miss
DB本地缓存用极短 TTL(10~60s),命中率不高也没关系,关键是 Redis 挂了时还能扛住一会。
B. 限流 + 降级
┌──────────┐
请求 ─▶│ 限流器 │ 比如令牌桶:每秒最多 1000 个 → DB
└──────────┘
│
通过的请求 ─▶ DB
超额的请求 ─▶ 返回兜底数据(默认值/上次缓存/空列表)典型工具:
- 单机限流:Guava RateLimiter
- 集群限流:Sentinel、Nginx limit_req
C. 熔断器(Circuit Breaker)
当下游(如 DB)错误率超过阈值时,直接短路返回兜底数据,不让请求继续打过去,给下游恢复时间。
状态机:
Closed(正常)─── 错误率 > 50% ───▶ Open(熔断)
▲ │
│ 探测请求成功 │ 30s 后
│ ▼
Half-Open(试探)◀───────────────────────主流实现:
- Hystrix(Netflix,已停止维护,但思想经典)
- Sentinel(阿里开源,国内主流)
- Resilience4j(Hystrix 的现代继任者)
D. Redis 高可用本身
参考第 9 章哨兵 / 第 10 章 Cluster,让 Redis 自身具备故障转移能力,从源头降低「整体宕机」概率。
12.5.5 雪崩防御组合矩阵
| 风险来源 | 主防御 | 兜底 |
|---|---|---|
| 同时过期 | TTL 抖动 | 永不过期 + 异步刷新 |
| Redis 宕机 | 哨兵 / Cluster 自动切换 | 本地缓存 + 限流降级 |
| DB 突发慢查询 | 熔断器 | 返回兜底数据 |
| 流量突增 | 限流 | 排队 / 队列削峰 |
12.6 缓存与数据库一致性
这是面试常青树。下面我们把所有主流方案、所有「先 X 后 Y」的组合一次讲透。
12.6.1 模式 1:Cache Aside(最常用)
写路径只有两步——「更新 DB」和「删(or 改)缓存」。
排列组合一共 4 种
| 顺序 | 是否常用 | 主要问题 |
|---|---|---|
| ① 先更 DB → 再更缓存 | ❌ | 并发写时缓存可能被旧值覆盖;浪费(写完未必有人读) |
| ② 先更缓存 → 再更 DB | ❌ | DB 更新失败时缓存已被改,永久脏数据 |
| ③ 先删缓存 → 再更 DB | ⚠️ | 并发读写时,读线程可能把旧值再写回缓存(详见 12.6.2) |
| ④ 先更 DB → 再删缓存 | ✅✅ | 业界主流。极小概率不一致,能用延迟双删进一步收敛 |
12.6.2 「先删缓存 → 再更 DB」为什么有问题
时间 写线程W 读线程R
T1 ① cache.delete(k)
T2 ① cache.get(k) → miss
T3 ② db.query(k) → 旧值 V_old
T4 ② db.update(k, V_new)
T5 ③ cache.set(k, V_old) ← ❌ 把旧值写回了
结果:DB = V_new Cache = V_old 永久不一致(直到 TTL 过期)12.6.3 「先更 DB → 再删缓存」依然可能不一致(极低概率)
时间 写线程W 读线程R
T1 ① cache.get(k) → miss(恰好刚过期)
T2 ② db.query(k) → V_old
T3 ① db.update(k, V_new)
T4 ② cache.delete(k)
T5 ③ cache.set(k, V_old) ← 写晚了一步
结果:DB = V_new Cache = V_old 不一致发生条件极苛刻:
- 读 miss 必须正好发生在写之前
- 读取 DB 必须比写 DB + 删缓存 还慢(一般 DB 读快于写)
- 缓存写回必须晚于写线程的删缓存
线上实测发生率 < 0.1%,对绝大多数业务可接受。
12.6.4 终极武器:延迟双删(Delayed Double Delete)
更新 DB → 立刻删缓存 → 延迟 N 毫秒 → 再删一次
T1 ① db.update(k, V_new)
T2 ② cache.delete(k) ← 第一次删
T3 (在这中间,可能有读线程把 V_old 写回缓存)
T2+Δ ③ cache.delete(k) ← 第二次删,把脏数据清掉伪代码:
python
def update_with_double_delete(k, v):
db.update(k, v)
cache.delete(k)
threading.Timer(0.5, lambda: cache.delete(k)).start() # 延迟 500msΔ 怎么定?
- 大于读线程「DB 查询 + 写回缓存」的总耗时,常见值 100ms ~ 1s
- 太短:脏数据可能还没写回就删了,第二次删无效
- 太长:用户感受到不一致的时间变长
⚠️ 注意:延迟双删是「概率优化」,不是「强一致保证」。第二次删之后,如果又有读线程持有旧数据准备写入,依然会脏。要 100% 一致,必须用 Read/Write Through 或基于 Binlog 的方案。
12.6.5 模式 2:Read/Write Through(缓存层负责读写 DB)
应用只跟缓存说话,缓存自己负责读 / 写穿透到 DB。
┌────────┐
│ App │
└───┬────┘
│ get/set
▼
┌────────┐
│ Cache │ ← 内置 Loader / Writer
└───┬────┘
│ DB IO
▼
┌────────┐
│ DB │
└────────┘- Read Through:缓存 miss 时,缓存自己去 DB 拉,然后返回 + 缓存。应用代码无感。
- Write Through:写请求先写缓存,缓存同步写 DB;只有两边都成功才返回。
优点:应用代码极简洁;强一致(同步写) 缺点:
- 需要支持 Loader/Writer 的缓存中间件(Redis 本身不具备)
- 写延迟 = 写缓存 + 写 DB(比 Cache Aside 高)
典型实现:
- 本地缓存:Caffeine 自带
LoadingCache - 分布式缓存:商业 Redis 企业版、应用层封装一层 Cache Service
12.6.6 模式 3:Write Behind(写回 / 异步刷盘)
写请求只写缓存,缓存定期或攒批量后异步刷到 DB。
App
│ set k v
▼
Cache ← 立刻返回
│
│ 异步队列(延迟几秒~几分钟)
▼
DB优点:写延迟极低(仅缓存);可合并多次写为 1 次(如计数器场景,每秒 1 次而不是每次都写) 缺点:
- 缓存挂了 = 丢数据
- DB 数据有秒~分钟级滞后,对账类业务不可用
典型场景:
- 文章浏览量(少量丢失可接受)
- 操作日志聚合
- MySQL 自身的 InnoDB Buffer Pool 就是 Write Behind 思想
12.6.7 基于 Binlog 的最终一致:Canal
由 DB 的 Binlog 作为唯一事实源,订阅 Binlog 变更后异步删/更新缓存。
App Canal Consumer
│ ① UPDATE │ │
▼ │ │
┌──────┐ │ │
│ MySQL│ │ │
└──────┘ ② Binlog 流 ▼ │
──────────────▶┌──────┐ ③ 解析消息 │
│Canal │──────────────▶│
└──────┘ │
│ ④ cache.delete(k)
▼
┌──────┐
│ Redis│
└──────┘优点:
- 应用代码完全不用关心缓存——只管写 DB,缓存自动失效
- 顺序性、可靠性都由 Binlog 保证
- 适合多个系统都依赖同一份缓存(解耦)
缺点:
- 引入 Canal/Maxwell/Debezium 等中间件,运维成本上升
- 链路变长:DB 写 → Binlog → Canal → MQ → 消费者 → Redis,端到端有秒级延迟
- 异常场景(消费失败、消息积压)需要重试和告警
业界谁在用:
- 阿里:Canal(Java 实现,原生支持 MySQL)
- LinkedIn:Databus
- Debezium(基于 Kafka Connect 的 CDC 平台)
12.6.8 一张图看懂四种方案
┌────────────────┬──────────┬──────────┬──────────┬─────────────┐
│ │ Cache │ Read/ │ Write │ Binlog │
│ │ Aside │ Write │ Behind │ 订阅 │
│ │ │ Through │ │ │
├────────────────┼──────────┼──────────┼──────────┼─────────────┤
│ 应用复杂度 │ 中 │ 低 │ 低 │ 极低 │
│ 写延迟 │ DB+删 │ DB+缓存 │ 仅缓存 │ DB │
│ 一致性强度 │ 最终一致 │ 强一致 │ 最终一致 │ 最终一致 │
│ 一致性窗口 │ 毫秒~秒 │ 0 │ 秒~分钟 │ 秒级 │
│ 数据丢失风险 │ 无 │ 无 │ 缓存挂会丢│ 无 │
│ 中间件依赖 │ 无 │ 缓存中间件│ 缓存中间件│ Canal/MQ │
│ 业界占比 │ ~80% │ ~5% │ ~5% │ ~10% │
└────────────────┴──────────┴──────────┴──────────┴─────────────┘12.7 真实业务的「不一致」如何理解
12.7.1 强一致 vs 最终一致
强一致(Strong Consistency):
任意时刻、任意客户端读,看到的都是「最新写入」
代价:通常需要分布式事务、共识协议(Paxos/Raft),性能差
场景:银行账户余额、库存扣减(强业务一致)
最终一致(Eventual Consistency):
写入后经过一段(短)时间,所有读都能看到最新值
代价:那段时间内可能读到旧值
场景:评论数、点赞数、商品详情、用户头像12.7.2 业务能接受多久的不一致?
| 业务类型 | 可接受不一致时长 | 选型建议 |
|---|---|---|
| 商品详情、用户资料 | 几秒~几分钟 | Cache Aside + 延迟双删 |
| 评论数、点赞数、阅读数 | 几分钟 | Write Behind + 定时同步 |
| 推荐流、Feed 流 | 几分钟~几十分钟 | 永不过期 + 异步刷新 |
| 价格、库存 | 0(强一致) | 不走缓存,直接 DB + 行锁 |
| 配置中心 | 秒级 | Binlog 订阅 / 长轮询 |
💡 一致性是业务问题,不是技术问题。先和产品 / 业务方明确「能接受几秒不一致」,再选技术方案。
12.7.3 如何监控一致性
工程上常做:
- 缓存与 DB 抽样对账:每分钟随机抽 N 个 key,比 cache 与 db 是否一致,不一致计数。
- TTL 兜底:所有缓存 key 必须有 TTL,最坏情况下过期重建。
- 删失败告警:删缓存的关键操作要重试 + MQ 兜底,最终失败要告警。
12.8 实操:跑一遍配套代码 & 演示页面
实战代码见 12_cache_problems/code/:
01_bloom_filter.py:手写一个布隆过滤器(Redis Bitmap + 多 hash),实测假阳性率02_mutex_lock.py:用分布式锁防击穿,模拟 100 个并发请求只有 1 个真正查 DB03_random_ttl.py:对比固定 TTL 和抖动 TTL 的过期分布,演示雪崩 vs 平滑04_double_delete.py:演示「先更新 DB → 立刻删缓存 → 延迟再删」的延迟双删模式
浏览器演示见 12_cache_problems/demo.html:
- ① 三大问题对比动画(穿透/击穿/雪崩三栏并列,可切换「无防护 / 有防护」)
- ② 布隆过滤器交互(128 位位数组 + 可调 hash 函数数 + 假阳性可视化)
- ③ 「先更新 DB 还是先删缓存」并发动画(4 种顺序对比)
- ④ 缓存一致性方案对比(Cache Aside / Read/Write Through / Write Behind / Binlog)
12.9 本章小结
┌────────────────────────────────────────────────────────────┐
│ 本章核心要点 │
├────────────────────────────────────────────────────────────┤
│ │
│ ① Cache Aside 是 90% 业务的标准模式:读 miss 回写、写删缓存 │
│ │
│ ② 三大问题口诀: │
│ 穿透 = 数据根本不存在 │
│ 击穿 = 单热点 Key 过期瞬间 │
│ 雪崩 = 大量 Key 同时过期 / Redis 宕机 │
│ │
│ ③ 穿透防御:参数校验 + 布隆过滤器 + 空值缓存(三件套) │
│ │
│ ④ 击穿防御:互斥锁 / 永不过期+异步 / 多副本 │
│ │
│ ⑤ 雪崩防御:TTL 抖动 + 多级缓存 + 限流熔断 │
│ │
│ ⑥ 一致性顺序:先更 DB 再删缓存 > 其他三种组合 │
│ │
│ ⑦ 延迟双删:更新 DB → 删缓存 → sleep → 再删一次 │
│ │
│ ⑧ 业界主流:80% Cache Aside + 10% Binlog 订阅 + 其他 │
│ │
│ ⑨ 一致性是业务问题,先定 SLA 再选技术方案 │
│ │
└────────────────────────────────────────────────────────────┘12.10 面试高频题
Q1:缓存穿透 / 击穿 / 雪崩的区别和解决方案?
考察点:对三大问题的定义和解法是否清晰。
标准答案:
| 问题 | 含义 | 主要解法 |
|---|---|---|
| 穿透 | 查的数据 DB 也没有 | 参数校验 / 空值缓存 / 布隆过滤器 |
| 击穿 | 单个热点 Key 过期瞬间被打穿 | 互斥锁 / 永不过期+异步 / 热点 Key 多副本 |
| 雪崩 | 大量 Key 同时过期或 Redis 宕机 | TTL 抖动 / 多级缓存 / 限流熔断 / 高可用 |
加分项:
- 三者本质都是「请求穿透 Cache → 砸 DB」,区别在于「为什么穿透」。
- 实战中通常多种方案叠加使用,不是二选一。
- 提到 Redis Cluster + 哨兵 + 限流降级是雪崩的「最后一道防线」。
Q2:布隆过滤器的原理?误差从哪来?
考察点:对概率数据结构的理解。
标准答案:
原理:
- 一个长度 m 的位数组(初始全 0),k 个独立 hash 函数。
- 插入 x:把 hash1(x)%m, hash2(x)%m, ..., hashk(x)%m 这 k 个位都置 1。
- 查询 x:检查这 k 个位,全为 1 → 「可能存在」;任一为 0 → 「一定不存在」。
误差来源(假阳性):
- 不同元素的 hash 结果可能落到相同位上,所以「k 个位都为 1」可能是其他元素留下的痕迹,不一定 x 真插入过。
- 假阳性概率公式:(P \approx (1 - e^{-kn/m})^k)
- 没有假阴性(说不在就一定不在)。
加分项:
- 提到误差率 1% 时大约每个元素 9.6 bit,1 亿 ID 仅需 ~120 MB。
- 缺点:不能删除(删一个位会影响多个元素),需要 Counting Bloom Filter 或定期重建。
- Redis 4.0 起官方有 RedisBloom 模块。
Q3:「先更新数据库还是先删缓存」?为什么?
考察点:对并发一致性的细致理解。
标准答案:
主流答案:先更新 DB,再删缓存(Cache Aside 标准实践)。
对比四种顺序:
| 顺序 | 问题 |
|---|---|
| 先更缓存→再更 DB | DB 写失败时缓存已脏,且并发写易乱序 |
| 先更 DB→再更缓存 | 浪费(写完没人读);并发写时缓存可能被旧值覆盖 |
| 先删缓存→再更 DB | 并发读写时读线程可能把旧值再写回缓存(脏数据时间长) |
| 先更 DB→再删缓存 | 不一致窗口最短,依然有极小概率(< 0.1%)不一致 |
加分项:
- 解释「先删缓存 → 再更 DB」的具体时序问题(读 miss → 读旧 DB → 写回缓存)。
- 即使「先更 DB → 再删缓存」也非完全一致,可用延迟双删或 Binlog 订阅进一步收敛。
- 强一致需求请走 Read/Write Through 或干脆不用缓存。
Q4:延迟双删是什么?解决了什么问题?
考察点:对一致性补丁机制的理解。
标准答案:
步骤:
- 更新数据库
- 立即删除缓存
- sleep N 毫秒(典型 100ms ~ 1s)
- 再删一次缓存
解决的问题:「先更 DB → 再删缓存」依然有一个极小窗口——某个读线程在更新前已经查到旧 DB 值,更新后才把旧值写回缓存。第一次删时它还没写回;第二次删时把脏数据清掉。
Δ 怎么定:
- 略大于「读线程查 DB + 写回缓存」的总耗时
- 太短:脏数据还没写回就删了,第二次删无效
- 太长:用户感受到的不一致时间变长
加分项:
- 这只是概率优化,不是强一致保证。极端并发下仍可能脏。
- 第二次删可以用消息队列异步执行,避免阻塞主线程。
- 真正强一致请用 Binlog 订阅或 Read/Write Through。
Q5:Cache Aside 模式下如何保证强一致?
考察点:对「一致性 vs 性能」trade-off 的理解。
标准答案:
严格说,Cache Aside 不能保证强一致——因为「更 DB」和「删缓存」不是原子操作,必然有窗口。
可以通过以下手段逼近强一致:
- 加分布式锁:写时锁住整个 key,读写互斥(性能损失大)。
- 延迟双删:把窗口收窄到几百毫秒。
- 同步删缓存重试 + MQ 兜底:删失败必须告警和补偿。
- 设置短 TTL:最坏情况靠过期兜底。
真正要强一致,就别用 Cache Aside:
- 用 Read/Write Through:写请求由缓存中间件同步刷 DB。
- 用 基于 Binlog 的 CDC:DB 是唯一事实源,缓存只是异步同步出来的镜像。
- 干脆不用缓存:金融账户、库存扣减直接走 DB 行锁。
加分项:
- 提到 CAP 理论:CP(一致性)和 AP(可用性)只能选一个,缓存场景通常选 AP + 最终一致。
- 业务能接受秒级不一致,就不要追求强一致——成本不成比例。
Q6:用 Binlog 同步缓存有什么优劣?
考察点:对 CDC 架构的理解。
标准答案:
架构:DB 写入 → 产生 Binlog → Canal/Debezium 订阅 → MQ → 消费者删/更新 Redis。
优点:
- 应用解耦:业务代码只管写 DB,不操心缓存。
- 多消费者复用:搜索引擎、数仓、缓存可以共享同一份 Binlog。
- 顺序保证:Binlog 自带顺序,避免并发乱序。
- 容错可靠:MQ 重试 + 消费位点持久化,即使消费者宕机重启也不会丢消息。
缺点:
- 链路长:DB → Binlog → Canal → MQ → 消费者 → Redis,端到端秒级延迟,不能做毫秒级一致。
- 运维复杂:要部署 Canal/Debezium、MQ、消费者三套东西。
- 依赖 DB 类型:MySQL 有 Binlog,PostgreSQL 用 logical replication,NoSQL 通常没现成方案。
- 大事务问题:一个事务改了上千行,会突发产生上千条消息。
加分项:
- 提到主流工具:阿里 Canal(Java),开源 Debezium(Kafka Connect 生态),LinkedIn Databus。
- 提到适用场景:多系统共享缓存 / 跨业务线复用 DB 数据 / 业务方拒绝改代码。
- 反例:实时性敏感(秒杀、库存)和单一业务线通常不上 Binlog。
📌 下一章预告:第 13 章我们看 Redis 的「性能优化、监控与排障」——慢查询日志、
SLOWLOG、MONITOR、INFO关键指标怎么读、大 Key / 热 Key 怎么发现、内存碎片怎么治理、生产事故复盘清单。
🎬 可视化演示
演示加载缓慢或样式异常?点此在新标签页打开 ↗
💻 示例代码
python
"""
Ch12 配套代码 1 / 4 —— 布隆过滤器(Bloom Filter)
演示:
1. 用 Redis 的 Bitmap(SETBIT/GETBIT)作为底层位数组
2. 多个 hash 函数(mmh3,不同 seed 当作 k 个独立 hash)
3. 实测「假阳性率」与理论值对比
依赖:
pip install redis mmh3
"""
import math
import random
import string
import redis
try:
import mmh3
except ImportError:
print("请先安装:pip install mmh3")
raise
r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)
class BloomFilter:
"""基于 Redis Bitmap 的简易布隆过滤器。
参数:
redis_client: redis.Redis 实例
key: 位数组在 Redis 中的 key 名
m: 位数组长度(bit 数)
k: hash 函数数量
"""
def __init__(self, redis_client, key: str, m: int, k: int):
self.r = redis_client
self.key = key
self.m = m
self.k = k
def _bit_positions(self, item: str):
return [mmh3.hash(item, seed) % self.m for seed in range(self.k)]
def add(self, item: str) -> None:
pipe = self.r.pipeline()
for pos in self._bit_positions(item):
pipe.setbit(self.key, pos, 1)
pipe.execute()
def contains(self, item: str) -> bool:
pipe = self.r.pipeline()
for pos in self._bit_positions(item):
pipe.getbit(self.key, pos)
return all(pipe.execute())
def reset(self) -> None:
self.r.delete(self.key)
def section(title: str) -> None:
print("\n" + "=" * 60)
print(title)
print("=" * 60)
def random_str(length: int = 10) -> str:
return "".join(random.choices(string.ascii_lowercase + string.digits, k=length))
def demo_basic_usage() -> None:
section("Demo 1: 基本插入与查询")
bf = BloomFilter(r, "demo:bf:basic", m=2 ** 14, k=4)
bf.reset()
members = ["alice", "bob", "charlie", "david"]
for m in members:
bf.add(m)
for m in members:
print(f" contains({m!r:10}) = {bf.contains(m)} (插入过,应为 True)")
for m in ["zoe", "frank", "ivan"]:
print(f" contains({m!r:10}) = {bf.contains(m)} (未插入,应基本为 False)")
bf.reset()
def demo_false_positive_rate(n: int = 5000, m: int = 2 ** 16, k: int = 4) -> None:
section(f"Demo 2: 假阳性率实测 n={n} m={m} k={k}")
bf = BloomFilter(r, "demo:bf:fpr", m=m, k=k)
bf.reset()
inserted = {f"user:{i}" for i in range(n)}
for item in inserted:
bf.add(item)
trials = 5000
false_positive = 0
for _ in range(trials):
candidate = "x:" + random_str(8)
if candidate in inserted:
continue
if bf.contains(candidate):
false_positive += 1
actual = false_positive / trials
theory = (1 - math.exp(-k * n / m)) ** k
print(f" 实测假阳性率: {actual:.4%} ({false_positive}/{trials})")
print(f" 理论假阳性率: {theory:.4%}")
print(f" 最优 k 值 : {round(m / n * math.log(2))}(当前 k={k})")
bf.reset()
def demo_anti_penetration() -> None:
section("Demo 3: 用布隆过滤器防穿透")
valid_ids = list(range(1, 1001))
bf = BloomFilter(r, "demo:bf:user", m=2 ** 14, k=3)
bf.reset()
for uid in valid_ids:
bf.add(f"user:{uid}")
queries = [1, 50, 999, -1, 99999, 0, 1000000]
db_hit = 0
blocked = 0
for q in queries:
if not bf.contains(f"user:{q}"):
blocked += 1
print(f" 查询 user:{q:>7} → 布隆判定「一定不存在」,直接拒绝 ✗")
else:
db_hit += 1
print(f" 查询 user:{q:>7} → 布隆判定「可能存在」,下沉到 DB ✓")
print(f"\n 总计 {len(queries)} 次查询:拦截 {blocked} 次,真正下沉 {db_hit} 次")
print(" 💡 没有布隆的话这 7 次查询都要打到 DB(其中 4 次是穿透)")
bf.reset()
if __name__ == "__main__":
try:
demo_basic_usage()
demo_false_positive_rate()
demo_anti_penetration()
except redis.ConnectionError as e:
print(f"Redis 连接失败: {e}")python
"""
Ch12 配套代码 2 / 4 —— 互斥锁防缓存击穿
场景:
100 个并发请求同时查 sku:1001,缓存 miss。
- 无防护:100 个请求全打到 DB
- 加互斥锁:只有 1 个查 DB 并回写,其他 99 个等回写后命中缓存
依赖:
pip install redis
"""
import time
import threading
import uuid
import redis
r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)
db_query_count = 0
db_lock = threading.Lock()
def fake_db_query(sku_id: int) -> str:
"""模拟一次较慢的 DB 查询。"""
global db_query_count
with db_lock:
db_query_count += 1
time.sleep(0.2)
return f"sku-{sku_id}-data"
def section(title: str) -> None:
print("\n" + "=" * 60)
print(title)
print("=" * 60)
def reset() -> None:
global db_query_count
db_query_count = 0
r.delete("sku:1001", "lock:sku:1001")
def get_sku_no_protection(sku_id: int) -> str:
"""无任何保护的 Cache Aside —— 击穿场景的反例。"""
key = f"sku:{sku_id}"
v = r.get(key)
if v is not None:
return v
v = fake_db_query(sku_id)
r.set(key, v, ex=600)
return v
def get_sku_with_mutex(sku_id: int, retry_interval: float = 0.05) -> str:
"""互斥锁版本:拿到锁的查 DB,没拿到的等一会重试。"""
key = f"sku:{sku_id}"
v = r.get(key)
if v is not None:
return v
lock_key = f"lock:sku:{sku_id}"
lock_val = uuid.uuid4().hex
while True:
if r.set(lock_key, lock_val, nx=True, ex=10):
try:
v = r.get(key)
if v is not None:
return v
v = fake_db_query(sku_id)
r.set(key, v, ex=600)
return v
finally:
cur = r.get(lock_key)
if cur == lock_val:
r.delete(lock_key)
else:
time.sleep(retry_interval)
v = r.get(key)
if v is not None:
return v
def run_concurrent(getter, n_threads: int) -> float:
threads = []
start = time.time()
for _ in range(n_threads):
t = threading.Thread(target=getter, args=(1001,))
threads.append(t)
t.start()
for t in threads:
t.join()
return time.time() - start
def demo() -> None:
n_threads = 100
section(f"Demo: {n_threads} 并发线程同时查询冷缓存的 sku:1001")
reset()
elapsed_no = run_concurrent(get_sku_no_protection, n_threads)
db_no = db_query_count
print(f" ❌ 无防护 : DB 查询次数 = {db_no:3} 耗时 = {elapsed_no:.2f}s")
print(f" → {n_threads} 个请求全打到 DB(击穿现象)")
reset()
elapsed_lock = run_concurrent(get_sku_with_mutex, n_threads)
db_lock_n = db_query_count
print(f" ✅ 互斥锁防护 : DB 查询次数 = {db_lock_n:3} 耗时 = {elapsed_lock:.2f}s")
print(f" → 只有 {db_lock_n} 个请求真正查 DB,其他都被锁挡住后命中缓存")
print("\n 说明:")
print(" - 无防护场景下 DB 被打 100 次(实际生产可能是 10 万次 → DB 雪崩)")
print(" - 互斥锁后 DB 仅被打 1 次,性能提升数十倍")
print(" - 代价:拿不到锁的线程多了 retry_interval × N 的延迟")
r.delete("sku:1001", "lock:sku:1001")
if __name__ == "__main__":
try:
demo()
except redis.ConnectionError as e:
print(f"Redis 连接失败: {e}")python
"""
Ch12 配套代码 3 / 4 —— TTL 随机抖动防雪崩
场景:
10000 个商品缓存同时被预热。
- 固定 TTL:3600s 后所有 key 同一秒过期 → 雪崩
- 抖动 TTL:3600s + rand(0, 600) → 过期时间在 3600~4200s 内均匀分布 → 平滑
本脚本通过统计「每秒过期的 key 数」绘制出对比直方图(ASCII)。
依赖:
pip install redis
"""
import random
import collections
import redis
r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)
def section(title: str) -> None:
print("\n" + "=" * 60)
print(title)
print("=" * 60)
def histogram(buckets: collections.Counter, label: str, max_width: int = 50) -> None:
print(f"\n [{label}] X 轴: 过期时间偏移(秒) Y 轴: 同秒过期 key 数")
if not buckets:
print(" (无数据)")
return
max_count = max(buckets.values())
sorted_keys = sorted(buckets.keys())
for k in sorted_keys:
count = buckets[k]
bar_len = int(count / max_count * max_width)
bar = "█" * bar_len if bar_len else "▏"
print(f" {k:>5}s | {bar} {count}")
def demo_fixed_ttl(n: int = 10000, base_ttl: int = 3600) -> None:
section(f"Demo 1: 固定 TTL = {base_ttl}s ({n} 个 key)")
pipe = r.pipeline()
for i in range(n):
pipe.set(f"demo:fixed:{i}", "x", ex=base_ttl)
pipe.execute()
pipe = r.pipeline()
for i in range(n):
pipe.ttl(f"demo:fixed:{i}")
ttls = pipe.execute()
buckets: collections.Counter = collections.Counter()
for t in ttls:
if t > 0:
buckets[t // 60 * 60] += 1
histogram(buckets, "固定 TTL(X 轴单位:分钟刻度)")
print(f"\n 💥 全部集中在 {base_ttl}s 这一刻过期 → 单秒过期峰值 = {max(buckets.values())}")
pipe = r.pipeline()
for i in range(n):
pipe.delete(f"demo:fixed:{i}")
pipe.execute()
def demo_random_ttl(n: int = 10000, base_ttl: int = 3600, jitter: int = 600) -> None:
section(f"Demo 2: 抖动 TTL = {base_ttl} + rand(0, {jitter}) ({n} 个 key)")
pipe = r.pipeline()
for i in range(n):
pipe.set(f"demo:rand:{i}", "x", ex=base_ttl + random.randint(0, jitter))
pipe.execute()
pipe = r.pipeline()
for i in range(n):
pipe.ttl(f"demo:rand:{i}")
ttls = pipe.execute()
buckets: collections.Counter = collections.Counter()
for t in ttls:
if t > 0:
buckets[t // 60 * 60] += 1
histogram(buckets, "抖动 TTL(X 轴单位:分钟刻度)")
peak = max(buckets.values())
avg = sum(buckets.values()) / len(buckets)
print(f"\n ✅ 过期时间均匀分散 → 峰值 = {peak},均值 = {avg:.1f}")
print(f" ✅ 相比固定 TTL,DB 压力降为 ~ {peak / n * 100:.2f}%")
pipe = r.pipeline()
for i in range(n):
pipe.delete(f"demo:rand:{i}")
pipe.execute()
if __name__ == "__main__":
try:
demo_fixed_ttl()
demo_random_ttl()
print("\n" + "=" * 60)
print("结论:")
print(" - 固定 TTL 在某一秒会有峰值过期,导致下一秒大量请求 miss → 打 DB")
print(" - 抖动 TTL 把过期时间打散到一个区间,DB 压力恒定且可控")
print(" - 经验值:jitter = base_ttl 的 5%~20%")
print("=" * 60)
except redis.ConnectionError as e:
print(f"Redis 连接失败: {e}")python
"""
Ch12 配套代码 4 / 4 —— 延迟双删(Delayed Double Delete)
场景:
写线程更新 DB 时,恰好有读线程已经查到了 DB 旧值正准备写回缓存。
- 单删(先更 DB → 删缓存):读线程把旧值写回,缓存脏
- 双删(更 DB → 删 → sleep → 再删):第二次删兜底,把脏值清掉
本脚本通过线程编排「精准复现」单删失效的场景,再演示双删如何修复。
依赖:
pip install redis
"""
import time
import threading
import redis
r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)
_db = {"user:1": "v_old"}
_db_lock = threading.Lock()
def db_query(key: str) -> str:
with _db_lock:
return _db[key]
def db_update(key: str, value: str) -> None:
with _db_lock:
_db[key] = value
def section(title: str) -> None:
print("\n" + "=" * 60)
print(title)
print("=" * 60)
def reset() -> None:
_db["user:1"] = "v_old"
r.delete("user:1")
def slow_reader(delay_before_writeback: float, label: str) -> None:
"""模拟慢读线程:cache miss → 读 DB → sleep → 写回缓存。"""
if r.get("user:1") is None:
v = db_query("user:1")
time.sleep(delay_before_writeback)
r.set("user:1", v, ex=600)
print(f" [{label}] 读线程把 DB 值 {v!r} 写回了缓存")
def writer_single_delete() -> None:
db_update("user:1", "v_new")
r.delete("user:1")
print(" [单删] 写线程:更新 DB 为 v_new + 删缓存")
def writer_double_delete(delay: float = 0.3) -> None:
db_update("user:1", "v_new")
r.delete("user:1")
print(" [双删] 写线程:更新 DB 为 v_new + 第一次删缓存")
time.sleep(delay)
r.delete("user:1")
print(f" [双删] 写线程:sleep {delay}s 后第二次删缓存(清理可能的脏数据)")
def demo_single_delete_fail() -> None:
section("Demo 1: 仅「先更 DB → 再删缓存」—— 复现脏数据场景")
reset()
reader_thread = threading.Thread(target=slow_reader, args=(0.2, "单删"))
reader_thread.start()
time.sleep(0.05)
writer_single_delete()
reader_thread.join()
cached = r.get("user:1")
in_db = db_query("user:1")
print("\n 最终状态:")
print(f" DB = {in_db!r}")
print(f" Cache = {cached!r}")
print(f" → {'❌ 不一致' if cached != in_db else '✅ 一致'}(缓存被读线程写回了 v_old)")
def demo_double_delete_fix() -> None:
section("Demo 2: 延迟双删 —— 修复同样的并发场景")
reset()
reader_thread = threading.Thread(target=slow_reader, args=(0.2, "双删"))
reader_thread.start()
time.sleep(0.05)
writer_thread = threading.Thread(target=writer_double_delete, args=(0.3,))
writer_thread.start()
reader_thread.join()
writer_thread.join()
cached = r.get("user:1")
in_db = db_query("user:1")
print("\n 最终状态:")
print(f" DB = {in_db!r}")
print(f" Cache = {cached!r}")
if cached is None:
print(" → ✅ 一致(缓存被双删清理为空,下次读会重新从 DB 加载 v_new)")
elif cached == in_db:
print(" → ✅ 一致")
else:
print(" → ❌ 不一致(说明 sleep 间隔还不够,需调大 delay)")
def demo_async_double_delete() -> None:
section("Demo 3: 工程实战写法 —— 第二次删走异步")
reset()
def update_async_double_delete(key, value, delay=0.3):
db_update(key, value)
r.delete(key)
threading.Timer(delay, lambda: r.delete(key)).start()
reader_thread = threading.Thread(target=slow_reader, args=(0.15, "异步双删"))
reader_thread.start()
time.sleep(0.05)
update_async_double_delete("user:1", "v_new", delay=0.3)
print(" [异步双删] 主线程:更 DB + 删缓存后立即返回(第二次删 0.3s 后异步执行)")
reader_thread.join()
time.sleep(0.4)
cached = r.get("user:1")
in_db = db_query("user:1")
print("\n 0.4s 后状态:")
print(f" DB = {in_db!r}")
print(f" Cache = {cached!r}")
if cached is None or cached == in_db:
print(" → ✅ 一致")
else:
print(" → ❌ 不一致")
if __name__ == "__main__":
try:
demo_single_delete_fail()
demo_double_delete_fix()
demo_async_double_delete()
print("\n" + "=" * 60)
print("总结:")
print(" - 延迟双删的「延迟」要大于读线程「DB 查询 + 写回缓存」的总耗时")
print(" - 工程上第二次删通常走异步线程或 MQ,避免阻塞主流程")
print(" - 这是「概率优化」,不是强一致保证。要 100% 一致请用 Binlog 订阅")
print("=" * 60)
r.delete("user:1")
except redis.ConnectionError as e:
print(f"Redis 连接失败: {e}")01_bloom_filter.py ↗ · 02_mutex_lock.py ↗ · 03_random_ttl.py ↗ · 04_double_delete.py ↗