既然之前的总结“过于简略”,那我们这次来一次深度解剖。我们将从数据结构、底层交互流程、Offset 管理机制这几个维度,把 Kafka 和 RocketMQ 在“消息处理失败”这一场景下的表现差异彻底讲透。
核心场景设定
- Topic:
OrderTopic - Partition:
0 - 消息流:
Offset 100: 订单 A(处理失败,数据库死锁)Offset 101: 订单 B(处理成功)
一、Kafka:流式日志(The Log)
Kafka 的设计哲学是 “哑代理,智客户端” (Dumb Broker, Smart Client)。Broker 只是一个负责追加写文件的系统,它根本不关心消息内容,也不关心单条消息的状态。
1. 只有“进度”,没有“状态”
Kafka 维护消费进度的方式非常粗犷。它不在每条消息上打勾,它只维护一个 High Watermark(水位线) 或 Committed Offset。
- Kafka 的逻辑:Consumer 告诉我它提交了
101,那我就认为0 ~ 101之间的所有数据全完了。
2. 详细执行流程(Offset 100 失败,101 成功)
-
拉取 (Fetch):
- Consumer 向 Broker 拉取一批消息(Batch),比如一次拉了
100, 101, 102三条。 - 这三条消息现在都在 Consumer 的内存里。
- Consumer 向 Broker 拉取一批消息(Batch),比如一次拉了
-
处理 Offset 100 (失败):
- Consumer 执行业务逻辑,报错。
- 关键点:Broker 对此一无所知。Broker 也没有“把 100 标记为失败”这种接口。
- 代码选择:如果你的代码
catch了错误并continue,Consumer 内存里的游标指向了下一条。
-
处理 Offset 101 (成功):
- Consumer 执行业务逻辑,成功。
- 代码动作:
session.MarkMessage(101)。此时 Sarama 客户端在本地记录:current_offset = 101。
-
提交 (Commit):
- Sarama 后台协程周期性启动(默认 1秒)。
- 它发现本地
current_offset = 101。 - 它向 Broker 发送请求:
GroupCommit(Topic, Partition0, Offset=102)(意为下次从 102 开始)。
-
结果 (后果):
- Broker 更新元数据:该组消费进度 = 102。
- Offset 100 彻底丢失。下次重启、Rebalance,都会从 102 开始。
3. Kafka 如何救赎?(只能靠开发者硬写)
如果你不能丢消息,在 Kafka 里你必须在代码层面阻塞:
- 死循环重试:在处理 100 失败时,
while(true)一直重试。这会导致 101 即使已经到了内存,也无法被处理(Head-of-line blocking,队头阻塞)。 - 手动死信队列:自己写代码把 100 发送到
Topic_DLQ,然后标记 100 成功,继续处理 101。
二、RocketMQ:消息队列(The Queue)
RocketMQ(普通/并发模式)的设计哲学是 “业务消息引擎”。Broker 非常“聪明”,它知道每一条消息的状态,并且内置了定时器和重试队列。
1. 既有“进度”,又有“单条 Ack”
RocketMQ 在 Broker 端维护了一个逻辑上的消费进度,但它允许“空洞”。更重要的是,它引入了 “服务端重试” 机制。
2. 详细执行流程(Offset 100 失败,101 成功)
-
拉取 (Pull):
- Consumer 拉取消息
100, 101。
- Consumer 拉取消息
-
处理 Offset 100 (失败):
- Consumer 执行业务逻辑,报错。
- 代码动作:Consumer 返回
ConsumeConcurrentlyStatus.RECONSUME_LATER。 - 幕后黑科技 (ACK 阶段):
- Consumer 客户端自动把这条消息 100 发回给 Broker。
- Broker 收到后,把消息 100 存入一个特殊的 Topic:
%RETRY%ConsumerGroup。 - 原队列(OrderTopic)的进度,此时实际上已经向前推进了。
-
处理 Offset 101 (成功):
- Consumer 执行业务逻辑,成功。
- 代码动作:Consumer 返回
ConsumeConcurrentlyStatus.CONSUME_SUCCESS。 - Broker 收到 ACK,确认 101 完成。
-
重试调度 (The Magic):
- 那条被扔进
%RETRY%的 100 号消息,Broker 会根据重试次数设置一个定时器(例如 level 1 = 10秒)。 - 10秒后:Broker 把这条消息再次投递给 Consumer。
- Consumer 再次收到消息(MessageID 不变),此时可以再次尝试处理。
- 那条被扔进
-
结果:
- 101 先处理完。
- 100 稍后处理。
- 没有丢消息,但是消息乱序了。
三、深度对比总结表
这个表格展示了两者最本质的区别:
| 特性 | Kafka | RocketMQ (并发模式) |
|---|---|---|
| 数据模型 | 流 (Stream) 像录像带,连续读取。 | 队列 (Queue) 像任务列表,单据独立处理。 |
| Offset 提交粒度 | 累计位点 (Cumulative) 提交 N,代表 N 之前全完了。 | 单条 ACK (Individual) 虽然也维护 Offset,但配合重试队列实现了单条管理的错觉。 |
| 中间消息失败 (100失败) | 阻塞或跳过 要么死循环卡住全部分区,要么标记已消费直接丢弃。 | 服务端暂存 (Re-queue) 失败的消息被“踢”出原队列,放入重试队列,后续消息继续跑。 |
| 对 Offset 101 的影响 | 如果 100 卡住,101 永远轮不到处理(队头阻塞)。 | 101 可以先处理成功,完全不受 100 影响。 |
| 顺序性 | 严格有序 只要分区不变,处理顺序永远是 100 -> 101。 | 乱序 最终执行顺序可能是 101 -> 100(重试)。 |
| 重试成本 | 高 需客户端自研(死循环、DB记录、Redis记录)。 | 低 框架自带,改个返回值即可。 |
| 适用场景 | 日志、埋点、流计算、CDC (Change Data Capture)。 | 交易、订单、扣款、通知等核心业务。 |
四、直观图解
Kafka 场景 (Offset 100 失败)
[Broker Log] : ... [99] [100] [101] [102] ...
^ ^
[Consumer] : 处理失败 处理成功
| |
[Action] : (Skip?) Mark(101)
| |
[Commit] : V V
[Broker Offset]: Updated to 102 (100 is GONE)
RocketMQ 场景 (Offset 100 失败)
[Broker Queue]: ... [99] [100] [101] [102] ...
| |
[Consumer] : Ret_Later Ret_Success
| |
[Internal Op] : Send back |
to RetryQ |
| |
[Broker State]: Offset moves past 101
[RetryQueue]: [100 (Delay 10s)]
.
. (10s later)
.
[Consumer] : Recieve [100] again -> Process
五、结论与建议
- 结论:你用 Sarama (Kafka) 写代码时,千万不能带着 RocketMQ 的思维。在 Kafka 里,“提交 Offset”是一个不可逆的“批量确认”操作。
- 针对你当前代码的建议:
由于 Kafka 原生不支持“单条失败重试”,为了数据不丢,你通常只有两个选择:
- 强一致性(不能丢,不能乱):在
ConsumeClaim里遇到错误使用retry库进行指数退避重试(Backoff Retry),一直重试直到成功。这会阻塞该分区后续消息的消费。 - 最终一致性(不能丢,允许乱):遇到错误,把消息手动生产到另一个 Topic(如
topic_retry),然后对当前消息MarkMessage。你自己还需要写一个 Consumer 去消费topic_retry。这其实就是人工实现了 RocketMQ 的功能。
- 强一致性(不能丢,不能乱):在