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 是缺失,不是连接错误

GETHGET 等查无数据通常返回 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 USAGESCAN 家族和 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 使用 XREADGROUPXACK 和 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 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。