go-redis 与 Redis 版本兼容性:一次高延时问题的完整排查
原作者:Xargin。原文 go-redis 和 redis server 版本错位导致的高延时问题一例,发表于 2022-06-19,来源 No Headback。本文整理原有中文正文,保留实验数据及推理链,并标明历史表述与演示代码的修订。
一个只调用 Redis 的接口,为什么会出现半秒甚至秒级延迟?这个案例的入口是同一业务中并存的多个 go-redis 客户端版本和 Redis Cluster 版本。作者用一个字节大小的键和值,对简单 GET 请求进行组合压测,得到以下结果。
| 客户端 | 服务端 | 平均延迟 | QPS |
|---|---|---|---|
| v6 | 5.0 | 2.58 ms | 42138.85 |
| v8 | 5.0 | 2.29 ms | 43567.68 |
| v6 | 6.0 | 549.22 ms | 117 |
| v8 | 6.0 | 2.48 ms | 41794.86 |
表中数字全部来自原作者当年的测试,本文没有重跑。异常集中在 v6 客户端搭配 Redis 6.0 的组合。这里的“版本错位”指具体协议响应兼容性问题,不能解释为客户端和 Redis 的主版本号必须相同。
版本记录边界:原文正文称测试 Redis 5.0 和 6.0;其终端目录截图可见 redis-5.0.14/、redis-6.2.7/ 和 redis-7.0.2/。截图是目录列表,不能据此确定表中 Redis 6.0 测试的具体补丁版本。本文保留作者表格与正文说法,并说明这一证据差异。

