← [ BACK_TO_NOTES ]

Go 缓存策略

Go 服务缓存:本地内存与 Redis、Cache-aside、TTL/失效、击穿穿透与 singleflight、并发安全取舍。

#concept #growing #backend #go

[!info] related notes

Go 缓存策略

这个概念为什么会出现

读多写少时,每次打数据库或远程 API 的成本是延迟、连接池与下游配额。缓存用空间与一致性复杂度换延迟与容量。Go 服务常见两层:

  1. 进程内:map+锁、sync.Map、或 ristretto/bigcache 等
  2. 分布式:Redis 等,多实例共享

选错模型会出现脏读、缓存击穿把 DB 打穿、内存无限涨。

[!abstract] 一句话理解 先定一致性与命中率目标,再用 Cache-aside:读 miss 回源并回填,写时更新或删除缓存;热点用 singleflight 合并,TTL 与主动失效组合控脏。

最小可运行示例

先把示例放进业务场景,再看代码:

场景:用户资料读多写少,Cache-aside + singleflight

个人主页接口 GET /users/{id} 读多写少。
每次打 DB 成本高;用进程内缓存:

  1. 命中且未过期 → 直接返回
  2. miss → 回源 DB(这里用 load 模拟),再回填 TTL
  3. 同一 key 并发 miss 时,singleflight 合并成一次回源,避免击穿

依赖 golang.org/x/sync/singleflight;本次以 Go 1.26.8、x/sync v0.23.0 验证。示例聚焦命中与合并回源:构造时需提供正 TTL 和非 nil loader,尚未实现容量淘汰、过期清理或强一致失效。

package main

import (
	"context"
	"fmt"
	"sync"
	"time"

	"golang.org/x/sync/singleflight"
)

// entry:缓存值 + 绝对过期时间。
type entry struct {
	val       string
	expiresAt time.Time
}

// MemoryCache:带 TTL 与 singleflight 的旁路缓存。
type MemoryCache struct {
	mu    sync.RWMutex
	items map[string]entry
	ttl   time.Duration
	group singleflight.Group
	// load:miss 时回源(DB / 远程 API)
	load func(ctx context.Context, key string) (string, error)
}

func NewMemoryCache(ttl time.Duration, load func(context.Context, string) (string, error)) *MemoryCache {
	return &MemoryCache{items: make(map[string]entry), ttl: ttl, load: load}
}

func (c *MemoryCache) Get(ctx context.Context, key string) (string, error) {
	// 1) 快路径:读锁查缓存
	c.mu.RLock()
	e, ok := c.items[key]
	c.mu.RUnlock()
	if ok && time.Now().Before(e.expiresAt) {
		return e.val, nil
	}

	// 2) miss 或过期:按 key 合并并发回源
	v, err, _ := c.group.Do(key, func() (any, error) {
		// double-check:可能别人刚回填完
		c.mu.RLock()
		e, ok := c.items[key]
		c.mu.RUnlock()
		if ok && time.Now().Before(e.expiresAt) {
			return e.val, nil
		}
		// 用首个调用者的 ctx 回源;loader 是否响应取消由其实现决定
		val, err := c.load(ctx, key)
		if err != nil {
			return "", err
		}
		// 回填带 TTL
		c.mu.Lock()
		c.items[key] = entry{val: val, expiresAt: time.Now().Add(c.ttl)}
		c.mu.Unlock()
		return val, nil
	})
	if err != nil {
		return "", err
	}
	return v.(string), nil
}

// Invalidate 删除已有条目,但不会终止/阻止正在进行的旧读回填。
func (c *MemoryCache) Invalidate(key string) {
	c.mu.Lock()
	delete(c.items, key)
	c.mu.Unlock()
}

func main() {
	// hits 统计回源次数:演示“三次读只打一次 DB”
	var hits int
	cache := NewMemoryCache(time.Minute, func(ctx context.Context, key string) (string, error) {
		hits++
		// 假装 SELECT nickname FROM users WHERE id=?
		return "value-of-" + key, nil
	})
	ctx := context.Background()
	for i := 0; i < 3; i++ {
		v, _ := cache.Get(ctx, "u:1")
		fmt.Println(v)
	}
	fmt.Println("load calls:", hits) // 期望 1
}

建议运行(需已 go get golang.org/x/sync):

go run .

期望输出:

value-of-u:1
value-of-u:1
value-of-u:1
load calls: 1

结合场景再看三个关注点

  1. TTL 限制本次回填条目的有效期;回源本身可能读到旧副本,不能单凭 TTL 推出端到端新鲜度上限。
  2. singleflight 合并同一 Group、同一 key 的重叠调用,不是跨进程锁,也不限制不同 key 的回源并发。
  3. 写后失效仍需防止旧读回填;当前示例只演示弱一致缓存,下面的失效实验要区分串行与并发。

核心概念与准确模型

Cache-aside(旁路)

读: cache → hit 返回
         → miss → DB → 写 cache → 返回
写: 写 DB → 删/更新 cache

其他模式

模式要点
Read-through缓存组件自己回源
Write-through写缓存同步写存储
Write-behind异步落库,吞吐高、丢数据风险

本地 vs Redis

本地Redis
延迟极低网络级
数据范围多实例各自一份实例可共享 Redis 中的值;不等于与 DB 强一致
容量受进程内存限制可独立扩
失效广播/版本/过期策略删除共享项仍需处理旧读回填及 L1 副本

