《Go 语言编程实战》7.2 Redis 缓存模式

把 Redis 接进 TaskHub 做跨实例共享缓存:用 go-redis 实现缓存旁路、区分 redis.Nil 未命中、用 SETNX 加 Lua 做安全分布式锁、用 MGET 与 pipeline 合并往返,并实测 pipeline 相对逐条命令的加速比与键设计要点。

本节把 TaskHub 推进到「跨实例共享缓存」:在本地缓存之后接上 Redis,实现缓存旁路、未命中语义、分布式锁与批量读取,让 5 个实例看到同一份缓存。
适用版本:Go 1.27(实测 go1.27.0),Redis 7(redis:7-alpine 容器)。

7.2 Redis 缓存模式

本地缓存只在单实例内有效,跨实例要共享就得用集中式缓存。TaskHub 用 Redis 承担这一层:租户配置、项目元信息、会话状态都放这里。本节用 github.com/redis/go-redis/v9 逐个实现常用的缓存模式,示例全部跑在本地 redis:7-alpine 容器上。

7.2.1 连接与客户端

go-redis 的客户端是并发安全的,整个进程共用一个即可,不要每次请求新建:

rdb := redis.NewClient(&redis.Options{
	Addr:         "127.0.0.1:56379",
	PoolSize:     50,               // 连接池上限
	MinIdleConns: 5,                // 预热连接,避免冷启动抖动
	DialTimeout:  2 * time.Second,
	ReadTimeout:  1 * time.Second,
	WriteTimeout: 1 * time.Second,
})
if err := rdb.Ping(ctx).Err(); err != nil {
	return err
}

超时和连接池是必须显式设的——默认值在故障时会放大延迟,与第 6 章连接池的取舍是同一套逻辑。

7.2.2 缓存旁路(Cache-Aside)

最常用的模式:读时查缓存,未命中回源并回填;写时更新数据库并删除缓存。读路径:

func (r *Repo) getTask(ctx context.Context, id int64) (Task, error) {
	key := fmt.Sprintf("task:%d", id)
	val, err := r.rdb.Get(ctx, key).Result()
	if err == nil {
		return decodeTask(val), nil // 命中
	}
	if err != redis.Nil {
		return Task{}, err // 真错误(网络等),不是未命中
	}
	t, err := r.db.LoadTask(ctx, id) // 回源
	if err != nil {
		return Task{}, err
	}
	r.rdb.Set(ctx, key, encodeTask(t), 30*time.Second) // 回填
	return t, nil
}

跑一遍看真实返回:

$ go run ./ch7/redis
GET task:42 = {"id":42,"title":"deploy"} (ttl=30s)
GET task:99 -> redis.Nil (miss)

7.2.3 redis.Nil 不是错误

上面代码里最容易被写错的一行是 if err != redis.Nil。go-redis 把「key 不存在」表示成一个专门的错误值 redis.Nil,而不是返回一个空字符串加 nil 错误。区别很重要:

返回含义处理
err == nil命中,val 是真实值直接返回
err == redis.Nil未命中(key 不存在或已过期)回源 + 回填
其它 err网络/超时/服务端错误上报,不要当成未命中回源

把 redis.Nil 和其它错误混为一谈,会在 Redis 抖动时把所有请求都打向数据库,等于缓存失效瞬间发生雪崩。缓存故障时的降级策略要显式决策:TaskHub 选择「Redis 报真错误时直接回源,但记录错误指标并告警」,而不是让请求失败。

7.2.4 分布式锁:SETNX + Lua 安全释放

多个实例可能同时想刷新同一份缓存,需要一把分布式锁。SET key value NX EX 是原子加锁,value 放一个唯一令牌(如 UUID),解锁时校验令牌,防止「锁过期后自己又删掉别人的锁」:

$ go run ./ch7/redis
SETNX A=true B=false

worker-A 抢到锁(true),worker-B 失败(false)。解锁不能直接 DEL,必须用 Lua 原子地「比较令牌再删」,否则存在竞态:

unlock := redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
  return redis.call("DEL", KEYS[1])
