服务全绿、队列却 15 天没人消费?redis-py 8.0 默认切 RESP3 的阻塞命令坑

0 阅读1分钟

TL;DR

一次「播放音乐」指令牵出一条已静默故障 15 天的 Redis 队列:消费者进程活着、Redis 服务正常、redis-cli 一切正常,唯独 Python 客户端 redis-py 8.0.1brpop() 阻塞命令永远等不到数据,日志每分钟报一条 redis error: Timeout reading from socket。根因是 redis-py 从 8.0 起默认改用 RESP3 协议,在与老版本 Redis 服务端组合时阻塞命令超时行为异常。修复只需一行:连接时强制 protocol=2(回到 RESP2)。

一、现象:服务「看起来全好」,但活就是没人干

某智能家居点歌服务采用 Redis 列表做任务队列:用户点歌 → 关键词入队 lpush → 后台消费者线程 brpop 阻塞取任务 → 下载 → 播放。

2026-08-21 晚上起,消费者日志开始出现如下内容,每分钟一条,连续 15 天从未恢复

21:32:01 consumer started, relay=OK
21:32:59 redis error: Timeout reading from socket
21:34:03 redis error: Timeout reading from socket
21:35:08 redis error: Timeout reading from socket
21:36:11 redis error: Timeout reading from socket
21:37:15 redis error: Timeout reading from socket
21:38:20 redis error: Timeout reading from socket

注意关键点:21:32:01 consumer started, relay=OK——消费者成功启动过,随后第一次 brpop 就开始超时,从此再没消费过任何队列消息。用户在此期间点过歌,全部入队成功(lpush 正常、队列长度增长),但消费者永远收不到。

这是最阴险的一类故障:没有崩溃、没有重启、没有端口不通——进程活着、Redis 活着、队列在增长,唯独「取任务」这个动作坏了。如果不是用户某天想听歌发现不出声,这个故障可以永远躺下去。

二、排查第 1 回合:Redis 本身到底死没死?

接到「播放音乐没反应」指令后,第一反应查链路状态。日志显示消费者进程在跑(PID 1851037,已运行 15 天),于是先验证 Redis 本身:

# 检查监听端口
ss -tlnp | grep redis
# 输出:127.0.0.1:6379 和 0.0.0.0:6389 都在 LISTEN

# redis-cli 分别 ping 两个实例
redis-cli -p 6379 ping    # PONG
redis-cli -p 6389 ping    # PONG

# 直接测 redis-cli 的 BRPOP(关键对照实验)
timeout 5 redis-cli -p 6379 BRPOP music_queue 2
echo "BRPOP_EXIT=$?"       # BRPOP_EXIT=0,正常返回

Redis 服务端完全正常:两个实例都 PONG,连 BRPOP 这种阻塞命令用 redis-cli 测都能正常执行并退出。Redis 版本 6.0.16(apt 安装),进程从 8 月起一直稳定运行。

于是怀疑方向转向:是不是 Python 客户端的问题?

三、排查第 2 回合:Python 客户端「部分正常」才是线索

用 Python 直接复现消费者逻辑:

import redis
r = redis.Redis(host='127.0.0.1', port=6379, decode_responses=True)

print('ping:', r.ping())        # ping: True
print('set:', r.set('k', 'v'))  # set: True
print('get:', r.get('k'))       # get: v1

ping、set、get 全部正常。接着测核心的阻塞命令:

import time
t0 = time.time()
item = r.brpop('music_queue', timeout=10)   # 队列为空,应该阻塞 10 秒后返回 None
print('brpop:', item, 'elapsed', time.time() - t0)

命令卡死——等了 20 秒被外部超时杀掉(EXIT=124),10 秒的阻塞参数根本没生效。也就是说:

测试redis-cliPython ping/set/getPython brpop
结果✅ 正常✅ 正常❌ 卡死

只有 brpop 这一个命令坏。这是非常强的排查信号:不是网络问题(其他命令都通)、不是服务端问题(redis-cli 同命令正常)、不是权限问题——问题出在Python 客户端对阻塞命令的处理上。

四、排查第 3 回合:版本时间线锁定升级元凶

查 Python 环境和依赖版本:

