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