41.4 缓存一致性策略

一句话

缓存一致性不是“选一个算法”,而是把并发窗口算出来,再用顺序、延迟或版本号把它盖住——窗口 = 写库耗时 + 读回填耗时,窗口内有多少读请求,就有多大概率留下一条脏缓存,且它会被 TTL 一直“保存”下去。

三种顺序,三个窗口

顺序 写路径 脏缓存窗口 谁踩中
① 先删缓存 → 写库 DEL → UPDATE 写库全过程 窗口内 miss 的读,读到旧库值并回填
② 先写库 → 删缓存 UPDATE → DEL 同上再加“读回填晚到” 窗口内 miss、且在 DEL 之后才回填的读
③ 写库 → 删缓存 → 延迟 T 再删 UPDATE → DEL → sleep T → DEL 被第二次 DEL 兜住 只要 T > 读回填耗时,就不会留下脏值

🔑 顺序②比①好,但不能消除问题:真正的漏洞是“读请求在缓存 miss 时读到旧值,然后在写请求删除缓存之后才把旧值写回缓存”。延迟双删就是专门补这个时间差的。

代码:三种顺序,同样的并发时序

package main

import (
	"fmt"
	"sync"
	"time"
)

type KV struct {
	mu sync.Mutex
	m  map[string]string
}

func newKV(init map[string]string) *KV { return &KV{m: init} }

func (k *KV) get(key string) (string, bool) {
	k.mu.Lock()
	defer k.mu.Unlock()
	v, ok := k.m[key]
	return v, ok
}

func (k *KV) set(key, v string) {
	k.mu.Lock()
	defer k.mu.Unlock()
	k.m[key] = v
}

func (k *KV) del(key string) {
	k.mu.Lock()
	defer k.mu.Unlock()
	delete(k.m, key)
}

// 一次“并发读写”:写请求把 order:1 从 v1 改成 v2,读请求在窗口内 miss 并回填
func run(name string, order func(db, cache *KV)) {
	db := newKV(map[string]string{"order:1": "v1"})
	cache := newKV(map[string]string{})
	var wg sync.WaitGroup

	wg.Add(1)
	go func() { // 写请求
		defer wg.Done()
		order(db, cache)
	}()

	// 读请求:在写请求开始后 1ms 到达,缓存 miss,读库得到旧值,1ms 后才回填
	time.Sleep(time.Millisecond)
	if _, hit := cache.get("order:1"); !hit {
		old, _ := db.get("order:1")
		time.Sleep(2 * time.Millisecond) // 回填不是即时的:序列化/网络/GC 都会拖
		cache.set("order:1", old)
	}

	wg.Wait()
	time.Sleep(20 * time.Millisecond) // 等延迟删除/晚到的回填都执行完
	cv, ok := cache.get("order:1")
	cs := "<空>"
	if ok {
		cs = cv
	}
	dv, _ := db.get("order:1")
	verdict := "✓ 一致"
	if ok && cv != dv {
		verdict = "✗ 缓存脏,直到 TTL 才恢复"
	}
	fmt.Printf("%-28s db=%s cache=%-5s %s\n", name, dv, cs, verdict)
}

func main() {
	fmt.Println("=== 同样的并发时序,三种删除顺序 ===")

	// ① 先删缓存,再写库(写库耗时 3ms)
	run("① 先删缓存 → 写库", func(db, cache *KV) {
		cache.del("order:1")
		time.Sleep(3 * time.Millisecond) // 事务/网络耗时
		db.set("order:1", "v2")
	})

	// ② 先写库,再删缓存
	run("② 先写库 → 删缓存", func(db, cache *KV) {
		time.Sleep(2 * time.Millisecond)
		db.set("order:1", "v2")
		cache.del("order:1")
	})

	// ③ 写库 → 删缓存 → 延迟 T 再删(T=10ms,覆盖读回填窗口)
	run("③ 写库 → 删 → 延迟再删", func(db, cache *KV) {
		time.Sleep(2 * time.Millisecond)
		db.set("order:1", "v2")
		cache.del("order:1")
		time.Sleep(10 * time.Millisecond)
		cache.del("order:1")
	})

	// ④ 反例:两个写并发,各自“写库 + 更新缓存”
	run("④ 写库 → 更新缓存(双写)", func(db, cache *KV) {
		go func() { // 写 A:写库早、更新缓存晚
			time.Sleep(2 * time.Millisecond)
			db.set("order:1", "v2")
			time.Sleep(4 * time.Millisecond)
			cache.set("order:1", "v2")
		}()
		time.Sleep(3 * time.Millisecond)
		db.set("order:1", "v3")    // 写 B:写库晚
		time.Sleep(time.Millisecond)
		cache.set("order:1", "v3") // 但更新缓存早
	})
}

本机实测(macOS,Apple M1,Go 1.27):

=== 同样的并发时序,三种删除顺序 ===
① 先删缓存 → 写库                  db=v2 cache=v1    ✗ 缓存脏,直到 TTL 才恢复
② 先写库 → 删缓存                  db=v2 cache=v1    ✗ 缓存脏,直到 TTL 才恢复
③ 写库 → 删 → 延迟再删              db=v2 cache=<空>   ✓ 一致
④ 写库 → 更新缓存(双写)              db=v3 cache=v2    ✗ 缓存脏,直到 TTL 才恢复

读数:

顺序 结果 原因
① 先删后写 脏 读请求在写库期间 miss,读到旧值并回填;写库完成后没人再删
② 先写后删 脏 读请求的回填发生在删除之后(回填有延迟),把旧值又写回去了
③ 延迟双删 一致 第二次删除把“晚到的回填”清掉
④ 更新缓存 脏 两个写请求的“更新缓存”顺序可能与“写库”顺序相反,缓存停在旧值

⚠️ 第 ④ 种最隐蔽:单看一次写,“写库 + 更新缓存”看起来最直观;但只要两个写并发(A 写库 v2、B 写库 v3),就可能出现“B 先写库、A 后写库,而 A 后更新缓存”的顺序,缓存最终是 v2、数据库是 v3。

把窗口算成一个数

脏缓存概率 ≈ min(1, 读 QPS × 窗口长度) × P(窗口内 miss)
窗口长度 = 写库耗时 + 读回填耗时
写库耗时 读回填耗时 窗口 读 QPS 窗口内读请求数
2 ms 1 ms 3 ms 1,000 3
5 ms 2 ms 7 ms 5,000 35
20 ms 5 ms 25 ms 10,000 250

窗口内 250 个请求里只要有一个 miss,就会留下脏缓存;而缓存命中率越高,miss 的那一个越“致命”(因为它是从库里读的旧值)。

延迟双删的 T 怎么定

取值依据 做法
读回填耗时上限 T ≥ P99(回填耗时),回填 = 反序列化 + 写缓存 + 网络
主从延迟 若有只读副本,T 还要覆盖“副本追上新主”的时间,否则回填读到副本旧值
太长也不好 T 越大,第二次删除前缓存一直可能被回填脏值(只是概率变低),且延迟任务队列变长

🔑 工程上更稳的做法不是“调 T”,而是让缓存可校验:写入时带版本号(v 或 updated_at),回填时用 SET key value 改成 SET key value+版本比较(Lua 脚本),旧版本的回填直接被拒。这样就不再依赖“时间差猜得准不准”。

比延迟双删更强的四种方案

方案 做法 代价 适用
版本号 / CAS 回填 缓存里存 version,回填时比较,旧版本丢弃 每次写要生成单调版本(DB 时间戳/自增) 大多数业务表
订阅 binlog 失效 用 CDC 订阅数据库变更,异步删缓存(46.4 CDC (变更数据捕获)) 多一套链路与延迟 多写入口、怕漏删
只删不更新 + 短 TTL 接受短暂不一致,用 TTL 兜底 回源压力 非关键数据
读时校验 读到缓存后校验版本/时间戳,过期则回源 多一次判断 强一致要求高的配置类数据

反模式

反模式 后果
写库 + 更新缓存(不删除) 并发写导致缓存永久旧值(本节④)
只靠延迟双删,不设 TTL 某次删除失败 = 永久脏数据
先删缓存再写库 窗口最大,且写库失败后缓存已被清空(回源压力)
延迟双删的 T 拍脑袋定 500ms 主从延迟或慢查询超过 T 时就失效
缓存删除失败不重试、不告警 静默脏数据,靠用户投诉发现
多级缓存只删 Redis 不删本地 本地缓存脏到进程重启(41.7)
用“最终一致”当借口不设监控 不知道窗口有多长、脏了多久

自检

Q1 为什么“先写库再删缓存”仍然可能留下脏数据?画一下时序。

▸ 答案:时序是:T1 读请求 miss → T2 读请求读库(旧值 v1)→ T3 写请求写库(v2)并删除缓存 → T4 读请求把 v1 回填进缓存。删除发生在 T3,回填发生在 T4(晚于删除),所以缓存最终是 v1。关键不是“先删还是后删”,而是回填动作可能晚于删除动作。延迟双删(T4 之后再删一次)或版本号比较(拒绝旧版本回填)才能覆盖。

Q2 延迟双删的延迟时间 T 应该怎么估?估小了会怎样,估大了会怎样?

▸ 答案:T 至少要覆盖“读请求从读库到写回缓存”的 P99 耗时(含反序列化、网络、GC 停顿),若有只读副本还要加上“副本追平主库”的延迟。T 估小 → 第二次删除发生在脏回填之前,等于没删;T 估大 → 脏值在缓存里多存活 T 时间(期间仍可能被读到),且延迟任务堆积。所以 T 是“覆盖窗口”的下界,不是越大越好。

Q3 为什么“写库 + 更新缓存”比“写库 + 删除缓存”更危险?

▸ 答案:因为更新缓存也要写一次缓存,而这次写会与并发写的缓存写竞争。两个写请求 A(v2)、B(v3)可能按“B 写库 → A 写库 → A 更新缓存 → B 更新缓存”的顺序执行,最终缓存是 v3、库是 v2(或反之),且没有任何机制会自动纠正。删除缓存则把“值是什么”交给下一次读决定,读一定从库拿最新值(在无副本延迟时),出错概率小得多。

Q4 一个系统读 QPS 5000、写库耗时 5ms、读回填耗时 2ms。估算每小时可能产生多少条脏缓存?

▸ 答案:窗口 5 + 2 = 7ms,窗口内读请求约 5000 × 0.007 = 35 个。每条脏缓存需要一个“窗口内 miss 的读”踩中;若缓存命中率 99%,miss 率 1%,则每个写请求产生脏缓存的概率约 1 - (1-0.01)^35 ≈ 30%。按每小时 1000 次写估算,约 300 条脏缓存/小时——这不是小概率事件,必须有版本号或延迟双删兜底,且需要监控“缓存与库的差异”(抽样对账)。

关联