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 条脏缓存/小时——这不是小概率事件,必须有版本号或延迟双删兜底,且需要监控“缓存与库的差异”(抽样对账)。