
前阵子有个同事找我排查一个诡异问题Redis 服务正常redis-cli 连上去 PING 直接 PONG甚至LLEN看队列都不为空但他的 Python 消费进程用brpop跑了两周多一条消息都没消费到。我下意识问了一句你说的“服务正常”是你现验证过的还是监控面板上绿的他愣了一下。后来我发现这个“正常”正是最大的误导源。. Redis 本身没问题和你的 Python 消费链路没问题是两码事。很多人在这种场景下会反复检查 Redis 进程、检查 redis-cli 连通性、检查 Python 代码逻辑最后绕了一大圈才发现问题藏在 TCP 长连接、队列 key 归属、甚至另一个看不见的消费者身上。这篇文章就围绕这个场景把我这些年排查类似问题的完整思路、验证命令、修复方案一次讲透。如果你是运维、后端开发、或者自己写脚本用 Redis 做队列的人这篇值得收藏。1. 别急着查代码先重新定义“服务正常”1.1 redis-cli 验证了什么又没验证什么redis-cli 能连上、能 PING、甚至能读到某个 key只能说明一个事实有一个 TCP 连接成功到达了某个 Redis 实例并且这个实例上恰好存在那个 key。但它证明不了另外三件关键事它证明不了 Python 连的就是这一个实例。redis-cli 默认走127.0.0.1:6379但你的 Python 代码可能从环境变量里读了一个内网域名指向的完全是另一套 Redis。这种情况在云环境和容器环境里极其常见。它证明不了两者连的是同一个 DB。redis-cli默认选中db0而代码里如果写了db5你在 db0 查到队列有数据Python 在 db5 干等两者互不可见。它证明不了“消息能到消费者手里”。就算队列里有消息阻塞连接是否还活着、是否有其他消费者抢在前面、服务端是否已经悄悄把你的连接丢弃了redis-cli 一概看不出来。所以听到“服务正常”这三个字我第一反应永远是请把“正常”拆成四个维度——实例能连、key 存在、数据可读、消费链路可用。前三个都通过才能看第四个。而本文标题里的问题恰恰是前三个都通过、第四个挂了。1.2 从消费端视角直接看队列不要用监控面板代替实战验证。我排查这类问题时第一步永远是直接站在消费者视角手动跑一遍# 用 redis-cli 模拟消费者的阻塞读取 redis-cli -p 6379 BRPOP my_queue 5如果这条命令在 5 秒内返回了消息说明数据链路没问题问题一定出在 Python 进程本身。如果这条命令一直阻塞到超时那就去查数据源队列里到底有没有消息、消息是不是从另一个实例生产进来的、key 是不是根本不是这个。这一步能帮你快速划定战场。我见过太多人花了一整天在 Python 代码里加日志、排查依赖版本结果最后发现消息生产端早就把 key 写成了my_queue_v2消费端还在等my_queue。你说服务正常吗正常。你说收不到吗当然收不到。2. “15 天”是破案关键长连接被静默切断的全过程2.1 TCP 半开连接两个端点各怀心事先搞清楚一个网络常识TCP 连接是两端的共同状态。但如果一端因为故障、重启、网络切换而消失了另一端并不会立刻知道除非有数据要发送或者有 keepalive 探测。Redis 客户端执行brpop时整条链路是“挂起等待”状态——客户端不发任何数据服务端也不回任何数据。如果中间的 NAT 网关、云负载均衡、或者 Kubernetes 宿主机上的 conntrack 表在某个时间点把这条连接静默回收了结果就是客户端以为自己还在等消息服务端早就不知道这个客户端是谁了。这种现象叫半开连接。我用一个生活化的类比解释你手里拿着对讲机以为频道还通着但对面电台早就关了你喊破喉咙也没有回应。你占据着这个“频道”却收不到任何内容。brpop最危险的地方就在这它是完全被动的等待不会主动发送任何探活数据。如果连接被静默切断你的 Python 进程会永远卡在brpop这一行不报错、不退出、不消费。别说 15 天挂一个月都不会有任何动静。2.2 四种最可能造成 15 天断链的场景回顾我踩过的坑能让一条阻塞连接“无声死亡”的事件通常有四种主机或容器重启。服务所在 Pod 被重新调度、宿主机重启、或者发布时滚动更新旧 socket 被内核直接清理但应用程序进程没有退出旧 socket 文件描述符还躺在那应用层无感知。网络设备会话超时回收。云厂商的 SLB、NAT 网关、四层代理对空闲连接普遍有超时回收机制常见的是 120 秒到 15 分钟不等。brpop阻塞期间完全没有数据包一旦超过会话超时连接就被回收。conntrack 表溢出或 iptables 规则刷新。Linux 宿主机的 conntrack 表满了之后会直接丢包防火墙规则热加载也可能清掉已有连接。这种场景下你用ss -tn看socket 还是ESTABLISHED但数据包早就到不了对端了。Redis 主从切换或实例迁移。Redis 实例发生故障转移、或者运维做了主从切换旧主节点上的连接并不会原样移植到新主节点。客户端如果没做重连连接就会永久挂起。这四种场景有一个共同特征从“连接正常”到“连接死亡”整个过程没有任何一个 RST 或 FIN 包到达客户端。客户端完全被蒙在鼓里。2.3 socket_timeout 与无限阻塞的副作用很多人写brpop时图省事直接timeout0让它无限阻塞。这本身没错但在连接可能被静默切断的分布式环境里无限阻塞等于把命门交给了网络设备。另一个隐蔽问题是socket_timeout的配置。redis-py 连接时如果设置了socket_timeout5而brpop的阻塞超时设成了 15 秒甚至更大客户端会在 5 秒时被 socket 超时打断抛TimeoutError。如果你的代码在except里把这个异常吞掉然后继续循环表面上日志里什么都没有实际上每 5 秒就会白忙活一次永远不消费消息。我见过不止一个团队把这种异常静默吞掉的代码带到线上。排查时看到日志干干净净以为一切正常实际上消费线程一直在“尝试-失败-吞掉-重试”的循环里空转。3. 排除了连接问题后再看队列数据与消费语义3.1 key 对不上环境、库号、集群分片如果确认连接存活、网络没有断那问题大概率出在数据语义上。第一类常见原因就是 key 的归属不一致。生产环境里最典型的一种代码里 Redis 连接配置来自环境变量本地开发用的.env没提交部署到 K8s 后环境变量指向了另一套 Redis 实例。你在本地 redis-cli 查my_queue有数据可线上 Python 连接的是redis-prod.example.internal那个实例上的my_queue一直是空的。你用 redis-cli 验证的那台跟你服务真正连接的那台压根不是一个东西。还有一种情况更隐蔽云 Redis 开启了集群模式key 会按照 CRC16 哈希分布到不同的 slot 节点上。你用 redis-cli 连的是其中某一个节点碰巧另一个节点上才有你需要的 key。于是你以为“Redis 里没有消息”实际上消息在另一个分片节点上躺着。排查这类问题不要只看 host 和 port还要把三个值全部对齐实例地址、DB 编号、key 的完整拼写包括前后缀的拼接逻辑。这三项任何一个对不上都会出现“服务正常但收不到”的假象。3.2 type 不对List 还是 Stream第二类常见原因是数据结构类型对不上。brpop只能操作 List 类型如果你队列的 key 被写成了 StreamXADD、或者是一个 String、Setbrpop会直接返回WRONGTYPE Operation against a key holding the wrong kind of value。这个报错本来不难发现但问题在于很多消费者代码里有一个大而全的except Exception: pass把类型错误、连接错误全部吞掉。加上没有日志、没有告警这个WRONGTYPE就变成了一声闷响程序继续跑消息一条都收不到。我建议所有消费循环里至少对ResponseError这类明确异常单独捕获并打日志不要一股脑吞掉。尤其是WRONGTYPE它通常说明项目里有人把队列 key 迁移到了别的数据结构或者有人误用了已存在的 key 名。3.3 不止你一个消费者BRPOP 有一个很容易被忽略的语义当多个客户端同时阻塞在同一个 key 上时消息到达后只有等待时间最久的那个客户端会被唤醒。换句话说如果有另一个消费者进程也阻塞在同一个 key 上而它排在你前面那么所有新消息都会被它抢先接走你的进程就一直阻塞着看起来就像“收不到”。这种场景在容器化部署后特别容易出现。服务用 Deployment 部署不小心扩容成了 3 个副本三个进程都在抢同一个队列。如果你只盯着其中一个 Pod 的日志以为它“该收到消息却没收”那自然百思不得其解。实际上消息被另一个副本消费了只是你看不到那个副本的日志而已。验证方法很简单临时把其他消费者副本缩容到 0只留那一个进程在等然后手动向队列生产一条消息。如果它立刻收到了说明问题就是“多消费者竞争”。4. 一套可以直接照抄的排查清单4.1 数据层队列里到底有没有消息从最基础的开始按顺序执行一组命令每一步都有明确结论。检查项命令预期结果不符合说明什么Redis 存活redis-cli -p 6379 PINGPONG实例挂了或网络不通队列类型redis-cli -p 6379 TYPE my_queuelist类型被改成了别的数据结构队列长度redis-cli -p 6379 LLEN my_queue大于 0 表示有积压积压为 0 说明真实消息流没到这队列内容redis-cli -p 6379 LRANGE my_queue 0 -1能看到消息内容看不到内容说明生产端根本没写模拟阻塞消费redis-cli -p 6379 BRPOP my_queue 55 秒内返回消息如果前面 LLEN 有积压但 BRPOP 没返回说明有其他消费者抢着消费注意BRPOP my_queue 5一定要放在最后执行。一旦它真的消费到了消息队列积压就少一条你后续再想复现就得多生产一条。如果LLEN一直是 0那问题就变成了另一个方向生产者有没有在往这个 key 写数据生产者写的是同一个 Redis 实例的同一个 DB 吗生产者的任务调度是不是早就停了不要觉得这问题低级——我排查过一个持续两周的数据中断最后发现是生产者的 cron 表达式被手动改错了消费者傻等两周完全不知情因为消费者逻辑没问题、Redis 也没问题。4.2 连接层CLIENT LIST 与 INFO clients接下来验证你的 Python 进程到底有没有在服务端留下阻塞连接。redis-cli -p 6379 INFO clients重点看一个值blocked_clients:1正常情况下这个数值应该等于你正在运行的消费者连接数。如果是 0说明你的 Python 进程当前根本没有阻塞等待的动作那就不是“收不到”而是“没在等”。这个时候应该回到代码层确认brpop是不是真的执行到了、有没有提前 return、有没有被外层异常打死。CLIENT LIST能看得更细redis-cli -p 6379 CLIENT LIST输出里找到cmdbrpop或者cmdblmove的连接像这样id3 addr10.0.0.2:56789 laddr10.0.0.1:6379 fd8 name age1296000 idle1296000 flagsb db0 sub0 psub0 multi-1 rbs1 eventsr cmdbrpop userdefault redir-1 resp2 lib-nameredis-py lib-ver...这里flagsb表示阻塞状态age是连接建立后的总存活时间rbs1表示正在等待一个阻塞命令的结果。如果这个连接已经不存在了那就证明旧连接早就从服务端视角消失了你的客户端还挂在半开连接上傻等。4.3 链路层MONITOR 与抓包取证如果数据层、连接层都没问题但你的 Python 还是收不到那就需要链路层的证据了。在生产 Redis 上执行MONITOR要非常谨慎它会实时打印所有命令高并发下会明显影响性能。但在低峰期短时间启用或者在一个单独的从节点上执行是可以接受的。开一个终端跑 MONITOR另一个终端手动向队列生产一条消息redis-cli -p 6379 LPUSH my_queue test_message然后看 MONITOR 的输出里有没有这条命令。如果能看到LPUSH但你的 Python 没有任何反应而 redis-cli 手动 BRPOP 能收到那就说明 Python 进程的连接状态虽然存在但数据包已经在某个环节被丢弃了。更彻底的取证是抓包。在 Python 进程所在机器上执行tcpdump -i any -nn port 6379 -w /tmp/redis_brpop.pcap然后生产一条消息如果抓包文件里只有 TCP 建立连接时的包、或者之后很长一段时间没有任何数据包说明消费链路已经处于“死而不自知”的状态。这个时候再看连接状态十有八九是半开连接。5. 修复与预防别再让 brpop 裸奔5.1 消费者侧阻塞超时要短、重连要快最有效的修复方式也是最简单的不要用brpop无限阻塞。给阻塞设置一个合理上限比如 5 到 20 秒。一旦超时返回None循环里执行一次轻量命令比如PING利用这次往返检测连接是否还活着。如果异常就重建连接再继续消费。伪代码大概是这个样子import redis import time def consume(): client redis.Redis(host10.0.0.1, port6379, db0, socket_timeout10, socket_connect_timeout5) while True: try: # 不阻塞太久给连接自检留出机会 result client.brpop(my_queue, timeout10) if result: key, message result handle_message(message) else: # 超时无消息趁机做一次存活探测 client.ping() except Exception as exc: # 注意这里一定要记日志不要吞 print(fconsume error: {exc}) try: client.close() except Exception: pass client redis.Redis(host10.0.0.1, port6379, db0, socket_timeout10, socket_connect_timeout5) time.sleep(1) def handle_message(message): # 实际业务处理 pass这里的核心思路是每 10 秒让brpop返回一次哪怕没有消息也会产生一次PING往返。链路如果断了这次PING会立刻触发异常然后代码重建连接。整个过程最多延迟 10 秒而不是 15 天。socket_timeout也要注意必须大于brpop的阻塞超时否则客户端会先被 socket 超时打断。上面例子中brpop是 10 秒socket_timeout设成 10 秒刚刚好如果担心网络抖动可以设 15 秒。5.2 服务端与网络侧keepalive 与 conntrack服务端也要做相应加固。Redis 配置里有一个 TCP keepalive 参数# redis.conf tcp-keepalive 300单位是秒。它的作用是让 Redis 主动向空闲连接发送 keepalive 探测包一旦发现对端已经消失就主动清理这条连接。默认值一般是 300 秒也就是 5 分钟一次探测。如果被改成了 0Redis 就永远不会主动探测半开连接就能一直挂到天荒地老。云上 Redis 一般默认是开启的但如果你是自己部署的务必检查一下redis-cli -p 6379 CONFIG GET tcp-keepalive至于网络层的 conntrack 表问题主要发生在 Docker/K8s 环境。排查时可以在宿主机器上执行dmesg | grep -i conntrack如果看到nf_conntrack: table full, dropping packet之类的日志基本上就能解释为什么所有长连接都被静默清掉了。调整 conntrack 参数属于宿主级优化这里不展开但你至少要知道这类问题确实是真实存在的。5.3 监控侧队列深度和消费时间戳修复之后还要把盲区变成可观测。没有监控下一次问题只会换一个姿势重新出现。我建议至少加三个监控指标队列积压量。通过LLEN定期采集正常情况下应该在一个稳定区间波动。如果突然持续上涨说明消费慢了如果长期为零说明没消息进来可能是消费端问题也可能是生产端停了。最后消费时间戳。消费者每消费一条消息就把当前时间写入一个独立 key比如last_consume_ts。监控系统定时检查这个值如果超过 N 分钟没有更新立即告警。这个指标能直接反映“消费动作是否还在发生”比 CPU、内存那些都本质。阻塞连接数。采集INFO clients里的blocked_clients正常情况下应该等于消费者进程数。如果这个值变成了 0说明没有人在等待消费那肯定是连接断了或者代码挂了。这三个指标覆盖了“数据有没有进来”“消费有没有发生”“连接有没有存活”三个层面。只要它们都在正常范围基本就能避免再出现这种 15 天无感知的故障。6. 最后再提醒几个容易踩的细节代码层面容易踩的坑其实就那么多但每一个都能让你折腾好几天。第一个是异常被吞。消费循环里不要写大而全的except Exception: pass。至少要把ResponseError、ConnectionError、TimeoutError区分开并且全部打日志。静默吞异常是这类问题“15 天没被发现”的最大推手。第二个是多线程共用连接。redis-py 的 Connection 对象不是线程安全的如果你用多线程共享同一个连接执行brpop命令响应可能被其他线程的命令污染出现各种诡异行为。最稳妥的做法是每个线程独立创建连接或者用连接池单独取连接。第三个是环境变量覆盖。线上环境里 Redis 地址、密码、db 编号最容易通过环境变量被“悄悄”覆盖。排查时不要只看代码里的默认值要看你部署环境里实际注入的值。你可以临时在代码里打印一下实际连接的 host、port、db确认和 redis-cli 验证的是同一个实例。第四个是重启后恢复的假象。如果一个问题在重启进程后就好了不要以为万事大吉。重启会强制重建连接相当于把半开连接清除了但根因——比如 NAT 会话回收、keepalive 未开启、阻塞无限等待——还在。下一次长连接挂起问题还会回来。我在实际处理这类问题时通常先看三个数据LLEN、blocked_clients、CLIENT LIST 上是否有cmdbrpop的连接。这三个数据能覆盖掉 80% 的排查路径。如果它们都是正常的再往生产者和网络链路深挖。你手上如果也在被类似问题困扰可以按这篇文章的顺序过一遍大概率能在半小时内定位到根因。