☰
多线程BLPOP阻塞导致Redis连接池耗尽:排查与解决方案
2026/10/23 8:23:14 网站建设 项目流程

前阵子我负责的一个队列消费服务,凌晨突然在日志里刷出一片“客户端异常”,一查全是Could not get a resource from the pool和Unexpected end of stream。这个服务已经线上跑了大半年,代码几乎没有动过,唯一的嫌疑就是最近我们把消费线程从一个改成了一组,而且消费逻辑里清一色用的是BLPOP。那段时间队列里又恰好频繁出现积压,线程一多,事故就来了。

我花了两个晚上排查定位,最后结论其实不复杂:多线程环境下,把BLPOP这种阻塞命令直接丢进通用连接池,连接被占满只是时间问题。这篇文章把整个排查过程、原理分析和最终改法完整记录下来,给正在用 Redis List 做队列、准备上多线程消费的团队当个参考。

1. 现场复盘:日志里的客户端异常到底长什么样

1.1 凌晨出现的连续报错

先还原一下当时的日志现场。服务是用 Java 写的,Redis 客户端是常见连接池模式,消费线程从线程池里取任务执行,每个线程内部通过连接池获取一个 Redis 连接,然后调用BLPOP从队列里取数据,处理完再归还连接。简化后的伪代码就是:

while (running) { try (Jedis jedis = jedisPool.getResource()) { List<String> items = jedis.blpop(0, "queue:task"); if (items != null) { handleTask(items.get(1)); } } }

本来单线程跑的时候,一切正常。多线程扩展之后,日志里开始出现两类典型错误。第一类是连接池获取失败:

java.util.NoSuchElementException: Unable to fetch a connection from the pool at redis.clients.jedis.util.Pool.getResource(Pool.java:55) Caused by: redis.clients.jedis.exceptions.JedisConnectionException: Unexpected end of stream.

第二类是命令执行期间连接被服务端关闭:

redis.clients.jedis.exceptions.JedisConnectionException: Unexpected end of stream.

还有一部分线程直接卡在blpop调用上,迟迟没有日志输出,直到客户端 socket 读超时才抛异常退出。从字面上看,症状集中在“拿不到连接”和“连接被远端断开”两种,但真正的病根并不在网络,而是在连接占用模型上。

1.2 表象和影响范围

如果只是报错倒还好,最恶心的是服务会出现间歇性不可用。具体表现有下面几个:

现象说明
消费延迟急剧上升队列里有积压,但消费者线程拿不到连接,消息处理停滞
健康检查失败健康检查需要读 Redis 做探活,探活也走同一个连接池,被阻塞线程拖死
重启后短暂恢复重启后连接池重建,连接被释放,但随着消费线程再次进入阻塞状态,问题复发
连接数持续处于峰值Redis 服务端CLIENT LIST里能看到大量连接处于阻塞状态

影响范围不只是消费业务本身。由于 Redis 连接池被耗尽,其他依赖 Redis 的逻辑,比如缓存读取、分布式锁、限流计数器,全都会被牵连。这个牵一发动全身的特性,才是这次事故最麻烦的地方。

2. 问题定位:多线程与 BLPOP 是怎么“互相伤害”的

2.1 BLPOP 的阻塞模型

要理解事故原因,必须先把BLPOP的原理讲清楚。BLPOP是 Redis 提供的阻塞式列表弹出命令,调用格式是:

BLPOP key timeout

当key对应的列表里有元素时,命令立即弹出一个元素返回;当列表为空时,命令不会立刻返回,而是阻塞等待,直到有新元素被LPUSH/RPUSH进来,或者等待时间超过timeout。如果timeout传 0,表示永久阻塞,直到有数据进来为止。

这个设计本来是为了省掉“轮询 + 睡眠”这种浪费资源的做法,让消费者在数据到达时第一时间被唤醒。问题在于,阻塞期间,这个 Redis 连接上的命令处理是完全停摆的。

Redis 服务端是单线程事件循环处理命令,它不会为某个阻塞连接单独开线程。阻塞期间,服务端把该连接挂起,不再读取这条连接上的新请求。客户端这边也一样,发送BLPOP后必须等响应,通常也不会再往同一个 socket 上写其他命令。也就是说,一条连接一旦进入BLPOP阻塞态,在这段时间内它就被“焊死”了,既不能接收新命令,也不能被其他线程复用。

2.2 连接池是整个事故的核心瓶颈

