Go 缓存策略

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

#type / concept #status / growing #tech / dev / backend #resource / go

[!info] 关联笔记

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

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)
		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:写路径在更新 DB 后删缓存,缩短脏读窗口。
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 控制脏数据窗口。
  2. singleflight 防止热点 miss 并发打穿。
  3. 写路径要 Invalidate 或更新,否则读到旧值直到过期。

核心概念与准确模型

Cache-aside(旁路)

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

其他模式

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

本地 vs Redis

本地Redis
延迟极低网络级
一致性多实例各自一份共享
容量受进程内存限制可独立扩
失效实例间需广播一处删除即可

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

sync.Map 适用边界

利于键稳定、写少读多。需要 TTL、淘汰、最大内存时,自研 map+锁或成熟库更合适。

穿透 / 击穿 / 雪崩

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

设计动机

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

边界情况与反直觉行为

1. 缓存与事务

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

2. 删除 vs 更新

并发下“更新缓存”可能用旧值覆盖新值;先写 DB 再删缓存是常见务实选择。

3. 内存保留

缓存 value 若引用巨大对象图,进程 RSS 会悄悄涨。设上限与淘汰。

4. 上下文取消

回源应尊重 ctx;singleflight 共享调用时,一个请求取消不应误杀他人——需仔细设计。

常见误区

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

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

[!warning] 常见误区:所有读都上 Redis
正确:本地 L1 挡最热路径。

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

与相邻概念对比

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

工程实践

  1. Key 设计user:v1:{id},带命名空间与版本。
  2. 指标:命中率、回源延迟、singleflight 共享次数。
  3. 序列化:Redis 存 protobuf/JSON,注意版本兼容。
  4. 测试:用接口抽象 Cache;集成测失效路径。
  5. 分层:缓存属于 service 或专用 cache 层。

可验证实验

实验 1:命中

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

实验 2:过期

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

实验 3:击穿

TTL 到期瞬间 100 个并发 Get,无 singleflight 时 load≈100;有则≈1。

实验 4:失效

更新数据后 Invalidate,下一读应看到新值。

本节总结

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

自测题

概念题

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

代码推理题

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

工程思考题

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

参考答案

展开
  1. 查缓存 → miss → 读 DB → 回填 → 返回。
  2. 同一 key 并发 miss 时合并回源,防击穿。
  3. 减少并发下旧值覆盖新值的窗口;删除简单。
    代码题:直到 B 的 TTL 到期或收到失效广播;否则一直旧。
    工程题:key 含 pwd_ver 或统一前缀 + 版本号,改密时递增版本。

延伸阅读与资料来源

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

创建于 2026/6/25 更新于 2026/7/15