Skip to content

第 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 v

12.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__"│ ← 第二次请求直接命中
                         └────────┘

优点:实现极简,立竿见影 缺点

  1. 占用内存:恶意攻击者可以用海量不同 ID 把 Redis 内存撑爆
  2. 数据延迟:如果某 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 高度分散
参数校验030~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 → 再更缓存并发写时缓存可能被旧值覆盖;浪费(写完未必有人读)
② 先更缓存 → 再更 DBDB 更新失败时缓存已被改,永久脏数据
③ 先删缓存 → 再更 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   不一致

发生条件极苛刻:

  1. 读 miss 必须正好发生在写之前
  2. 读取 DB 必须比写 DB + 删缓存 还慢(一般 DB 读快于写)
  3. 缓存写回必须晚于写线程的删缓存

线上实测发生率 < 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 如何监控一致性

工程上常做:

  1. 缓存与 DB 抽样对账:每分钟随机抽 N 个 key,比 cache 与 db 是否一致,不一致计数。
  2. TTL 兜底:所有缓存 key 必须有 TTL,最坏情况下过期重建。
  3. 删失败告警:删缓存的关键操作要重试 + MQ 兜底,最终失败要告警。

12.8 实操:跑一遍配套代码 & 演示页面

实战代码见 12_cache_problems/code/

  • 01_bloom_filter.py:手写一个布隆过滤器(Redis Bitmap + 多 hash),实测假阳性率
  • 02_mutex_lock.py:用分布式锁防击穿,模拟 100 个并发请求只有 1 个真正查 DB
  • 03_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 标准实践)。

对比四种顺序

顺序问题
先更缓存→再更 DBDB 写失败时缓存已脏,且并发写易乱序
先更 DB→再更缓存浪费(写完没人读);并发写时缓存可能被旧值覆盖
先删缓存→再更 DB并发读写时读线程可能把旧值再写回缓存(脏数据时间长)
先更 DB→再删缓存不一致窗口最短,依然有极小概率(< 0.1%)不一致

加分项

  • 解释「先删缓存 → 再更 DB」的具体时序问题(读 miss → 读旧 DB → 写回缓存)。
  • 即使「先更 DB → 再删缓存」也非完全一致,可用延迟双删Binlog 订阅进一步收敛。
  • 强一致需求请走 Read/Write Through 或干脆不用缓存。

Q4:延迟双删是什么?解决了什么问题?

考察点:对一致性补丁机制的理解。

标准答案

步骤

  1. 更新数据库
  2. 立即删除缓存
  3. sleep N 毫秒(典型 100ms ~ 1s)
  4. 再删一次缓存

解决的问题:「先更 DB → 再删缓存」依然有一个极小窗口——某个读线程在更新前已经查到旧 DB 值,更新后才把旧值写回缓存。第一次删时它还没写回;第二次删时把脏数据清掉。

Δ 怎么定

  • 略大于「读线程查 DB + 写回缓存」的总耗时
  • 太短:脏数据还没写回就删了,第二次删无效
  • 太长:用户感受到的不一致时间变长

加分项

  • 这只是概率优化,不是强一致保证。极端并发下仍可能脏。
  • 第二次删可以用消息队列异步执行,避免阻塞主线程。
  • 真正强一致请用 Binlog 订阅或 Read/Write Through。

Q5:Cache Aside 模式下如何保证强一致?

考察点:对「一致性 vs 性能」trade-off 的理解。

标准答案

严格说,Cache Aside 不能保证强一致——因为「更 DB」和「删缓存」不是原子操作,必然有窗口。

可以通过以下手段逼近强一致:

  1. 加分布式锁:写时锁住整个 key,读写互斥(性能损失大)。
  2. 延迟双删:把窗口收窄到几百毫秒。
  3. 同步删缓存重试 + MQ 兜底:删失败必须告警和补偿。
  4. 设置短 TTL:最坏情况靠过期兜底。

真正要强一致,就别用 Cache Aside

  • Read/Write Through:写请求由缓存中间件同步刷 DB。
  • 基于 Binlog 的 CDC:DB 是唯一事实源,缓存只是异步同步出来的镜像。
  • 干脆不用缓存:金融账户、库存扣减直接走 DB 行锁。

加分项

  • 提到 CAP 理论:CP(一致性)和 AP(可用性)只能选一个,缓存场景通常选 AP + 最终一致。
  • 业务能接受秒级不一致,就不要追求强一致——成本不成比例。

Q6:用 Binlog 同步缓存有什么优劣?

考察点:对 CDC 架构的理解。

标准答案

架构:DB 写入 → 产生 Binlog → Canal/Debezium 订阅 → MQ → 消费者删/更新 Redis。

优点

  1. 应用解耦:业务代码只管写 DB,不操心缓存。
  2. 多消费者复用:搜索引擎、数仓、缓存可以共享同一份 Binlog。
  3. 顺序保证:Binlog 自带顺序,避免并发乱序。
  4. 容错可靠:MQ 重试 + 消费位点持久化,即使消费者宕机重启也不会丢消息。

缺点

  1. 链路长:DB → Binlog → Canal → MQ → 消费者 → Redis,端到端秒级延迟,不能做毫秒级一致。
  2. 运维复杂:要部署 Canal/Debezium、MQ、消费者三套东西。
  3. 依赖 DB 类型:MySQL 有 Binlog,PostgreSQL 用 logical replication,NoSQL 通常没现成方案。
  4. 大事务问题:一个事务改了上千行,会突发产生上千条消息。

加分项

  • 提到主流工具:阿里 Canal(Java),开源 Debezium(Kafka Connect 生态),LinkedIn Databus
  • 提到适用场景:多系统共享缓存 / 跨业务线复用 DB 数据 / 业务方拒绝改代码
  • 反例:实时性敏感(秒杀、库存)和单一业务线通常不上 Binlog。

📌 下一章预告:第 13 章我们看 Redis 的「性能优化、监控与排障」——慢查询日志、SLOWLOGMONITORINFO 关键指标怎么读、大 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 ↗