连接池本质上是“多线程共享有限连接资源”的抽象层。每次调用getResource()可以从池里借出一条连接,用完再还回去。如果所有连接都被借走,新的请求只能等待或者抛异常。

多线程消费 + 无限阻塞的BLPOP,恰好就是连接池的克星。假设线程池里有 10 个消费线程,连接池的maxTotal也是 10。每个消费线程进入while循环后,各自从池中拿一条连接执行BLPOP(0, "queue:task")。当队列持续为空时,10 个线程全部阻塞在这 10 条连接上。此时,任何其他业务想从同一个连接池拿连接,都会陷入等待,直到maxWait超时,然后抛出NoSuchElementException。

我画了一个简单映射关系:

消费线程1 -> 连接1 -> BLPOP 阻塞 消费线程2 -> 连接2 -> BLPOP 阻塞 ... 消费线程10 -> 连接10 -> BLPOP 阻塞 其他业务线程 -> 等待连接 -> maxWait 超时 -> 异常

阻塞时间越长,积压的等待线程越多,最终整个应用里凡是碰 Redis 的地方全部卡住。这次事故中,Redis 本身并不慢,慢的是连接池分配链路。

2.3 跨线程共享一个客户端的事故现场

还有一种更容易踩的坑,比连接池耗尽更隐蔽:多个线程直接共享同一个非线程安全的 Redis 客户端实例,并且在同一个实例上并发执行BLPOP。

这类客户端的底层通常是一条 TCP 连接,内部有请求队列和响应读取机制。如果客户端本身不支持并发命令交错,那么线程 A 发出BLPOP后,线程 B 再发出GET,命令确实写进了 socket,但服务端还在等线程 A 的阻塞命令返回,不能处理线程 B 的GET,导致线程 B 长时间挂起;一旦连接超时或者被服务端关闭,线程 A 和线程 B 都会收到不可预期的异常,甚至可能出现响应数据错位。

即使某些异步客户端支持多路复用,阻塞命令也会把对应 channel 占住,因为BLPOP的响应迟迟不返回,后续命令的响应就无法被顺序匹配。这个坑在 Lettuce 这类基于 Netty 的客户端里表现出来的形态,通常是某个 channel 的并发请求堆积,然后是StackOverflowError或者RedisException: Connection closed by server。

3. 排查过程:从日志到线程 Dump 再到复现

3.1 步骤一:收集日志和连接池监控

我排查这类问题有一个固定顺序,先拿日志,再拿监控,最后才看代码。拿到日志之后,先把异常堆栈分类,区分出“连接获取失败”“连接被关闭”“读超时”三类;然后去监控系统里拉 Redis 连接池的活跃连接数、空闲连接数和等待获取连接的线程数。

当时监控面板上的曲线非常典型:活跃连接数稳定在 10(连接池上限),空闲连接数长时间为 0,等待获取连接的线程数不断攀升。看到这个曲线,基本可以把问题范围锁定在连接池分配上。

接着去 Redis 服务端执行了INFO CLIENTS和CLIENT LIST,发现有几个连接的状态很扎眼:

id=128 addr=127.0.0.1:56789 fd=8 name= age=180 idle=0 flags=b db=0 sub=0 psub=0 multi=-1 qbuf=0 qbuf-free=0 obl=0 oll=0 omem=0 events=r cmd=blpop user=default

注意flags=b,表示连接处于阻塞状态,cmd=blpop表示当前阻塞在BLPOP命令上。阻塞时间最长的一条连接已经持续了 180 秒。到这里,基本坐实了:大量连接被阻塞在BLPOP上。

3.2 步骤二:线程栈和 Redis 服务端会话

只看服务端还不够,还得确认客户端线程当时卡在哪个方法上。我拍了两次线程 dump,间隔三秒,然后搜索所有线程栈。10 个消费线程的栈全部指向同一个位置:

"pool-6-thread-4" prio=5 Id=24 RUNNABLE at java.net.SocketInputStream.socketRead0(Native Method) at redis.clients.jedis.Connection.readFully(Connection.java:91) at redis.clients.jedis.Connection.readProtocol(Connection.java:165) at redis.clients.jedis.CommandObjects.blpopCommandObject(CommandObjects.java:...)

再次确认这些线程都阻塞在 socket 读等待上,也就是BLPOP的等待阶段。业务线程则卡在:

"http-nio-8080-exec-12" prio=5 Id=56 WAITING at java.base/java.util.concurrent.locks.LockSupport.parkNanos at redis.clients.jedis.util.Pool.getResource(Pool.java:...)

