本节把 TaskHub 推进到「缓存不会说谎」:回答写数据库与删缓存的顺序问题,用实验复现并发读写下的脏缓存,给出延迟双删与版本守卫两种解法,并明确 TaskHub 每个缓存该用哪一档一致性。
适用版本:Go 1.27(实测go1.27.0),Redis 7。
7.3 缓存一致性
7.2 节把 Redis 接进来了,但留了一个悬而未决的问题:写数据时,数据库和缓存怎么协同才不至于读到旧值?这不是「删一下缓存」那么简单——并发读写会让缓存被旧值重新填满。本节把一致性这件事讲透。
7.3.1 先厘清目标:你要的是哪种一致
「缓存一致性」是个含糊的说法,先分层:
| 目标 | 含义 | 代价 | 典型场景 |
|---|---|---|---|
| 强一致 | 任何时刻读到的都是最新值 | 缓存几乎失去意义(每次都要锁/校验) | 账户余额、库存扣减 |
| 线性一致的缓存 | 写后立即可见,读多写少 | 需要同步失效或版本校验 | 用户资料、配置 |
| 最终一致(秒级) | 短暂陈旧窗口后收敛 | 实现最简单 | 列表页、统计、推荐 |
| 容忍陈旧 | 允许分钟级过期 | 成本最低 | 全站排行榜、公告 |
绝大多数业务数据落在最终一致这一档。把强一致当默认要求,往往既做不到、也没必要。TaskHub 的选择是:绝大多数缓存走最终一致,少数强一致字段(如计费开关)干脆不走缓存。
7.3.2 为什么写路径是「删缓存」而不是「更新缓存」
直觉上写数据后应该把新值写进缓存(更新缓存),但工程上几乎都选删除缓存。三个原因:
- 并发更新会写反。两个写请求 A、B 并发,A 先更新库,B 后更新库,但若 B 先写缓存、A 后写缓存,缓存里就留下了 A 的旧值——顺序无法保证。
- 写多读少的 key 浪费。更新缓存意味着每次写都要维护一个可能马上又被删的缓存,很多更新后根本没人读。
- 删除是幂等的。删不存在的 key 无害;写缓存则可能写入一个「基于旧快照算出来的值」。
所以写路径的默认形态是:更新数据库 → 删除缓存。
7.3.3 并发读写下的脏缓存
即便「更新库 → 删缓存」,并发下依然会出问题。看这个交错:
- 读者 R 缓存未命中,从数据库读到 v1(尚未回填);
- 写者 W 更新数据库为 v2,并删除缓存;
- 读者 R 此时才把自己手里的 v1 回填进缓存。
结果是缓存里躺着 v1,数据库里是 v2,而且缓存不会自己变回来,直到 TTL 过期。用一段确定性代码复现这个交错:
read := func() string {
if v, err := rdb.Get(ctx, "task:42").Result(); err == nil {
return v
}
v := db.val // 从 DB 读
rdb.Set(ctx, "task:42", v, 10*time.Second) // 回填
return v
}
read() // 预置缓存
stale := db.val // ① 读者读到 v1
db.val = "v2" // ② 写者更新 DB
rdb.Del(ctx, "task:42") // ② 写者删缓存
rdb.Set(ctx, "task:42", stale, 10*time.Second) // ③ 读者回填 v1
$ go run ./ch7/consist
初始缓存 = v1
写后读缓存 = v1(DB=v2)
=> 脏缓存:缓存与 DB 不一致,需等 TTL 过期
这个交错无法靠「调整删除顺序」根治,因为它本质是回填动作发生在写之后。下面两种方案分别从时间与版本两个维度化解它。
7.3.4 先删缓存还是先写库
两种顺序各有各的坏情况:
| 顺序 | 坏情况 | 概率 |
|---|---|---|
| 先删缓存,再写库 | 删除后、写库前有读请求,把旧值回填进缓存 | 低(要求删除与写库之间有读且慢) |
| 先写库,再删缓存 | 删缓存前的读请求,可能把旧值回填 | 低(即 7.3.3 的交错) |
业界主流选先写库、再删缓存(Cache-Aside 的标准写法)。理由是「先写库」的坏情况窗口更小:删除动作紧跟写库之后,中间被读请求插进来的概率比「先删后写」低得多。它不是绝对正确,而是大概率正确 + TTL 兜底。
7.3.5 延迟双删
如果业务对陈旧窗口特别敏感,可以用延迟双删:写库后删一次缓存,再等一小段时间删第二次,把「写库后、第一次删除前」回填进去的旧值也清掉:
func (r *Repo) updateTask(ctx context.Context, t Task) error {
if err := r.db.Save(ctx, t); err != nil {
return err
}
key := fmt.Sprintf("task:%d", t.ID)
r.rdb.Del(ctx, key) // 第一次删
time.AfterFunc(500*time.Millisecond, func() { // 延迟第二次删
r.rdb.Del(context.WithoutCancel(ctx), key)
})
return nil
}
第二次删除的延迟要大于一次读 + 回填的耗时(通常几百毫秒)。注意 time.AfterFunc 里的 context 不能直接用请求的 ctx——请求早就结束了,要用 context.WithoutCancel 派生一个不带取消的 context(第 12 章)。延迟双删的代价是实现复杂、且延迟时间难精确,只有在真正需要时才上。
把这段时序跑出来看更直观——写库删缓存后,读者迟到回填了脏值 v1,300ms 后的第二次删除把它清掉:
$ go run ./ch7/dd
+9ms 写库完成并第一次删缓存
+14ms 读者回填 v1(脏)
+320ms 第二次删缓存,脏值被清掉
当前缓存 = 未命中,下次读将回源拿到 v2
可以看到脏值只在 +14ms 到 +320ms 之间短暂存在,下一次读就会回源拿到 v2。延迟双删把脏窗口从「一个 TTL」压缩到「一次延迟」,这是它相对裸 Cache-Aside 的收益。
7.3.6 版本守卫:拒绝迟到的回填
比延迟双删更确定的做法是给缓存值带上版本号,回填时只允许版本不低于当前缓存的值写入,把迟到的旧值挡在门外。用一段 Lua 原子完成「比较版本再写」:
putIfNewer := redis.NewScript(`
local cur = redis.call("GET", KEYS[1])
if cur then
local curVer = tonumber(string.match(cur, ":(%d+)$"))
if curVer and curVer > tonumber(ARGV[2]) then
return 0
end
end
redis.call("SET", KEYS[1], ARGV[1], "EX", ARGV[3])
return 1`)
实测:写者已把缓存推进到 v2:2,此时读者带着旧版本 v1:1 迟到回填,被拒绝;只有版本更高的 v3:3 才能写入:
$ go run ./ch7/verguard
迟到回填 v1:1 -> 0
缓存仍为 = v2:2
回填 v3:3 -> 1
缓存更新为 = v3:3
版本守卫把「时间上谁先到」的判断,换成了「逻辑上谁更新」的判断——不再依赖时钟与延迟,比延迟双删可靠。代价是数据库行要带一个自增版本号(或 updated_at 时间戳),且所有读写路径都要传递它。TaskHub 对关键实体(项目、任务)采用版本守卫。
7.3.7 多级缓存叠加下的一致性
TaskHub 实际是「本地缓存 + Redis + 数据库」三级。写路径的删除必须两级一起删,否则本地那份脏值会绕过 Redis 直接返回:
func (r *Repo) invalidate(ctx context.Context, id int64) {
key := fmt.Sprintf("task:%d", id)
r.local.Del(key) // 删本地
r.rdb.Del(ctx, key) // 删 Redis
}
问题在于本地缓存分布在 5 个实例上,当前实例能删自己的,删不掉别人的。于是多级缓存的一致性等级由本地缓存的失效能力决定:
| 组合 | 一致性 | 说明 |
|---|---|---|
| 只有 Redis | 最终一致(删缓存后立即生效) | 全局一份,删除即生效 |
| 本地 + Redis,本地 TTL 短 | 最终一致(最多一个本地 TTL) | 其余实例靠 TTL 自然过期 |
| 本地 + Redis,广播失效 | 最终一致(百毫秒级) | 写时通过 Pub/Sub 广播让各实例删本地 |
| 本地 + Redis,强一致 | 基本不可得 | 需要分布式锁 + 版本校验,收益不抵成本 |
TaskHub 对多实例可见的字段只用 Redis 层,本地缓存只放「允许各自陈旧」的数据(如全站公告、静态配置)。这样把一致性问题的边界收窄到单一 Redis 层,删一次就全局生效。
7.3.8 四档选型
把上面的手段按一致性强度排开,TaskHub 按数据特性各取所需:
| 档位 | 手段 | 一致性 | 复杂度 | TaskHub 用例 |
|---|---|---|---|---|
| 0 | 不缓存,直读库 | 强一致 | 无 | 计费开关、权限判定 |
| 1 | 写库 + 删缓存 + 短 TTL | 最终一致(秒级) | 低 | 租户配置、项目元信息 |
| 2 | 写库 + 延迟双删 | 最终一致(百毫秒) | 中 | 任务详情(读多写少) |
| 3 | 版本守卫回填 | 近线性一致 | 高 | 任务状态、库存类字段 |
选型的原则是从低档位开始,只在真的观察到脏读时升档。绝大多数接口档位 1 就够,盲目上版本守卫会把读写路径都复杂化。
7.3.9 兜底:定时对账
再周密的失效逻辑也可能因为删缓存失败、代码 bug、网络抖动而漏掉个别 key。工程上的最后一道保险是定时对账:后台任务周期性地抽样比对缓存与数据库,发现不一致就删缓存(或告警):
func (r *Repo) reconcile(ctx context.Context) {
t := time.NewTicker(10 * time.Minute)
defer t.Stop()
for range t.C {
// 抽样最近更新的 N 条记录,比对缓存
rows, _ := r.db.RecentlyUpdated(ctx, 100)
for _, row := range rows {
key := fmt.Sprintf("task:%d", row.ID)
cached, err := r.rdb.Get(ctx, key).Result()
if err == redis.Nil {
continue // 没缓存,无需对账
}
if err == nil && versionOf(cached) != row.Version {
r.rdb.Del(ctx, key) // 不一致就删,让下次读回源
r.metrics.CacheDrift.Inc()
}
}
}
}
对账不追求「修好每一个」,而是把长期存在的脏值概率压到可忽略,并把「发生了多少次漂移」暴露成指标——指标持续非零就说明失效逻辑本身有 bug,该回去修根因。这与第 10 章的「用指标驱动运维」是一脉相承的。
7.3.10 常见坑
- 更新缓存而不是删除:并发写会写反,默认删缓存。
- 先删缓存后写库:删除与写库之间的读会把旧值回填,窗口比「先写库」大。
- 删缓存失败不重试:删失败意味着脏值要等 TTL 才恢复,应该记录失败并告警,必要时重试。
- TTL 设成永久:写脏后没有自愈机会,TTL 是一致性的最后兜底。
- 延迟双删的第二次删除用请求 context:请求早已结束,删除根本不执行,要用
WithoutCancel或后台任务。 - 本地缓存忘了失效:多实例下本地缓存要靠广播或短 TTL,删 Redis 不影响本地那一份(7.1 节)。
- 把缓存当唯一真相:缓存永远是数据库的副本,任何「以缓存为准」的写入都会丢数据。
小结
- 缓存一致性有四档目标,多数业务落在「最终一致」,强一致字段干脆别缓存。
- 写路径默认「更新数据库 → 删除缓存」,删而非改,因为删除幂等且不会被并发写反。
- 并发读写会在「读旧值 → 写库删缓存 → 回填旧值」的交错下产生脏缓存,靠 TTL 兜底。
- 延迟双删从时间维度缩小窗口,版本守卫从逻辑维度拒绝旧值;后者更可靠但更重。
- 选型从低档位起步,观察到真实脏读再升档。
- 定时对账是最后一道兜底:修不修得好另说,先把「漂移次数」变成可观测的指标。
到这里,TaskHub 的同步读路径与缓存都就位了。但有些工作天生不该在请求线程里做——发邮件、生成报表、跨系统同步。下一章把 TaskHub 推进到异步:用消息把请求与处理解耦,并解决「消息会不会丢、会不会重复处理」这些可靠性问题。
阅读导航:上一节:7.2 Redis 缓存模式 · 下一节:8.1 生产者/消费者与可靠投递 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。