最近有位做电商系统的朋友来找我,说他们的订单状态同步服务一到活动促销就出问题,Redis里的消息越堆越多,消费者进程看着像卡死了一样,重启没用,换了个更大的机器也撑不住。我问他用的什么队列方案,他说Redis Pub/Sub。电话这边我基本就猜到问题出在哪了——不是Redis不行,是压根没分清Redis、RabbitMQ、Kafka这三类消息中间件各自管的是哪块地。
这篇文章我想把这件事讲透。我们在Python后端架构里聊“消息队列”的时候,通常默认它们是同一个东西,但Redis的Stream、RabbitMQ的AMQP协议、Kafka的日志模型,从底层设计上就是三条不同的路。你选哪个,不取决于哪个“更火”,而取决于你的业务到底需要解耦、削峰、异步,还是需要可回放的事件流。这篇文章适合正在做技术选型的后端开发,也适合已经把某个中间件跑起来了、但遇到奇奇怪怪生产问题不知道怎么排查的人。
1. 事故复盘:Redis 里的消息越堆越多,问题不在 Redis
1.1 现场:A同学的订单状态同步服务是怎么崩的
A同学的项目并不复杂:用户下单后,订单服务把“订单已创建”这个事件发布出来,下游有好几个服务要消费这个事件,比如库存扣减、积分发放、通知推送。他一开始图省事,直接用Redis的Pub/Sub通道,订单服务负责publish,其他服务各自subscribe。平时流量低没毛病,但一到活动促销,消费者处理速度跟不上发布速度,他就开始收到告警:消息堆积、延迟陡增、下游数据对不上。
最诡异的是,他看Redis内存和CPU都很健康,没有OOM,没有慢查询,发布端也确认每次都执行成功了。但消费者就是消息时有时无,重启之后有些消息彻底消失了。他以为是自己代码写错了,反复看了几十遍发布代码也没找到问题。
1.2 拆解:Pub/Sub 模型为什么会“看起来像队列又不像队列”
这个问题拆开其实很清晰。Redis Pub/Sub本质上是一个即时广播通道,不是队列。发布者把消息发给频道之后,Redis不会把消息存到任何地方,消费者如果当时不在线或者网络抖动导致没收到,这条消息就直接丢了,没有任何重试、补偿、回溯机制。也就是说,Pub/Sub既不做持久化,也不提供确认机制,它迎合的是“实时通知”场景,而不是“可靠消息传递”场景。
A同学看到的现象是:某个消费者进程在活动开始后因为处理超时被监控系统kill掉了,重启的间隙里发布端发过来的那批订单事件全部蒸发。他以为消息“堆在Redis里”,其实Redis里根本啥也没有。他看到的“堆积”,是另一个更隐蔽的问题——消费者虽然活着,但它的CPU被某些重业务逻辑占满,处理不过来,而Pub/Sub又没有堆积缓冲区,消息只能被网络层丢掉。这一来一回,丢消息就成了必然。
这个案例给了我一个很重要的判断标准:选消息中间件之前,先想清楚你允不允许丢消息。如果一条消息丢了会导致对账不平、用户权益损失,那Redis Pub/Sub这种“即发即忘”的模型从一开始就不该出现在选项里。Redis里真正能承担可靠队列任务的是Stream,这个后面会细说。
2. 三个底层模型的本质区别:队列、路由器、日志
2.1 Redis:从 List 到 Pub/Sub 再到 Stream,它到底算什么
Redis在消息领域其实有三副面孔。最早大家用List结构做队列,LPUSH往左推,BRPOP从右边阻塞弹出,数据是存在Redis里的,理论上不怕消费者离线。但List天然没有ACK机制,消费者把消息pop走了,如果处理过程中崩了,这条消息就再也找不回来。后来出了Pub/Sub,又把持久化这个优点给丢了。直到Redis 5.0引入了Stream,才算真正补齐了消息领域的核心能力。
Stream的数据结构可以理解成一条只能追加的日志,每条消息都有一个自动递增的ID,消费者组(Consumer Group)机制让多个消费者可以分工读取,读取之后必须显式ACK,没ACK的消息会留在Pending列表里,供崩溃恢复后重新处理。它还支持从任意历史位置读取,这个能力非常像Kafka的offset机制。所以你在2025年的今天如果还要在Redis上做消息队列,我的建议是直接用Stream,别再惦记Pub/Sub和List那套老玩法了。
但Redis Stream也有它的天花板:它毕竟是内存型数据库的附属品,消息堆积量受内存上限约束,持久化靠RDB/AOF,极端场景下的数据可靠性不如专为消息设计的系统。它更适合消息量可控、不想额外引入中间件、希望一鱼两吃(既当缓存又当队列)的场景。
2.2 RabbitMQ:一个不折不扣的路由分发网络
RabbitMQ的底层模型跟Redis完全不同。它不叫队列,而叫Exchange + Binding的路由体系。生产者不直接往队列里扔消息,而是把消息发给Exchange,Exchange再根据Routing Key和Binding规则,把消息复制到匹配的一个或多个Queue里。消费者订阅的是Queue,Queue有独立的持久化、ACK、死信、TTL策略。
这个设计的好处是路由能力极强。你可以用Direct Exchange做点对点精确分发,用Topic Exchange做按通配符匹配的“按业务类型订阅”,用Fanout Exchange做广播。一个系统里可以同时存在多个Queue接收同一类消息,但每个Queue的消费者互不干扰,各自独立ACK。
生产上RabbitMQ更被人看重的是它的确认机制做得非常细致:生产端有Publisher Confirm,消费者有Manual ACK,消息没被确认就不会从Queue中删除,消费者断开连接后消息会被重新投递给其他消费者。这种“宁可重复,不可丢失”的模型,特别适合订单系统、支付回调这类不允许丢消息的业务。RabbitMQ的吞吐量虽然不如Kafka,但单机几万条每秒对于绝大多数内部业务完全够用。
2.3 Kafka:只追加的日志,天然为回放和吞吐而生
Kafka跟前面两个最大的区别在于:它从设计之初就不是“队列”,而是一个分布式的提交日志。消息按Topic划分,每个Topic又拆成多个Partition,消息永远只追加到Partition尾部,消费者自己维护读取位置(offset)。所以Kafka里消息不会因为被消费了就消失,它会在磁盘上保留你配置的时长或大小,过期后才被清理。这意味着任何时刻,你想让一个新消费者从三天前开始重新读一遍整个事件流,都是完全可行的。
正是这种“日志”特性,让Kafka成了数据管道事实上的标准。日志采集、用户行为追踪、数据库变更捕获(CDC)、多个异构系统之间的数据同步,这些场景的核心诉求不是“发条消息让下游干个活”,而是“把发生过的事实完整保存下来,供多个系统各取所需地消费和回放”。Kafka的单分区内保证有序,全局吞吐可以靠增加分区数水平扩展,单集群一天处理几百亿条消息是很常见的数字。
2.4 比方说:冰箱、驿站和仓库账本
为了记住三者差异,我常用的类比是这样的。Redis Stream像是一个带保存功能的普通队列,东西放进去能存,但你得自己看好容量,崩溃了可能丢一部分。RabbitMQ像一个智能快递驿站,它能按贴的标签把每件物品分到正确的货架上,签收确认做得很好,但驿站每天能处理的快件总数有上限。Kafka则像一个仓库的流水账本,每一笔进出都按顺序记下来,账本不会因为东西出库就撕掉,所以什么时候想对账都能从头翻。
这套类比基本对应了它们的真实定位:Redis重“轻巧和灵活”,RabbitMQ重“分发和可靠”,Kafka重“吞吐和留存”。
3. 硬指标与软肋对照:延迟、吞吐、可靠性和有序性如何取舍
3.1 一张表看清三种消息中间件的边界
很多人在选型时会问“谁快谁慢”,但比单一指标没意义。我干脆把常见维度整理成一张表,方便对照着看。
| 维度 | Redis Stream | RabbitMQ | Kafka |
|---|---|---|---|
| 消息模型 | 追加日志 + 消费者组 | 队列 + 路由规则 | 分区日志 + 消费者组 |
| 底层存储 | 内存 / RDB / AOF | 磁盘(可持久化) | 磁盘,多副本 |
| 典型吞吐量 | 万级/秒(单实例上限明显) | 万级到十万级/秒 | 百万级/秒可水平扩展 |
| 消息确认 | ACK,但整体机制较轻量 | 生产端Confirm + 消费端Manual ACK | 消费者位移提交,At-least-once |
| 消息重放 | 支持从历史ID读取 | 不支持按时间重放,重入队靠死信/重新发布 | 支持任意offset和时间回溯 |
| 有序性 | 单Stream内有序 | 单队列单消费者有序 | 单Partition内有序 |
| 路由能力 | 弱,主要按Stream分类 | 强,Exchange可按Routing Key灵活绑定 | 弱,靠Topic和Partition Key |
| 延迟 | 毫秒级(内存型) | 毫秒级到几十毫秒 | 十毫秒到数百毫秒(轮询拉取) |
| 运维复杂度 | 低,复用Redis | 中,需要管理RabbitMQ集群 | 高,需要管理ZooKeeper或KRaft、分区、副本 |
| Python生态 | redis-py | pika / aio-pika | confluent-kafka / kafka-python |
这张表不是我拍脑袋做的,是根据我在不同项目里实际压测和线上观察总结出来的量级经验,具体数值会受硬件、网络、消息大小、分区数影响,但相对大小关系是稳定的。
3.2 可靠性阶梯:从最多丢一点到基本不丢
消息丢失这件事,在这三者身上有不同的含义。Redis Pub/Sub是“根本不保证不丢”;Redis Stream在默认配置下,如果发生故障切换,AOF落盘延迟范围内的消息可能丢,你必须配合WAIT和合理的持久化配置来降低风险。RabbitMQ在持久化队列加持久化消息、生产端confirm全开的情况下,能实现很高的可靠性,但也不是零丢失,磁盘故障或者集群脑裂时依然存在窗口。Kafka依靠ISR机制和副本同步,配合acks=all配置,在Broker正常工作的前提下能做到不丢已提交消息。
我见过很多团队在选型时把“可靠性”当成一个二值变量,要么觉得丢一点无所谓,要么觉得必须绝对不丢。实际业务里更常见的划分是:这条消息丢了,损失是几块钱还是几百万。普通通知推送用Redis Stream足够,订单支付回调必须RabbitMQ或Kafka级别的确认机制,审计日志则只有Kafka那种留存模型能让追责变得可行。
3.3 消费模型:推、抢占式拉取、还是按分区协作
三个系统的消费模型差异也直接影响你的代码组织方式。RabbitMQ是推模型,Broker主动把消息推给消费者,消费者处理完ACK;多个消费者共享一个队列时,消息按Round-Robin分发,谁先处理完谁接下一单,天然负载均衡。Redis Stream的消费者组也类似,但更偏向“抢占式拉取”,消费者主动从Stream里读,读完ACK,谁抢到归谁。Kafka则是拉模型加分区协作,消费组里的每个消费者分到固定Partition,同一Partition的消息只会发给同一个消费者,不存在谁抢到归谁的说法。
这个差异带来一个很实际的选型判断:如果你的下游处理能力波动很大,希望快的消费者多干点活,RabbitMQ的竞争消费模式更舒服;如果你的下游需要按某个业务键保证顺序,比如同一个订单的所有事件必须被同一个处理节点按序处理,Kafka的Partition机制天然满足,Redis Stream只要保证消息ID单调递增也能在这个Stream内有序,但多了消费者组设计成本。
3.4 有序性:全局有序本来就是伪需求居多
先说结论:大部分业务要的有序性,其实是“同一个业务实体的事件按顺序处理”,比如同一订单的创建、支付、发货、完成必须是这个顺序。这属于Partition内有序或单队列有序,三者都能通过合理设计满足。Redis Stream用同一Stream并发的消费者组处理,或者用Kafka把同一个订单号作为Partition Key路由到同一Partition。RabbitMQ则要保持单队列单消费者或按业务路由分散到不同队列再单消费者处理。
“全局有序”几乎是个伪需求,因为一旦涉及多消费者并行处理,全局有序和水平扩展就是矛盾的。如果真有强制全局有序的场景,比如某种只能单线程处理的批任务,那选择面会非常窄,大概率只能接受Redis Stream单消费者或者Kafka单分区、单消费者这种低并发方案。我在项目里遇到不合理的“必须全局有序”需求时,通常的做法是先追问业务细节,大多最后都能拆成局部维度。
4. Python 三个可运行方案:从 Hello World 到生产可用
4.1 Redis Stream:轻量任务队列的标准接入姿势
在PyPI里装redis-py之后,Stream的核心API并不复杂。我下面给的是一个生产者加一个消费者的最小闭环,复制到本地改一下Redis连接就能跑通。
import json import redis r = redis.Redis(host="localhost", port=6379, decode_responses=True) STREAM = "task:queue" GROUP = "task-worker" # 创建消费组。如果已存在会报 BUSYGROUP,这里做幂等处理。 try: r.xgroup_create(STREAM, GROUP, id="0", mkstream=True) except redis.exceptions.ResponseError as exc: if "BUSYGROUP" not in str(exc): raise def publish(payload: dict): r.xadd(STREAM, {"data": json.dumps(payload)}) def consume(): while True: # 从消费组读取新消息,">" 表示只读没投递过的消息 entries = r.xreadgroup(GROUP, "worker-1", {STREAM: ">"}, count=10, block=5000) if not entries: continue for _stream, msgs in entries: for msg_id, fields in msgs: try: handle_task(json.loads(fields["data"])) except Exception: # 处理失败的消息不要 ACK,让它留在 Pending 里 continue # 处理成功才 ACK,否则重启后会重新投递 r.xack(STREAM, GROUP, msg_id)消费逻辑里最关键的细节是这条:处理成功之后再xack。如果先xack再处理业务,业务崩了消息就没了;如果不ack,消息会一直在Pending列表里,同一消费者恢复后还能从Pending里捞回。很多团队第一次用Redis Stream踩的坑就是漏了xack,导致消费组位移一直不走,消息被反复投递,跟死循环一样。
另外注意xreadgroup的block参数:设置成5000毫秒而不是0,是为了让循环每隔几秒能退出来做点别的事,比如上报心跳、刷新配置,写业务代码时灵活很多。
4.2 RabbitMQ + pika:手动确认和预取限制是必须做的功课
RabbitMQ在Python里最常用的库就是pika。最简单能跑的代码长这样:
import pika params = pika.ConnectionParameters( host="localhost", heartbeat=600, ) connection = pika.BlockingConnection(params) channel = connection.channel() channel.queue_declare(queue="task.queue", durable=True) channel.basic_qos(prefetch_count=1) def callback(ch, method, properties, body): try: handle_task(body) ch.basic_ack(delivery_tag=method.delivery_tag) except Exception as exc: # 可以根据异常类型决定是否重新入队、投递到死信队列 ch.basic_nack(delivery_tag=method.delivery_tag, requeue=False) print(f"处理失败: {exc}") channel.basic_consume(queue="task.queue", on_message_callback=callback) channel.start_consuming()我这里特意加了两行很多教程不会提的配置:durable=True和basic_qos(prefetch_count=1)。durable保证队列和消息在Broker重启后不消失,prefetch_count=1则告诉Broker每次只给这个消费者发一条未确认消息,等它ACK之后再发下一条。没有这个限制,RabbitMQ会把队列里所有消息一口气推给消费者,堆在本地内存里,消费者一旦崩溃,这批消息全部重新入队,下游会出现大面积重复消费。
pika的BlockingConnection在单线程程序里很好用,但如果你打算在Web框架的多线程worker里共享连接,强烈建议改成线程内独立连接,或者直接用aio-pika写异步版本。这不是性能问题,而是pika的BlockingConnection本身不是线程安全的,多个线程同时调用容易触发不可预期的状态错乱。
4.3 Kafka + confluent-kafka:消费者组参数是第一个分水岭
Python连Kafka,我一般优先用confluent-kafka,它是librdkafka的绑定,性能比纯Python实现的kafka-python好一个量级,接口也更贴近生产环境。
import json from confluent_kafka import Producer, Consumer, KafkaError, KafkaException producer = Producer({"bootstrap.servers": "localhost:9092"}) def delivery_report(err, msg): if err is not None: print(f"消息发送失败: {err}") else: print(f"已写入 {msg.topic()} 分区 {msg.partition()} 偏移量 {msg.offset()}") def send(topic: str, key: str, value: dict): producer.produce( topic, key=key.encode(), value=json.dumps(value).encode(), callback=delivery_report, ) producer.poll(0) consumer_conf = { "bootstrap.servers": "localhost:9092", "group.id": "task-consumer-group", "auto.offset.reset": "earliest", "enable.auto.commit": False, "max.poll.interval.ms": 300000, } consumer = Consumer(consumer_conf) consumer.subscribe(["task.topic"]) while True: msg = consumer.poll(1.0) if msg is None: continue if msg.error(): if msg.error().code() == KafkaError._PARTITION_EOF: continue raise KafkaException(msg.error()) try: handle_task(json.loads(msg.value().decode())) consumer.commit() except Exception as exc: print(f"处理失败: {exc}")这段代码里的enable.auto.commit=False是很多人容易忽略的重点。默认情况下Kafka消费者会每5秒自动提交一次消费位移,如果你业务处理刚好在自动提交之后崩了,这条消息虽然没处理成功,但位移已经提交,重启后就直接跳过了。关掉自动提交,在handle_task成功之后再手动commit(),才能保证“处理成功才算消费成功”,也才能让Kafka做到常见文档里说的at-least-once语义。
同理,max.poll.interval.ms如果太小,消费者在处理一批消息时花费时间超过这个阈值,就会被判定为“失联”踢出消费者组,触发一次rebalance,其他消费者就会接手它的分区,而这个消费者手里的消息可能还没处理完,重复消费就来了。生产中这个值我通常会结合单条消息处理时长和单次拉取条数一起算,给到5分钟以上是比较稳妥的起点。
4.4 关于消息规范和序列化设计的补充
三个系统的代码跑通之后,还有一件跟具体中间件无关、但比选型本身更重要的事:消息体长什么样。我强烈建议所有消息在进入队列/Stream/Topic之前,都规范成类似下面这种结构:
{ "message_id": "uuid-xxx", "event_type": "order.created", "version": 1, "timestamp": 1736000000, "payload": {} }message_id给幂等消费用,event_type方便网关做分类,version是为了将来字段演进时兼容老消费者。不要图省事直接把业务表模型序列化丢进去,也不要直接pickle一个Python对象。一旦你哪天想把消费端换成另一个语言写的服务,pickle就直接卡死升级路径了。
5. 生产环境排错实录:三类工具各自的翻车点
5.1 Redis:Metirc一查发现消费者在无限重启
有一次帮另一个团队排查,现象是Redis Stream里消息堆积持续走高,消费者日志每隔几分钟就打一行异常,然后进程退出被守护进程拉起。翻代码后发现他们把xreadgroup的循环写在了队列消费函数里,这个函数又被Web框架的业务线程调用,结果每个请求都创建一个消费者,抢到一条消息处理后线程结束连接关闭,没ACK的消息又回到Pending。一个消费者组里有几十个临时消费者在打架。
这种问题不看监控指标很难发现,因为单看Redis内存是正常的,只有Stream的pending和lag指标能暴露真相。我后来养成了一个习惯:接入Redis Stream的第一天就把xlen、pending、lag这三个指标接进监控大盘。xlen是总消息数,pending是会重复投递的未确认消息,lag是消费落后程度。三个指标放到一起,消费者是死是活、消息是否重复处理、消费速度是否跟得上,一眼就能看清。
5.2 RabbitMQ:unacked堆积与prefetch的关系
RabbitMQ的队列里有个状态叫unacked,表示已经被推给消费者但还没收到ACK的消息。默认情况下,RabbitMQ在消费者数量不足时会不停向消费者推送消息,没有任何窗口限制。如果消费者处理每条消息需要几百毫秒,而队列里瞬间涌入几千条消息,这几千条消息会全部堆积在消费者的客户端缓冲区里,表现为RabbitMQ管理界面里unacked数值飙升。
很多人遇到这个第一反应是加消费者机器,但其实问题根子是无界分发。你加再多的消费者,只要prefetch不设限,每台机器都会先被灌满一批消息再慢慢处理,负载均衡完全失效。正确做法是先设置一个合理的prefetch值,我一般从1开始调,加并发再用多消费者或 RabbitMQ 的高性能客户端,比如多channel。调到多少算合理?一个经验公式是prefetch = 单消费者期望的并发处理量,如果你想让消费者同时处理10条消息,就设为10,同时做好内存允许等待消息数量的评估。
5.3 Kafka:一次max.poll.interval.ms的连环效应
Kafka消费者翻车最经典的场景是:业务方在回调里调了一个外部接口,这个接口偶尔会阻塞几十秒,结果单次消息处理时间超过了max.poll.interval.ms默认的5分钟,消费者被判定死亡,触发rebalance。rebalance之后,这个分区被分配给组内另一个消费者,那个消费者从头重新消费,又把同样卡住的外部接口调了一遍,再次超时,再次rebalance。整个消费者组陷入无限rebalance的循环,吞吐量掉到几乎为零,而监控里看到的只有一条“消费者组正在rebalance”的告警。
这种问题排查的时候,去看看消费者的poll间隔和离开组日志就能定案。修复方式通常有两个方向:一是调大max.poll.interval.ms,二是把耗时的外部调用挪到独立线程或异步队列里,让主线程持续poll保持心跳。我本人更推荐第二种,因为把业务处理和消息拉取解耦,能避免很多隐藏的连锁问题。
5.4 通用排查方法论:从指标倒推代码,而不是读代码猜指标
有没有一套通用的排错思路?我在三个系统上踩完坑之后总结下来,其实是同一套打法:先看消费位点相关的指标,再结合日志定位消费者状态,最后才看业务代码。Redis看pending和lag,RabbitMQ看ready和unacked,Kafka看consumer lag和rebalance次数。指标能告诉你是“消息没到消费者手里”还是“到了但处理失败”,这两种情况的排查路径完全不同。
不要一上来就翻代码,也不要一上来就怪中间件。我见过太多团队把消息中间件的参数和配置调来调去,结果最后发现是业务代码里有条慢SQL让消费者线程卡住了。中间件是背锅侠,但它也只负责把消息可靠送到,不负责你的处理器是否高效。
6. 选型决策:把“哪个火”换成“哪个合适”
6.1 一张决策表,直接照着选
综合前面的底层模型、运行指标和实战坑点,我最后给一套可以直接套用的选型建议。
| 你的核心诉求 | 推荐方案 | 一句话理由 |
|---|---|---|
| 异步任务、简单解耦、消息量级小、不想新增部署组件 | Redis Stream | 最轻量,复用已有Redis,够用且省心 |
| 订单/支付等核心业务,可靠性和路由能力要求高 | RabbitMQ | ACK机制完善,Exchange模型灵活,适合内部系统 |
| 日志采集、行为事件、CDC、多系统数据同步 | Kafka | 高吞吐加日志留存,天然支持重放和多订阅者 |
| 已有Redis,临时需要发布订阅通知且允许少量丢失 | Redis Pub/Sub | 仅限实时通知,不能当可靠队列用 |
| 团队运维能力有限但又要高可靠消息 | RabbitMQ | 比Kafka好运维得多,生态文档丰富 |
| 消息量会随业务爆发式增长,需要水平扩展 | Kafka | 分区机制就是为大规模并行设计的 |
| 未来需要按时间回溯消费历史数据 | Kafka | 消息保留期内可任意回放,Redis和RabbitMQ都较难 |
这张表解决的是“主选型”。很多团队最后会发现,系统里同时存在两种中间件是常态,比如用Redis Stream处理轻量异步任务,用Kafka处理核心事件流。中间件不是单选题,混用也没问题,只要维护边界清晰。
6.2 延迟队列、事务消息、顺序消息怎么落地
进阶需求里最常见的三个是延迟队列、事务消息和顺序消息。延迟队列在RabbitMQ里可以通过DLX加TTL实现:消息先发到一个带TTL的队列,过期后自动转入真正的业务队列。Redis可以用一个ZSet模拟延迟队列,score存执行时间戳,定时任务取到期的消息。Kafka没有内建的延迟消息,通常做法是拆成多个延迟分级Topic,比如delay-5m、delay-30m,由定时任务扫描转发。
事务消息这里要提醒一下,消息队列的事务和数据库事务不是一个东西。RabbitMQ的Publisher Confirm配合生产者本地事务表,可以做到“业务操作和发消息在同一事务控制下”;Kafka有事务性API,可以实现对多个分区的原子写入;Redis Stream则没有类似事务语义,如果你需要事务型消息,还是不要选择Redis做核心通道。
顺序消息前面提过,Kafka靠Partition Key解决,RabbitMQ靠单队列单消费者解决,Redis Stream靠单个Stream内顺序消费解决。没有银弹,需求里必须想清楚顺序维度是什么,然后把这个维度变成中间件的路由键。
6.3 架构师的真正功课:幂等、监控和演进空间
选型只是起点。无论你最后选了哪个消息队列,都必须把下面三件事一起做了,不然迟早会在生产环境交学费。
第一件是消费幂等。几乎所有消息中间件在异常场景下都只会给你at-least-once语义,重复消费不是偶然,而是常态。常用做法是在消费端拿message_id去查重,比如Redis的SETNX或者数据库唯一索引。幂等设计一定要在消息规范阶段就定下来,不要在出问题后再补。
第二件是监控度量。消息积压指标要在上线第一天就接入监控告警,而不是等业务报警了再去找。消费lag超过一定阈值就该告警,这个阈值可以根据业务容忍度来定,比如订单事件延迟超过10分钟就要介入。
第三件是演进空间。消息体带版本号,消费端预留字段扩展,消息路由不要写死。这样将来哪怕要从Redis Stream迁移到RabbitMQ或Kafka,也只需要在网关层做一次转发,业务代码改动最小。我见过太多系统因为消息体没有版本概念,升级时所有消费者被迫同时发版,零发布窗口的痛谁排过谁知道。
最后再分享一点经验
做了这么多年后端,我的体会是:消息队列选型最大的敌人不是性能不够,而是“拿复杂工具解决简单问题”和“拿简单工具解决复杂问题”这两件事反着来。Redis Stream在大多数中小项目里完全够用,RabbitMQ能扛住绝大多数业务的可靠性要求,Kafka也只有在数据量或重放需求真正出现时才值得引入那套更重的运维体系。
如果你还在纠结,不妨先做一次最小验证:把同一条业务链路分别用Redis Stream和RabbitMQ各实现一遍,跑一天,看看吞吐、堆积和排查问题时的心智负担差异有多大。多数情况下,你心里自然就有答案了。