《Go 语言编程实战》7.3 缓存一致性

讲清 TaskHub 缓存与数据库的一致性:为什么写路径要删缓存而不是更新缓存、并发读写下脏缓存如何产生、先删还是先写库的取舍、延迟双删与版本守卫如何化解,并给出从强一致到最终一致的四档选型表、多级缓存叠加下的一致性、定时对账兜底与各自的适用场景。

本节把 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 并发读写下的脏缓存

即便「更新库 → 删缓存」,并发下依然会出问题。看这个交错:

  1. 读者 R 缓存未命中,从数据库读到 v1(尚未回填);
  2. 写者 W 更新数据库为 v2,并删除缓存;
  3. 读者 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 生产者/消费者与可靠投递 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练