ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Redis做消息队列的三种方案与可靠性设计:从List到Stream实战

2026/10/6 3:08:32 拓冰建站 浏览量
Redis做消息队列的三种方案与可靠性设计:从List到Stream实战 字节二面碰到这道题的时候我正在写一段 Redis 的消费脚本脑子里的第一反应不是能不能而是这题到底想考我什么。Redis 做消息队列这事网上资料多而杂真正能答到点子上的很少。你光说能用 List 实现或者说不行得用 Kafka其实都栽了——这题没有标准答案只有分场景的取舍。我用这套思路把三种方案、可靠性设计、真实踩坑全捋一遍不管是准备面试还是想落地一个轻量队列都能直接用上。1. 面试题拆解这道题到底在问什么1.1 考察点不是让你背 API很多人看到Redis 能做消息队列吗第一反应是背命令LPUSH加BRPOP或者 Redis 5.0 的Stream。但面试官问这道题背后至少叠加了三层考察。第一层是基础掌握程度。你会不会用 Redis 的数据结构去表达一个队列List、Pub/Sub、Stream三者的差异是什么这层答不上来后面基本没戏。第二层是对可靠性的理解。消息队列的核心不是能塞进去、能取出来而是消息在传输链路中不丢、不重、不乱。Redis 默认的读写模型和真正 MQ 的 ACK 机制差距在哪你能不能说出来第三层是工程判断力。不是所有场景都需要 RabbitMQ 或 KafkaRedis 做 MQ 的优势是轻、快、运维成本低。什么场景可以接受它的缺陷什么场景必须换专业 MQ这才是区分普通选手和资深选手的分水岭。所以我建议你把这道题当成一个系统设计题来答而不是一个Redis 命令题。1.2 面试官的潜台词这句话再翻译一下其实是问你对消息队列的可靠性模型理解到什么程度因为只有理解了 MQ 的可靠性模型你才能判断 Redis 能不能胜任。消息队列有三个最基本的可靠性诉求不丢消息生产者发了消费者一定能收到。不重复消费网络抖动、重试机制可能导致同一条消息被处理两次。不阻塞生产消费速度慢的时候生产者不能因为队列满而崩溃。Redis 的三种方案在这三条诉求上的表现天差地别。你把这三条作为主线去答整个回答就有骨架了。下面逐个拆。2. 三种主流实现方案与选型逻辑2.1 List 方案最简单但最不安全的队列用List做队列是 Redis 面试的经典套路核心就两条命令生产者LPUSH queue_key message消费者BRPOP queue_key timeout阻塞式弹出BRPOP有个好处队列里没消息时消费者会阻塞等待不会陷入死循环空转这对客户端 CPU 很友好。配合LPUSH就是一个标准的先进先出队列。但这个方案最大的问题是可靠性太弱主要体现在三个方面。第一BRPOP把消息弹出来之后这条消息就从 Redis 里消失了。如果消费者拿到消息后、处理到一半崩溃了这条消息就永久丢失。生产环境里消费者从拿到消息到执行业务逻辑之间有大量机会宕机。第二List没有消费组的概念。多个消费者同时BRPOP同一条队列Redis 的模型是谁抢到算谁的每条消息只会被一个消费者取走这其实是点对点模式但你没有办法给这批消费者划分职责也没法做类似于 Kafka 分区的定向消费。第三消息没有任何元数据。没有 ID、没有时间戳、没有重试计数出了问题你想排查这条消息是谁生产的、什么时候生产的、被消费了几次全靠业务字段自己带排查效率极低。为了解决弹出来就丢的问题老版本 Redis 有个补救命令BRPOPLPUSH。它能把弹出的消息同时备份到另一个List里业务侧处理完再删备份这样即使消费者崩了消息还留在备份队列里重启后可以恢复。# 从 queue 弹出并备份到 backup BRPOPLPUSH queue backup 0 # 处理逻辑跑完后手动删除备份里的消息 LREM backup 1 message_idRedis 6.2 之后推荐用LMOVE取代它语法更通用LMOVE queue backup LEFT RIGHT但要我说这套方案只适合对可靠性要求极低、甚至能接受偶发丢消息的场景比如一些异步通知、临时任务分发。真要拿它做核心链路后续的补偿代码会让你写到怀疑人生。2.2 Pub/Sub 方案广播一时爽消费火葬场第二个方案是Pub/Sub也就是发布订阅模式。命令非常简单生产者PUBLISH channel message消费者SUBSCRIBE channel和List最大的区别是Pub/Sub是广播模型所有订阅了同一个channel的消费者都会收到消息而不是抢消息。听起来很美好但它的致命缺陷是完全不留存消息。如果消息发布时没有消费者在线这条消息就凭空消失了没有任何补偿办法。和List那种消息起码还在队列里躺着相比Pub/Sub是真正的发了就没了。正因为这个特性Pub/Sub被业界定位成实时消息通知工具而不是可靠消息队列。典型应用是聊天室的在线广播、集群节点间的配置变更通知、或者前端页面的实时推送。这类场景里消息的价值只有此时此刻存在晚一秒收到就没意义了丢失完全可接受。面试时提到Pub/Sub一定要主动点出它的即发即弃特性和消息不持久化导致无法回放这个短板。如果你能把这两点说清楚面试官就会知道你不只是见过这个命令而是理解过它的设计边界。2.3 Stream 方案Redis 5.0 给出的官方答案如果你要正儿八经在 Redis 上做消息队列我的选择永远是Stream。Redis 5.0 引入的这套数据结构几乎就是照着专业 MQ 的需求设计的把前两个方案的坑都填上了。先看核心命令。生产者写入一条消息XADD order_events * order_id 1001 status paid这里的*表示让 Redis 自动生成消息 ID生成的 ID 是毫秒时间戳-序号的格式天然有序且带时间信息。你也可以自己指定 ID但基本没必要。消费者读取消息XREAD COUNT 10 BLOCK 5000 STREAMS order_events 0BLOCK 5000表示如果没消息就阻塞 5 秒0表示从开头读。注意XREAD是每个消费者各自独立读游标的方式适合简单的多消费者各自读全量消息的场景。如果要实现一条消息只被一个消费者消费一次的点对点模式需要引入消费组# 创建消费组 XGROUP CREATE order_events payment_group 0 # 组内消费者读取新消息 XREADGROUP GROUP payment_group consumer_1 COUNT 10 STREAMS order_events 这里有两个细节值得注意。第一个细节是0这个参数。它表示创建消费组时组内消费者的起始读取位置。传0意味着从头开始消费历史消息传$意味着只消费创建之后的新消息。如果业务上需要补偿历史数据递0如果只需要新消息递$。第二个细节是符号。它表示只读取从未被本组其他消费者读取过的消息。XREADGROUP每次读取后消息会进入这个消费组的PELPending Entries List待确认消息列表只有消费者显式确认之后它才会从 PEL 里移除。消费确认的命令是XACK order_events payment_group 1698765432100-0这个机制就是流式消息队列的核心消费 ! 确认。消费者可以先把消息拉下来处理处理完再XACK。如果消费者崩溃消息会一直留在 PEL 里重启后可以用XPENDING查出来、用XAUTOCLAIM重新分配给其他消费者。这就解决了List方案里弹出来就丢的问题。也就是说Stream一次性补齐了前两种方案的短板消息持久化、消费组、ACK 确认、待消费列表、消息 ID。论可靠性它已经接近专业 MQ 的最低配水准。3. 消息可靠性的四个核心问题光知道命令还不够面试答到这一步只能说基础扎实。接下来要进入真正的核心区可靠性设计。我分四个问题讲这四个问题也是你回答中最能体现深度的部分。3.1 消息不丢持久化、ACK 与 PEL消息不丢要拆成三段看生产者到 Redis、Redis 自身存储、Redis 到消费者。生产者到 Redis 这一段Stream的XADD是同步写入命令返回成功就说明消息已经进 Redis 内存。但如果你的代码是先发消息、后提交数据库事务这种顺序事务回滚时消息已经发出去了这种业务层面的丢得靠事务消息方案去处理和 Redis 无关。Redis 自身存储这一段核心是持久化配置。Redis 默认的RDB快照是异步的宕机可能丢最近几分钟的数据AOF默认appendfsync everysec极端情况下丢 1 秒的数据appendfsync always每次写都刷盘最安全但性能下降明显。如果你用 Redis 做 MQ我建议至少开AOF并根据业务对延时的容忍度在everysec和always之间做取舍。Redis 到消费者这一段就是ACK机制的主场。XREADGROUP把消息读走之后消息进入 PEL消费者崩溃也不怕。但这里有个容易踩的坑XREADGROUP默认不会自动确认如果你不调XACKPEL 会越积越大最终可能导致内存膨胀。我见过有的团队上线后忘了加确认逻辑两小时就把 Redis 内存干到了 90%排查半天才发现是 PEL 堆了上百万条消息。3.2 重复消费幂等是最后的防线就算你把确认和持久化都做好了重复消费依然无法完全避免。为什么因为ACK可能丢。消费者处理完消息、还没来得及XACK客户端就宕机了重启后XAUTOCLAIM会把这消息重新交给别的消费者于是同一条消息被处理了两次。所以做消息队列幂等性是必修课不管你是用 Redis 还是 Kafka这条躲不掉。常用的幂等方案有三种。第一种是业务唯一键去重。给每条消息带上业务唯一标识比如订单号、支付流水号消费者处理前先查一下这个订单号是不是已经处理过了处理过就直接跳过。查重可以查数据库也可以用 Redis 的SETNX做一个短期的处理记录。SETNX processed:order:1001 1 EX 86400返回 1 说明没处理过可以继续返回 0 说明已经处理过了直接跳过。加上过期时间可以防止记录无限膨胀但过期时间得比消息重试的最长间隔大否则过期后重复消息又会被处理一遍。第二种是数据库唯一约束。在业务表上建唯一索引重复插入会报错数据库层面直接挡掉。这个方案最稳但侵入业务表结构适合核心链路。第三种是版本号乐观锁。更新数据时对比版本号版本对不上就不更新。适合处理消息会对同一行数据做多次更新的场景。说实话这三种方案我都用过通用性最强的是第一种配合SETNX加过期时间简单直接对业务代码侵入最小。记住消息队列只能保证至少一次at-least-once投递无法保证恰好一次exactly-once这是分布式系统的基本面谁承诺恰好一次谁就是在吹牛。3.3 堆积与背压消费能力跟不上怎么办消息堆积的核心原因是消费速度 生产速度。我处理过的一个真实场景促销活动上线后订单消息的生产量瞬间翻了 20 倍消费端还是原来的 3 个消费者队列长度肉眼可见地飙升最终把 Redis 内存撑爆了。Stream的堆积问题首先要靠监控发现。Redis 提供了现成的命令XINFO STREAM order_events XINFO GROUPS order_eventsXINFO GROUPS能看到每个消费组的待消费消息数和 PEL 里待确认的消息数。如果待消费消息数持续增长就是消费跟不上了。建议把它接进你的监控系统设一个阈值告警。发现堆积之后常规解法有三种给消费组加消费者。Stream消费组支持多个消费者加消费者就是水平扩容。但注意XREADGROUP的消息分配是谁先读谁拿不是严格的负载均衡新增消费者后存量消息不会自动重新分配得靠新消费者主动去读。临时降级消费逻辑。把非核心的消费逻辑比如发通知、写日志先停掉优先处理核心链路。限流生产端。如果堆积持续恶化说明系统整体过载光扩消费者不够需要对生产端做限流。另外还有个背压问题XREADGROUP的COUNT参数决定一次拉取多少条。如果你家消费者每条消息处理耗时较长建议COUNT设置小一点比如 10~50避免一次拉太多把消费者内存打爆。3.4 顺序性单消费者下的有序保障Stream天然按消息 ID 排序同一时刻追加的消息严格有序。但你要搞清楚这个有序性只在同一个消费者身上成立。看一个具体场景两个消费者同时读同一个消费组消息 A 被消费者 1 拉走消息 BA 的后一条被消费者 2 拉走。两个消费者并行处理A 和 B 的完成顺序就无法保证。如果业务要求同一条订单的消息必须按顺序处理这就出问题了。解决方案有两种。第一种是同一个业务 key 的消息走同一个消费者。比如订单消息按order_id做哈希路由到固定的消费者去读业界叫分区有序。但Stream消费组本身不支持类似 Kafka 的 key 分区路由你得自己在客户端做路由逻辑。第二种是把状态收敛到数据库。即使消息乱序到达处理逻辑不依赖消息顺序而是每次都从库里读最新状态再决定怎么处理。这其实是更优雅的做法——你不需要保证消息顺序只需要保证最终状态正确。大多数业务场景第二种方案都更实际。4. 实操演示基于 Stream 实现一个可靠消息队列前面原理讲了一大堆但面试和落地都讲究能跑起来。这一节我直接给一套基于Stream的完整实现方案。4.1 环境准备与选型确认先确认 Redis 版本。Stream需要 Redis 5.0 以上XAUTOCLAIM需要 6.2 以上。用info server看一眼版本别在生产环境踩了版本坑。持久化配置我建议这样设appendonly yes appendfsync everyseceverysec是性能和可靠性的平衡点每秒刷一次盘最坏丢 1 秒数据。如果业务对消息要求极其严格可以改always但写入吞吐会明显下降具体看你的业务体量。另外给 Redis 设好maxmemory和淘汰策略。消息队列和缓存不一样缓存可以 LRU 淘汰消息队列不能随便淘汰——消息丢了很难补。如果内存不够优先拒绝写入而不是淘汰消息maxmemory 4gb maxmemory-policy noeviction这样内存满时XADD会报错至少消息不会在 Redis 层面被静默丢弃。4.2 生产者与消费者代码这里我用一个非常简洁的 Java 示例Spring Boot 环境核心逻辑和语言无关。生产者发送消息// 引入 spring-data-redis StringRedisTemplate redis ...; // 发送消息返回消息ID String messageId redis.opsForStream() .add(order_events, Map.of(orderId, 1001, status, paid, timestamp, String.valueOf(System.currentTimeMillis())));消费者读取并确认消息// 读取消息属于消费组 payment_group 的 consumer_1 ListMapRecordString, Object, Object records redis.opsForStream() .readGroup(payment_group, consumer_1, StreamOffset.create(order_events, ReadOffset.lastConsumed())) .block(Duration.ofSeconds(5)); for (MapRecordString, Object, Object record : records) { try { // 1. 业务处理 handleOrderPaid(record.getValue()); // 2. 业务成功后确认消息 redis.opsForStream().acknowledge(order_events, payment_group, record.getId()); } catch (Exception e) { // 3. 处理失败不确认让消息留在 PEL 等待重试 log.error(消费失败消息ID: {}, record.getId(), e); } }这段代码里有一个非常重要的设计点acknowledge必须在业务处理成功之后调用。如果你放在处理之前消息确认了但业务没执行那跟List方案弹出来就丢就没什么区别了。还要注意ReadOffset.lastConsumed()它表示从我这个消费者上次消费的位置继续读配合block可以实现阻塞等待新消息体验和BRPOP差不多。4.3 消费组与 ACK 机制落地第一次跑的时候记得先创建消费组XGROUP CREATE order_events payment_group 0如果 Redis 里已经有历史消息0表示从最早的消息开始消费如果只是想消费创建之后的新消息用$。XGROUP CREATE order_events payment_group $创建消费组之后消费者就可以用上面那段 Java 代码开始消费了。现在模拟消费者崩溃的场景。假设consumer_1读到消息 1698765432100-0 之后处理到一半宕机没有调用XACK。这条消息就留在 PEL 里。用XPENDING查看待确认消息XPENDING order_events payment_group输出大概长这样1) (integer) 1 # PEL 里的消息总数 2) 1698765432100-0 # 最早的消息ID 3) 1698765432100-0 # 最新的消息ID 4) 1) 1) consumer_1 # 谁的消息还没确认 2) 1这时候用XAUTOCLAIM把超时未确认的消息重新分配给另一个消费者XAUTOCLAIM order_events payment_group consumer_2 60000 1698765432100-060000是最小空闲时间单位毫秒意思是只认领空闲超过 60 秒的消息避免刚发出去还没处理完的消息被误抢。这个命令执行后consumer_1的 PEL 记录会转移到consumer_2名下consumer_2拿到消息后重新处理处理完再XACK。这里有两个坑要提醒。第一个坑连不上的消费者不会自动清理。XAUTOCLAIM需要你主动触发通常的做法是起一个定时任务每隔几分钟扫描一遍消费组的 PEL超过阈值就自动认领。别指望 Redis 帮你做这件事。第二个坑消费组要处理死信。如果一条消息反复被认领、反复处理失败不能无限循环。常规做法是设定最大重试次数超过后把消息 ID 和内容记录到另一个死信队列同样可以用Stream实现人工介入排查。4.4 关键参数与监控指标把生产级配置和监控指标整理了一下大家可以直接抄作业。配置/指标推荐值说明appendonlyyes必须开 AOFappendfsynceverysec平衡性能与可靠性maxmemory-policynoeviction消息不允许淘汰XREADGROUP COUNT10~50单次拉取量别太大XAUTOCLAIM 超时60000ms避免抢到正在处理的消息最大重试次数3~5超过进死信队列XINFO GROUPS 待消费数持续增长即告警消费堆积的早期信号PEL 待确认数超过阈值告警排查是否忘调 ACK这些参数都是一线经验值不是铁律但你从这些值起步大概率不会出大问题。5. 面试作答思路与追问应对5.1 回答框架从结论到细节面试时回答这道题我建议按结论 → 方案演进 → 取舍边界的顺序说每层控制在两三句让面试官有追问的空间。第一句给结论Redis 能做消息队列而且有三种做法List、Pub/Sub 和 Stream。前两种有各自的硬伤生产环境建议用 Stream。第二句讲方案演进List 的问题是消息是破坏性读取消费即删除没有 ACK 机制Pub/Sub 的问题是消息即发即弃不持久化没有消费者在线就丢失Stream 是 Redis 5.0 引入的原生支持持久化、消费组、ACK 和 PEL是目前最接近专业 MQ 的方案。第三句讲取舍边界但 Redis 做 MQ 的定位是轻量。如果对消息的可靠性和堆积能力要求高或者需要消息回放、消息过滤这类高级特性建议上 Kafka 或 RabbitMQ。这个框架的好处是一句话给出结论三句话展示知识广度最后一句展示判断力。面试官无论往哪个方向追问你都能接上。5.2 高频追问场景拆解我总结了几个大概率会被追问的问题提前准备好面试不慌张。追问一Redis 做 MQ 和 Kafka 比最大的短板是什么这个问题的核心是堆积能力。Kafka 用磁盘顺序读写单机能扛几十万甚至上百万条积压积压几天都不慌Redis 的Stream虽然在内存里但内存是有限的积压超过maxmemory就只能拒绝写入。另一个短板是消费语义Kafka 支持分区级别严格有序、消息可以按 offset 任意回放Redis 的Stream做不到这种细粒度控制。所以一旦业务对堆积容忍度低、消息量级大直接选 Kafka。追问二消息重复消费了怎么办这个话题我前面已经展开过面试时核心答两点第一MQ 投递语义本身就是 at-least-once重复消费是常态不是异常第二解决方案是消费端幂等用SETNX或数据库唯一索引做去重。最好能举一个自己项目里的例子比如我用订单号做SETNX 过期时间的方案处理了某次消费者重启导致的重复支付回调。追问三消息积压严重怎么办答先定位是生产快还是消费慢用XINFO GROUPS看待消费数量。如果是消费慢优先扩容消费者如果单个消息处理逻辑太重考虑批量消费和异步处理如果积压已经非常严重可以把部分非核心消息丢弃或延迟处理保住核心链路。追问四为什么不用 Redis 的发布订阅做 MQ答因为Pub/Sub的消息不持久化Redis 重启或消费者不在线期间发布的消息会全部丢失无法保证至少一次投递。它适合实时广播通知不适合数据链路。5.3 什么时候不该用 Redis 做 MQ作为工程师比怎么实现更重要的是什么时候不该这么实现。Redis 做 MQ 有几个明确的红线碰了就出事。第一条红线是消息量大且可能长时间积压。Redis 内存贵积压超过物理内存只能拒绝写入等于生产链路直接中断。你见过 Kafka 积压几千万条照样稳定运行的但你很难让 Redis 积压几千万条还不出事。第二条红线是严格的有序性要求。Stream的消费组在多消费者并行时无法保证全局顺序虽然可以用业务 key 做分区路由但实现复杂度不低远不如 Kafka 原生分区有序来得自然。第三条红线是要求严格不丢、恰好一次投递的场景。比如支付、对账这一类核心资金链路Redis 的持久化机制在多节点集群环境下存在主从切换丢消息的风险。这种场景该上 RocketMQ 或 Kafka 就上不要为了省运维成本拿核心链路开玩笑。一句话总结我的选型标准Redis MQ 适合轻量、短时、可容忍少量丢失的异步任务核心交易链路、大规模积压、严格顺序请选专业 MQ。6. 实战踩坑与经验总结最后这部分把我在真实项目里踩过的坑集中说一下都是文档里查不到的。6.1 经典坑位BRPOPLPUSH 的隐藏问题用List方案配BRPOPLPUSH做可靠队列时有一个非常隐蔽的坑备份队列里的消息没有过期机制如果消费者一直崩溃备份队列会无限增长。我遇到过一台 Redis 实例因为备份队列增长到几个 GB最后把整个实例的内存都吃掉了。解决办法要么给备份队列配一个定期清理任务把超过 N 天的消息清掉要么干脆用Stream用XAUTOCLAIM配合超时机制去管理待确认消息这个模型从设计上就避免了无限备份的问题。6.2 消费组扩容与重平衡的认知误区很多人以为消费组加一个消费者存量消息就会自动分摊实际上Stream的消费组跟 Kafka 不一样新消费者上线后只会从当前游标继续读新消息老消费者手里没读完的消息不会重新分配。想要负载均衡得让老消费者把 PEL 里的消息处理完或者用XAUTOCLAIM主动认领。这说明扩容需要一定的停机窗口或灰度时间不是秒级生效的。6.3 我的个人建议结合我的实际经验给几条硬建议。第一Redis 做 MQ定好位再用。消息量小、团队运维 Kafka 成本高、能接受极端情况丢少量消息——这三个条件同时满足再用。我目前维护的一个内部通知系统每天消息量也就几万条用Stream做完全够比裸上 Kafka 省了一整套运维负担这是 Redis MQ 最舒服的生态位。第二监控一定要提前做。不管用什么方案队列长度、消费延迟、PEL 数量这三项指标必须接入监控而且告警阈值要在平常就测试好。等你发现队列堆积的时候往往已经晚了半小时这半小时里消费端可能已经积压了上百万条消息。第三幂等代码永远不要省。我踩过最痛的一次坑就是以为用了Stream的 ACK 就不会重复消费结果一次消费者批量重启后几千条消息被重复处理直接导致一批重复的优惠券发放。从那以后我所有的消费逻辑默认都要接一个幂等层这已经成为我的代码习惯。这套方案的完整链路写下来就是Stream负责存储和分发XACK负责确认XAUTOCLAIM负责故障恢复SETNX负责幂等监控系统负责预警。Redis 做 MQ 能不能用能用用得好就是一套轻量可靠的消息中间件用得糙就是给自己埋雷。希望这个从原理到实战的全过程能让你在面对这道面试题的时候不只是背出几个命令而是真正讲清楚一套完整的工程思路。