升级完客户端,redis 又开始大量返回 MOVED:一个埋了七个月的本地配置

2026/09/05

前言

上一篇(redis 加了个分片,把线上写挂了一个多小时)的结尾,是把 redis-py 从 5.0.0 升到 8.x,客户端卡死的问题收住了。

收住的只是卡死。

第二天再去看那个集群,INFO errorstats 里躺着几千万次 MOVED,读命令的拒绝率在 40% 上下:

zrevrange         42.5% 被 MOVED
zrangebyscore     35.6%
hgetall           44.9%

CloudWatch 上每个分片每秒新建 440 条连接,但同时在线的只有 280 条。也就是说每条连接平均活半秒就被丢掉重建。

业务那边也有影响。推荐服务里有个 provider 负责读用户的点赞、播放、收藏历史,redis 读失败会让整个 provider 抛异常,外层 except Exception 接住打一行 critical 日志就继续往下走,接口照常返回 200。用户画像就这样丢了,请求本身看不出任何异常。而且那行 critical 日志并没有进 Datadog,这个故障几乎没有留下容易被看到的痕迹。

(配图占位:Datadog 上 redis 错误曲线,或 CloudWatch 的 NewConnections 曲线,持续在高位)


这篇记录一下接下来一天半的排查。最后的原因不复杂,但中间几个假设是怎么提出来、又是怎么被推翻的,我觉得比结论本身更有参考价值。

一、先搞清楚是哪些机器在连

这次没有先猜原因,而是先把”谁在连这个集群”摸清楚。

CLIENT LIST 拉出来 21 个客户端 IP,拿去 ECS 反查,映射出来是这样:

服务 机器数 redis-py 连接寿命
推荐服务 8 8.1.0 1–3 秒
API 服务 8 8.0.0 1.8–5 小时
事件消费服务 1 7.1.0 68 分钟

推荐服务 8 台机器全部在反复重建连接。旁边另一个后端 API 服务(下面简称 API 服务)连的是同一个集群、用的也是 redis-py 8.x,连接能活几个小时。同一个集群、同一个大版本的库,一个秒级一个小时级。从这一步开始,API 服务就是最好的对照组。

再看错误归属。服务端的 errorstats 是全局计数,不分客户端,但三个服务用的命令集正好不重叠:推荐服务用 zrevrangezrangebyscore,API 服务只用 zrevrangebyscore,事件消费服务只用 zremrangebyscore。对上各命令的拒绝率:

zrevrange          42.5% 被 MOVED   ← 推荐服务独占
zrangebyscore      35.6%            ← 推荐服务独占
zrevrangebyscore    0.004%          ← API 服务独占
zremrangebyscore    0.0%            ← 事件消费服务独占

同一个集群、同一时刻,推荐服务独占的命令 40% 被 MOVED,另外两个服务是 0。MOVED 全部来自推荐服务。

问题被压缩成一句话:同一个库、同一个集群,为什么推荐服务出问题,API 服务不会?

二、假设一:删掉的 socket_timeout

第一个想到的是 socket 超时。理由有两个:

一是推荐服务和 API 服务在客户端配置上唯一的差别就在这——API 服务设了 socket_timeout=3, socket_connect_timeout=2,推荐服务前一天刚把这两个参数删掉。二是被删掉的注释里写得很明白:

-  # 有界的连接/读写:挂起的 socket 自己超时断开,而不是把阻塞的
-  #   await 暴露给外部取消(那正是池槽位泄漏的窗口)

没有超时,阻塞的 await 只能靠外部取消来结束,而取消正好是连接池泄漏的窗口。看起来是一条完整的因果链。

但去核对的时候有两个地方对不上。第一,超时是兜底机制,服务本身并没有异常,健康的服务不可能每秒有 440 次挂起的 socket。兜底缺失不会制造故障,它只是在故障发生时兜不住。 第二,如果真的是泄漏,在线连接数应该一路涨到上限 300 然后报错,但它一直稳定在 280。在线连接数稳定,新建连接却每秒几百条,说明连接对象没有泄漏,是同一批对象的 socket 在反复断开重连。

