ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Redis队列与阻塞队列详解:从BRPOP到Stream的异步改造实践

Redis队列与阻塞队列详解:从BRPOP到Stream的异步改造实践 很多项目最初都是把 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 之间不存在并发竞争问题。两个消费者线程同时在队列另一端 RPOPRedis 会保证它们各自拿到一条不同的消息绝不会出现两条消息同时发给同一个消费者的误判。对大多数业务来说这一层原子性已经比你在 JVM 里自己写一个LinkedList Lock可靠得多。队列里具体存什么这个细节我建议所有团队提前统一口径。最好只放业务 ID、JSON 字符串、事件标识这类轻量结构不要把几十 KB 甚至上百 KB 的完整报文直接塞进 List。我见过一个项目把用户全量画像 JSON 往队列里写单 key 积压后占了几个 G 内存最后排查了大半天才定位到是队列把 Redis 内存打满了。正确做法是大对象落到独立的存储或对象存储队列里只放一个引用 ID消费者拿到 ID 后自行回源。1.2 轮询取消息的代价延迟、空转和无效 QPSList 队列一旦跑起来你很快就会遇到一个尴尬问题生产者可以随时 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 RedisTemplateString, 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 管的是资源调度和权限和业务消息队列八竿子打不着。很多做后端的人一开始接触
返回列表