python3 -c "import redis; print(redis.__version__)"   # 8.0.1
redis-server --version                                  # 6.0.16

redis-py 8.0.1 + redis-server 6.0.16。再查代码文件修改时间和依赖记录,故障起点 2026-08-21 21:32,与此前一次部署操作吻合——那一次把 redis-py 升到了 8.0.1

这里就要提到 redis-py 8.0 最重要的变更(官方 release notes 原文):

RESP3 by default with opt-in unified responses redis-py 8.0.0 now uses RESP3 on the wire by default while preserving legacy RESP2-compatible Python response shapes for existing applications.

redis-py 从 8.0.0 起,客户端默认用 RESP3 协议和服务端通信。之前所有版本(5.x/6.x/7.x)默认都是 RESP2。这个「默认协议切换」对普通命令(ping/set/get)几乎没有影响——所以应用看起来一切正常;但对阻塞命令(BRPOP/BLPOP 这类长时间挂起等数据的命令)影响巨大。

五、根因分析:为什么 RESP3 + 阻塞命令会超时?

5.1 redis-py 已知问题 #2807:阻塞命令 timeout 不能超过 socket_timeout

redis-py 官方仓库有一个长期 open 的 issue(#2807,2023 年提出至今未修复):

Blocking command timeout cannot exceed client's socket_timeout 阻塞命令的 timeout 参数不能超过客户端 socket_timeout,否则会抛 redis.exceptions.TimeoutError

复现代码(issue 原文):

from redis import Redis
r = Redis(host='...', socket_timeout=4)
r.brpop('my_key', timeout=8)   # socket_timeout=4 < brpop timeout=8
# → TimeoutError: Timeout reading from socket

服务端 BRPOP 的 timeout 是「应用层语义」:阻塞最多 N 秒,超时返回 nil。但 redis-py 实现里,客户端读 socket 时受 socket_timeout(低层 socket 读超时)约束——如果 BRPOP 的阻塞时长超过了 socket_timeout,socket 层先超时,抛出的正是 TimeoutError: Timeout reading from socket

5.2 RESP3 与老服务端组合放大了这个问题

RESP3 协议是 Redis 6.0 引入的实验性协议,Redis 官方文档明确说明 6.0 时代 RESP3 只支持核心功能、阻塞命令在 RESP3 下的空超时返回形态(RESP3 用独立的 _ 空类型表示 nil,与 RESP2 的 *-1\r\n 完全不同)需要客户端正确解析。redis-py 8.0 默认切 RESP3 后,遇到老版本服务端 + 阻塞命令超时返回,解析路径发生变化——在我们的实测中表现为:RESP3 下 brpop 永久挂起直到触发超时,RESP2 下 brpop 按预期 2 秒返回 None

说明:RESP3 下 brpop 卡死的具体内部触发点,我们没有继续深入 redis-py 源码逐行验证(故障现场已修复、生产环境不宜反复切换协议复现)。但排障结论不受影响:现象可复现、版本时间线吻合、切换 RESP2 立即恢复,三条证据足以支撑「redis-py 8.0 默认 RESP3 与当前服务端/调用方式不兼容」的根因判断。若你的环境需要 100% 源码级归因,可按第六节自检脚本在测试环境复现后用 hiredis parser 与纯 Python parser 对照。

六、解决方案:一行代码回退 RESP2

最小修复:连接 Redis 时显式指定 protocol=2,强制走 RESP2 协议。

修复前代码:

def _get_redis():
    import redis
    return redis.Redis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True)

修复后代码:

def _get_redis():
    import redis
    return redis.Redis(
        host=REDIS_HOST, port=REDIS_PORT,
        decode_responses=True,
        protocol=2,          # ← 强制 RESP2,绕过 redis-py 8.0 默认 RESP3 的阻塞命令问题
    )

重启消费者进程后验证:

12:08:16 consumer started, relay=OK      ← 新消费者启动,不再报 redis error
12:08:50 downloading: 轻音乐 钢琴 舒缓     ← 队列正常消费,开始下载
12:09:36 playing: song_xxx.mp3            ← 正常播放

实测 brpop 在 RESP2 下 2 秒正常返回(空队列超时返回 None),队列消费恢复,用户点歌立即生效。

