之前把 Redis 从 4 升级到 6,遇到过一个有点反直觉的问题。
项目使用的客户端是 go-redis v6.15.5。Redis 原地升级后,应用没有重启,业务运行正常。但应用重启之后,低流量时看起来没问题,流量上到约 40 QPS,就开始出现超时。
最后,我们把客户端升级到 go-redis v8,问题解决了。
这件事最值得记录的地方,是同一套应用、同一个升级后的 Redis,为什么重启前后会有这么大差别。事后对照社区资料,一个很关键的线索是:应用进程里已经建立的命令元数据缓存,可能把兼容问题暂时藏了起来。
下文围绕 go-redis 的 ClusterClient 展开,分析命令元数据缓存如何影响升级前后的行为。普通 NewClient 的请求路径不在这次机制分析的范围内。
一个相近的公开案例
曹大的文章 《go-redis 和 redis server 版本错位导致的高延时问题一例》记录过类似现象:旧版 go-redis 访问 Redis 6 集群时,简单 GET 的延迟明显升高,换成 v8 后恢复。
更直接的依据来自 go-redis 的 PR #1355。提交者定位到了 Redis 6 下命令信息无法缓存、请求反复执行 COMMAND,进而引起互斥锁竞争的问题。维护者随后通过 PR #1357合入相关修复。
这给了我们一个比“客户端太旧”更具体的解释:变化发生在命令元数据的返回格式,以及加载失败后的处理路径上。
COMMAND 多了一个字段
COMMAND 返回 Redis 支持的命令及其属性。客户端可以用这些信息判断命令的 key 参数位置、是否只读等,辅助集群路由。
Redis 6 增加了 ACL 分类信息,每条命令描述从此前的 6 个字段变成 7 个。这里的 ACL 分类是命令元数据,不意味着应用一定修改过账号或密码。官方问题说明
go-redis v6.15.5 的解析器却把字段数固定为 6:
if n != 6 {
return nil, fmt.Errorf("redis: got %d elements in COMMAND reply, wanted 6", n)
}
因此,遇到新响应时,会产生 redis: got 7 elements in COMMAND reply, wanted 6。这段检查就在 v6.15.5 的 commandInfoParser 中。
这个兼容问题不要求 Redis 切换到 RESP3:即使仍使用旧协议,具体命令的响应结构也可能发生变化。
一次初始化,为什么变成了反复加载?
ClusterClient 创建时会建立 cmdsInfoCache,后续按需加载命令信息。加载成功后,信息保存在客户端内存中,供后续请求复用。集群客户端源码
这里使用的是 go-redis 自己实现的 internal.Once。它只有在初始化函数返回成功时,才会设置完成标记;如果失败,下一次调用仍会尝试。执行初始化函数期间,互斥锁一直被持有。Once 源码
可以把它理解成下面的逻辑,注意这只是简化示意:
已经初始化成功 → 直接使用缓存
尚未成功 → 加锁 → 执行加载
├─ 成功:保存缓存,标记完成
└─ 失败:不标记完成
本来这种设计允许临时故障后再次初始化。但固定的格式不兼容不会随着重试消失:每次加载都失败,后续请求便不断进入同一条路径。缓存实现
而加载过程包含向 Redis 请求 COMMAND 的网络调用。如果一个节点失败,代码还会继续尝试其他节点。这些操作发生在初始化锁内,同一个客户端上的其他请求只能排队等待。加载实现
为什么不重启应用就没问题?
把缓存的生命周期加进来,重启前后的差异就容易理解了。下面是基于上述代码,对我们现象的解释。
第一阶段:应用连接 Redis 4。
客户端加载命令信息,6 字段响应解析成功,缓存建立,初始化完成标记被设置。
第二阶段:Redis 升级到 6,应用继续运行。
只要原来的客户端对象还在,就继续使用已缓存的命令信息,不必再次解析 Redis 6 的 COMMAND 响应。已有缓存绕过了存在兼容问题的初始化路径。
第三阶段:应用重启。
进程内存清空,客户端对象重新创建。首次加载命令信息时面对的是 Redis 6,新响应无法被旧解析器接受,缓存始终建不起来。
| 时刻 | 客户端缓存 | 对应行为 |
|---|---|---|
| Redis 4 时已经处理过请求 | 已成功建立 | 后续复用 |
| Redis 升到 6,原客户端仍在 | 缓存仍在 | 不再触发这次解析 |
| 应用重启,重新创建客户端 | 缓存为空 | 重新请求并解析 COMMAND |
| 解析持续失败 | 始终未完成初始化 | 后续请求反复尝试 |
这里的缓存属于应用进程,不是 Redis 中的业务 key,也不绑定某一条 TCP 连接。连接断开重连,不等于客户端对象被重建。 因此,“升级期间发生过重连”与“应用仍保留命令缓存”并不矛盾。
同理,即使不重启进程,只要重新创建了 ClusterClient,也可能暴露问题。反过来,应用虽然早已启动,但升级前从未成功加载过命令信息,也不能获得这份缓存的保护。
为什么低流量正常,流量上来才超时?
低流量时,请求之间可能几乎没有竞争。即使每次都付出额外的元数据加载成本,也未必超过业务超时限制。流量增加之后,初始化锁成了共享瓶颈,等待开始累积。
另外,v6.15.5 的 cmdInfo() 在加载失败后返回 nil,没有直接把这个解析错误作为业务命令错误返回。对于普通 GET 等命令,缺少 key 位置信息还可能让初始路由退化为随机槽位,随后依赖 MOVED 重定向找到目标节点,增加额外开销。路由源码、key 位置处理
这也解释了为什么表面上可能是“还能读写,只是越来越慢”,而不是每个请求立即报出同一条解析错误。
这里要区分请求延迟与连接超时:初始化锁竞争发生在客户端,会拉长 Redis 调用的整体耗时;dial tcp 超时表示 TCP 建连超时,connection pool timeout 则表示等待连接池超时,它们对应不同的等待阶段。
约 40 QPS 也是我们当时环境里的观察值。业务请求包含多少次 Redis 操作、节点数量、网络耗时和超时配置,都会影响问题何时显现,不能把它当成这个 bug 的通用阈值。
升级 v8 为什么有效?
我们当时验证有效的处理方式是升级 go-redis v8。从代码来看,v8 已经兼容这项响应变化。例如 v8.11.5 的 commandInfoParser 同时接受 6 字段和 7 字段的命令描述。
一旦加载成功,命令信息就能缓存,后续请求无需再反复执行这段初始化。这个变化与我们观察到的恢复结果一致。
需要区分两个版本号:Redis 6 是服务端版本,go-redis v6、v8 是客户端库版本,它们不要求主版本数字相同。本文记录的是一次历史升级的处理结果,实际迁移时仍应按目标 Redis 版本、客户端支持范围及项目约束选定具体版本。
后续版本还改过 COMMAND 吗?
改过。Redis 7.0 又把每条命令描述从 7 个字段扩展到了 10 个。 因此,“升级 v8 解决了 Redis 6 的问题”不能推广成“这个客户端以后连接更高版本都没问题”。
这里的字段数量,指每条命令描述数组的长度,不是服务器支持的命令总数。根据 COMMAND 官方文档,结构变化如下:
| Redis 版本 | 每条命令描述的字段数 | 变化 |
|---|---|---|
| 4.x、5.x | 6 | 名称、参数数量、标志、首个 key 位置、最后一个 key 位置、步长 |
| 6.x | 7 | 增加 ACL categories |
| 7.0 | 10 | 增加 tips、key specifications、subcommands |
新增的三个字段各有用途:
- Tips:给客户端或代理的执行提示,例如集群请求应发往哪些节点、如何处理结果。Redis 7 还把部分旧 flags 的信息移到了 tips 中。命令提示文档
- Key specifications:更完整地描述如何从参数中提取 key,支持传统“首位置、末位置、步长”难以表达的规则。旧的三个位置字段仍然保留。Key 规则文档
- Subcommands:描述子命令各自的属性。例如同属
CLIENT的不同子命令,可以有独立的元数据。Redis 7 设计说明
到了 Redis 8,不能简单按主版本号推断字段又增加了。本次核对的 Redis 8.2.0 源码中,addReplyCommandInfo() 仍然写出长度为 10 的数组。当前官方文档列出的结构也仍是这 10 项。不过,字段数量保持不变,不代表命令列表、flags 或内部元数据内容完全不变。
go-redis v8 能兼容 Redis 7.0 吗?
官方发布的 go-redis v8.11.5 不完整兼容 Redis 7.0:普通单机 GET、SET、PING 不受下述格式变化影响,默认集群客户端则存在两处明确的响应解析问题。
普通 NewClient 的连接初始化不会自动加载 COMMAND 或 CLUSTER SLOTS,GET、SET、PING 等调用不经过下面的元数据解析路径。普通客户端初始化源码中可以看到这一点。若云服务通过代理暴露单一地址,由代理负责分片路由,应用使用普通 NewClient 时也不会执行客户端侧的集群拓扑加载。
前面引用的 go-redis v8.11.5 解析器只接受 6 或 7 个字段。按这段代码,收到 Redis 7 的 10 字段命令描述时,会返回:
redis: got 10 elements in COMMAND reply, wanted 7
这是由 v8.11.5 的字段数检查直接推导出的结果。它说明当时升级到 v8 的修复范围有明确边界;至于是否表现为相同的业务超时,还取决于客户端类型和调用路径。
集群模式还有另一个独立问题:Redis 7.0 为 CLUSTER SLOTS 返回的节点信息增加了网络元数据,而 v8.11.5 的解析器只接受节点数组长度为 2 或 3,收到 4 个元素时会报 got 4 elements in cluster info address, expected 2 or 3。这可能直接阻断拓扑加载,不能只修复 COMMAND 后就认定集群可用。服务端文档、v8.11.5 解析代码
| v8.11.5 访问 Redis 7.0 的场景 | 实际行为 |
|---|---|
普通 NewClient 执行传统 GET、SET、PING | 不经过上述元数据加载路径,不受这两处格式变化影响 |
使用 Command(ctx) 解析命令信息 | 10 字段响应不被该解析器接受 |
使用默认 ClusterClient 发现拓扑和路由 | 存在 COMMAND 与 CLUSTER SLOTS 两处明确的格式兼容问题 |
作为对比,go-redis v9.0.0 已明确接受 6、7、10 三种长度,并在读完前七项后消费、跳过新增的三项。这解决了该响应格式的解析问题,但不能仅凭这一点就断言它支持所有后续 Redis 功能。维护者也在 2022 年的版本选择讨论中明确建议 Redis 7 使用 go-redis v9。
Redis 7 对 COMMAND 命令族还有其他扩展,例如新增 COMMAND DOCS 来查询命令文档,以及 COMMAND GETKEYSANDFLAGS 来提取 key 及其访问标志。它们是命令能力的扩展,应与上述“已有响应数组变长”分开理解。
因此,后续再升级 Redis 时,需要核对实际使用的客户端具体版本。一个很直接的检查点,就是在目标版本上执行 COMMAND,确认客户端可以成功解析,并在新建客户端后的并发访问中验证初始化行为。
升级验证要覆盖应用重启
这次经历之后,我会把数据库升级的验证分成两种状态:已有应用继续运行,以及应用重新启动。两者经过的代码路径可能不同。
对这类依赖升级,至少要验证下面几项:
- 原进程继续访问升级后的服务端,检查存量运行状态。
- 重新创建客户端或重启应用,检查元数据加载等初始化路径。
- 从低流量逐步增加到有代表性的并发,检查错误率和尾延迟。
- 在异常时保留原始错误、依赖版本和 goroutine profile,便于把客户端排队与网络超时区分开。
“数据库已经升级,业务还在正常跑”是一项验证结果,但它只覆盖了当前进程已有的状态。对于会缓存拓扑、命令信息或其他元数据的客户端,下一次应用重启,才可能是新版本真正接受初始化检验的时候。