这个假设排除。

三、假设二:MOVED 和连接重建互相触发

第二个想法是从库源码里来的。redis-py 处理 MOVED 的逻辑是这样:

except MovedError as e:
    self.reinitialize_counter += 1
    if self.reinitialize_counter % self.reinitialize_steps == 0:   # 默认 5
        await self.aclose()       # 关掉整个客户端
    else:
        await self.nodes_manager.move_slot(e)   # 只修正这一个 slot

每收到 5 次 MOVED,客户端就认为自己的拓扑过期了,把整个客户端关掉重建,所有连接作废。而每次重建,set_nodes() 会把所有节点的所有连接标记为需要重连——库作者的注释说得很直接,”reconnect is lazy and cheap”,前提是拓扑变更是罕见事件。

于是有了一条看起来能闭环的链:MOVED 触发重建,重建作废所有连接,重建期间路由表在变,命令发错节点,产生更多 MOVED。数字也对得上:MOVED 每秒 208 次,除以 5 约等于 41 次重建,实测 CLUSTER SLOTS 每秒 30 次,同一个量级。

但这条链有一个环节说不过去。连接坏了,命令应该是发不出去,为什么会发错地方?连接健康和路由是否正确是两件事。回去核对”重建期间路由表在变”这一步:redis-py 查不到 slot 时是抛异常,不会随机发;aclose() 也不会清空路由表。这一步是想当然的。

这条链解释了为什么连接在反复重建,但解释不了 MOVED 从哪来。 MOVED 是因,不是果,得另外找。

四、跳板机上复现不出来

那就去复现。跳板机在同一个 VPC,能直连集群,只做读操作。

用推荐服务一模一样的构造参数建客户端,发几百条读命令,0 次 MOVED。把并发提到 256,0。用 pipeline,0。把 hiredis、ddtrace 这些依赖一个个加上,还是 0。

把路由表 16384 个 slot 全部指向一个分片,模拟扩容前的旧表——大约 20 条命令之后客户端就自己修回来了。这个结果其实是一条有用的信息:路由表陈旧不可能是原因,客户端自己就能修,而线上 42% 持续了两天。

五、一次不成立的复现

跳板机恢复之后换了个思路:后台每秒 33 次强制刷新拓扑,同时打命令,用服务端的 MOVED 计数器量增量。

A  无刷新               每命令 MOVED = 0.00
B  拓扑刷新 33 次/秒     每命令 MOVED = 0.22

有信号了。当时准备照这个方向去改配置。

不过在改之前,我在客户端里 hook 了处理 MOVED 的那个方法,想抓一下现场看”客户端以为 slot 归谁、服务端说归谁”。结果 3000 条命令,那个方法一次都没被调用。我的客户端一次 MOVED 都没产生过。

那 B 组的 0.22 是哪来的?我量的是服务端的全局计数器,而线上推荐服务自己就有每秒 390 次的背景 MOVED。我的窗口里背景值有九千多,测到的”新增”只占十分之一,而线上自身的波动就有这个量级。那个 0.22 完全在噪声范围内。

换成客户端侧计数重跑,两组都是 0。

在一个本身就有大量背景噪声的地方,去量自己那点信号,量出来的东西不可靠。而且噪声有时候会给出一个看起来很像回事的数字。

六、能复现出症状,但不是线上的原因

再翻源码,发现路由策略是在客户端初始化之前决定的:如果客户端还没有 default_node,这条命令就会被当成”无 key 命令”,不查路由表,随机选一个节点发。而 aclose() 的第一行就是把 default_node 置空。

于是人为把 default_node 一直置为 None,并发 300,客户端侧计数:

正常                      0.00 / 0.00 / 0.00
default_node 置为 None     0.91 / 0.89 / 0.89     ← 持续,不衰减

这次确实复现了,而且是持续的。机制本身成立。