常组合:本地短 TTL + Redis 较长 TTL(L1/L2)。

sync.Map 适用边界

sync.Map 针对写一次多次读、或多个 goroutine 操作基本不相交的 key 集等场景优化;不自带 TTL/容量淘汰,也不保护 value 内部可变数据。普通 map+锁常更容易维护额外不变量。

穿透 / 击穿 / 雪崩

问题现象缓解
穿透查不存在的 key 一直打 DB空值短 TTL、布隆过滤
击穿热点 key 过期瞬间并发回源singleflight、逻辑过期
雪崩大量 key 同时过期TTL 加抖动、预热

设计动机

  1. 延迟 SLA:P99 读不能总走磁盘/跨机房。
  2. 保护下游:DB QPS 有上限。
  3. 成本:缓存命中换机器规格。

边界情况与反直觉行为

1. 缓存与事务

事务未提交就写缓存 → 别的请求读到未提交数据。应在提交成功后失效/更新。

2. 删除 vs 更新

先写 DB 再删缓存是常见策略,但并非强一致保证:T1 回源读到旧值,T2 提交新值并 Invalidate,T1 随后仍可回填旧值。本例就存在此竞争。更强要求需版本校验、协调回填或绕过缓存;Group.Forget 只让后续调用不再等待原调用,不会取消旧回源。

3. 内存保留

本例 TTL 只决定命中与否,不主动删除过期 entry;再也不访问的 key 会留在 map。必须另设清理与容量限制,TTL 不能单独约束内存。value 若含巨大对象图,浅拷贝也可能继续保留引用。

4. 上下文取消

本例 Group.Do 的 loader 使用首个调用者的 ctx:其取消可能使同 key 等待者共同收到错误;其他等待者无法通过各自 ctx 提前退出 Do。命中快路径也不检查 ctx。若需要独立取消,可用 DoChan + select,并给共享回源单独的生命周期和超时;DoChan 返回的 channel 不关闭,只接收一个结果。不能简单换成无期限 Background。

常见误区

[!warning] 常见误区:无上限 map 当缓存
正确:TTL + 最大键数/字节。

[!warning] 常见误区:缓存业务对象直接可变共享
正确:不可变快照或复制。

[!warning] 常见误区:所有读都上 Redis
是否增加 L1 取决于延迟、命中收益和多副本失效成本;并非所有业务都需要多级缓存。

[!warning] 常见误区:缓存当真相源
权威仍在 DB(除非明确设计如此)。

与相邻概念对比

概念差异
连接池复用连接,不是结果缓存
CDN边缘缓存静态/准静态内容
限流保护容量;缓存减少需求
物化视图DB 侧预计算,一致性模型不同

工程实践

  1. Key 设计:user:v1:{id} 只在结果确实仅依赖 id 时充分;租户、权限范围、语言等影响结果的输入也要纳入 key 或隔离缓存。
  2. 指标:命中率、回源延迟、singleflight 共享次数。
  3. 序列化:Redis 存 protobuf/JSON,注意版本兼容。
  4. 测试:用接口抽象 Cache;集成测失效路径。
  5. 分层:缓存属于 service 或专用 cache 层。

可验证实验

实验 1:命中

连续 Get 同一 key,load 只调用一次。

实验 2:过期

TTL 极短,睡过后 load 再次调用。

实验 3:击穿

用屏障控制同 key 回源重叠,统计共享加载;不要靠同时启动 goroutine 就断言精确次数。失败结果不回填,后到的请求可触发下一轮加载。

实验 4:失效

串行更新、失效、再读应重新回源。再用屏障复现「旧回源读取 → 更新并失效 → 旧回源回填」,确认本例不能保证并发写后立即读新。

本节总结

  • 本质:用可接受的不一致窗口换性能。
  • 关键规则:Cache-aside、TTL 抖动、失效策略、防击穿。
  • 最易错:无淘汰、写后不失效、多实例本地缓存当全局真相。
  • 下一步:限流 与缓存一起做容量防护;SQL 仍是权威源。

自测题

概念题

  1. Cache-aside 读 miss 的步骤?
  2. singleflight 解决哪类问题?
  3. 为何写后常选择删缓存而不是更新缓存?

代码推理题

两个实例本地缓存用户资料,只在实例 A 更新并删本地 key——实例 B 多久一致?

工程思考题

如何设计 key 以便“用户改密后所有会话相关缓存失效”?

参考答案

展开
  1. 查缓存 → miss → 读 DB → 回填 → 返回。
  2. 同一 key 并发 miss 时合并回源,防击穿。
  3. 减少并发下旧值覆盖新值的窗口;删除简单。
    代码题:B 的本次条目到期或被失效后才重新取值;若回源仍旧或旧读重新回填,新鲜度还会延后,不能断言从 A 写入起一个 TTL 内一致。 工程题:以权威会话/凭证版本作为校验依据,改密时原子递增;读取方必须获取并检查当前版本。只把旧版本放进 key、却继续信任旧缓存,不能撤销会话。

延伸阅读与资料来源

资料类型支撑
sync.Map标准库并发 map 场景
singleflightx/sync合并飞行中调用
context标准库回源超时
go-sync-package · database-sql-in-go本库并发与数据源

创建于 2026/6/25 更新于 2026/10/6
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本