end
return 0`)

n, err := unlock.Run(ctx, rdb, []string{"lock:task:42"}, token).Result()

实测令牌不匹配时删不掉、匹配时删成功:

$ go run ./ch7/redis
unlock with wrong token -> 0 (err=<nil>)
unlock with right token -> 1

GET + DEL 两步在 Lua 里作为一个原子脚本执行,中间不会被其它客户端插进来。第 8 章的消费幂等也会用到同样的「比较后再改」思路。

7.2.5 批量读取:MGET 与 pipeline

缓存最怕 N 次往返。列一页任务时如果逐个 GET,延迟会随 N 线性增长。MGET 一次拿多个 key:

vals, _ := rdb.MGet(ctx, "a", "b", "c", "missing").Result()
$ go run ./ch7/redis
MGET = [1 2 3 <nil>]

不存在的 key 返回 nil(不是错误),调用方按位置判断哪个未命中、需要回源。MGET 只能读、且命令固定;需要混合多种命令时用 pipeline,把一批命令一次发出去、一次收回来:

pipe := rdb.Pipeline()
for _, id := range ids {
	pipe.Get(ctx, fmt.Sprintf("task:%d", id))
}
cmds, _ := pipe.Exec(ctx)

实测 100 次读的往返次数从 100 降到 1,耗时显著下降:

$ go run ./ch7/pipe
逐条 100 GET : 356.611ms (3566 µs/次)
pipeline     : 19.022ms (19021 µs/批)
加速比 = 18.7x

本机 Redis 经 colima 端口转发,单次 RTT 被放大到毫秒级,绝对数字波动很大;但「合并往返能省掉 N-1 个 RTT」这个结论与机器无关,生产直连 Redis 时同样成立。注意 pipeline 不是事务:命令之间没有原子性,中间可能插进别的客户端命令;要原子就用 Lua 脚本或 MULTI/EXEC。

7.2.6 键设计与序列化

键名要可读、可批量清理、带命名空间前缀:

用途键格式说明
任务缓存task:{id}单实体
租户配置tenant:{id}:config分层前缀便于 SCAN 清理
分布式锁lock:{resource}与业务 key 隔离
幂等去重processed:{msgid}见第 8 章

序列化优先选 JSON:可读、跨语言、schema 演进容忍度高。别用 Go 的 gob,它绑定语言、字段改动容易炸。值尽量小——大对象放缓存既费带宽又费内存,超大值应该存对象存储、缓存里只放引用(第 13 章)。

需要按前缀清理一批 key 时,用 SCAN 游标分批遍历,不要 KEYS:

var cursor uint64
for {
	var keys []string
	keys, cursor, _ = rdb.Scan(ctx, cursor, "tenant:42:*", 100).Result()
	if len(keys) > 0 {
		rdb.Del(ctx, keys...)
	}
	if cursor == 0 {
		break
	}
}

SCAN 每次只返回一小批,不会像 KEYS * 那样一次性阻塞整个 Redis。注意 SCAN 是弱一致的:遍历期间新增或删除的 key 不保证被看到,清理场景可以接受。

7.2.7 缓存删除与 TTL 的配合

写路径统一走「更新数据库 → 删除缓存」,删除而不是更新缓存的原因在第 7.3 节详述。删除用 Del:

$ go run ./ch7/redis
after DEL exists=0

删除后下一次读会自然回填。所有缓存 key 都要设 TTL,哪怕是「永不过期」的配置——TTL 是缓存写脏、代码 bug、异常路径的最后一道保险。TaskHub 的约定:热点实体 30 秒、租户配置 5 分钟、会话随 token 有效期。

7.2.8 缓存穿透与空值缓存

有一类请求专挑不存在的 key:GET /tasks/999999999。缓存永远未命中,每次都穿透到数据库,如果被人用不存在的 ID 高频扫描,缓存形同虚设——这叫缓存穿透。缓解手段是空值缓存:回源发现不存在时,往缓存写一个哨兵值,用短 TTL 挡住后续同样的请求:

const sentinel = "\x00null"

val, err := rdb.Get(ctx, key).Result()
switch {
case err == nil && val == sentinel:
	return ErrNotFound // 命中空值缓存,直接 404,不打 DB
case err == nil:
	return decodeTask(val), nil
case err == redis.Nil:
	t, ok := loadTask(id)
	if !ok {
		rdb.Set(ctx, key, sentinel, 30*time.Second) // 缓存「不存在」
		return ErrNotFound
	}
	...
}

实测同一批 100 次不存在 ID 的查询,空值缓存把数据库压力从 100 次降到 0 次:

$ go run ./ch7/null
无空值缓存: DB 被查 100 次
有空值缓存: DB 被查 0 次

空值缓存的 TTL 要短(几十秒),否则新建的记录会被旧的「不存在」哨兵挡住。更彻底的方案是布隆过滤器:启动时把所有存在的 ID 灌进去,请求先过一遍过滤器,判定「一定不存在」就根本不查数据库。代价是需要维护过滤器的同步,TaskHub 规模下空值缓存已经够用。

7.2.9 常见坑

  • 把 redis.Nil 当成普通错误:未命中被判为故障,回源逻辑永远走不到,或故障时误判成未命中引发雪崩。
  • 解锁直接 DEL:锁过期后删掉别人的锁,用 Lua 校验令牌。
  • 锁不设过期:持有者崩溃后锁永不释放,必须 EX。
  • 每个请求 NewClient:连接池失效、连接泄漏,客户端要全局单例。
  • KEYS * 清理缓存:会阻塞整个 Redis,用 SCAN 游标遍历。
  • 缓存大对象:单 value 几 MB,带宽与内存双杀,大对象走对象存储。
  • 不设 TTL:写脏的 key 永远留存在缓存里,没有自愈机会。
  • pipeline 当事务用:以为一批命令会原子执行,实际中间可被插入。

小结

  • 缓存旁路是默认模式:读查缓存、未命中回源回填,写更新数据库并删缓存。
  • redis.Nil 是「未命中」的专用信号,必须和真错误分开处理,否则缓存故障会引发雪崩。
  • 分布式锁用 SET NX EX + 唯一令牌,释放用 Lua 原子校验,绝不裸 DEL。
  • 合并往返是降延迟的关键:MGET 与 pipeline 把 N 次 RTT 压成 1 次(实测约 19 倍)。
  • 键名带命名空间前缀,值用 JSON,一律设 TTL。

Redis 层解决了跨实例共享,但「写数据库和删缓存谁先谁后、删了会不会被旧值回填」这些问题还没答。下一节专门讲缓存与数据库的一致性。

阅读导航:上一节:7.1 本地缓存与失效(singleflight) · 下一节:7.3 缓存一致性 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

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