《Go 语言编程实战》7.1 本地缓存与失效(singleflight)

给 TaskHub 的读多写少接口加本地 TTL 缓存,并用 golang.org/x/sync/singleflight 合并并发回源,实测 100 个并发未命中只打一次数据库;对比本地缓存与 Redis 的延迟量级,讲清本地缓存在多实例下的失效难点与选型边界。

本节把 TaskHub 推进到「读路径扛得住」:给租户配置、项目元信息这类读多写少的数据加上进程内 TTL 缓存,并用 singleflight 合并并发回源,避免一次缓存过期被放大成对数据库的雪崩。
适用版本:Go 1.27(实测 go1.27.0)。

7.1 本地缓存与失效(singleflight)

第 6 章把数据访问理顺之后,TaskHub 的读接口在压测里暴露了新问题:GET /tenants/{id}/config 这类接口 QPS 上不去,每次请求都要走一遍数据库。这类数据的共同特征是读远多于写、单行小、变更不频繁,正是缓存的最佳靶子。本节先做最靠近请求的一层——进程内缓存。

7.1.1 缓存的两层位置

TaskHub 是多实例部署的,缓存至少有两个可选位置:

位置命中延迟容量多实例一致性适用
进程内(本地)纳秒级受单机内存限制各实例各一份,失效难同步变化极少、允许短暂陈旧的配置
Redis(集中式)网络 RTT可横向扩全局一份,失效即时跨实例必须一致、需要共享的会话/计数

工程上常见的是两层并存:本地缓存挡第一层,未命中再查 Redis,Redis 未命中才回源数据库。本节只做第一层,7.2 节接上 Redis。

7.1.2 本地 TTL 缓存的最小实现

先不引第三方库,用 map + sync.RWMutex + 过期时间写一个最小可用的 TTL 缓存,把命中/未命中计数也带上,方便观察:

type entry struct {
	val any
	exp time.Time
}

type TTLCache struct {
	mu   sync.RWMutex
	m    map[string]entry
	ttl  time.Duration
	hits atomic.Int64
	miss atomic.Int64
}

func New(ttl time.Duration) *TTLCache {
	return &TTLCache{m: make(map[string]entry), ttl: ttl}
}

func (c *TTLCache) Get(k string) (any, bool) {
	c.mu.RLock()
	e, ok := c.m[k]
	c.mu.RUnlock()
	if !ok || time.Now().After(e.exp) {
		c.miss.Add(1)
		return nil, false
	}
	c.hits.Add(1)
	return e.val, true
}

func (c *TTLCache) Set(k string, v any) {
	c.mu.Lock()
	c.m[k] = entry{val: v, exp: time.Now().Add(c.ttl)}
	c.mu.Unlock()
}

读路径用 RLock(允许并发读),写路径用 Lock。atomic.Int64 做计数是第 11 章「能用原子量就别上锁」的延续。跑一遍看行为:

$ go run ./ch7/ttl
hit  task:1 = A
miss task:2
expired task:1 after TTL
hits=1 misses=2

task:1 首次 Set 后命中;task:2 未命中;等 100ms 的 TTL 过去后 task:1 也判定为过期——惰性过期:不主动清理,读到才发现过期。这个实现有两个明显短板,先记下:

  • 没有淘汰上限:key 无限增长会撑爆内存,真实实现要么加容量上限 + LRU,要么加定期清扫。
  • 惰性过期留下垃圾:过期但没人读的 key 会一直占着内存,需要一个后台 time.Ticker 定期 Delete 过期的项。

生产上这两点交给成熟库即可(如 hashicorp/golang-lru/v2 的 expirable),本节为了讲清机制手写,选型建议放在小结。

7.1.3 缓存击穿:并发回源被放大

本地缓存最危险的不是未命中,而是同一时刻大量请求同时未命中。设想租户配置的 TTL 到点,此刻 100 个并发请求同时 Get 失败,于是 100 个 goroutine 一起冲向数据库——这就是缓存击穿(cache stampede)。

用一个实验把它量化。loadFromDB 每次耗时 50ms,模拟一次慢查询,先看不加任何保护时 100 个并发会发生什么:

