Go 基础体系 · 第 63/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。
Go Redis 生产模式:缓存一致性、穿透击穿、锁与 Streams
本文以 Go 1.26.4、Redis 8.0、go-redis/v9 为基线。示例中的命令适用于 Redis 8.0 单机、Sentinel 或 Cluster,但拓扑会改变故障语义。Redis 可以是缓存、协调器或流存储;先明确权威数据源、允许多旧、能否丢失,再选择模式。缓存命中率高并不自动代表正确,分布式锁成功也不等于业务互斥永久成立。
1. 从数据角色而不是命令开始设计
Cache Aside 中数据库是事实源,Redis 保存可丢弃副本。计数器若只在 Redis 中递增,Redis 就成了事实源,需要持久化、复制和恢复承诺。Streams 若承载业务事件,同样不能按普通缓存设置淘汰。三种角色应使用不同实例或至少不同内存预算、ACL 与告警,避免缓存淘汰策略误伤队列。
键应表达命名空间、租户、实体和格式版本,例如 content:{tenant-42}:article:v3:781。Cluster 的花括号是 hash tag:需要原子操作的多个键必须位于同一 slot,但把整个租户放入同一 slot 会制造热点。值中保留业务版本、生成时间和负缓存标记,方便判断新旧与诊断。
2. 客户端生命周期、连接池和请求预算
redis.Client 并发安全且内含池,应在进程启动时创建、探活后复用,退出时关闭。每请求创建客户端会产生连接风暴。PoolSize 是每进程预算;总连接约为实例数乘每实例池大小,还要给发布订阅、阻塞命令和运维连接留余量。
client := redis.NewClient(&redis.Options{
Addr: "redis:6379",
Username: "article-api",
Password: password,
DB: 0,
PoolSize: 32,
MinIdleConns: 4,
DialTimeout: 800 * time.Millisecond,
ReadTimeout: 300 * time.Millisecond,
WriteTimeout: 300 * time.Millisecond,
})
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := client.Ping(ctx).Err(); err != nil {
client.Close()
return fmt.Errorf("ping redis: %w", err)
}
连接超时限制建连,读写超时限制单次网络 I/O,context 则携带请求的端到端取消。三者要共同配置。重试只能用于语义幂等且仍有预算的操作;超时后的写命令可能已在服务端执行,不能据此断言“未写入”。阻塞式 XREADGROUP 应使用单独客户端或容量预算,避免占满普通请求池。
3. Cache Aside 读路径与错误分类
读取时必须区分 miss、缓存故障和反序列化故障。redis.Nil 是正常 miss;连接错误若直接伪装成 miss,会让数据库在 Redis 故障时承受全量流量。可在容量允许时降级回源,但必须限并发、记录降级指标,并保留原始错误分类。
func (s *Service) Get(ctx context.Context, id string) (Article, error) {
key := "article:v3:" + id
raw, err := s.cache.Get(ctx, key).Bytes()
if err == nil {
var article Article
if err := json.Unmarshal(raw, &article); err != nil {
return Article{}, fmt.Errorf("decode cached article %q: %w", id, err)
}
return article, nil
}
if !errors.Is(err, redis.Nil) {
return Article{}, fmt.Errorf("get cached article %q: %w", id, err)
}
return s.loadAndFill(ctx, key, id)
}
回填值要从不可变快照编码,不缓存会被调用方原地修改的指针。大对象压缩会换取 CPU,先按网络、内存和延迟指标决定。一次请求的回填 context 不应无限脱离上游;确需后台回填时,应进入有界、可关闭的工作队列。
4. 写后删除为什么仍有竞态
常用写路径是先提交数据库,再删除缓存。若先删缓存再写数据库,另一个读者可能在数据库提交前回填旧值。即使采用“写库后删”,仍可能出现慢读:读者先读旧数据库,写者提交并删键,慢读随后把旧值写回缓存。
解决方案取决于可接受窗口:短 TTL 让旧值收敛;值携带版本并用 Lua 只允许新版本覆盖;通过数据库 Outbox 可靠投递失效事件;严格场景直接读权威库或让更新走单一串行边界。所谓延迟双删只是缩小概率窗口,延迟值无法覆盖所有调度和网络暂停,不是严格证明。
local current = redis.call('HGET', KEYS[1], 'version')
if current and tonumber(current) >= tonumber(ARGV[1]) then
return 0
end
redis.call('HSET', KEYS[1], 'version', ARGV[1], 'payload', ARGV[2])
redis.call('PEXPIRE', KEYS[1], ARGV[3])
return 1
数据库事务不能和 Redis 命令组成同一原子事务。缓存删除失败不应回滚已经提交的业务写入,而应记录待修复任务;反过来,也不能记录日志后静默遗忘,必须有重试上限、积压指标和人工修复入口。
5. TTL、抖动与逻辑过期
TTL 是一致性上限、容量回收机制,也是故障恢复保险。没有 TTL 的派生缓存最终会积累孤儿键。为同类键使用 base + random(0,jitter),避免整批同时过期;随机源无需密码学强度,但要避免每次用相同种子。
func ttlWithJitter(base, spread time.Duration) time.Duration {
if spread <= 0 {
return base
}
return base + time.Duration(rand.Int64N(int64(spread)))
}
ttl := ttlWithJitter(10*time.Minute, 2*time.Minute)
if err := client.Set(ctx, key, payload, ttl).Err(); err != nil {
return fmt.Errorf("cache article %q: %w", id, err)
}
逻辑过期把 freshUntil 放在值中,物理 TTL 留得更长。过期后一个工作者刷新,其余请求短暂返回旧值,适合允许陈旧的热点读;价格、权限和库存通常不能无条件这样做。刷新失败要延长退避而不是高频重试,并明确“最多旧多久”的服务目标。
6. 穿透:不存在的键也会消耗资源
攻击者或错误客户端不断请求不存在 ID,会绕过缓存直达数据库。第一层应做格式、租户和权限校验;第二层可缓存短 TTL 的 not-found 哨兵,并区分“确实不存在”与“数据库查询失败”。负缓存 TTL 要短于正常值,创建实体时主动删除负缓存。
Bloom Filter 用位图和多个哈希快速判定“一定不存在/可能存在”,存在假阳性但不能有假阴性。它适合数据集合相对稳定的场景;新增必须及时写入,删除通常不清位。过滤器故障不得变成错误拒绝真实数据,重建期间要有旁路策略。对枚举型查询和任意搜索条件,Bloom Filter 往往不合适。
7. 击穿:合并同键并发而非隐藏容量问题
热点键失效时,上千请求同时回源称为击穿。进程内 singleflight 可合并同键并发,但不同实例仍各自回源;它也不缓存结果,只共享一次调用。等待者应能响应自己的 context,且 key 中必须包含租户、权限和表示版本,防止错误共享。
resultCh := s.group.DoChan(key, func() (any, error) {
loadCtx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond)
defer cancel()
return s.repo.Get(loadCtx, id)
})
select {
case <-ctx.Done():
return Article{}, ctx.Err()
case result := <-resultCh:
if result.Err != nil {
return Article{}, result.Err
}
return result.Val.(Article), nil
}
这里共享调用使用独立且有界的预算,避免首个请求取消导致所有等待者失败;类型断言在封装内应检查。跨实例可用短租约刷新锁加旧值服务,但数据库仍需限流。若一个键本身需要不可承受的回源吞吐,应预热、拆分数据或改变读取模型。
8. 雪崩、淘汰与降级闸门
大量键同时失效、Redis 整体不可用或内存淘汰热点,都会把流量推向数据库。随机 TTL 只解决同步过期,不解决节点故障。生产需要数据库回源 semaphore、请求级超时、熔断、过载拒绝、静态兜底和恢复时渐进放量。
maxmemory-policy 要匹配实例角色。纯缓存常用近似 LRU/LFU 淘汰;混放持久状态时禁止让缓存把关键键挤出。监控 evicted_keys、expired_keys、命中率、内存碎片率、热点命令和回源比例。命中率按业务和键类别拆分,聚合平均值会掩盖热点失效。
9. Pipeline、事务和 Lua 的原子边界
Pipeline 把多条命令批量发送以减少 RTT,不保证原子;中间命令可被其他客户端穿插。MULTI/EXEC 保证批次顺序执行,但没有关系数据库式回滚,运行时类型错误不会撤销此前命令。WATCH 是乐观并发控制,冲突后应在总 deadline 内有限重试。
Lua 脚本在单个 Redis 执行线程中原子运行,适合比较版本、限流和安全释放锁。脚本必须短小,禁止无界遍历;慢脚本会阻塞该节点全部命令。Cluster 脚本访问的键需同 slot。上线时处理 NOSCRIPT,以 EVALSHA 为主并在缓存丢失时重新加载。
10. 分布式锁、租约与 fencing token
加锁使用 SET resource token NX PX ttl,token 必须每次随机且不可复用。释放必须由 Lua 比较 token 后删除,不能先 GET 再 DEL,因为两条命令间锁可能已过期并被新持有者取得。
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
租约到期、GC 停顿、网络分区或进程暂停后,旧持有者可能继续操作。续租只是降低概率,不能恢复已经丢失的互斥。关键写入使用单调递增 fencing token,让下游拒绝旧 token;数据库唯一约束、条件版本更新或单写者通常更可靠。锁只保护遵守同一协议的参与者,不能替代资源自身约束。
11. Streams 的日志、消费组与确认生命周期
Stream entry 有递增 ID 和字段集合。XADD 写入,消费组用 XREADGROUP 分配未投递消息;消息进入 Pending Entries List,业务副作用成功后才 XACK。消费者崩溃后用 XAUTOCLAIM 接管空闲过久的 pending。它提供至少一次效果,重复不可避免,消费者必须以 event ID 或业务唯一键幂等。
XGROUP CREATE article-events article-indexer $ MKSTREAM
XADD article-events MAXLEN ~ 100000 * type published article_id 781 version 12
XREADGROUP GROUP article-indexer worker-3 COUNT 20 BLOCK 2000 STREAMS article-events >
XAUTOCLAIM article-events article-indexer worker-4 60000 0-0 COUNT 20
XACK article-events article-indexer 1710000000000-0
MAXLEN ~ 是近似裁剪;保留窗口必须大于最大故障恢复时间,否则 pending 或落后消费者可能失去历史。不要确认后再做副作用。毒消息需有限重试、隔离流和审计入口。Pub/Sub 不持久化、断线即丢,不应承担必须送达事件。
12. Sentinel、Cluster 与一致性现实
主从复制通常异步。主节点确认后、复制前故障,故障转移可能丢失已确认写;WAIT 能等待副本确认但不能把系统变成强一致。Sentinel 负责发现主节点和故障转移,客户端必须配置多个 Sentinel 地址与主节点名。Cluster 将 slot 分布到节点,客户端处理 MOVED/ASK;多键、事务和脚本受 slot 限制。
故障转移期间会出现连接错误、只读错误和短暂拓扑陈旧。重试必须考虑命令是否幂等,例如重复 INCR 会多计。缓存业务可降级,锁和事实源业务则必须明确丢写与脑裂风险。不要把“有三个节点”直接等同于高可用。
Keyspace notification 可提示过期、删除等事件,但默认关闭,启用后增加 CPU 开销,而且仍基于 Pub/Sub:订阅者断线期间的事件不会补发。它适合辅助统计或尽力触发刷新,不能作为数据库状态机、可靠失效日志或任务调度器。若业务必须知道每次变化,应在写入源头产生持久事件,而不是从缓存副作用反推事实。
客户端缓存与服务端 client-side caching 还能减少网络读取,但失效消息同样存在连接生命周期和内存预算。使用前明确 tracking 模式、重连后的清空策略与多租户隔离;连接恢复时最保守做法是丢弃本地副本,不能继续信任断线窗口内的值。
13. 诊断、测试与性能验证
诊断先看应用端池等待、命令 P50/P99、错误类别和回源,再看 Redis INFO、SLOWLOG GET、LATENCY DOCTOR、CPU、网络、内存和复制偏移。KEYS * 会阻塞生产实例;抽样遍历用 SCAN,但它不是一致快照。大 key 用 MEMORY USAGE 和类型专用长度命令定位。
单元测试应覆盖 miss、负缓存、坏值、context 取消、删除失败和版本拒绝;并发测试验证同键回源次数。集成测试用真实 Redis 覆盖 Lua、过期、Streams reclaim 和故障转移。基准测试同时报告分配、对象大小与热点分布,均匀随机 key 的漂亮数字通常不代表生产。
go test -count=1 ./...
go test -race ./...
redis-cli --latency-history -h 127.0.0.1 -p 6379
redis-cli INFO memory
redis-cli SLOWLOG GET 20
14. 原子限流、计数与时间窗口
Redis 常被用作跨实例限流器,但算法决定用户体验与故障边界。固定窗口用 INCR 加首次 PEXPIRE,实现简单,却会在窗口交界允许近两倍突发;滑动日志用 sorted set 保存每个请求,精确但内存与清理成本随流量增长;令牌桶通常用 Lua 根据服务端时间补充令牌,在一段空闲后允许受控突发。
INCR 与设置 TTL 必须放入 Lua 或事务,否则进程在两条命令间崩溃会留下永久计数键。key 要包含租户、主体、规则版本与受保护资源,TTL 覆盖窗口后留少量余量。返回值应包含允许与否、剩余额度和重试时间,但不要信任客户端上报的主体标识。Redis Cluster 中一条规则涉及的键需同 slot。
限流器失效时选择 fail-open 还是 fail-closed 是业务决策:公开内容读取常限量放行,支付提交和验证码可能宁可拒绝。无论哪种选择,都要有本地应急限流,防止 Redis 故障将无限流量推向下游;应急限制更保守,并在指标中与正常拒绝分开。
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('PEXPIRE', KEYS[1], ARGV[1])
end
local ttl = redis.call('PTTL', KEYS[1])
return {current <= tonumber(ARGV[2]) and 1 or 0, current, ttl}
15. 安全、持久化与备份恢复
启用 TLS,使用 ACL 为应用分配最小命令和 key pattern 权限,不暴露公网端口,不使用默认管理员账号。密码从秘密管理系统注入,日志不得记录 URI、token 或值正文。Lua、键名和搜索条件也可能泄露租户信息,应纳入脱敏。
RDB 提供时间点快照,AOF 记录写命令;两者可组合,但都要测量恢复时间与可接受数据丢失。复制不是备份,误删会同步到副本。备份应加密、异地、定期恢复演练,并记录 RPO/RTO。纯缓存可以选择不备份,但冷启动回源能力必须压测,预热不能击垮数据库。
故障演练不能只杀客户端连接。至少覆盖主节点在确认写入后立即失效、网络单向丢包、磁盘写满、AOF rewrite 变慢、复制延迟扩大、证书过期和 Cluster slot 迁移。每次演练记录应用错误分类、回源峰值、恢复时间及是否出现重复副作用,再据此调整重试和降级,而不是只确认“最终恢复”。
恢复运行手册应明确谁能执行故障转移、何时停止写入、怎样确认新主数据水位、是否允许从旧主补数据,以及业务如何核对未知结果。自动化负责可重复步骤,数据取舍仍需值班人员基于 RPO 做决定。
16. 生产部署检查清单
- 明确每类 Redis 数据的事实源、丢失容忍、TTL 和淘汰策略。
- 客户端长期复用,连接总量、阻塞命令和重试有全局预算。
- Cache Aside 明确旧值窗口,删除失败有可靠修复路径。
- 穿透、击穿、雪崩分别使用校验/负缓存、请求合并、容量隔离治理。
- 锁用随机 token 安全释放,关键资源增加 fencing 或数据库约束。
- Streams 在副作用后确认,消费者幂等并监控 pending 最老年龄。
- 真实演练节点故障、缓存全失、慢命令、备份恢复与证书轮换。
- 将关键假设写进容量表和运行手册:实例扩容后的总连接、最坏回源 QPS、允许旧值窗口、Stream 保留时长及恢复负责人都应可量化和演练。
系列导航与关联阅读
- 系列入口:Go 完整技术体系学习路线:从语法、并发到框架、中间件与 AI
- 上一篇:Go Redis 与 go-redis:连接、数据结构、Pipeline 和事务
- 下一篇:Go 本地缓存:sync.Map、LRU、Ristretto 与多级缓存一致性
- 延伸:Go 并发工具:errgroup、singleflight、semaphore 与 ants
- 延伸:Go 可靠消息统一设计:Outbox、幂等、重试、顺序与死信
官方资料
本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论