问题在于,这证明的是”如果把 X 弄坏就会出现这个症状”,但没有证明线上的 X 是坏的。 去看线上容器的启动日志:

22:39:05 | app.core.database_manager:322
  Redis Cluster connection initialized - host=clustercfg.xxx...

初始化是成功的,default_node 就是在这一步赋值的。线上的 default_node 是有值的,我构造出来的那个状态线上并不存在。

七、启动日志里的一行 INFO

不过启动日志没有白翻。就在 initialized 那一行的前面:

22:39:05 | app.core.database_manager:383
  Redis Cluster address_remap enabled for local development

8 个容器,每一个都打了这行。

address_remap 是 redis-py 的一个参数。正常情况下,集群客户端会先问一次 CLUSTER SLOTS,集群告诉它”哪些 slot 归哪个节点、节点地址是多少”,之后按这张表把命令发给对应的节点。address_remap 允许你在拿到节点地址之后改写它。推荐服务里的实现是:

# 地址重映射:将集群返回的内网地址映射到本地端口转发地址(仅用于本地开发)
def address_remap(address):
    return (host, port)      # host = clustercfg

不管集群告诉它哪个节点在哪,一律改写成配置里那个 clustercfg 地址。本质上就是把所有节点的地址硬编码成了同一个。

它的用途是本地开发:笔记本通过 SSM 端口转发连 VPC 里的集群,集群返回的是内网 IP,笔记本连不上,只能全部改写回隧道地址。注释写着”仅用于本地开发”,代码默认值是 false,这些都没有问题。

那线上是怎么开的?task definition 的环境变量里没有 REDIS_USER_ADDRESS_REMAP。再查仓库:

.env(提交在 git 里):  REDIS_USER_ADDRESS_REMAP=true
仓库没有 .dockerignore
Dockerfile:            COPY . .           ← .env 被打进了镜像
app/main.py:16         load_dotenv()      ← 给"没有设置的变量"填上 .env 里的值

load_dotenv() 不会覆盖已经存在的环境变量,所以 ECS 设了的那些都是对的。但 ECS 没有设 REDIS_USER_ADDRESS_REMAP,于是 .env 里本地用的 true 就生效了。

开了之后,集群回答”0-8191 归节点 A,8192-16383 归节点 B”,remap 把两个地址都改写成 clustercfg。路由表变成:

正常:  slot 0-8191 → 节点 A,       slot 8192-16383 → 节点 B
实际:  slot 0-8191 → clustercfg,   slot 8192-16383 → clustercfg

slot 算得是对的,但两个结果指向同一个名字。真正建连接时,clustercfg 的 DNS 在两个节点之间轮询,效果等于随机选一个分片,一半会发错。

这次是先有线上证据,再去复现:

address_remap 关闭   1500 条   MOVED = 0        路由表 = [节点 A, 节点 B]
address_remap 开启   1500 条   MOVED = 1459     路由表 = [clustercfg]

路由表里两个真实节点合并成了一个。

八、为什么七个月没事,为什么 API 服务没事

git log -S 查了一下,address_remap 的代码和 .env 里的 true 是 2026-01-28 同一个 commit 进来的,到出问题隔了七个月。

七个月没事是因为扩容之前只有一个分片。clustercfg 只会解析到唯一的那个节点,”把所有地址改写成 clustercfg”等于没改。09-02 加了第二个分片,这个配置才从无害变成随机路由。

然后它被一个更明显的问题盖住了——上一篇里 5.0.0 客户端卡死,症状是写入全部失败,比 MOVED 显眼得多。等 09-03 升到 8.x 把卡死修好,MOVED 才成了剩下的那条曲线。

API 服务为什么没事,看它的初始化代码就清楚了:

if _is_cluster_host(host):              # 是不是 clustercfg 开头
    _client = RedisCluster(**common)        # prod/stage:集群客户端
else:
    _client = aioredis.Redis(**common)      # dev:普通单节点客户端

