Go 基础体系 · 第 62/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。
Go Redis 与 go-redis:连接、数据结构、Pipeline 和事务
本文以 Go 1.26.4、Redis 8.2 和 github.com/redis/go-redis/v9 v9.19.0 为基准。版本是本文实际固定的稳定版本。go-redis 提供单机、Sentinel、Cluster、Pipeline、Pub/Sub、Streams 和脚本 API;Redis 是内存数据服务,不是“更快的 map”,其过期、淘汰、复制、故障切换和网络不确定性都会改变应用语义。
客户端命令返回成功只说明所连接节点接受并执行了命令;副本是否同步、缓存与数据库是否一致、故障切换是否丢最近写入,需要架构另行保证。本文从生命周期和失败边界出发,而不是只罗列命令。
1. Client 是并发安全的长期连接池
redis.Client 内部维护连接池,应在进程生命周期内复用。每请求创建 Client 会反复建连、认证并制造池风暴。创建 Client 不立即联网,启动依赖 Redis 时用有期限的 Ping 验证,退出时 Close。
func OpenRedis(ctx context.Context, address, username, password string) (*redis.Client, error) {
client := redis.NewClient(&redis.Options{
Addr: address,
Username: username,
Password: password,
DB: 0,
PoolSize: 40,
MinIdleConns: 4,
ConnMaxIdleTime: 5 * time.Minute,
ConnMaxLifetime: 30 * time.Minute,
DialTimeout: 2 * time.Second,
ReadTimeout: 800 * time.Millisecond,
WriteTimeout: 800 * time.Millisecond,
})
pingCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
if err := client.Ping(pingCtx).Err(); err != nil {
_ = client.Close()
return nil, fmt.Errorf("ping redis: %w", err)
}
return client, nil
}
密码不出现在日志。PoolSize 要乘实例数与 worker 数评估 Redis 最大连接;扩大池只会提高服务端并发,无法修复慢命令或网络问题。
2. Context、客户端超时和服务端工作
每条命令接收 context。请求 context 控制排队和等待结果,Dial/Read/WriteTimeout 是连接层边界,两者共同生效。数据层不得替换为 context.Background()。
func GetTitle(ctx context.Context, client redis.Cmdable, key string) (string, error) {
commandCtx, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
defer cancel()
value, err := client.Get(commandCtx, key).Result()
switch {
case errors.Is(err, redis.Nil):
return "", ErrCacheMiss
case err != nil:
return "", fmt.Errorf("get cached title: %w", err)
default:
return value, nil
}
}
context 超时不保证服务端没有执行写命令:请求可能已到达 Redis,只是响应未收到。对 INCR、队列消费等非幂等操作,超时后盲目重试可能重复执行;使用幂等键、Lua 状态机或读取最终状态。
3. redis.Nil 是缺失,不是连接错误
GET、HGET 等查无数据通常返回 redis.Nil。它是缓存 miss,可回源数据库,不应记录 Error 告警。网络、认证、超时、READONLY 和 MOVED 等错误才是基础设施或拓扑问题。
命令对象可以用 .Result()、.Val() 和 .Err(),生产路径优先同时处理值和错误;只调用 .Val() 会把失败伪装成零值。批量命令也要逐条检查命令错误,因为 Pipeline 总错误不能完整表达每个结果。
错误包装保留 %w,上层使用 errors.Is/As 分类。日志记录 operation、节点类别、耗时和错误类型,不记录 key 中的个人数据、完整值或密码。
4. Key 设计是数据模型的一部分
key 需要稳定命名空间、租户、实体和 schema 版本,例如 article:v3:{tenant-42}:a-100。版本前缀允许更换编码并渐进淘汰旧 key;tenant 防止跨租户碰撞;Cluster hash tag {tenant-42} 控制相关多 key 落在同一 slot。
func ArticleKey(tenantID, articleID int64) string {
return "article:v3:{tenant-" + strconv.FormatInt(tenantID, 10) + "}:" +
strconv.FormatInt(articleID, 10)
}
用户输入不能直接成为无限长 key;先验证长度与字符集,必要时使用带域前缀的哈希。不要用 KEYS pattern 扫生产全库;管理任务用受限 SCAN,但 SCAN 迭代期间数据变化,不能当一致快照。
5. String 缓存与 TTL 生命周期
Cache-aside 的读取流程是先查缓存,miss 后查数据库,再带 TTL 写缓存。缓存是派生数据,数据库仍是权威源。所有可重建缓存应设置 TTL,避免旧数据永久存活和内存只增不减。
type Article struct {
ID int64 `json:"id"`
Title string `json:"title"`
}
func CacheArticle(ctx context.Context, client redis.Cmdable, key string, article Article) error {
payload, err := json.Marshal(article)
if err != nil {
return fmt.Errorf("encode cached article: %w", err)
}
if len(payload) > 64<<10 {
return errors.New("cached article exceeds 64 KiB")
}
ttl := 10*time.Minute + time.Duration(rand.Int64N(int64(time.Minute)))
if err := client.Set(ctx, key, payload, ttl).Err(); err != nil {
return fmt.Errorf("cache article: %w", err)
}
return nil
}
TTL 加随机抖动避免大量 key 同时失效。序列化格式带版本并限制大小。负缓存可以短 TTL 保存“不存在”,但权限错误和临时数据库故障不能写成不存在。
6. 缓存一致性与失效顺序
常见写路径是先提交数据库,再删除缓存。若先删缓存再提交,另一个请求可能从数据库读到旧值并重新填充。即使“先库后删”,删除失败仍会短暂陈旧,所以 TTL 是最终兜底,重要业务可用 outbox/CDC 重试失效。
并发读写可能发生旧回填覆盖新删除:读请求在更新前查库,更新后才 Set。可在 value 中加入数据库 version,写入 Lua 脚本只允许较新版本;或接受短暂陈旧并选择合理 TTL。强一致读取不要经过普通缓存。
Redis 故障时,多数缓存场景应降级到受限数据库回源,而不是让所有请求同时冲击数据库。使用本地并发合并、回源限流和断路策略;不能用无界 goroutine 异步写缓存。
7. Hash、Set、Sorted Set 与数据边界
Hash 适合字段独立读写的小对象,但不会自动继承字段级 TTL;整个 key 共用过期时间。Set 适合唯一成员关系,Sorted Set 以 score 排序,适合排行榜或时间队列,但浮点 score 对超大整数精度有限。
pipe := client.TxPipeline()
pipe.HSet(ctx, "profile:v1:{u-42}", map[string]any{
"name": "Ada",
"version": 7,
})
pipe.Expire(ctx, "profile:v1:{u-42}", 30*time.Minute)
if _, err := pipe.Exec(ctx); err != nil {
return fmt.Errorf("write profile hash: %w", err)
}
集合必须有基数上限与清理策略。单个百万成员 Hash/Set/ZSet 是大 key,会阻塞删除、复制和迁移。用 MEMORY USAGE、SCAN 家族和 slowlog 抽样诊断,删除大 key 可考虑 UNLINK,但后台释放仍消耗资源。
8. Pipeline 减少往返但不提供原子性
普通 Pipeline 把多条命令批量写入连接并批量读取响应,主要收益是减少网络 RTT。其他客户端命令可以穿插执行;其中一条失败不会回滚其他命令。
commands, err := client.Pipelined(ctx, func(pipe redis.Pipeliner) error {
for _, id := range articleIDs {
pipe.Get(ctx, ArticleKey(tenantID, id))
}
return nil
})
if err != nil && !errors.Is(err, redis.Nil) {
return nil, fmt.Errorf("pipeline article cache: %w", err)
}
values := make([]string, 0, len(commands))
for _, command := range commands {
value, err := command.(*redis.StringCmd).Result()
if errors.Is(err, redis.Nil) {
continue
}
if err != nil {
return nil, fmt.Errorf("read pipeline result: %w", err)
}
values = append(values, value)
}
批量大小受内存、报文和尾延迟限制,例如每批 100 至 1000 条后按负载测试调整。Pipeline 占用一条连接直到读取全部响应,巨大批次会拖慢同池请求。
9. MULTI/EXEC 与 TxPipeline 的真实语义
TxPipeline/TxPipelined 通常用 MULTI、排队命令、EXEC 提交,使批次执行期间不被其他客户端命令穿插。Redis 事务没有关系数据库式回滚:EXEC 后某条命令运行时类型错误,其他命令仍可能成功。
_, err := client.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
pipe.HIncrBy(ctx, "quota:v1:{tenant-42}", "used", 1)
pipe.Expire(ctx, "quota:v1:{tenant-42}", 24*time.Hour)
return nil
})
if err != nil {
return fmt.Errorf("update tenant quota: %w", err)
}
事务能保证命令批次原子执行,不保证读取后判断再写的条件逻辑。涉及多个 key 时 Cluster 要求同 slot。不要把 MULTI/EXEC 描述成支持 rollback 的数据库事务。
10. WATCH 乐观锁与受限重试
WATCH 监视 key;事务执行前 key 被其他客户端修改,EXEC 失败并返回 redis.TxFailedErr。回调可能执行多次,不能在其中发送邮件或调用非幂等外部接口。
func Reserve(ctx context.Context, client *redis.Client, key string, amount int64) error {
const maxAttempts = 4
for attempt := 0; attempt < maxAttempts; attempt++ {
err := client.Watch(ctx, func(tx *redis.Tx) error {
available, err := tx.Get(ctx, key).Int64()
if err != nil && !errors.Is(err, redis.Nil) {
return fmt.Errorf("read available quota: %w", err)
}
if available < amount {
return ErrInsufficientQuota
}
_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
pipe.DecrBy(ctx, key, amount)
return nil
})
return err
}, key)
if !errors.Is(err, redis.TxFailedErr) {
return err
}
if err := sleepContext(ctx, time.Duration(attempt+1)*10*time.Millisecond); err != nil {
return err
}
}
return ErrConflict
}
高竞争 key 上 WATCH 会频繁失败,Lua 通常更合适。重试受 context 和次数限制,并加入抖动,避免同步重试放大热点。
11. Lua 脚本把读改写成单个原子操作
Redis 在单线程命令执行路径中原子运行 Lua 脚本,脚本期间其他命令不能穿插,因此脚本必须短小、确定且不扫描大集合。key 通过 KEYS、值通过 ARGV 传入,不拼接用户输入。
var reserveScript = redis.NewScript(`
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
local amount = tonumber(ARGV[1])
if amount <= 0 then return redis.error_reply('invalid amount') end
if current < amount then return 0 end
redis.call('DECRBY', KEYS[1], amount)
return 1
`)
func ReserveWithScript(ctx context.Context, client redis.Scripter, key string, amount int64) error {
result, err := reserveScript.Run(ctx, client, []string{key}, amount).Int()
if err != nil {
return fmt.Errorf("run reserve script: %w", err)
}
if result != 1 {
return ErrInsufficientQuota
}
return nil
}
redis.NewScript 优先 EVALSHA,缺脚本时自动加载/执行。Cluster 中所有 KEYS 必须同 slot。脚本版本随应用发布,返回值形成协议,应为错误、冲突和成功定义稳定编码并测试。
12. Sentinel、Cluster 与拓扑变化
单机 Client 连接一个地址;FailoverClient 通过 Sentinel 发现主节点;ClusterClient 根据 slot 路由并处理 MOVED/ASK。三者都应长期复用,但配置、故障模型和多 key 限制不同。
Sentinel 切换不是零中断,短时间会有连接错误或 READONLY;重试只用于已确认幂等命令。Cluster 扩缩容时 slot 迁移增加重定向,多 key、事务和脚本必须同 slot。hash tag 能控制位置,也可能把某租户全部流量压到单节点,需按负载选择粒度。
读副本会增加陈旧读风险。刚写主节点后从副本读取不保证 read-your-writes。权限、余额和幂等状态通常不应以可能陈旧的副本读作为判定依据。
13. Pub/Sub 与 Streams 生命周期
Pub/Sub 是实时广播,订阅者离线时消息丢失,没有 ack 和历史重放。订阅占用专用连接,创建后必须 Close;接收循环由 context 停止并等待退出,不能 fire-and-forget。
Streams 保存消息,consumer group 使用 XREADGROUP、XACK 和 pending entries 支持至少一次处理。消费者可能在处理后、ACK 前崩溃,因此 handler 必须幂等,并定期认领超时 pending。Stream 用 MAXLEN 控制长度,但裁剪策略会影响未消费消息。
可靠业务事件首先写数据库 outbox,再由发布器送 Streams/消息系统;直接“提交数据库后 Publish”会在进程崩溃窗口丢事件。Redis 持久化和复制策略仍决定可恢复程度。
14. 缓存穿透、击穿、热 key 与淘汰
穿透是大量不存在 key 回源,可用输入校验、短期负缓存和受控 Bloom filter;击穿是热点 key 过期瞬间并发回源,可用 TTL 抖动、请求合并或后台提前刷新。请求合并必须有 deadline,leader 失败时等待者也要退出。
热 key 可能把单节点 CPU/网卡打满。拆 key、客户端本地只读缓存、复制读取或业务分片前先用监控确认。大 key 增加命令、网络、fork、复制与删除延迟。内存达到 maxmemory 后按策略淘汰;noeviction 返回写错误,LRU/LFU 策略可能删除仍被依赖的 key。缓存代码必须把 key 被淘汰当正常 miss。
15. 连接池、重试与故障降级
观察 PoolStats 的 Hits、Misses、Timeouts、TotalConns、IdleConns 和 StaleConns。PoolTimeout 增长可能来自池过小、慢命令、阻塞命令、巨大 Pipeline 或网络故障。先查 slowlog、命令延迟和 goroutine,再决定池大小。
go-redis 可能按配置重试部分网络错误。增加重试会延长尾延迟并放大故障;写命令只有具备幂等语义才可安全重试。缓存读取失败可以受控回源,缓存写失败通常记录指标后继续,但限流器、锁或幂等存储失败时应 fail closed 还是 fail open 必须按业务明确。
关闭时先停止接受请求和后台消费者,等待在途任务,再 Close Client。直接退出可能丢异步处理;普通命令没有需要 flush 的客户端无界队列,应用也不应自行创建这种队列。
16. 安全与生产配置
Redis 不应暴露公网。使用私网、防火墙、TLS 和 ACL,为应用授予所需命令与 key pattern;管理、迁移和监控身份分离。禁用或限制高风险管理命令不能替代网络隔离。密码和证书从秘密系统加载并轮换。
key 名也可能泄漏租户、邮箱或订单号,监控和慢日志会记录命令,应使用内部 ID 并控制日志访问。Lua、模块和配置变更属于代码发布,固定版本并审计。备份、AOF/RDB、复制与故障切换按恢复目标演练,缓存可丢不代表限流、会话或 Stream 数据也可丢。
17. 测试、竞态与外部服务边界
纯函数单元测试覆盖 key 构造、序列化、TTL 范围和错误映射。miniredis 等内存实现适合常用命令与 Lua 快速测试,但不模拟真实网络、连接池、Cluster、故障切换、淘汰和全部 Redis 版本语义。关键路径仍连接与生产同版本的 Redis。
go test ./...
go test -race ./...
go test -run Integration -count=1 ./internal/cache/...
redis-cli --tls -u "$REDIS_URL" INFO commandstats
redis-cli --tls -u "$REDIS_URL" SLOWLOG GET 20
集成测试覆盖 miss、TTL、过期、Pipeline 部分错误、WATCH 冲突、Lua、连接断开和取消;Cluster 测试覆盖跨 slot 错误与重定向。race 检查应用共享状态,但不能发现 Redis 端逻辑竞争,条件写必须用并发集成用例验证最终不变量。
18. 性能诊断与上线清单
基准必须包含网络和真实 payload,记录 ops/s、P50/P95/P99、错误率、连接等待、Redis CPU、内存、evicted keys、命中率、复制延迟与网卡。命中率高不代表健康:一个超大热 key 仍可能拖垮节点;平均延迟也会掩盖阻塞命令。
上线前固定 Go、go-redis 和 Redis 版本;确认 key 前缀、TTL、最大 value/集合、失败策略和幂等边界;按实例总数计算连接;设置 dial/read/write/pool/request timeout;验证 ACL/TLS、拓扑发现、优雅关闭和告警。故障时区分客户端池等待、网络、服务端慢命令、内存淘汰、主从切换和 Cluster 重定向,再选择限流、旁路缓存、停止回填或切换节点,避免把所有错误简单归为“Redis 慢”。
系列导航与关联阅读
- 系列入口:Go 完整技术体系学习路线:从语法、并发到框架、中间件与 AI
- 上一篇:Go 数据库迁移:golang-migrate、Atlas、回滚与零停机变更
- 下一篇:Go Redis 生产模式:缓存一致性、穿透击穿、锁与 Streams
- 延伸:Go 本地缓存:sync.Map、LRU、Ristretto 与多级缓存一致性
- 延伸:Go context 完整指南:取消、超时、Deadline 与 Value
- 延伸:Go OpenTelemetry 实战:Trace、Metric、Context 与 OTLP
官方资料
本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论