
1. 这个面试题到底在考什么字节二面问到“Redis 能做消息队列吗怎么实现”我第一反应是这题看着基础实际上是个深水坑。很多人一听“Redis 消息队列”张口就是LPUSH BRPOP然后就开始背命令觉得稳了。但面试官真正想听的不是你会不会敲那几条命令而是你有没有在真实业务里权衡过“用 Redis 做消息队列”这件事的边界。这个问题的核心至少涉及四层第一层你知不知道消息队列的基本模型生产者、消费者、队列/主题、消息确认、消费位点。第二层你知不知道 Redis 的哪几种数据结构可以用来模拟这些模型各自的天然限制是什么。第三层你知不知道 Redis 本身不是为消息队列设计的它在持久化、堆积能力、可靠投递上有什么硬伤。第四层你会不会在面试里把“能用”和“该用”分开给出一个带工程判断的答案而不是无脑吹。所以这篇文章我打算把从简到繁的三套 Redis 消息队列实现方案全拆一遍基于 List 的、基于 Pub/Sub 的、基于 Stream 的再把面试里常见的追问点都揉进实操细节里。你看完不只是会答这道题而是真的能自己在项目里搭一套并且知道它什么时候会翻车翻车了怎么排查。先说结论Redis 能做消息队列但它只适合特定场景。如果是低频、允许小概率丢失、不需要消息回放和严格确认的任务队列Redis 完全够用而且比引入 Kafka 或 RabbitMQ 轻太多。如果是核心交易链路要求不丢消息、顺序严格、堆积能力强那直接用专业消息队列。这个判断本身就比任何一条命令都值钱。下面我按方案演进顺序逐个拆给你看。2. 方案一List 实现“先进先出”任务队列2.1 最基础的生产消费模型Redis 的 List 是一个双向链表天然支持在一端推入、在另一端弹出这不就是“先进先出”的队列模型吗所以LPUSH RPOP这对组合是最原始的消息队列实现。生产端LPUSH task_queue task:1001 LPUSH task_queue task:1002消费端RPOP task_queue当然实际项目里没人这么裸着用因为RPOP是非阻塞的队列空了消费者就得轮询空转非常浪费 CPU。于是就有了阻塞版BRPOPBRPOP task_queue 0第二个参数是超时时间单位秒传 0 表示永远阻塞等待。这比轮询优雅多了队列没消息的时候消费者线程挂起不来空转一旦有消息进来Redis 立刻把数据推给消费者。2.2 为什么说这版只能算“能跑”这套模型跑通一个简单异步任务比如发邮件、生成缩略图没问题但仔细抠细节全是坑。第一消息没有确认机制。BRPOP把消息弹出来以后消费者拿到手还没来得及处理进程崩了这条消息就永久丢失了。因为你已经把它从队列里弹走了Redis 里不剩任何痕迹。第二无法延迟消费消息进了队列就得马上被处理想加个“5 秒后再处理”的延迟功能List 原生做不到得自己造轮子。第三重复消费问题怎么解决如果消费者处理完业务、还没来得及记录 offset 就崩了消息也没丢但从队列角度它已经出队了新的消息不会被重复投递。可如果你换一种姿势——比如用LRANGE先看一眼再删除——那就可能两个消费者同时读到同一条消息。所以基于 List 的方案最佳使用场景是消息丢了能接受、消费速度远大于生产速度、不需要分组消费的内部小任务。它最大的优点是极致的简单Redis 都不用额外配置一个 key 搞定。2.3 给 List 方案补上“可靠性”面试如果只聊到这里明显不够。进阶一点你可以说在我的项目里我会用 List 实现一个“至少一次消费”的可靠队列。思路是引入两个队列一个“待消费队列”一个“处理中队列”。消费者不是直接RPOP而是先RPOPLPUSH task_queue processing_queue原子地把消息从待消费队列挪到处理中队列。处理完业务之后再LREM processing_queue 0 message把消息从处理中队列移除。如果消费者在处理过程中崩溃了消息停留在 processing_queue 里不会被遗忘。另一个守护进程定期扫描 processing_queue把超时的消息重新塞回 task_queue。这就是一个简易版的“超时重试 确认删除”。# 原子操作弹出 task_queue 末尾的消息并推入 processing_queue 头部 RPOPLPUSH task_queue processing_queue这套方案虽然代码复杂度上去了但可靠性从“可能丢”变成了“至少一次”代价是消息可能重复处理。所以下游必须做幂等或者说只要你选择 Redis 做消息队列幂等消费就应该是默认配置。没有幂等谈什么可靠。3. 方案二Pub/Sub 实现实时广播3.1 从“点对点”到“发布订阅”List 队列是点对点模型一条消息只能被一个消费者拿走。但业务里经常有“广播”需求比如用户下单后需要同时通知积分系统、短信系统、审计系统这三个系统各自关心不同类型的数据。这时候就该用 Redis 的发布订阅PUBLISH发布消息多个SUBSCRIBE订阅者都能收到。# 订阅者 1 SUBSCRIBE order_event # 订阅者 2 SUBSCRIBE order_event # 发布者 PUBLISH order_event order_id10086,typecreated只要订阅者的连接是保持住的Redis 会实时把消息推给所有订阅了该 channel 的客户端。这个过程是推模式不需要消费者去拉延迟极低非常适合做实时通知、实时聊天室的“广播”消息。3.2 致命短板离线即丢积压即丢如果你要在面试里表现出真正的理解一定要把这个方案的致命短板说透Pub/Sub 的消息是即发即弃的。消费者不在线等它上线后什么消息都收不到。消费者处理不过来Redis 没有为订阅者提供消息缓存消息直接丢弃。网络抖动导致订阅连接断开断线期间的消息全部丢失。这些限制来自设计底层Pub/Sub 根本不关心你有没有收到它只负责把消息从 sender 推给所有当前在线的 subscriber。这跟 Kafka 的 consumer group 完全是两码事Kafka 会为每个消费者保存 offset离线重启后还能接着消费。Redis Pub/Sub 做不到。3.3 面试怎么评价这个方案我当时面试时给面试官的判断是Pub/Sub 适合做“实时性要求极高、消息价值低、不接受积压”的场景。比如在线人数变更通知、Web 端实时推送小尾巴提醒、分布式环境下刷新本地缓存。举个例子你有多台应用服务器每台本地缓存了一份配置。后台修改配置后只要往config_change频道发一条消息所有应用服务器收到后主动刷新本地缓存比轮询数据库快得多。这种场景下如果某台服务器刚好断网错过了通知那也没关系它下次启动时本来就会加载最新配置本地缓存短暂不同步可以接受。反之千万别拿 Pub/Sub 去传订单数据、交易流水。我见过有人用 Pub/Sub 收发业务消息消费者一重启订单创建消息直接丢了最后只能靠对账任务手工补单差点线上事故。4. 方案三Stream —— Redis 官方给出的“正经答案”4.1 为什么需要 Stream前两种方案各有硬伤List 无法多消费者协同消费Pub/Sub 不持久化。所以 Redis 5.0 引入了 Stream这是 Redis 官方正儿八经为消息队列设计的数据结构。它集合了 List 的持久化、Pub/Sub 的多播以及类 Kafka 的消费者组模型。Stream 核心机制消息按追加方式写入内部结构是一个精简的日志每条消息有唯一 ID。消息持久化保存除非被主动删除或触发淘汰策略否则不会消失。支持消费者组同一组内多个消费者共同分担消息组与组之间独立消费同一份数据。支持消息确认XACK消费者处理完消息后显式告诉 Redis “这条我收下了”未确认的消息会进入 Pending 列表支持重新投递。这基本把前两个方案的坑填平了。4.2 用一个完整案例跑通 Stream假设我们有一个“订单超时自动取消”的任务需要按订单生成时间依次处理。用 Stream 实现一套可靠的消费者组。生产端XADD order_delay_queue MAXLEN 10000 * order_id 1024 create_time 1699999999这条命令往 stream 里追加一条消息MAXLEN 10000表示最多保留最近 10000 条防止无限增长。*表示让 Redis 自动生成毫秒级时间戳 序号的消息 ID比如1699999999000-0。消费端创建消费者组XGROUP CREATE order_delay_queue group1 0第二个参数是组名第三个参数0表示从 stream 开头开始消费如果传$表示只消费今后新增的消息。创建好组之后组内成员用XREADGROUP读消息XREADGROUP GROUP group1 consumer1 COUNT 10 BLOCK 5000 STREAMS order_delay_queue 这里是特殊 ID表示“给我消费组内还没投递过的新消息”。COUNT 10表示最多拿 10 条BLOCK 5000表示 5 秒内等不到新消息就返回。消费者收到消息处理完订单超时逻辑后调用确认XACK order_delay_queue group1 1699999999000-0确认之后这条消息才会从消费者的 Pending 列表里移除。假如消费者在XACK之前崩溃了这条消息的状态是“已投递未确认”另一个新消费者启动后可以指定读取 Pending 中的未确认消息继续处理。4.3 消费者组为什么能避免重复消费这里要重点讲讲 Pending Entries ListPEL。消费者组在 Redis 内部为每个消费者保存一个 PEL记录它读过但没确认的消息 ID。同一组内一条消息只会分配给一个消费者因为分配时 Redis 会用定位到最新的未投递消息结合 stream 的游标保证多个消费者不会拿到同一条。但面试中你要能接住一个戳破美好幻想的追问“这样就没重复了吗”其实不是。重复消费有两个来源消费者 A 收到消息处理成功但在XACK之前网络断了消息留在 A 的 PEL 里。消费者 B 接手后会把这条消息重新投递给 B。消费者拿到消息后阻塞住了超过了XCLAIM的判定时间别的消费者通过XCLAIM抢走这条消息继续处理。所以Stream 给的保障是“至少一次”不是“恰好一次”。想要恰好一次在 Redis 层是无解的必须业务侧保证幂等。这也是为什么我一直强调选 Redis 做消息队列幂等设计必须前置。4.4 如何实现消息回放和死信队列Stream 的消息 ID 是时间戳 序号的单调递增结构所以天然支持按时间范围读取XRANGE order_delay_queue 1699999999000-0 COUNT 100这个特性让它具备了一定的消息回放能力。某条消息处理失败想要重新消费可以用XCLAIM把它转移给另一个消费者XCLAIM order_delay_queue group1 consumer2 60000 1699999999000-060000是最小空闲时间毫秒意思是这条消息至少在 PEL 里躺了 60 秒才允许被转移。我实际项目里会额外用一张 Redis Hash 记录每个消息的重试次数超过 3 次就手动写入一个dlq:order_delay_queue的 Stream通知报警平台然后人工介入处理。Redis Stream 本身不带死信队列概念但你可以套一层约定实现它不算复杂但很实用。5. 面试官想听的高阶对比与工程取舍5.1 Redis Stream 对比 RabbitMQ 和 Kafka聊到这份上面试官大概率会追问一句“既然 Stream 这么全那 Redis 是不是可以直接替代 Kafka 了”你要坚定地摇头。这是整个回答最关键的落地点。建议用下面这张表直接回答维度Redis StreamRabbitMQKafka消息堆积能力弱受限于内存和 maxmemory 策略中磁盘存储强顺序写磁盘堆积能力天花板高数据可靠性依赖 AOF/RDB主从切换有丢消息概率支持 publisher confirm 和持久化队列通过副本同步和 acks 机制保证各种级别可靠顺序性分区内严格有序单队列内有序分区内严格有序消费者模型消费者组多种交换机路由模式消费者组 分区多副本吞吐量单线程模型受限于 CPU适合万级以下 TPS中等适合业务消息百万级 TPS适合大数据管道运维复杂度极低复用 Redis 集群中等高依赖 ZooKeeper/KRaft这张表背下来没有意义你要理解每一行的原因。比如为什么 Redis Stream 堆积能力弱因为它底层是内存数据结构虽然有 AOF 和 RDB 持久化但数据先要存在内存里。消息量超过内存上限Redis 就扛不住了Kafka 直接把数据写磁盘靠页缓存加速所以堆积几百 G 的消息也不会把内存打爆。5.2 真实业务里什么时候选 Redis MQ我在实际项目中总结出了一个判断口诀小、快、轻的场景选 Redis大、稳、严的场景选专业 MQ。适合 Redis 的异步发送短信、邮件、站内信丢几条可以补发。秒杀接口的请求削峰先写 Stream 再异步扣库存失败的单子在流里回放排查。分布式系统里的缓存失效通知、配置刷新广播延迟毫秒级不需要持久化。定时任务调度用 Stream XREADGROUP实现延迟队列和重试机制替代数据库表轮询。不适合 Redis 的交易支付、订单状态流转等核心链路消息价值极高不能丢、不能乱。大量离线和积压场景消费者每天只在固定时间跑批任务攒了一整天几千万条消息。需要复杂的路由规则、死信交换机、消息优先级RabbitMQ 更成熟。需要超高吞吐、多副本跨机房同步Kafka 更顺手。这里我想提一个容易被忽略的坑Redis 本身是单线程模型虽然 6.0 之后有 IO 多线程但执行 Lua 和数据处理命令仍然是主线程串行执行。如果你把 Redis 同时用作缓存和消息队列队列里消息突发暴涨BRPOP的唤醒风暴可能会拖慢主线程影响缓存命中率。所以真要拿 Redis 做消息队列最好单独部署一套实例别和核心缓存混用。我就是这么干的宁可机器多一台也别让两个业务互相踩脚。5.3 另一个必踩的坑持久化与主从切换很多人以为 Redis 开启了 AOF 就万事大吉其实在消息队列场景下AOF 配置不对照样丢消息。appendfsync有三个选项always每次写命令都刷盘最安全但性能下降非常明显。everysec每秒刷一次盘性能和数据安全折中。no交给操作系统刷盘性能最好但宕机可能丢几秒的数据。对于消息队列我建议至少用everysec。如果你对丢消息零容忍那就得always同时做好性能压测。注意这只是单个实例的持久化。如果 Redis 是主从架构当主库发生故障切换时从库可能没有完全同步主库的最新消息这就可能丢数据。加上wait命令可以同步等待从库确认但会增加写延迟。你需要在面试中坦然说出Redis 主从是异步复制Redis 消息队列无法保证不丢消息除非你愿意牺牲性能换来更高的可靠等级。6. 实际落地时的消息可靠性与重复消费排查6.1 消费端必须做幂等别偷懒我见过太多团队Redis 队列用得很欢一聊到重复消费就说“应该不会重复吧”。大哥一定会重复。因为网络抖动时消费者可能已经做好所有处理但在给 Redis 回XACK的路上断了或者任务超时被其他消费者重新领取。所以你的处理逻辑一定要设计成幂等的。最省事的幂等方案用业务唯一键落库数据库的唯一约束去重。把处理结果写入 Redis同一个业务 ID 只处理一次。状态机前置判断比如“订单已取消”就不再做重复取消。不要相信“我们处理很快所以不会重复”该加的唯一约束一个都不能少。6.2 消费者挂了怎么办重新投递与死信如果消费者在BRPOP或XREADGROUP中拿到消息后进程崩溃消息会滞留在 PEL 里。一段时间后可以用XCLAIM把还在 PEL 里的消息重新分配。但要注意XCLAIM只会检查消息距离上次投递时间是否超过你指定的最小空闲时间它不会自动判断这条消息是不是真的处理失败了。所以要有监控。我的做法是用定时任务每分钟扫描各消费者的 PEL 长度。PEL 里的消息 ID 越积越多、或者最长等待时间大于 5 分钟就报警。报警后手动或自动执行XCLAIM把卡住的消息转移到备用消费者。同一消息重投 3 次后转入死信 Stream触发钉钉群通知。6.3 一个完整的 Stream 消费者代码骨架下面用 Python redis-py 写了一个可运行的消费循环重点看异常处理和确认逻辑import time import redis r redis.Redis(host127.0.0.1, port6379, decode_responsesTrue) stream_key order_delay_queue group_name group1 consumer_name consumer_py # 如果消费者组不存在先创建 try: r.xgroup_create(stream_key, group_name, id0, mkstreamTrue) except redis.ResponseError: pass while True: try: # 阻塞读取新的未投递消息 resp r.xreadgroup( groupnamegroup_name, consumernameconsumer_name, streams{stream_key: }, count10, block5000, ) if not resp: time.sleep(0.1) continue for stream_id, messages in resp: for msg_id, fields in messages: order_id fields.get(order_id) try: # 模拟业务处理 print(f处理订单: {order_id}) # 处理成功确认消息 r.xack(stream_key, group_name, msg_id) except Exception: # 业务失败记录日志不 ACK等待超时后重投 print(f业务处理失败: {order_id}, msg_id{msg_id}) except redis.RedisError as e: # 连接异常等退避重试 time.sleep(1)这段代码看起来简单但胜过网上很多“玩具代码”。它做了三件正确的事消费者组不存在时自动创建而不是启动项目前手动执行。消息处理失败不XACK让消息留在 PEL 里具备重新投递能力。外层捕获 Redis 异常发生断线时退避重连不会死循环。6.4 重复消费问题排查实录某次线上问题让我印象很深用户反馈优惠券发重了。查日志发现同一个order_id入了两次发送优惠券的 MQ 消息。定位过程如下看了 Stream 的 PEL果然有两条一样 order_id 的消息都是同一消费者的 PEL 里躺着。原因其实很简单生产端对同一订单调用了两次XADD。那是生产端逻辑重复了跟消费端没关系但也说明一个道理幂等要防两头生产端和消费端都有各自的坑。生产端防重怎么做我在落库的时候把订单号和业务请求 ID 绑在一起每次XADD之前先查一下这个业务请求 ID 是否已经写入过。做得再彻底一点可以用 Redis Set 保存已写入的消息 IDSISMEMBER判断是否重复。当然如果你的 Redis 消息量极大Set 占的内存也要考虑那就用布隆过滤器兜底。7. 常见问题速查与避坑清单我把这些年用 Redis 做消息队列踩过的坑汇总成一张表面试前或者做方案评审前可以直接翻出来看。场景常见问题解决方案List 队列消息被RPOP后消费者崩溃丢失改用RPOPLPUSH 处理中队列List 队列队列堆积太多内存不够设置MAXLEN或换 StreamPub/Sub订阅者离线丢消息业务允许则忽略不允许则换 StreamStream多消费者组消费同一条消息这是特性各组独立游标不冲突Stream消费失败一直重试设置最大重试次数转入死信队列所有方案恢复后重复消费业务幂等 数据库唯一约束主从主库故障切换丢消息wait同步等待、AOFalways、或者压根别用 Redis MQ 接核心链路Stream PELPEL 无限增长定时扫描超过阈值自动转移或告警几个额外的实操心得Redis 的BRPOP第一个参数建议不要传 0 永远阻塞。一旦 Redis 连接异常这个线程可能卡死。我会用BRPOP key 10配合外层循环检测到异常能自动重连不会一睡不醒。消费者并发量别开太高。Redis Stream 的消费者组适合“每个消费者一个连接去拉”的模式。之前有人直接用多线程抢同一个 stream结果消息重复投递的频率明显上升原因是一些线程还没XACK其他线程又通过XCLAIM把 PEL 里的消息抢走了。给消息体加一个created_at或业务时间戳。排查延迟、单调顺序、以及做延迟队列的时候都靠它比盯着 Redis 的自动 ID 更直观。聊到这里Redis 做消息队列这件事在你脑里应该已经不是一个简单能用/不能用的判断题了而是一道组合题什么场景用什么方案、怎么补可靠性、怎么防重复。如果面试官让你手写消息队列实现你直接从 Stream 展开说清XADD、XREADGROUP、XACK、XCLAIM并主动讲一讲 PEL 和幂等性这个深度已经超出绝大多数候选人了。至于方案选型记住两个原则Redis 适合轻量异步、可容忍偶发丢失的场景核心链路、严格数据可靠的消息队列别拿 Redis 硬刚。这锅 Redis 背不起你也背不起。最后分享一个我自己的习惯每次接到“用 Redis 做消息队列”的需求我会先问三个问题——消息丢了能接受吗需要分布式多消费者共同处理吗堆积量预估有多大如果消息价值低、消费者单机够用、堆积量在内存可控范围内放心用 Stream否则直接告诉产品经理咱们去评估 RabbitMQ。这不是技术洁癖而是让合适的东西待在合适的位置。