API 服务的 dev 环境连的是一个 cluster mode 关闭的单节点实例,客户端当普通 Redis 用,不走拓扑发现,也就不存在”内网地址够不着”的问题。它从来没有需要过 remap 这个配置。 推荐服务没有做这个分支,本地也用集群客户端,才需要 remap 来处理内网地址。

这里补充一个容易混淆的地方。AWS 控制台里所有 ElastiCache 实例都叫 cluster,但 Cluster mode: EnabledDisabled 是两种东西:Disabled 是主从复制组,Enabled 才是 Redis Cluster 协议。同事提到”其他服务走 SSM 隧道连 cluster 一直正常”,那些实例都是 Disabled 的。

九、修复

修复用的是最保守的方式:不动 .env,在 prod 和 stage 的 task definition 里显式加一行 REDIS_USER_ADDRESS_REMAP=false。ECS 的环境变量优先级高于 .env,四行改动。

不删 .env 是因为算了一下,它里面还有 14 个变量当前正在线上生效。删掉会连带什么不确定,这是另一个需要单独处理的问题。

部署之后:

  修复前 修复后
读命令 MOVED 比例 35–45% 0
每秒新建连接 ~880 0.1
provider 崩溃 11 次/分钟 0
(配图占位:修复前后的错误曲线,部署时间点之后掉到 0)


最后一行说明一下。provider 崩溃的直接原因是 redis-py 在连接重连路径上的一个 bug(#4028,修复已合入但还没有发版):连接被反复断开重建,重连握手的过程中被并发的断开撞上,抛出 AttributeError。它不是一个独立的问题,连接不再反复重建之后,这个竞态就没有触发条件了,修复上线后一并归零。

十、复盘

1. 先看日志,再做实验

整个排查里最有效的一步,是去读 prod 容器的启动日志。address_remap enabled 那一行出来之后问题就定了。而在这之前,我在跳板机上试了十几种组合,每一种都是在猜。

日志里其实早就写着了,只是没有先去看。

2. 能做出症状,不等于找到了原因

这次有两个假设都能在跳板机上稳定复现出和线上一样的 MOVED 率,两个都不是线上实际发生的事。区别在于证据的方向:是先看到线上的证据再去复现,还是先构造一个状态再去凑症状。通往同一个症状的路可能有很多条。

3. 有背景噪声的地方,先问信号能不能分得开

那次 0.22 的”复现”,是服务端计数器上的噪声。换成客户端侧计数就是 0。以后在有流量的地方做实验,先要问一句:信号有多大,噪声有多大,能不能分开。分不开就换个地方量。

4. 本地配置怎么防止带上线

.env 提交进 git、COPY . . 打进镜像、load_dotenv 填空缺,三个东西单独看都很常见,合在一起就是:任何本地用的开关,只要 ECS 没显式设,就会跟着上线。这次是 address_remap.env 里还有 14 个变量处在同样的状态。

比较实际的做法:.dockerignore 排除 .env;生产的 task definition 把开关显式列出来,不依赖默认值;代码里对”仅本地”的开关加环境守卫。

总结

这次的原因说白了很简单:一个本地开发用的配置,把所有节点地址写死成了同一个,通过 .env 带到了线上。只有一个分片的时候没有影响,扩容成两个分片之后就变成了随机路由,一半的读发错节点。redis-py 每收到 5 次 MOVED 就把客户端整个重建一次,连接活不过 1 秒,重建过程中又撞出竞态,用户画像跟着丢了。

改四行配置,全部归零。

但从看到那几千万次 MOVED 到改这四行,中间隔了一天半,七八个假设。每一个假设当时看都能自圆其说,也都能在跳板机上做出点现象,但拿到线上一对就对不上。真正定下来的那一步,是去读了一遍容器的启动日志。

这大概是这次最实际的一条经验:排查线上问题,先把线上已有的东西看完,再去做实验。

本博客所有文章除特别声明外,均采用 @Oreoft 许可协议。转载请注明出处!



┌┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┐
├ 记得关注公众号:没有气的汽水 ┤
└┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┘

文章目录