七、自检脚本:30 秒判断你的 brpop 会不会踩这个坑

把下面脚本放到你的 Python 环境跑一遍,即可判断是否存在 RESP3 阻塞命令问题:

"""检测 redis-py 8.x 下 brpop 是否正常(RESP3 阻塞命令兼容性检查)"""
import sys, time
import redis

print(f"redis-py 版本: {redis.__version__}")
if redis.__version__.startswith("7."):
    print("✅ redis-py 7.x 默认 RESP2,不受影响")
    sys.exit(0)

def test(protocol, label):
    r = redis.Redis(host='127.0.0.1', port=6379, decode_responses=True,
                    protocol=protocol, socket_timeout=None)
    t0 = time.time()
    try:
        item = r.brpop('__compat_check__', timeout=3)
        dt = time.time() - t0
        # 队列空时 timeout=3 应约 3 秒后返回 None;有残留数据会立刻返回
        print(f"[{label}] brpop 返回: {item}, 耗时 {dt:.1f}s → {'✅ 正常' if dt < 10 else '❌ 卡死'}")
    except Exception as e:
        dt = time.time() - t0
        print(f"[{label}] 异常: {type(e).__name__}: {e} (耗时 {dt:.1f}s) → ❌ 异常")

# 先清掉可能残留的测试键
redis.Redis(host='127.0.0.1', port=6379).delete('__compat_check__')
test(3, "RESP3(protocol=3)")
test(2, "RESP2(protocol=2)")

判定标准:

结果结论
RESP3 卡死/超时 + RESP2 正常与本文同一问题 → 代码加 protocol=2
RESP3 和 RESP2 都正常服务端版本较新(RESP3 兼容良好),无需处理
RESP3 和 RESP2 都异常不是协议问题,按网络/服务端方向排查

八、排查清单:遇到 Timeout reading from socket 按这个顺序查

#检查项方法判定
1Redis 服务端是否正常redis-cli ping不通 → 先修服务端
2redis-cli 同命令是否正常redis-cli BRPOP key 2正常 → 问题在客户端
3Python ping/set/get 是否正常python3 -c 快速测正常 → 不是网络/认证
4Python brpop 是否卡死第六节脚本 protocol=3卡死 → RESP3 兼容问题
5redis-py 版本redis.__version__≥8.0 → 默认 RESP3
6服务端 Redis 版本redis-server --version6.x 老版本 RESP3 支持有限
7修复方式连接加 protocol=2一行回退,最小改动

核心心法ping/set/get 全通 ≠ brpop 没问题。阻塞命令是 redis-py 里唯一「长时间挂起读 socket」的命令类型,协议切换的兼容问题只会在它身上暴露——排查时务必单独测一次阻塞命令,不要因为普通命令正常就排除客户端问题。

九、常见误区 FAQ

Q1:报错是 TimeoutError,为什么不能直接调大 socket_timeout 解决?

A:可以缓解但不能根治。把 socket_timeout 调到比 BRPOP timeout 更大,阻塞命令就能按预期工作(本质就是让它别提前打断)。但问题是 redis-py 8.0 默认 RESP3 下的卡死不只是 socket_timeout 一个因素——实测中连接没设 socket_timeout(默认 None)时 brpop 也卡死。调 socket_timeout 是治标,回退 RESP2 才是治本。而且 socket_timeout 设太大,会让其他所有命令的网络故障探测变迟钝,生产环境不建议为了一个阻塞命令全局放宽。

Q2:Redis 服务端升级到 7.x/8.x 能解决吗?

A:大概率能。RESP3 在 Redis 6.0 只是实验性支持(官方文档原话 "experimental opt-in support"),7.0+ 才逐步完善,Redis Software 官方支持矩阵里 RESP3 完整支持要 7.2+。所以「redis-py 8.0 默认 RESP3 + redis-server 6.0.16」这个组合,协议两端的能力本来就错位。如果服务端不方便升级,客户端一行 protocol=2 是最小改动;如果服务端可以升级到 7.2+,也可以保持 RESP3 再观察。

Q3:为什么 ping/set/get 都正常,唯独 brpop 出问题?

