本节把 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 缓存模式 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。