线程 dump 里的两个栈,一个代表“连接被 BLPOP 占住”,一个代表“业务拿不到连接”,前后呼应,因果很清晰。

3.3 步骤三:本地复现实验

为了验证判断,我写了一个最小复现 Demo。构造一个空的 Redis List,用 10 个线程同时执行BLPOP,另外再用 5 个线程并发执行普通的GET。代码模型和线上完全一致,只是简化了 Redis 里的 key 数量。

ExecutorService consumerPool = Executors.newFixedThreadPool(10); ExecutorService businessPool = Executors.newFixedThreadPool(5); for (int i = 0; i < 10; i++) { consumerPool.submit(() -> { try (Jedis jedis = jedisPool.getResource()) { jedis.blpop(0, "queue:empty"); } catch (Exception e) { System.err.println("consumer error: " + e.getMessage()); } }); } for (int i = 0; i < 5; i++) { businessPool.submit(() -> { while (true) { try (Jedis jedis = jedisPool.getResource()) { jedis.get("some:key"); } catch (Exception e) { System.err.println("business error: " + e.getMessage()); } } }); }

复现结果不出所料。前几秒业务线程还能偶尔拿到连接,等到 10 个消费线程全都进入阻塞状态后,业务线程开始连续报错,错误类型和线上完全一致。这个复现实验基本敲定了问题根因。

4. 解决方案:改造 Consumer 设计

4.1 上线前最优先的方案:专用连接

我认为最正确的改法,是让阻塞类命令使用独立的专用连接,而不是和其他业务共享连接池。可以把“消费队列”和“业务 Redis 操作”彻底隔离,各走各的连接。

实现上很简单,单独维护一个长连接,由一个消费者线程持有,其他线程不碰它。伪代码如下:

public class DedicatedConsumer { private final RedisConnection connection; private volatile boolean running = true; public void start() { new Thread(() -> { while (running) { List<String> items = connection.blpop(0, "queue:task"); if (items != null) { executor.submit(() -> handleTask(items.get(1))); } } }).start(); } }

关键点在于connection是从独立连接池里取的,或者干脆就是单独创建的一条连接,业务代码不会再从这个池子里拿连接。这样就算BLPOP无限期阻塞,影响的也只是一条专用连接,不会波及 Redis 连接池里的其他连接。

这个方案改造量不大,代码结构也清晰。我后来直接把项目里的消费逻辑全部改成这种“专用消费者线程 + 独立连接 + 业务线程池处理消息”的模式,还顺手解决了BLPOP的消息处理慢导致队列头部消息被长时间占用的问题。

4.2 短平快改法:超时 + 循环轮询

如果不想大动代码,短期止血可以把BLPOP的超时时间从 0 改成一个有限值,比如 1 秒或 2 秒。这样每个消费线程执行完一次BLPOP后,要么拿到消息,要么超时返回null,随后归还连接。