实验是怎样搭起来的
作者使用 Redis 分发包里的 utils/create-cluster 脚本,分别搭建 5.0 和 6.0 集群。原文在 macOS 上进入 Redis 源码目录执行 make,并提醒当时 Homebrew 的安装结果不包含这个脚本。该说明属于 2022 年环境记录,不代表今天所有安装方式的内容。
# 原文历史实验步骤,会启动服务并创建集群
./create-cluster start
./create-cluster create
原文默认形成三主三从,随后用 redis-cli -c -p 30001 连接集群,执行 SET 1 1 准备测试数据。客户端种子节点为本机 30001、30002、30003 三个端口。创建集群和写入键都有实际副作用,只适用于独立且获准的测试环境;本文未执行。
v6 服务导入 github.com/go-redis/redis,共享一个 redis.NewClusterClient,HTTP 处理器调用 cli.Get("1");v8 改为 github.com/go-redis/redis/v8,并调用 cli.Get(context.TODO(), "1")。两份程序都在 :10003 上提供 HTTP 服务,匿名导入 net/http/pprof,将 Redis 错误打印到标准输出。
# 原文的施压参数;不应指向生产或无授权服务
wrk -t10 -c100 -d60 http://localhost:10003
这组参数用 10 个线程、100 个并发连接施压 60 秒。作者替换客户端和服务端版本后得到上表。原示例没有在 Redis 失败时设置 HTTP 错误码,因此 HTTP QPS 和成功响应不能直接当作 Redis 业务成功率。
第一条线索:大量 goroutine 堵在同一把锁
作者在压测期间检查 pprof goroutine profile。原文给出的总数为 205,其中 100 个 goroutine 在网络读取路径;另有 69 个及 29 个 goroutine 的栈进入互斥锁等待,关键调用链分别经过以下函数:
sync.runtime_SemacquireMutex
sync.(*Mutex).lockSlow
github.com/go-redis/redis/internal.(*Once).Do
github.com/go-redis/redis.(*cmdsInfoCache).Get
github.com/go-redis/redis.(*ClusterClient).cmdInfo
github.com/go-redis/redis.(*ClusterClient).cmdSlotAndNode
github.com/go-redis/redis.(*ClusterClient).defaultProcess
main.sayhello
另一组栈在 cmdInfo 与 cmdSlotAndNode 之间还经过 cmdSlot。这些栈把调查方向从“Redis 本身执行 GET 很慢”缩小到客户端命令元信息缓存的初始化。原栈明确标出 v6.15.9+incompatible;本文用对应标签源码做静态交叉核验。
第二条线索:命令元信息本来只应成功初始化一次
创建 ClusterClient 时,每个实例都会建立自己的 cmdsInfoCache,其加载函数指向该客户端的 cmdsInfo。首次需要命令信息时,缓存的 Get 方法交给内部 Once.Do 初始化:调用加载函数,成功后把返回的命令表存到 c.cmds;若失败,立即返回错误。
cmdsInfo 先取得节点地址,再遍历候选节点,通过 node.Client.Command().Result() 请求 Redis 的 COMMAND 元信息。只要一个节点提供可接受的结果,就能完成初始化。成功以后,后续读取本应主要检查原子标志,不再每次进行这段网络请求。
源码核验更正:原文在解释快速路径时借用了 sync.Once 的说法,但实际类型是 github.com/go-redis/redis/internal.Once。对应源码直接说明:传入函数返回错误时,会重新允许后续调用尝试。标准库 sync.Once 不提供这种“返回 error 后重新武装”的语义。这里必须区分二者。
内部实现先原子读取 done;若尚未成功便获取互斥锁,再检查一次 done,调用初始化函数。只有返回 nil 才把 done 设为 1。因此,失败越持久,进入慢路径并等待锁的请求就越多。
真正的兼容性差异:COMMAND 多了一个字段
Redis 5 的单条命令信息有 6 项;Redis 6 增加了 ACL command categories,形成第 7 项。对应 v6.15.9 的 commandInfoParser 却要求数组长度严格等于 6。收到 7 项便返回:
redis: got 7 elements in COMMAND reply, wanted 6
例如上游问题记录中的 COMMAND INFO xadd,Redis 6.0.1 在原有命令名、arity、flags 和键位置等字段之后,额外返回 @write、@stream、@fast 分类。这个元信息解析错误会让初始化始终失败,done 始终不置位,缓存始终没有完成。
于是每次业务命令又回到慢路径。慢路径内部不仅有互斥锁,还有相对昂贵的网络调用;并发请求都共享原示例的全局客户端,争用便集中在该客户端的缓存锁上。这解释了为什么 GET 很简单,端到端请求仍出现明显高延迟。
范围更正:锁属于每个 ClusterClient 的缓存实例,不是所有 Redis 客户端天然共享的一把进程级锁。原程序把客户端设为全局变量,才使这批请求汇聚到同一缓存。为了绕过锁而为每个请求新建客户端并不是本文建议,那会引入其他连接与初始化成本。
上游修复说明了什么
原文链接的 PR #1355 描述了相同的持续 COMMAND 请求、7 项响应和 done 未设置的问题。该提案让客户端处理 Redis 6 的额外 ACL 分类,同时保留对旧响应的兼容;页面显示其通过相关 PR #1357 关闭并落地修复。
原作者由此提醒读者关注受维护的客户端版本,并以 Go 的版本支持习惯类比。本文保留这个维护建议的含义,但不把“项目永远只维护最新两个版本”当成已经核实的长期政策,也不把 v8 作为 2026 年的新项目选型结论。升级应按目标客户端与 Redis 的实际兼容矩阵、变更说明和业务回归来决定。
演示代码的静态安全审查与修订示例
原 HTTP 程序直接监听 :10003,并在默认 mux 注册 pprof。这会把调试端点和业务演示服务放在同一监听器上,可能暴露到所有网卡。错误只打印而不改变 HTTP 状态,也会掩盖业务失败;context.TODO() 没有请求截止时间。示例没有 Redis 认证或 TLS 设置,应视为本机隔离实验,而不是可直接对外部署的服务。
下面是基于原 v8 示例的整理版,未编译或运行。差异是:绑定 loopback、使用独立 mux、不加载 pprof、使用请求 context 与超时、Redis 出错返回 502、成功返回 204、设置 HTTP 请求头读取超时,并在服务返回时执行延迟关闭客户端。它只演示更清晰的测量边界,不修复 v6 解析器,也不是完整生产服务。
package main
import (
"context"
"errors"
"log"
"net/http"
"time"
"github.com/go-redis/redis/v8"
)
var cli = redis.NewClusterClient(&redis.ClusterOptions{
Addrs: []string{
"127.0.0.1:30001", "127.0.0.1:30002", "127.0.0.1:30003",
},
})
func main() {
defer cli.Close()
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := cli.Get(ctx, "1").Err(); err != nil {
log.Printf("redis GET failed: %v", err)
http.Error(w, "redis request failed", http.StatusBadGateway)
return
}
w.WriteHeader(http.StatusNoContent)
})
srv := &http.Server{
Addr: "127.0.0.1:10003",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Printf("HTTP server error: %v", err)
}
}
若需要 pprof,应另设仅受控访问的调试监听器,并限制采样数据的可见范围;本文不提供或运行外网调试部署。错误日志也可能包含内部信息,应受访问控制。
整理、许可与未测试范围
本文已核对原文的环境准备、两版客户端、压测结果、pprof 栈、缓存初始化和上游修复讨论,并静态比对指定版本源码。历史压测数据归原作者;本文没有下载 Redis、建立集群、运行 wrk、抓取 profile 或修改生产系统。静态审阅不能排除代码中未发现的其他缺陷。
正文归 Xargin,保留原文标题、作者、来源与日期。该博客页没有显示开放内容转载许可;本文中文整理与原创示意图依据另行取得的授权用于本次发布,此说明不授予对源博客或源图的开放重用权。
上游 go-redis v6.15.9 发行包附带 BSD-2-Clause LICENSE,全文列在 LICENSE-go-redis-v6.15.9-BSD-2-Clause.txt;源码文件 internal/once.go 另有 Copyright 2014 The Camlistore Authors 与 Apache License 2.0 文件头,相关许可全文列在 LICENSE-APACHE-2.0.txt。这两种许可对应上游软件代码;本文仅解释内部实现,没有复制该实现代码,也不把软件许可误作博客文章或原创配图的许可。
源站终端与代码搜索截图只用来核验图中信息,本文未复用它们:表格中的延迟/QPS、pprof 栈计数与版本解析错误均以正文文字保留;原创机制图说明的是推理关系,不是测试结果。











暂无评论内容