var dbCalls atomic.Int64

func loadFromDB(ctx context.Context, key string) (string, error) {
	dbCalls.Add(1)
	time.Sleep(50 * time.Millisecond) // 模拟慢查询
	return "value-of-" + key, nil
}

// 100 个 goroutine 各自独立调用 loadFromDB
$ go run ./ch7/sf
no singleflight : dbCalls=100

100 个并发,数据库被打了 100 次。如果这是个每秒几千 QPS 的接口,一次过期就是一次小规模雪崩。

7.1.4 用 singleflight 合并并发回源

golang.org/x/sync/singleflight 解决的就是这个问题:对同一个 key,同一时刻只让一个 goroutine 真正执行,其余等待并共享同一个结果。签名很简洁:

func (g *Group) Do(key string, fn func() (any, error)) (v any, err error, shared bool)

把回源包进 g.Do,key 用缓存键:

var g singleflight.Group

v, err, shared := g.Do("task:42", func() (any, error) {
	return loadFromDB(ctx, "task:42")
})

其余 99 个 goroutine 会阻塞在 Do 上,等第一个拿到结果后共享返回,shared 为 true。同样 100 个并发,加上 singleflight 后:

$ go run ./ch7/sf
with singleflight: dbCalls=1

数据库调用从 100 次降到 1 次。这就是 singleflight 的价值——它不缓存结果(结果仍由 TTL 缓存保存),只负责把同一时刻的重复回源合并成一次。

有两个使用要点必须记住:

  • key 要与缓存键一致。如果 singleflight 的 key 和缓存的 key 对不上,合并不了不同请求,等于没加。
  • fn 里要尊重 context。Do 本身不接受 context,如果第一个 goroutine 的请求被客户端取消,其余等待者会一起拿到错误。要么让 fn 用独立的超时 context,要么用 DoChan + select 支持等待者自己的取消。

DoChan 返回一个 <-chan Result,允许调用方在等待时 select 自己的 ctx.Done(),适合请求级 context 会被取消的场景:

ch := g.DoChan(key, fn)
select {
case r := <-ch:
	return r.Val, r.Err
case <-ctx.Done():
	return nil, ctx.Err()
}

7.1.5 本地缓存 vs Redis:延迟差多少

本地缓存的卖点是快。在本机实测一次读操作的平均耗时(Redis 跑在容器里,经 colima 端口转发访问):

$ go run ./ch7/bench
local map : 2000 次 0s  (15 ns/op)
redis get : 2000 次 3.58s  (1790142 ns/op)

两者差了约 10 万倍。这里要诚实说明:本机 Redis 走 colima 端口转发,单次 RTT 被放大到约 1.2~1.8ms;同机房直连 Redis 通常在 0.10.5ms,不会这么慢。但即便按生产数字算,本地缓存仍比 Redis 快 34 个数量级——能在本地挡住的请求,就不要去碰网络。

结论不是「本地缓存更好」,而是两者职责不同:本地缓存吃高频、可容忍短暂陈旧的读;Redis 负责跨实例一致性与更大的共享容量。

7.1.6 本地缓存的失效难题

本地缓存最大的工程代价是失效。TaskHub 有 5 个实例,每个实例各有一份租户配置缓存。当租户在实例 A 上改了配置:

  • 实例 A 可以把本地缓存删掉,立刻读到新值;
  • 但实例 B、C、D、E 手里的缓存不会自动失效,它们会继续返回旧配置,直到 TTL 自然过期。

这就是「本地缓存只能靠短 TTL 兜底」的原因。两种应对:

方案做法代价
短 TTL 兜底TTL 设 5~30 秒,接受最长一个 TTL 的陈旧窗口失效不即时,但实现最简单
广播失效写时通过 Redis Pub/Sub 或消息广播「key 失效」事件,各实例收到后删本地缓存需要一套广播通道,且消息可能丢失,仍要 TTL 兜底

TaskHub 的选择是短 TTL + 可选广播:租户配置 TTL 设 10 秒,接受 10 秒内的陈旧;对「改完必须立刻生效」的字段(如计费开关),不走本地缓存,直接读 Redis 或数据库。这个取舍在第 7.3 节会从一致性角度再展开。