while (running) { try (Jedis jedis = jedisPool.getResource()) { List<String> items = jedis.blpop(2, "queue:task"); if (items != null) { handleTask(items.get(1)); } } catch (JedisConnectionException e) { Thread.sleep(100); // 连接异常后短暂退避 } }

这个改法确实能缓解连接池耗尽问题,但它有个明显的副作用:空队列时,每个线程每隔 2 秒就会唤醒一次,做一次无意义的BLPOP请求。如果消费者线程很多,Redis 服务端会收到大量空转命令,TPS 虚高,网络带宽也跟着浪费。

所以我的观点是,超时轮询只能作为临时止血方案,不适合长期使用。它本质上是把“阻塞等待”退化成“轮询”,丢了定时唤醒的优势,系统里电动机器空转的味道很重。

4.3 结构更稳的替代:BLMOVE 和 Stream

如果业务场景允许调整存储结构,我建议从 List +BLPOP换成 Redis Stream,或者至少使用带双子结构的BLMOVE。这两个方案可靠性更强,也能更好地规避多线程阻塞问题。

BLMOVE是BRPOPLPUSH的升级版,阻塞地弹出一个列表元素,并把它放入另一个列表。这个能力很适合做“处理中队列”,消费流程变成:

LPUSH queue:task -> BLMOVE queue:task queue:processing -> 处理消息 -> LREM queue:processing

BLMOVE同样有阻塞参数,所以前面讲的“专用连接”原则依然适用。不过它能把消息先转移到处理中队列,消费端宕机也不会丢失原始消息,比裸BLPOP多了一层可靠性保障。

Redis Stream 是另一个方向。用XREADGROUP BLOCK替代BLPOP,消费组天然支持多消费者、确认机制和重新投递。它的阻塞语义和BLPOP类似,因此仍然需要专用连接,但整体代码结构更清晰,消息堆积时可以查看 stream 长度,而不是盯着一个 List 的 size。如果你的队列未来需要多消费者协同或者做消息重放,Stream 会是一个更好的底座。

4.4 连接池参数该怎样调优

在架构无法立刻改动的情况下,调连接池参数只能缓解症状,不能根治问题。但该调还是得调,起码能让服务在极端情况下多撑一阵。

参数建议值说明
maxTotal消费线程数 + 业务最大并发数 + 冗余量必须大于所有可能同时占用的连接数
maxIdle略小于 maxTotal避免空闲连接过多占用 Redis fd
minIdle5~10保证低峰期也有兜底连接
maxWaitMillis1000~3000连接获取等待上限,避免无限卡线程
testOnBorrowtrue借出连接时验证可用性,避免拿到失效连接

需要特别强调的是,maxTotal不是越大越好。每一条连接在 Redis 服务端都是一个文件描述符,连接数过多会消耗服务端内存,也会拖慢 Redis 的常规命令处理。调大连接池只是把压力往后推,最根本的做法还是把阻塞命令从连接池里挪出去。

5. 经验速查:常见错误

5.1 手里已经踩过的坑

这次排查看似就一个原因,实际过程里绕了不少弯路,有些坑对新手特别不友好。

第一个坑是无限阻塞参数。代码里写的是blpop(0, key),当时觉得“反正队列有数据就会返回,设 0 挺省事”,完全没意识到这会长期占住连接。排查时我只盯着网络超时设置,浪费了很长时间。建议所有阻塞命令都设置一个有限超时,哪怕超时时间比较长,也比永久阻塞好。

第二个坑是连接池被占满后,业务表现出多种奇怪的错误。有段时间日志里混着Connection reset和Read timed out,一度怀疑是 Redis 服务端配置问题,其实都是连接池耗尽引起的连锁反应。排查时应该先看连接池活跃曲线,再决定要不要怀疑网络。

第三个坑是消费者线程异常退出后没有兜底。某个消费线程在BLPOP拿到连接后,处理任务抛了异常,连接没有正确归还,导致该连接泄漏。轻微泄漏一次两次看不出来,跑一整天之后连接池就被慢慢掏空。后来的代码里,不管任务处理是否异常,连接都必须在 finally 里归还,或者用 try-with-resources 结构。

5.2 避坑清单

把这次排查和之前几次类似案例合并成一张速查表,供大家直接参考:

症状可能原因排查手段处理建议
连接池获取失败阻塞命令占满连接看连接池活跃曲线、线程 dump阻塞命令使用专用连接
连接被服务端断开BLPOP 阻塞过久触发超时或 keepalive看 Redis CLIENT LIST 的阻塞会话设置合理超时,增加重连
消费线程集体停滞共享连接/共享池被占满线程 dump 搜索 socketRead独立消费者线程 + 独立连接
任务处理异常导致连接泄漏未正确归还连接监控连接池活跃数持续增长finally 归还或 try-with-resources
空队列时 Redis 负载偏高超时轮询空转查看 Redis 命令统计改用真正阻塞或专用连接

排查方向上有一条原则我一直坚持:看到 Redis 客户端报异常,不要先怀疑 Redis 本身,也不要一上来就改网络超时。先搞清楚异常是发生在“获取连接阶段”“发送命令阶段”还是“读取响应阶段”。这三个位置的异常,对应的排查思路完全不同。

获取连接阶段的异常,集中在连接池配置和连接占用;发送阶段的异常,集中在网络和协议;读取响应阶段的异常,则要重点怀疑阻塞命令、响应超时和服务端主动断开。

我个人在实际操作中的体会是,Redis 的 List +BLPOP虽然简单好用,但它本质上是一个让消费者“占住连接等消息”的模型。任何让大量线程同时阻塞等待消息的设计,都是在跟连接池的有限资源较劲。项目后续如果要继续扩展,建议从 Stream 消费组入手,把消费进度、消息确认和故障转移都交给 Redis 更成熟的机制来处理,而不是靠开发者手动维护 List 的从队列里弹来弹去。这个方向,既是这次事故给我的教训,也是我在后续项目里优先推荐的设计。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询