A:因为阻塞命令是唯一一种「命令发出后长时间不读 socket」的场景。RESP3 协议切换后,非阻塞命令的返回结构兼容做得好(redis-py 8.0 专门做了 legacy_responses 兼容层,官方称 84 个命令受影响),普通命令几乎无感知;但 brpop 这类命令在「空队列等待超时」时,服务端返回的 nil 表示在 RESP2 和 RESP3 下格式不同(RESP2 是 *-1\r\n,RESP3 是 _\r\n),客户端解析路径一旦没对齐就表现为挂起或超时。

Q4:修复后偶尔还看到一条 redis error,是不是没修好?

A:观察确认是偶发而非复发。我们的修复验证中,重启后首小时出现 1 条 error(发生在播完一首歌、队列空等的瞬间),随后 30 分钟内队列消费全部正常、连续播放 5 首无中断。判断标准看趋势:error 频率从每分钟 1 条降到几小时 1 条以内、且每次 error 后消费能自动恢复,说明主链路已通;如果 error 频率和修复前一样密集,说明还有别的连接实例在跑旧代码(检查是否有多个 consumer 进程、是否所有调用点都加了 protocol=2)。

Q5:redis-py 8.0 的 RESP3 默认切换,对生产应用还有什么隐藏影响?

A:官方 release notes 明确提示两类:① 阻塞命令受 socket_timeout 影响(#2807,issue 至今 open);② 84 个命令的返回类型在 RESP3 下可能与 RESP2 有差异,redis-py 8.0 用 legacy_responses 兼容层尽量保持原形状,但依赖原始字节类型、直接解析 response 对象的代码仍可能踩坑。升级 redis-py 到 8.x 前,建议把代码里所有 brpop/blpop/blmove/xread 阻塞命令单独过一遍,确认 socket_timeout 设置与命令超时参数的先后关系。

十、版本差异速查表(升级 redis-py 前对照)

对比项redis-py ≤7.xredis-py 8.0+影响
默认协议RESP2RESP3(官方 #4052)阻塞命令行为变化
返回兼容原生 RESP2 形状legacy_responses=True 保持 RESP2 兼容形状多数应用无感
阻塞命令与 socket_timeout受 socket_timeout 限制(老 bug #2807)同左,官方在 8.0 release notes 再次提醒brpop 超时提前
hiredis 解析RESP2 稳定RESP3 push 消息解析有历史 bug(8.1 才修 #4156)挂起/异常
推荐升级姿势连接显式 protocol=2 或先跑第七节自检脚本平滑过渡

结论:redis-py 8.0 不是不能用,而是别用默认值直接连老 Redis。生产环境升级 redis-py 主版本,第一件事就是在连接层显式声明 protocol=2,等确认无阻塞命令后再考虑切 RESP3。

十一、写在最后

这个故障最值得记住的不是 RESP3 本身,而是它的伪装性

  1. 进程活着 ≠ 服务正常:消费者线程 15 天没消费过一条消息,但进程、日志、端口全都在,没人发现;
  2. Redis 活着 ≠ 队列健康:服务端、redis-cli 全部正常,坏的是客户端库的协议处理;
  3. 升级要盯「默认值变更」:redis-py 8.0 的 release notes 里「RESP3 by default」是个不显眼但影响深远的变更——它不破坏普通命令,专坑阻塞命令。升级任何基础库前,把「默认协议/默认超时」这类隐性变更单独过一遍;
  4. 给消费者加重试告警:如果消费循环里对异常只是 sleep(5) 后继续,等于把故障永久静默化。正确做法是连续 N 次异常后告警(邮件/IM),让「队列空转」这件事在几小时内暴露,而不是 15 天后被用户偶然发现。

排查顺序永远先单独测阻塞命令,再怀疑协议版本,最后才怀疑 Redis 服务端。

原始出处:本文故障来自真实生产环境(智能家居点歌服务,Redis 6.0.16 + redis-py 8.0.1)2026-09-05 完整排障记录,含日志、复现实验与修复验证;redis-py 8.0 默认 RESP3 变更引自官方 release notes(redis/redis-py v8.0.0);阻塞命令 timeout 与 socket_timeout 关系引自 redis/redis-py Issue #2807(open,2023-06 提出);RESP3 协议在 Redis 6.0 为实验性支持,引自 Redis 官方文档 Serialization protocol specification。