7.1.7 命中率:缓存到底有没有用

缓存加上不等于有效,必须用命中率说话。用一个贴近真实的访问分布测一下:90% 的请求落在 10 个热 key 上,10% 落在 200 个长尾 key 上(幂律分布),跑 10000 次请求:

for i := 0; i < 10000; i++ {
	var k string
	if r.Float64() < 0.9 {
		k = fmt.Sprintf("hot-%d", r.Intn(10))  // 热 key 集合
	} else {
		k = fmt.Sprintf("cold-%d", r.Intn(200)) // 长尾
	}
	if _, ok := c.get(k); !ok {
		load++ // 回源
		c.set(k, "v")
	}
}
$ go run ./ch7/hitrate
请求=10000 命中=9792 未命中=208 回源=208 命中率=97.9%

97.9% 的命中率意味着数据库只承受 2.1% 的原始流量。但命中率是个事后指标:它需要你把 hits/miss 计数暴露成指标(第 10 章),否则缓存是黑盒。经验值:热数据缓存命中率低于 80% 通常说明 TTL 太短或 key 设计有问题。

还有一个隐蔽的坑——TTL 集中过期。如果一批 key 在同一时刻写入、TTL 又完全相同,它们会在同一时刻集体过期,回源流量瞬间尖峰。解决办法是给 TTL 加随机抖动:

func jitter(ttl time.Duration) time.Duration {
	// 在 [ttl, ttl+10%] 之间随机,摊平过期时刻
	return ttl + time.Duration(rand.Int63n(int64(ttl/10)))
}

把 Set 的过期时间从固定 ttl 换成 jitter(ttl),原本同步的过期峰就被打散成一段平缓的斜坡。

最后补上惰性过期的清扫。开一个后台 goroutine,按固定间隔扫一遍删掉过期项(本地缓存 key 不会太多,全量扫描可接受):

func (c *TTLCache) sweep(interval time.Duration) {
	t := time.NewTicker(interval)
	defer t.Stop()
	for range t.C {
		now := time.Now()
		c.mu.Lock()
		for k, e := range c.m {
			if now.After(e.exp) {
				delete(c.m, k)
			}
		}
		c.mu.Unlock()
	}
}

7.1.8 常见坑

  • 无上限增长:只 Set 不淘汰,key 一多就 OOM。要么用带 LRU + 容量上限的库,要么自己加清扫。
  • singleflight key 与缓存 key 不一致:合并不生效,回源照旧被放大。
  • fn 不尊重 context:等待者拿不到自己的取消信号,一个慢回源拖垮整批请求。
  • 本地缓存当成强一致:多实例下必然出现陈旧窗口,需要立刻一致的字段别放本地。
  • 忘了失效计数埋点:命中率、回源次数是判断缓存是否有效的唯一依据,上线前就要打点。
  • TTL 全设同一个值:大量 key 同时过期会造成周期性回源尖峰,加随机抖动(jitter)能摊平。
  • 缓存了可变对象后原地修改:Get 返回的是指针,调用方一改,缓存里的对象也被改了,等于污染缓存。

小结

  • 本地缓存用 map + RWMutex + 过期时间即可起步,但生产要解决容量上限与惰性过期留下的垃圾。
  • 缓存击穿的本质是「同一 key 并发未命中被放大成 N 次回源」,singleflight.Do 把它合并成一次,实测 100 → 1。
  • 本地缓存比 Redis 快 3~4 个数量级(本机 colima 转发下实测约 10 万倍),但代价是多实例失效难同步。
  • 本地缓存的失效只能靠「短 TTL 兜底 + 可选广播」,需要强一致的字段不要放本地。

到这里 TaskHub 的读路径有了第一层护甲,但它只在单实例内有效。下一节把 Redis 接进来,做跨实例共享的缓存层,并补齐缓存旁路、写穿、锁等模式。

阅读导航:上一节:6.3 N+1、批量与查询优化 · 下一节:7.2 Redis 缓存模式 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

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