很多项目最初都是把 Redis 当纯缓存用的,跑着跑着就冒出新需求:接口耗时太高,想把耗时的活丢到后台慢慢做;上游流量忽高忽低,想加一层缓冲削峰;定时任务拆出来的零碎事件,需要有一个地方排队处理。于是大家自然会把目光投向 Redis 队列——毕竟 Redis 已经在了,不想为了一个异步需求就引入一套新的消息中间件。一搜文章,网上关于 Redis 队列的示例铺天盖地都是 LPUSH + RPOP;再搜「阻塞队列」,出来的又变成了 Java 线程池里的 BlockingQueue 该怎么选。很多人到这里就懵了:这两者是一回事吗?用 Redis 做队列到底要不要阻塞?消息被消费之后为什么还会丢、还会重复?这篇文章就把这条线从头理一遍,结合我实际做异步改造时踩过的坑,把 Redis 队列、阻塞队列、Redis Stream 以及它们和线程池队列的关系一次说清。
1. 从 LPUSH/RPOP 到 BRPOP:为什么 Redis 队列绕不开「阻塞」
1.1 最简单的队列形态:一端写、一端读
先看 Redis 里最朴素的队列实现。Redis 的 List 是一个线性的数据结构,用它做队列只需要记住一条原则:写入和取出永远走相反方向。左侧写入,就从右侧取出;右侧写入,就从左侧取出。命令约定俗成的组合是 LPUSH + RPOP,一个左边塞、一个右边弹,形成先进先出(FIFO)的队列。
实际操作起来非常直观:
# 生产者:往队列里塞消息 LPUSH taskQueue task:1001 LPUSH taskQueue task:1002 LPUSH taskQueue task:1003 # 消费者:从队列尾部取一条 RPOP taskQueue # 返回 task:1001 RPOP taskQueue # 返回 task:1002这个模型足够简单,而且因为 Redis 命令天然是单线程逐个执行的,LPUSH 和 RPOP 之间不存在并发竞争问题。两个消费者线程同时在队列另一端 RPOP,Redis 会保证它们各自拿到一条不同的消息,绝不会出现两条消息同时发给同一个消费者的误判。对大多数业务来说,这一层原子性已经比你在 JVM 里自己写一个LinkedList + Lock可靠得多。
队列里具体存什么,这个细节我建议所有团队提前统一口径。最好只放业务 ID、JSON 字符串、事件标识这类轻量结构,不要把几十 KB 甚至上百 KB 的完整报文直接塞进 List。我见过一个项目把用户全量画像 JSON 往队列里写,单 key 积压后占了几个 G 内存,最后排查了大半天才定位到是队列把 Redis 内存打满了。正确做法是:大对象落到独立的存储或对象存储,队列里只放一个引用 ID,消费者拿到 ID 后自行回源。
1.2 轮询取消息的代价:延迟、空转和无效 QPS
List 队列一旦跑起来,你很快就会遇到一个尴尬问题:生产者可以随时 LPUSH,但消费者怎么知道队列里有新消息了?
Redis 是请求-响应模型,服务端不会主动把数据推给客户端。所以最初的实现往往会长这样:
# 伪代码,不断从队列里取 while true: task = RPOP taskQueue if task is null: sleep 100ms continue handle(task)这个写法不是不能用,但你很快会体会到三个问题。
第一个问题是延迟不稳定。消息可能在你刚 sleep 之后 1 毫秒就到达,但消费者要等完整地睡完 100 毫秒才能醒来拉到它,最坏情况下的延迟就是 100 毫秒。对日志、报表这类业务也许无所谓,但对超时敏感的异步任务来说,这种不确定的延迟很让人难受。
第二个问题是空转带来的无效 QPS。假设你把 sleep 调到 10ms,单个消费者每秒就会向 Redis 发 100 次空查询。听起来还好?如果一台机器起了 8 个消费者线程,Redis 侧每秒就是 800 次毫无业务价值的 RPOP;再横向扩展到 20 台实例,每秒就是 16000 次。而 Redis 是单线程模型,这些空转请求一样要消耗 CPU 去处理,最终挤占的是正常读写命令的时间片。
第三个问题是 sleep 时间怎么调都别扭。调大了延迟高,调小了浪费资源。本质上是因为你用一个定时器去模拟「事件通知」,方法用错了。
这个问题的解法其实就是「阻塞」两个字:让消费者在没有消息时真正挂起等待,而不是反复醒来查询。
1.3 这种简陋方案在什么场景下还够用
我得先泼一盆冷水:网上大量把 Redis List 当队列用的文章,并没有告诉你这个方案在什么前提下才成立。如果你的业务同时满足以下条件,RPOP + sleep 的轮询模型也能跑得挺好:
- 消息丢了能接受,不属于支付、订单、资金、券码这类必须精确处理的链路;
- 对实时性要求不高,消息晚几秒甚至几十秒被消费也无所谓;
- 整体 TPS 不大,最多每秒几百条,空转对 Redis 的压力可以忽略;
- 想快速上线,不想为了几个异步任务引入一套新的中间件。
很多内部系统的异步落库、统计事件上报、非核心状态同步就是这么跑的。但你必须心里有数:消息在 RPOP 弹出的瞬间就已经从 Redis 里消失了,如果消费者在handle(task)之前宕机,这条消息就永久丢失。所以在正式业务里,我的建议是不要抱着 List + RPOP 不放,下面讲的 BRPOP 和 Stream 才是更值得投入时间的方案。
2. BRPOP/BLPOP 的阻塞语义与实战注意事项
2.1 一条命令从「定时看消息」变成「消息来了叫我」
BRPOP 是 Redis 提供的阻塞弹出版本,用法非常简单:
BRPOP taskQueue 30这条命令的含义是:如果taskQueue不为空,立即弹出最右侧的一条消息;如果队列为空,客户端连接就挂起等待,等待期间一旦有人 LPUSH 新消息进来,Redis 会立刻把数据作为这条命令的响应返回;如果等了 30 秒还没有消息,返回 nil。
参数里的「30」单位是秒。网上经常能看到BRPOP key 0的写法,0 表示永久等待。我强烈不建议在生产环境设置永久阻塞,原因后面会单独讲。
从使用体验上看,BRPOP 带来的变化是根本性的。轮询模式等于每隔几秒去门口问一次「外卖到了吗」,BRPOP 是告诉外卖小哥「到了按门铃,我先睡一会儿」。正是因为 Redis 提供了这种网络层面的阻塞等待原语,我们才把它叫做阻塞队列——不是 JVM 里的 BlockingQueue,而是 Redis 服务端在等待一条新消息时将客户端挂起的机制。
它的底层逻辑可以这样理解:当客户端执行 BRPOP 且队列为空时,Redis 会把这个客户端连接标记为阻塞状态,放进该 key 的等待者列表;有生产者向这个 key 推送数据时,Redis 会唤醒一个或多个等待者,把新消息作为命令的返回值发送出去。整体过程不涉及客户端空转,也不消耗多余的查询流量。
2.2 多个消费者同时阻塞时,消息怎么分配
这是很多刚接触 BRPOP 的人最容易搞错的一点。
BRPOP 的消费模型是「竞争消费」,不是广播。多个客户端同时执行BRPOP taskQueue 30时,它们并不是都能拿到同一条消息。一条消息到达后,Redis 只会从等待者列表里唤醒一个客户端,把消息交给它,其余客户端会继续阻塞等待下一条。
我习惯在验证这个行为时开两个 redis-cli 终端:
# 终端 A BRPOP taskQueue 0 # 终端 B BRPOP taskQueue 0 # 终端 C,作为生产者 LPUSH taskQueue hello你会看到只有终端 A 或 B 中的一个返回hello,另一个仍然挂在那里不动。
这个行为看起来很符合直觉,但我在面试中经常遇到候选人把 BRPOP 和 Pub/Sub 混淆。Pub/Sub 是发布订阅模型,所有在线订阅者都能收到同一条消息;而 List + BRPOP 是任务分发模型,一条消息只会被一个消费者处理。如果你的需求是「一条事件广播给所有下游」,BRPOP 从一开始就不合适。
另外需要注意,BRPOP 并不区分「组」。多个消费者抢同一个 List 的资源,抢到就处理,没有组内进度、没有偏移量、也没有消费者上下线时的重新平衡机制。对于多数简单的任务队列,这种即插即用的竞争模型已经够用;但一旦需要为消费者组维护消费进度和精确确认,就该考虑后面的 Redis Stream。
2.3 阻塞读取最容易踩的四个坑
坑一:阻塞命令会长时间占用一个 Redis 连接。
BRPOP 挂起期间,这个客户端连接不能执行其他任何命令。很多人把 Redis 连接池配成 20 个连接,业务线程正常读写是一批,消费线程又占了几个长期执行 BRPOP,连接池立刻就不够用了。解决思路是给消费者单独建连接池或单独的连接,把阻塞读和普通业务读写隔离,避免互相影响。
坑二:阻塞超时不要设成 0。
永久阻塞在理论上看很完美,但实际网络环境中存在各种中间代理和负载均衡器,它们通常会配置空闲连接超时。如果一条 BRPOP 命令已经挂了几分钟没有任何响应,中间层可能悄悄把连接断开,而 Redis 服务端和客户端可能都没有立即感知到。结果就是一个消费者线程看起来还活着,实际上连接已经死了,队列里的消息开始积压。我生产上一般设置 20~30 秒的超时,超时返回 null 后循环继续执行 BRPOP,相当于定期和 Redis 打个照面,确认连接状态。
坑三:阻塞等待中的线程如果因为业务原因需要停机,处理起来比想象中麻烦。
我在自研消费者框架时遇到过:应用发起了停机指令,但消费线程还阻塞在 BRPOP 上,进程一直退出不了。后来在循环里加了终止标志,并且用有超时的 BRPOP 保证线程最多 30 秒能醒来检查一次标志位,优雅停机才真正落地。
坑四:消息一旦被 BRPOP 弹出,就和 Redis 无关了。
这是 List 方案绕不开的短板。弹出后进程在handle(task)执行前崩溃,这条消息不会回到队列,也不会进死信,就那么消失了。很多人以为有 BRPOP 就安全了,实际上它只解决了「如何实时拿到消息」,并没有解决「如何可靠地确认消息已处理」。
2.4 一个可运行的 Java 阻塞消费循环
Spring Boot 项目里用 RedisTemplate 调用 BRPOP 非常简洁,opsForList().rightPop(key, timeout, TimeUnit)对应的就是 BRPOP 命令。一个相对完整的消费循环核心长这样:
public class TaskQueueConsumer { private static final String QUEUE_KEY = "task:queue"; private final RedisTemplate<String, Object> redisTemplate; private volatile boolean running = true; public void consumeLoop() { while (running && !Thread.currentThread().isInterrupted()) { try { // 对应 BRPOP task:queue 30 Object task = redisTemplate.opsForList() .rightPop(QUEUE_KEY, 30, TimeUnit.SECONDS); if (task == null) { // 超时无消息,继续等待 continue; } handleTask(task); } catch (Exception e) { log.error("consumer loop error, will sleep 1s and retry", e); try { Thread.sleep(1000); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); break; } } } } }注意catch里必须有 sleep 兜底,避免消费者处理逻辑偶尔抛异常时,这个循环在没有任何停顿的情况下疯狂重试,反而把 CPU 打满。
消费线程数量也要控制好。每个消费线程在等待期间占用一条 Redis 连接,如果并发开得太多,连接数会暴涨。我一般建议单实例开 4~8 个消费线程,具体看任务耗时和链路后端的承受能力,而不是越多越好。Redis 的 BRPOP 阻塞等待本身几乎不占 CPU,瓶颈通常在后端业务处理上。
3. 线程池的阻塞队列和 Redis 队列,不是一回事但经常同框出现
3.1 为什么热搜词里这两个东西总被绑在一起
搜「阻塞队列」这个关键词时,搜索引擎返回的很大一部分结果并不是 Redis 的 BRPOP,而是 JavaThreadPoolExecutor构造参数里的BlockingQueue。这两个东西虽然都叫「队列」,也都有「阻塞」特性,但它们处于完全不同的层级。
Redis 队列解决的是跨进程、跨实例的生产消费问题。一条消息被一个服务实例 LPUSH 进去,可以由另一个服务实例、甚至几十个服务实例中的任意一个来消费。消息队列的可靠性、ACK、重试这些概念都建立在分布式协作之上。
线程池里的BlockingQueue解决的是单 JVM 内线程间的生产消费问题。任务从提交线程交给执行线程,Queue 全程只在进程内,不跨网络、不落盘、不持久化。它的「阻塞」主要体现为:队列满时生产者线程被阻塞,队列空时消费者线程被阻塞。
打个比方:线程池队列像同一间办公室里几排工位间传文件用的篮子,Redis 队列像公司不同楼层之间共用的快递柜。虽然都叫篮子,解决问题的边界完全不同。
另外,大数据调度领域里的「队列」又是一个意思,YARN 的队列、LSF 的 bqueues 管的是资源调度和权限,和业务消息队列八竿子打不着。很多做后端的人一开始接触