这件事我印象太深了。之前维护的一个内部系统,某天午后线上突然炸了一片,应用日志里全是连接超时,但真正让我后背发凉的,是顺手翻了翻Redis侧的日志——没有慢查询,没有内存淘汰,没有任何明显异常,客户端却集体"失联"。排查到最后,凶手竟然是代码里一句看似再正常不过的BLPOP,而且它还被一个多线程的消费框架包着。这个问题不是偶然一次,后来跟几个同行聊,发现凡是把BLPOP塞进多线程的,基本都踩过或正在踩这个坑。
1. 故障现场:客户端日志里的异常其实指向同一个根因
先描述一下当时的现象。应用侧的报错大致是两种:一种是从连接池拿连接时直接抛JedisConnectionException: Could not get a resource from pool,另一种是已经在跑的请求报redis.clients.jedis.exceptions.JedisDataException: Unexpected end of stream。两类异常交替出现,乍看像是连接池不够用,或者Redis容量到了瓶颈。
但Redis那边的日志异常"干净",CPU不到20%,内存稳定,慢查询日志几乎是空白。唯一有点异样的,是connected_clients这个指标始终比应用配置的最大连接数高出不少,而且大量连接的状态长时间停留在blpop上。
注意:
connected_clients偏高的原因很多元,比如空闲连接没释放、连接泄漏、或者客户端异常退出但四元组没及时回收。但结合"状态长时间停在blpop"这一点,排查方向基本就锁定了。
当时的第一反应是写脚本抓Redis的CLIENT LIST,把客户端的连接状态、上次交互时间、所在IP全部拉下来。结果非常直观:同一台应用服务器上的几十个连接,几乎全部卡在cmd=blpop,而且age已经拉到了几千秒。也就是说,这些连接不是"偶尔阻塞",而是从建立之后就一直挂在阻塞读取上,完全没有被复用去处理其他命令。
到这里,问题基本定性:某些线程长期霸占着连接,而其他线程想用连接时只能排队或失败。至于为什么"霸占"、为什么判断不出超时,答案藏在BLPOP的实现机制里。
2. 根因拆解:多线程BLPOP是怎么把连接语义搞坏的
2.1 阻塞命令的本质:它占用的不是数据,是连接
BLPOP的全称是Blocking Left Pop,它的行为很直接:如果目标列表没有数据,客户端就阻塞等待,直到有数据可弹出或超时。表面看它和普通的LPOP只差一个"等待",但这个"等待"对整个连接栈的语义影响是颠覆性的。
普通的Redis命令是"发请求-等响应-拿到结果",响应结束后这条连接就是空闲的,可以被线程池里的其他任务复用。BLPOP不同,它在服务端没有数据时不会立刻返回,而是把这个客户端连接挂起。挂起期间,连接上不能发送任何其他命令,客户端线程也拿不到这条连接的响应。
如果超时时间设成0,也就是永不超时,这条连接就成了一条"单行道",从进入阻塞的那一秒起,它从物理上和逻辑上都被绑死在这一个线程上。这就是"长连接命令"和"短连接命令"的本质差异。
关于这一点,Redis官方文档其实写得很清楚:BLPOP是特殊的,它占用的连接在阻塞期间不能被其他调用复用。但很多开发者根本没意识到"连接池"在阻塞命令面前是失效的——池子只能保证连接对象存在,不能保证连接此刻是可用的。
2.2 多线程 + 共享连接:最典型的错误打开方式
这次出问题的消费模块,用的是基于Jedis封装的一个多线程队列消费框架。代码的逻辑大致是:由一个调度线程往线程池里丢任务,每个任务里执行:
public void run() { while (running) { List<String> batch = jedis.blpop(0, "mytask:queue"); if (batch != null) { process(batch); } } }初看没什么问题,单线程跑这个循环确实没问题,别别扭扭但功能正确。问题在于,这个jedis对象是从连接池里"借"出来的,而不是专门为阻塞创建的独占连接。当多个线程同时执行上面的代码时,每个线程都会从连接池获取一个连接并BLPOP,直到池子里的连接全部被借出。
这些连接一旦进入阻塞状态,就再也不会回到池子里。后续哪怕只是执行一个SET、GET,也要排队等某个线程的BLPOP结束并归还连接。如果消息队列长期没数据,这些连接就永远是阻塞的。而一旦某个任务处理得慢,消费线程就会积压,线程池被迫创建新的连接,最终把maxTotal打满。
这还不是全部。就算某个时刻列表里有数据,BLPOP返回后,任务处理完进入下一轮循环,又会立刻发起新的BLPOP。只要并发足够高,连接池随时可能被全部占用。应用侧看到的报错,正是连接池耗尽后其他调用被拒绝。
2.3 谁动了我的连接:Redis侧怎么会显示"无异常"
Redis日志显示不出问题,是因为服务端真的"没毛病"。它只是忠实地执行了BLPOP,并把连接挂起等待。这种状态在服务端看来是完全合法、完全正常的,既不是超时,也不是错误。
但CLIENT LIST里能看到规律:客户端的lib-name、lib-ver都一样,连接数却远大于"应该复用的连接数"。我就是从这一步确认了"连接泄漏"——不是传统意义上忘了close()的那种泄漏,而是连接被阻塞语义"锁死"后无法归还池子的逻辑泄漏。
2.4 为什么收益型bug这么难发现:偶发性和延迟性共存
这种bug有非常强的迷惑性:
- 队列里有数据时,
BLPOP立即返回,连接被循环复用,一切正常; - 队列里没数据时,连接开始阻塞,但此时没人"抢"连接,所以也不出错;
- 只有当"队列空闲时长"超过连接池耗尽所需时间,并且有其他业务代码同时请求连接时,异常才爆发。
所以很多团队是在上线几周甚至几个月后,在某个业务低谷期突然被线上告警惊醒。像我这次,触发点就是一次大促后的流量回落——队列空了,连接全挂,紧接着另一套非队列业务开始疯狂报错。
3. 排查复盘:从Redis日志反推客户端异常的全链路定位过程
坦白说,这次排错花的时间不短,但回头看,路径其实非常清晰。完整复盘一遍,给遇到相似场景的同学一个参考。
3.1 第一步:先对时间线,不要急着一头扎进代码
拿到故障的第一时间,我把应用报错起始时间、Redis监控曲线的拐点、发布系统最近的变更记录全部重叠在一起看。结果发现,异常出现的时间点与某次代码发布完全吻合——那次发布恰好把原先的单线程消费改成了多线程。这一步基本确定了"是最近变更引入的回归",而不是环境问题。
排错铁律:先锁定变更,再谈原理。没有变更却突发故障的,优先看依赖资源(Redis、DB、MQ),有变更就直接往变更上去怀疑。
3.2 第二步:从Redis的CLIENT LIST里找证据
直接用命令行抓一次:
redis-cli -a 'password' client list | grep -E "cmd=blpop|cmd=subscribe"输出里能看到类似这样的行:
id=12345 addr=10.0.0.8:53124 laddr=10.0.0.8:6379 fd=8 name= age=3178 idle=3178 flags=b db=0 sub=0 psub=0 multi=-1 watch=0 qbuf=0 qbuf-free=0 argv-mem=10 multi-mem=0 tot-mem=0 rbs=1024 rbp=0 obl=0 oll=0 omem=0 events=r cmd=blpop user=default redir=-1 resp=2重点看age和idle。如果大量连接的age都很大(几百、几千秒),而且cmd=blpop,基本坐实了"连接长期阻塞"。再用awk统计一下不同cmd的分布:
redis-cli -a 'password' client list | awk '{print $NF}' | sort | uniq -c | sort -rn输出里如果blpop的数量等于连接池的maxTotal,那就不用猜了,池子全被占用。
3.3 第三步:回到应用侧做连接数审计
接下来看应用侧的连接池配置。
JedisPoolConfig config = new JedisPoolConfig(); config.setMaxTotal(200); config.setMaxIdle(50); config.setMinIdle(10); config.setBlockWhenExhausted(true); config.setMaxWaitMillis(3000);最大200个连接,但消费模块一跑起来,CLIENT LIST里能被观察到的连接数基本稳定在200上下,且几乎全是blpop。数值上已经能够对上了。
3.4 第四步:用最小demo复现,排除掉其他干扰因素
为了确认不是其他业务代码的问题,我写了一个最小化的模拟程序:线程池10个线程,每个线程从同一个JedisPool拿连接执行blpop(0, key),同时另起一个线程每秒调一次set。结果毫无悬念,只要列表为空,几秒内连接池全部被blpop打满,set调用开始报超时。
复现之后,修复方案反而变得简单了:从"让所有连接都能被阻塞"改成"只有专用连接才能被阻塞",或者干脆换掉阻塞命令的使用方式。
4. 修复方案:从客户端代码和运维策略两端同时下手
问题定死了,修复就不难了。这里给出四个方案,按推荐程度从高到低排列。
4.1 方案一:BLPOP的超时设为有限值,杜绝永久占用的可能性
把:
List<String> batch = jedis.blpop(0, "mytask:queue");改成:
List<String> batch = jedis.blpop(5, "mytask:queue"); // 最多等5秒这是最低成本的止损手段。超时后连接归还连接池,其他线程就有机会拿到连接。代价是可能会有一点点多余的轮询开销,但对大多数业务来说完全可以接受。
4.2 方案二:用独立的专用连接,而不是从连接池里"借"
更干净的做法:单独维护一个Jedis实例,只用于BLPOP,不参与其他命令。这个连接永远属于队列消费线程,不经过连接池的竞争。
Jedis dedicatedJedis = new Jedis(host, port); dedicatedJedis.auth(password); dedicatedJedis.select(0); // 仅在这个连接上执行blpop,用完单独管理好处是彻底隔离了阻塞语义对普通业务连接的影响。坏处是需要自己管理这条连接的断线重连,因为长连接命令遇到网络抖动、服务端重启时,连接会断开,要能自动重建。
4.3 方案三:用单线程消费 + 内部内存队列
另一个惯用套路:只用一个线程在BLPOP上阻塞读取,出数据后丢进线程池并发处理,处理完成后继续下一轮阻塞。
ExecutorService workers = Executors.newFixedThreadPool(8); Thread consumerThread = new Thread(() -> { while (running) { List<String> batch = dedicatedJedis.blpop(5, "mytask:queue"); if (batch != null) { workers.submit(() -> process(batch)); } } });这样连接只有一条,不影响连接池;并发处理能力靠线程池保证。代价是如果process内部有阻塞操作,线程池会积压,需要配合有界队列和拒绝策略。
4.4 方案四:监控报警与fail-fast防御
无论选哪种方案,监控都必须到位。我的建议是至少做到以下三条:
- 队列消费积压标准:
llen超过阈值报警; - Redis连接池指标:
active连接数、idle连接数、wait次数全部打点; CLIENT LIST里的cmd=blpop连接数超过预期即触发告警。
另外在代码层加一道保险:从连接池拿连接时设置合理的maxWaitMillis,不要无限等下去。setBlockWhenExhausted(true)配合setMaxWaitMillis(3000),让消费者在最坏情况下快速失败并上抛,而不是在池子里死等,这样至少能让我们在告警里看到 "Could not get a resource",而不是被一堆无关业务超时淹没。
关于四个方案怎么选,我个人的优先级是:方案一必须做,方案二和方案三选择其一作为架构调整,方案四作为长期保障。如果只是紧急止损,方案一够了;想要根治,方案二或方案三才是正路。
5. 除了BLPOP:这套坑背后的通用规律,不换命令下次还会翻车
5.1 带状态的长连接命令,天生不适合走普通连接池
思考这个问题的时候,最值得沉淀的不是BLPOP本身,而是一类问题:凡是让连接进入"长期挂起等待"状态的命令,都不应该从共享连接池里拿连接来执行。
类似的命令还有:
SUBSCRIBE/PSUBSCRIBE:订阅建立后连接就一直在监听通道,期间不能执行其他命令;BLPOP/BRPOP/BLMOVE:队列为空时连接被挂起;WAIT(Redis Cluster里用的副本同步等待命令):同步等待期间连接被占住。
这些命令的共同点是"占用连接的时间不确定,甚至可能无限长"。它们的存在,本质上和连接池"有限连接、快速复用"的设计哲学是冲突的。
如果你在代码里看到这些命令,立刻问一句:这条连接是从连接池里借的吗?借完之后多久归还?如果回答不清晰,这就是潜在的定时炸弹。
5.2 设计阶段就该定好的两条铁律
经验总结到最后,就两条铁律:
第一,任何阻塞类命令,超时必须设上限,哪怕设成10秒、20秒,也绝不能用0。0意味着服务正常时连接永远不还,一旦业务波动,排查代价远大于省下的那几毫秒轮询开销。
第二,阻塞类命令最好用专用连接,独立于连接池。专门开一条连接跑BLPOP,别让它跟高并发的普通读写混在一起。隔离,永远是处理"占用语义特殊"的最佳方式。
5.3 复盘时最应该记住的,往往不是"答案"而是"视角"
这次排错给我最大的启发不是记住了BLPOP的语义,而是养成了一个排查习惯:报错发生在客户端,但根因可能在服务端的连接状态里。Redis日志不等于客户端日志,服务端无异常不代表客户端无恙。遇到连接类问题,第一件事就是去看CLIENT LIST,把连接状态、阻塞命令、存活时长拉出来,比在代码里空想有效十倍。
后来我在好几个项目组做分享时都提过一个简单的自测方法:把应用所有涉及 Redis 的命令罗列出来,凡是在"等待数据、等待事件、等待状态"的一律标注为高风险命令,强制要求走专用连接 + 有限超时。这套方法虽然古老,但在避免踩坑这件事上,确实比任何花哨的分布式理论都实用。
回到最初的那个系统,最终我们采用的就是"有限超时 + 专用连接"组合拳,BLPOP只允许超时5秒,并且单独维护了一条消费连接,不参与普通连接池。上线后第二次大促的流量低谷,Redis 的connected_clients稳定在个位数,应用日志里再没出现过 "Could not get a resource"。
如果有人也遇到类似现象:连接池耗尽可能不是连接泄漏,而是某些命令把连接逻辑占用;Redis没有报错不代表连接健康,去看一眼CLIENT LIST就能确定。希望这篇复盘,能帮你少走几个小时的弯路。