go-redis 与 Redis 版本兼容性:一次高延时问题的完整排查

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 测试的具体补丁版本。本文保留作者表格与正文说法,并说明这一证据差异。

多个GET请求共享一个ClusterClient;缓存未初始化便进入互斥锁,COMMAND返回7字段而旧解析器只接受6字段,失败使done保持0,下一请求再次持锁访问网络。
根据原文及 v6.15.9 源码自绘的失败重试链路;不是压测截图。

实验是怎样搭起来的

作者使用 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 栈计数与版本解析错误均以正文文字保留;原创机制图说明的是推理关系,不是测试结果。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容