先问要不要 MQ
引入消息队列(Message Queue,下文简称 MQ)的正当动机通常只有三个:削峰——把瞬时写洪峰排队,让下游按自己的节奏消化;解耦——下单方只管发”订单已创建”事件,积分、短信、风控各自订阅,互不知晓;广播——一份事件要同时喂给多个下游系统。除此之外的需求,多数不该用 MQ。
反模式点名:把 MQ 当”万能异步器”,什么慢操作都丢队列。MQ 从来不是免费的异步,它一次性带来四个新问题——重复消费、顺序保证、消息堆积的运维、丢消息时横跨多个系统的排查链。判据很简单:这个”异步”需要跨进程甚至跨机器,且下游处理速度必须与上游解耦吗?如果只是单机内的异步,线程池和进程内事件就够了;如果量级不大,一张任务表加定时轮询(状态机、超时重投、幂等键都自己说了算)往往是更便宜的方案,站内那篇 PHP 队列实战就是这个思路的完整落地。先否掉伪需求,再谈选型。
三者机制对比
Kafka、RabbitMQ、Redis Streams 是三个常被放在一起比较、但定位差别很大的东西。先看机制层面:
| 维度 | Kafka | RabbitMQ | Redis Streams |
|---|---|---|---|
| 模型 | 分布式提交日志,主题下分分区,消息按序追加 | AMQP 队列,exchange 按规则路由到队列 | Redis 内的追加日志结构,带消费组 |
| 投递语义 | 默认 at-least-once;幂等生产者与事务可到 exactly-once,但只在 Kafka 体系闭环内成立 | at-least-once(消费者确认机制) | at-least-once(XACK 确认,未确认进入 PENDING) |
| 顺序保证 | 分区内严格有序,按业务键分区可保键内有序 | 单队列 FIFO,多消费者并发后即乱序 | 单个 Stream 内有序,消费组按条派发 |
| 堆积能力 | 强:磁盘顺序写,TB 级堆积是常态用法 | 中:海量堆积后性能明显下滑 | 受内存约束,靠 MAXLEN 修剪上限 |
| 延迟/死信/优先级 | 无内建延迟消息,死信要自建 | 内建死信交换机、优先级队列,延迟插件成熟 | 死信要自建(PENDING 检查加 XAUTOCLAIM) |
| 运维成本 | 最高:集群、KRaft 元数据、监控生态一套 | 中等:单节点也能跑,管理界面友好 | 最低:就是现有 Redis |
| 典型规模感 | 日志、埋点、大数据管道起步 | 业务任务队列、复杂路由的中等规模 | 已有 Redis 的小规模事件通知 |
三句话概括定位:Kafka 是”日志塔”——把事件当成可重放的记录,读多写多都能扛,代价是一套集群运维;RabbitMQ 是”邮局”——路由规则、死信、延迟这些投递细节最齐,业务队列开箱即用;Redis Streams 是”顺手捎带”——不引入新组件就能拿到消费组语义,但内存模型决定了它扛不住真正的海量堆积。
Redis Streams 的最小可用流程值得亲手跑一遍,消费组、确认、补偿三个概念就都落地了:
# 在 redis-cli 中执行,模拟一条订单事件的完整消费链路
XADD orders '*' uid 1001 amount 59.9 # * 让 Redis 自动生成消息 ID(毫秒时间戳-序号)
XGROUP CREATE orders pay-group 0 # 建消费组,0 表示从流的头开始读
XREADGROUP GROUP pay-group worker-1 COUNT 10 BLOCK 5000 STREAMS orders '>'
XACK orders pay-group 1715000000000-1 # 处理成功才确认;没 XACK 的消息留在 PENDING
XPENDING orders pay-group # 巡检哪些消息没确认、归谁管
XAUTOCLAIM orders pay-group worker-2 60000 0-0 # 超过 60 秒未确认的消息接管给 worker-2
这套原语对应的工程含义:XREADGROUP 的 '>' 表示”只给我没派发过的新消息”,处理崩溃后靠 PENDING 列表找回来,XAUTOCLAIM 就是消费组内的故障接管。轻量归轻量,该有的语义一件不少。
投递语义:重复消费必发生
先把丑话说完:在真实网络里,消费者处理完还没来得及确认就崩溃、网络抖动导致确认丢失、重平衡把消息派给别的消费者——这些情况都会让同一条消息被消费两次。at-least-once 不是选项,是现实默认。
反模式点名:指望 MQ 自己保证 exactly-once,然后消费侧裸写数据库。Kafka 的事务确实能提供 exactly-once 语义,但只覆盖”从 Kafka 读、处理、写回 Kafka”这个闭环;一旦写到外部数据库,事务鞭长莫及。正确姿势是幂等设计放在消费侧,常用三招:业务唯一键加数据库唯一约束(重复插入直接被拒绝);状态机校验(只允许”待支付到已支付”迁移一次,重复事件被状态机挡住);版本号乐观锁(更新时带上版本,不匹配则说明已处理过)。这三招的共同点是利用业务数据自身的结构去重,而不是维护一张”处理过的消息 ID”表去追赶所有边界情况。
顺序怎么做,代价是什么
全局有序的代价极高(等于串行化整个系统),绝大多数业务要的只是”同一个业务对象内有序”:同一个订单的状态变更不能乱,两个订单之间乱序无所谓。做法是按业务键(订单 ID、用户 ID)做分区键,同一键的消息落同一分区或同一队列,由单消费者顺序处理。
反模式点名:设了分区键,消费端又开了多线程并发处理同一个分区的消息——顺序在最后一公里被自己打破,前面全白设。验收方式写进测试:同一订单 ID 并发发十条状态变更消息,断言消费端按发送顺序落库。另外提醒一句,RabbitMQ 的多消费者模型下这种顺序天然难以保证,强顺序需求要么单消费者(吞吐受限),要么把顺序校验下沉到消费侧幂等逻辑。
避坑清单
| 坑 | 后果 | 对策 |
|---|---|---|
| 消费侧不做幂等 | 重复消费产生重复扣款、重复积分 | 唯一约束加状态机,把去重建在业务数据上 |
| 死信没设计 | 一条毒消息反复重投,卡住整个分区或队列 | 重试次数设上限,超限进死信队列并告警,人工介入兜底 |
| 消息体塞大对象 | 带宽与内存双放大,Redis 侧直接堆积爆内存 | 消息只传引用(ID、URL),正文放对象存储或数据库 |
| 没有 lag 监控 | 消费者悄悄挂掉,堆积几天才发现 | 监控消费延迟(Kafka 的 consumer lag、RabbitMQ 队列深度),分级告警 |
| 自建 Kafka 网络不通 | 容器内监听地址与对外广播地址混淆,客户端连上却拿不到消息 | 区分 listener 与 advertised listener 配置,排查思路见站内 Docker 网络篇 |
| 照搬老教程 | ZooKeeper 时代的部署文档已经过时 | Kafka 4.0(2025 年 3 月发布)起已移除 ZooKeeper,只支持 KRaft,新集群直接按 KRaft 部署 |
| 重试与重投不分 | 业务失败和瞬时故障混在同一队列,毒消息拖慢正常流量 | 区分重试队列与死信队列,瞬时故障退避重试,业务失败直接进死信 |
场景决策
| 场景 | 建议 | 理由 |
|---|---|---|
| 日志采集、埋点、大数据管道 | Kafka | 吞吐与堆积能力是刚需,下游生态(流计算)成熟 |
| 订单异步任务、通知发送等业务队列 | RabbitMQ,量小用 DB 任务表 | 死信与延迟内建,运维成本低;日均十万条以下任务表足够 |
| 已有 Redis 的轻量事件通知 | Redis Streams | 消费组语义够用,零新增组件;注意设 MAXLEN 防堆积 |
| 多系统事件广播、事件溯源 | Kafka | 多消费组独立消费、可重放,这是日志模型的主场 |
| 需要复杂路由(按主题、多播、过滤) | RabbitMQ | exchange 路由规则正是为这个设计的 |
结论与边界
回到开头的三句话做总结:MQ 解决的是削峰、解耦、广播,不解决数据一致性本身——一致性靠消费侧的幂等设计;顺序和死信都不是 MQ 送的,是设计出来的;选型时运维成本要当作一等公民来称量。诚实的规模判断:多数业务系统的日均事件量在百万条以下,这个量级上 RabbitMQ 或一张可靠的任务表往往比 Kafka 更合适——为”大厂同款”上一个 Kafka 集群,买到的是每月都在燃烧的运维负债。真正需要 Kafka 的信号是明确的:日志与事件量大到写库写不动,或者下游多到必须可重放。
延伸阅读:小规模队列的”无 MQ”方案见《用 PHP 写一个可靠的队列任务系统》;MQ 在整体架构中的定位思路,可结合本站的集群架构规划一文理解;容器化部署的网络坑(bridge、端口映射、DNS)排查思路与普通服务一致;云主机上 9092 这类端口”通了但不响应”的排查见《安全组与防火墙:”端口通了但不响应”的排查》。
版本核实(以官方文档为准):Apache Kafka 4.0 于 2025-03-18 发布,自此移除 ZooKeeper、仅支持 KRaft 模式;RabbitMQ 4.1 于 2025-04-15 发布,经典队列镜像已在 4.0 移除、官方推荐 quorum queue;Redis Streams 自 Redis 5.0 起可用,Redis 8 于 2025-05-01 发布(许可证回到 AGPLv3 等三许可并行)。细节以各项目官方文档为准。