上一篇我们介绍了 Kafka 的基本结构,重点理解了 Broker、Topic、Partition、Offset 和 Consumer Group。
通过这些概念,我们已经知道 Kafka 如何存储消息,以及不同消费者组为什么可以独立消费同一个 Topic。
但在实际微服务系统中,仅仅理解消息如何流转还不够。
假设用户支付成功,Payment Service 向 Kafka 发送一条事件:
json
{
"event_id": "E10001",
"event_type": "PaymentSucceeded",
"order_id": 10001,
"payment_id": "P10001"
}
Order Service 消费这条消息后,将订单状态修改为 PAID。
整个流程看起来很简单:
text
Payment Service
|
| PaymentSucceeded
v
Kafka
|
v
Order Consumer
|
v
MySQL
|
v
订单状态更新为 PAID
但如果在这个过程中出现故障,情况就复杂了。
例如,Order Consumer 已经成功更新 MySQL,却在提交 Kafka Offset 之前崩溃。Kafka 之后可能再次投递同一条消息。
反过来,如果 Consumer 已经提交 Offset,却还没有成功更新 MySQL,那么这条消息对应的业务操作就可能没有完成。
这两个场景看似只是执行顺序不同,却会产生完全不同的后果。
要理解这些问题,需要先区分 Kafka 消息处理过程中的几种"成功"。
一、消息发送成功,不代表业务处理成功
先看一条消息从生产到消费的完整过程。
text
Payment Service
|
| ① 发送消息
v
Kafka Broker
|
| ② 持久化消息
v
Kafka Partition
|
| ③ Consumer 读取
v
Order Consumer
|
| ④ 执行业务操作
v
MySQL
|
| ⑤ 事务提交
v
Order Consumer
|
| ⑥ 提交 Offset
v
Kafka
这里至少涉及三种不同的成功。
第一种:消息写入 Kafka 成功。
说明 Producer 按照相应的确认机制,获得了 Kafka 对消息写入的确认。
第二种:Consumer 读取消息成功。
说明 Consumer 已经从 Kafka 获取到消息,但不代表它完成了业务处理。
第三种:业务执行成功。
例如 MySQL 已经提交订单状态更新。
这三种成功不是一回事。
尤其需要注意:
Kafka 确认消息写入成功,不等于消息已经被 Consumer 处理成功,更不等于 MySQL 等外部系统中的业务操作已经完成。
因此,不能用一个简单的 SUCCESS 概括整个消息处理过程。
Kafka 的可靠性需要从生产端、Broker 存储端和消费端分别考虑。
二、Producer ACK:Kafka 怎么确认消息已经收到?
Producer 发送消息时,首先需要考虑:
消息发送到 Kafka 后,到什么程度才算成功?
假设 Kafka 集群有三个 Broker,一个 Partition 配置了三个副本:
text
Kafka Cluster
Broker 1
Partition 0 Leader
Broker 2
Partition 0 Follower
Broker 3
Partition 0 Follower
Producer 通常向该 Partition 的 Leader 发送消息。
Leader 负责处理对应分区的写入,Follower 从 Leader 复制数据。
这里需要引出 Producer 的一个重要参数:
properties
acks
它控制 Producer 需要等待哪种级别的写入确认。
1. acks=0:不等待 Broker 确认
例如:
properties
acks=0
Producer 把消息交给网络发送流程后,不等待 Broker 返回写入确认。
text
Producer
|
| Send Message
v
Broker
Producer 不等待写入 ACK
这种方式可以减少等待确认的开销,但存在明显风险。
如果消息实际上没有成功写入 Broker,Producer 可能也无法通过写入确认及时发现。
因此,它不适合对消息丢失非常敏感的业务。
2. acks=1:等待 Leader 确认
配置:
properties
acks=1
Producer 需要等待 Partition Leader 确认写入。
text
Producer
|
v
Partition Leader
|
| Leader 写入成功
v
返回 ACK
但这里存在一个故障窗口。
假设:
text
Leader 写入消息成功
|
v
Producer 收到 ACK
|
v
Follower 尚未复制完成
|
v
Leader 突然故障
如果后续选出的新 Leader 没有这条消息,就可能造成已经确认的消息丢失。
所以 acks=1 的确认范围主要是 Leader 本身,并不要求所有同步副本都确认复制完成。
3. acks=all:等待同步副本满足确认条件
配置:
properties
acks=all
Producer 需要等待 Leader 以及当前符合要求的同步副本完成写入确认。
但这里要特别注意:
acks=all 并不简单等于等待集群中所有 Broker。
Kafka 通过 ISR(In-Sync Replicas,同步副本集合)管理与 Leader 保持同步的副本。
例如:
text
Partition 0
Leader:Broker 1
Follower:Broker 2
Follower:Broker 3
ISR = [1, 2, 3]
正常情况下,三个副本都处于 ISR 中。
如果 Broker 3 因为复制落后退出 ISR:
text
ISR = [1, 2]
那么 acks=all 主要依据当前 ISR 和相应配置判断消息是否满足确认条件。
为了避免 ISR 缩小到只剩一个副本时仍然确认写入,可以配置:
properties
min.insync.replicas=2
假设 Topic 的副本因子为 3,且设置:
text
replication.factor = 3
min.insync.replicas = 2
Producer acks = all
那么在正常配置和副本状态下,至少需要满足两个同步副本的要求,才能成功确认写入。
如果同步副本数量不足,写入可能被拒绝。
这实际上是一种取舍:
宁可暂时降低写入可用性,也不希望在副本不足的情况下继续轻易确认关键消息。
需要注意,即使采用 acks=all,也不能脱离副本选举策略、磁盘持久性和故障条件,宣称消息绝对不会丢失。
三种 ACK 策略的区别
| 配置 | Producer 等待什么 | 主要风险 |
|---|---|---|
acks=0 |
不等待 Broker 写入确认 | 难以发现消息未成功写入 |
acks=1 |
Leader 确认 | Leader 故障时可能丢失尚未复制的数据 |
acks=all |
当前 ISR 满足确认条件 | 可靠性更高,但仍依赖副本和选举等配置 |
这里的 ACK 解决的是生产者如何确认消息已写入 Kafka ,还没有涉及 Consumer 是否处理成功。

三、Producer 重试为什么也可能产生重复消息?
现在假设 Producer 使用:
properties
acks=all
发送支付成功事件。
发生以下情况:
text
Payment Service
|
| 发送 E10001
v
Kafka Broker
|
| 消息写入成功
v
返回 ACK
|
X 网络中断
|
Payment Service 没收到 ACK
这里又出现了我们之前学过的问题:
Timeout 不代表操作没有成功。
Producer 看到发送超时,并不知道消息是否已经写入 Kafka。
如果它重新发送:
text
第一次发送 E10001
|
v
Kafka 已写入
|
v
响应丢失
|
v
Producer 再次发送 E10001
就可能在 Kafka 中出现重复记录。
Kafka 的幂等生产者能解决什么?
Kafka 提供幂等生产者机制:
properties
enable.idempotence=true
其核心作用是让 Broker 能够识别并去重同一个 Producer 会话中符合机制约束的重复发送。
例如,Producer 因为没有收到确认而重试相同批次时,可以避免某些重复写入。
但这项能力存在明确边界。
它并不意味着 Kafka 可以识别所有业务重复事件。
例如 Payment Service 因为业务层重试,重新构造一条相同的 PaymentSucceeded 事件,或者应用重启后使用新的 Producer 再发布一次业务事件,就不能仅凭 Kafka 的幂等生产者配置,断言这两次业务发布一定会被合并。
因此需要区分:
Kafka Producer 幂等: 主要控制生产者发送机制中的重复写入。
业务幂等: 保证同一个支付事件即使被重复发布或重复消费,也不会重复产生业务副作用。
这两者不能互相替代。
四、Consumer Offset:消费确认到底确认了什么?
现在消息已经保存在 Kafka 中。
假设一条支付成功事件位于:
text
Topic:payment-events
Partition:1
Offset:50
Order Consumer 读取它:
text
Partition 1
Offset 48:已处理
Offset 49:已处理
Offset 50:PaymentSucceeded(10001)
Offset 51:下一条消息
Kafka Consumer Group 会通过已提交的 Offset 记录消费进度。
这里最容易误解的地方是:
提交 Offset 并不是通知 Kafka 删除这条消息。
我们上一节已经学过,Kafka 的消息主要按照保留策略进行清理。
Consumer Commit Offset 的主要作用是记录消费者组后续应该从哪里继续读取。
例如,当 Offset 50 对应的消息处理完成后,可以提交:
text
Committed Offset = 51
它的含义是:
text
下一次从 Offset 51 继续
不是:
text
删除 Offset 50
因此,即使 Order Group 已经提交 Offset 51,Notification Group 仍然可以按照自己的消费进度读取 Offset 50。
当前读取位置和已提交位置不是一回事
假设 Consumer 已经读取:
text
Offset 50
Offset 51
Offset 52
但业务处理尚未全部完成。
此时 Consumer 在本次运行中的读取位置可能已经向前推进,但消费者组已提交的 Offset 仍然停留在较早的位置。
如果 Consumer 崩溃,接管该分区的 Consumer 会根据已提交位置继续读取。
因此:
读取消息、完成业务、提交 Offset,是三个需要区分的动作。
这三个动作的先后关系,直接决定了我们接下来讨论的消息重复和业务遗漏问题。
五、为什么 MySQL 已经更新成功,Kafka 还会重复消费?
这是 Kafka 消费可靠性中最经典的故障场景。
假设 Order Consumer 收到:
json
{
"event_id": "E10001",
"order_id": 10001,
"status": "PAID"
}
它需要把 MySQL 中的订单状态更新为 PAID。
正常流程:
text
Consumer 收到消息
|
v
执行 MySQL 事务
|
v
COMMIT 成功
|
v
提交 Kafka Offset
现在假设在 MySQL 事务成功以后,Consumer 突然崩溃。
text
Consumer 收到 Offset 50
|
v
MySQL 更新订单状态
|
v
COMMIT 成功
|
v
准备提交 Offset 51
|
X
Consumer 崩溃
此时:
text
MySQL:
order_id = 10001
status = PAID
但 Kafka 中:
text
Order Group 已提交 Offset 仍为 50
Consumer Group 后续发生分区重新分配。
新的 Consumer 从已提交位置继续读取,可能再次读到 Offset 50。
text
Consumer B
|
v
再次读取 E10001
|
v
再次执行业务
于是出现:
业务已经执行成功,但消息再次被消费。
这不是 Kafka 无缘无故发送了两份消息。
而是因为 Kafka 无法仅凭 Consumer 的崩溃判断 MySQL 事务是否已经成功提交。
Kafka 只知道该 Consumer Group 的已提交消费位置没有推进。
因此,恢复后重新读取相应消息是符合其消费语义的。
如果重复操作是扣款,会发生什么?
如果 Consumer 执行的是:
text
账户余额扣减 100 元
而且没有业务幂等控制,那么重复消费就可能导致重复扣减。
即使只是:
sql
UPDATE orders
SET status = 'PAID'
WHERE id = 10001;
这种重复执行通常不会直接改变最终状态,也不能因此认为所有消费逻辑都是安全的。
因为实际处理过程中可能还包括:
text
累计支付金额
增加统计次数
发送通知
发放积分
这些操作如果重复执行,就可能产生不正确的结果。
因此:
Kafka 采用至少一次处理方式时,应用必须预期某条消息可能被再次处理。
六、那先提交 Offset,再更新 MySQL 可以吗?
前面的顺序是:
text
先执行业务
再提交 Offset
问题是可能重复消费。
那么能不能反过来?
text
先提交 Offset
再执行业务
看起来可以避免重复。
但现在会出现更严重的问题。
假设 Consumer 收到 Offset 50:
text
Consumer 收到消息
|
v
先提交 Offset 51
|
v
准备更新 MySQL
|
X
Consumer 崩溃
此时 Kafka 认为:
text
Order Group:
下一次从 Offset 51 开始读取
但是 MySQL 中:
text
订单 10001 仍然是 UNPAID
当 Consumer 恢复后,它可能直接从 Offset 51 继续消费。
Offset 50 对应的业务操作没有执行成功,却已经不在正常恢复路径中重新处理。
注意,这不代表 Kafka 物理删除了消息,而是消费者组的提交位置已经越过了它。
这就产生了:
消费进度已经推进,但业务操作没有完成。
因此,两种顺序存在明显区别:
| 顺序 | 故障窗口 | 可能后果 |
|---|---|---|
| 先处理业务,再提交 Offset | 业务成功后 Consumer 崩溃 | 重复消费 |
| 先提交 Offset,再处理业务 | Offset 提交后 Consumer 崩溃 | 业务遗漏 |
如果业务操作不可丢失,通常更倾向于:
先确保业务处理成功,再推进消费进度。
这样即使发生故障,代价主要是需要处理重复,而不是让未完成的业务在正常消费流程中被跳过。
这就是我们之前讲过的:
At-least-once(至少一次)处理思路。
它不是说每条消息恰好执行一次,而是在满足相应可靠性条件时,通过失败重试使消息有机会至少被处理成功一次,同时接受可能出现的重复处理。
七、业务幂等:为什么它比"避免重复消息"更重要?
到这里,我们已经知道重复消费很难完全避免。
因此,与其假设系统永远不会收到重复消息,不如设计成:
即使同一条业务事件重复处理,也不会重复产生业务效果。
这就是幂等性(Idempotency)。
从业务角度看:
text
处理 E10001 一次
和:
text
处理 E10001 三次
应该产生相同的最终业务结果。
1. 使用业务唯一标识
例如每一条支付事件都包含:
text
event_id = E10001
这个标识应当在同一业务事件发生重复投递时保持不变。
Consumer 可以使用它判断:
text
这条事件是否已经完成处理?
但需要注意:
不能只依靠简单的先查询、再处理。
例如:
text
Consumer A 查询 E10001:不存在
Consumer B 查询 E10001:不存在
Consumer A 执行业务
Consumer B 也执行业务
如果没有数据库唯一约束或者原子性保护,并发处理仍然可能出现重复副作用。
2. 利用 MySQL 唯一约束实现消费幂等
一种常见设计是建立已处理事件表:
sql
CREATE TABLE processed_events (
consumer_group VARCHAR(64) NOT NULL,
event_id VARCHAR(64) NOT NULL,
processed_at DATETIME NOT NULL,
PRIMARY KEY (consumer_group, event_id)
);
这里使用:
text
consumer_group + event_id
作为唯一标识。
原因是同一条事件可能需要被不同业务消费者独立处理。
例如:
text
order-group
notification-group
两个组都可以处理 E10001,不能因为订单组处理过,就阻止通知组正常处理。
对于 Order Consumer,我们可以在一个 MySQL 本地事务中完成:
text
BEGIN
|
v
插入 processed_events
|
v
更新订单状态
|
v
COMMIT
例如:
sql
START TRANSACTION;
INSERT INTO processed_events (
consumer_group,
event_id,
processed_at
)
VALUES (
'order-group',
'E10001',
NOW()
);
UPDATE orders
SET status = 'PAID'
WHERE id = 10001
AND status = 'UNPAID';
COMMIT;
这段 SQL 用于说明事务结构。真实业务中还需要检查订单状态是否允许本次转换、影响行数是否符合预期,以及支付事件与订单是否匹配。
这里真正重要的是:
记录事件已处理,与实际业务修改,需要放在同一个本地事务中。
如果业务更新失败:
text
事务回滚
|
v
processed_events 插入也回滚
这样后续仍然可以安全重试。
如果事务已经提交成功,而 Consumer 在提交 Offset 之前崩溃:
text
再次收到 E10001
|
v
尝试插入 processed_events
|
v
唯一键冲突
Consumer 就可以识别这条事件已经处理过。
在确认属于已完成的重复事件后,可以跳过重复副作用,并继续推进消费进度。
如果是其他数据库错误,或者业务状态与事件不一致,则不能统统当成"重复消息成功"处理。
3. 幂等键需要和业务边界对应
有时只用 event_id 不够。
例如同一笔支付因为某个业务故障,产生了两个不同的事件 ID:
text
E10001:PaymentSucceeded(P10001)
E10002:PaymentSucceeded(P10001)
从事件 ID 看,它们是两条事件。
但从业务角度看,两条事件都指向同一笔支付。
如果业务要求同一笔支付只能执行一次积分发放,那么还可能需要:
text
payment_id
或者:
text
order_id + operation_type
作为额外业务唯一约束。
因此,幂等设计要区分两个层次:
事件级去重: 同一事件不要重复处理。
业务级幂等: 同一业务操作不要产生重复副作用。
对于资金、库存、积分等业务,后者往往更加重要。
八、自动提交 Offset 为什么需要谨慎?
Kafka Consumer 可以采用自动提交或手动提交消费进度的方式。
例如 Java Consumer 中可以配置:
properties
enable.auto.commit=true
自动提交能够减少部分消费进度管理代码,但不意味着 Kafka 知道 MySQL 事务是否真正完成。
自动提交主要依据 Consumer 客户端的消费和轮询机制推进 Offset,而不是通过检查外部业务系统的事务结果来决定是否提交。
假设应用把消息交给异步线程处理:
text
Consumer poll()
|
v
获取消息
|
v
提交到业务线程池
|
v
Consumer 继续 poll()
此时业务线程可能还没有完成 MySQL 更新。
如果已获取消息的消费进度被自动提交,而业务线程随后失败,就可能出现:
text
Offset 已推进
MySQL 业务未完成
所以对于需要严格控制业务处理与消费进度关系的场景,通常需要更明确的提交策略。
例如:
properties
enable.auto.commit=false
在业务处理成功之后提交 Offset。
但这里有一个值得强调的事实:
手动提交 Offset 不等于自动实现可靠消费。
因为手动提交只能让应用决定什么时候推进进度。
如果 MySQL 成功但 Offset 提交失败,重复消费仍然可能出现。
因此,手动提交应该和业务幂等一起使用,而不是单独依靠它保证正确性。
九、Consumer 崩溃、Rebalance 和重复消费有什么关系?
上一篇我们已经学习了 Consumer Group 和 Rebalance。
假设 payment-events 有三个 Partition:
text
Partition 0 → Consumer A
Partition 1 → Consumer B
Partition 2 → Consumer C
现在 Consumer B 负责的 Partition 1 中有一条事件:
text
Offset 50:PaymentSucceeded(10001)
Consumer B 已经读取并开始执行 MySQL 事务。
但在处理过程中,Consumer B 崩溃了。
Kafka 消费者组需要重新分配 Partition 1。
例如:
text
Partition 0 → Consumer A
Partition 1 → Consumer A
Partition 2 → Consumer C
Consumer A 接管 Partition 1 后,从该消费者组已经提交的 Offset 继续读取。
如果 Consumer B 没有提交相应进度,那么已经被 B 读取过的消息就可能再次出现。
这并不是 Kafka 的异常行为,而是故障恢复机制的一部分。
还有一个容易忽略的参数:max.poll.interval.ms
除了 Consumer 进程直接崩溃,还有一种情况。
Consumer 进程实际上还活着,但一次业务处理时间过长,迟迟没有继续调用 poll()。
Kafka Consumer 可以通过:
properties
max.poll.interval.ms
限制两次 poll() 调用之间允许的最大时间间隔。
如果应用长时间没有正常轮询,可能触发组成员状态变化和分区重新分配,具体行为与组协议和静态成员配置等因素有关。
因此,不能简单认为:
text
Consumer 进程还活着
就一定能够长期持有原来的 Partition。
如果单条消息处理时间很长,可以考虑控制单次拉取数量、限制处理并发,或者把耗时任务与 Kafka 的轮询过程分离。
但异步处理又会引入 Offset 与实际完成进度如何对应的问题,不能只做到"消息已经交给线程池",就认为消费完成。
这里最重要的原则是:
Consumer Group 可以重新分配工作,但不会自动替业务系统处理那些已经开始、却没有明确完成的副作用。
十、消费失败以后,应该一直重试吗?
现在假设 Kafka 中有三条消息:
text
Offset 50:Event A
Offset 51:Event B
Offset 52:Event C
Consumer 处理 Event A 成功。
但处理 Event B 时失败。
例如:
text
MySQL 暂时不可用
如果只是短暂连接故障,有限次数重试可能有意义。
例如:
text
处理 Event B
|
v
MySQL 连接失败
|
v
等待一段时间
|
v
重新处理 Event B
但是,如果 Event B 本身就是一条无法正确处理的消息呢?
例如消息缺少必需字段,或者业务状态已经不允许执行对应操作。
text
Event B
|
v
处理失败
|
v
重试
|
v
仍然失败
|
v
继续重试
这种情况可能反复发生。
这类长期无法成功处理的消息,经常称为:
Poison Message(毒消息)。
1. 为什么不能无限重试?
如果 Consumer 一直卡在 Offset 51,那么后续消息可能无法继续处理。
text
Offset 50:完成
Offset 51:不断失败
Offset 52:等待
结果是这个 Partition 的消费进度持续落后。
如果 Kafka 中还有大量新消息写入,积压会不断增加。
因此,对于消费失败,需要区分:
临时性错误: 可能通过重试恢复。
确定性错误: 单纯重试通常没有意义,需要业务修复或异常处理。
例如:
text
网络短暂超时
→ 可以有限次数重试
数据库连接瞬时失败
→ 可以退避后重试
消息格式无法解析
→ 应记录异常并按策略处理
业务状态不允许执行
→ 应分析状态冲突,而不是盲目重试
重试本身也应当有次数、时间和并发限制,避免下游服务已经过载时,重试进一步放大压力。
2. DLQ:死信队列解决什么问题?
DLQ(Dead Letter Queue,死信队列)可以保存无法在正常消费路径中继续处理的消息。
例如:
text
payment-events
|
v
Order Consumer
|
v
处理失败
|
v
有限次数重试
|
v
仍然失败
|
v
Dead Letter Topic
死信消息可以记录:
text
原始 Topic
原始 Partition
原始 Offset
原始事件内容
异常原因
重试次数
这些信息有助于后续诊断和恢复。
但这里要注意:
把消息放入 DLQ,不代表业务已经处理成功。
它只是将无法正常处理的消息转入单独的异常处理流程。
如果业务要求不丢失这类待处理事件,就必须保证死信消息已经可靠写入,再按照策略推进原 Topic 的消费进度。
如果 DLQ 写入也失败,不能随意跳过原消息。
3. DLQ 可能破坏业务顺序
这个问题经常被忽略。
假设同一个订单的事件按顺序写入 Partition:
text
Offset 50:OrderCreated
Offset 51:OrderPaid
Offset 52:OrderCancelled
如果 OrderPaid 处理失败,我们直接将它放进 DLQ,然后继续消费 OrderCancelled:
text
OrderCreated → 成功
OrderPaid → DLQ
OrderCancelled → 继续处理
业务状态就可能出现问题。
因为订单状态推进通常存在先后依赖。
所以是否能够跳过失败消息,取决于业务是否允许后续事件继续处理。
如果严格依赖分区内顺序,可能需要阻塞当前业务对象、暂停分区、使用单独的重试机制,或者让状态机能够正确处理延迟到达的事件。
没有一种适用于所有业务的 DLQ 处理策略。

十一、Exactly-once:Kafka 能保证消息只处理一次吗?
学习 Kafka 时,经常会看到:
text
At-most-once
At-least-once
Exactly-once
我们可以先从业务结果出发理解。
At-most-once:最多一次
消息可能没有被成功处理,但不会因为消费恢复而自动反复执行。
典型风险是:
text
消费进度已提交
|
v
业务还没完成
|
v
Consumer 崩溃
这会造成业务操作遗漏。
At-least-once:至少一次
在消息仍然可读取、系统最终恢复且重试机制正确工作的前提下,会持续尝试处理未确认完成的消息。
但可能出现:
text
业务已经成功
|
v
Offset 没提交
|
v
再次消费
所以需要幂等控制。
Exactly-once:恰好一次
这里要特别小心。
Kafka 提供幂等生产者、事务等机制,可以在特定范围内实现 Exactly-once Processing 语义。
例如,一个应用从 Kafka 读取消息,进行处理,再写入另一个 Kafka Topic。
在正确使用 Kafka 事务、消费隔离级别等机制的情况下,可以把输出消息的写入和消费进度推进纳入相应的事务协调范围。
但是:
Kafka 的 Exactly-once 能力,并不能自动把外部 MySQL 事务也包含进去。
例如:
text
Kafka Consumer
|
v
MySQL COMMIT 成功
|
v
Kafka Offset 提交失败
这两个系统的操作仍然存在独立的提交过程。
所以,即使 Producer 开启:
properties
enable.idempotence=true
或者应用使用 Kafka Transaction,也不能由此推断:
text
MySQL 中的支付、库存、积分操作
绝对只执行一次
对于外部数据库,仍然需要业务幂等、数据库唯一约束,以及必要的事务或一致性设计。
因此,可以把三种语义的关键区别记成:
| 语义 | 主要特点 | 业务需要注意什么 |
|---|---|---|
| At-most-once | 不重复尝试,但可能遗漏 | 可能丢失业务处理机会 |
| At-least-once | 尽量确保最终处理,但可能重复 | 必须考虑幂等 |
| Exactly-once | 在明确的系统与事务范围内避免重复效果 | 不能自动扩展到所有外部系统 |
真正设计系统时,最重要的不是口头宣称"我们实现了 Exactly-once",而是说明:
在哪个范围内,哪个业务副作用能够保证只生效一次。
十二、消息没有丢,为什么业务还是可能越来越慢?
现在假设 Kafka 消息保存正常,Consumer 也一直在运行。
但是:
text
Producer:每秒写入 5000 条
Consumer:每秒处理 2000 条
这时候每秒新增的积压大约是:
text
5000 - 2000 = 3000 条
如果持续一分钟,且没有其他影响因素:
text
约积压 180000 条消息
Kafka 仍然可能正常保存这些消息,但业务处理已经越来越滞后。
这里就需要我们之前讲过的:
Consumer Lag(消费积压或消费落后量)。
它反映消费者组的消费位置相对于分区日志末端的落后程度。
例如:
text
Partition 1:
最新日志位置:10000
Consumer Group 已提交位置:7000
那么按这一统计口径,Lag 大约为:
text
3000 条
需要注意,Lag 是位置差,不直接等于延迟时间。
如果 Consumer 每秒可以稳定处理 10000 条,3000 条积压可能很快消除。
但如果 Consumer 每秒只能处理 100 条,就可能造成明显的业务延迟。
怎么解决 Consumer Lag?
不能看到积压就只增加 Consumer 数量。
应该先判断瓶颈。
例如:
text
Consumer CPU 很高
可能需要优化处理逻辑或增加消费并行度。
如果:
text
Consumer 大量时间等待 MySQL
那么瓶颈可能是数据库查询或写入。
如果:
text
Topic 只有 3 个 Partition
Consumer Group 已经有 3 个 Consumer
再增加两个 Consumer,通常无法继续提高分区级消费并行度。
还可能是:
text
某条消息反复失败
导致某个 Partition 长时间无法推进。
因此,分析消费积压时应该同时查看:
text
Consumer Lag
消息处理耗时
Consumer 错误率
Partition 分配情况
MySQL / RPC 等下游延迟
Kafka 可以缓冲流量,但不能自动让下游拥有无限的处理能力。
当生产速度长期大于消费速度时,仍然需要控制流量、提升真实瓶颈处的处理能力,或者调整业务的异步处理策略。
十三、Kafka 消息可靠性还有一个容易遗漏的环节
前面重点讨论了:
text
Producer → Kafka → Consumer → MySQL
但还有一种故障发生在消息进入 Kafka 之前。
假设 Payment Service 需要完成两件事:
text
第一步:
MySQL 更新支付状态
第二步:
向 Kafka 发布 PaymentSucceeded
现在发生:
text
Payment Service
|
v
MySQL 更新成功
|
v
准备发送 Kafka
|
X
服务进程崩溃
结果:
text
MySQL:
支付状态已经成功
但是:
text
Kafka:
没有 PaymentSucceeded 事件
这时候无论 Consumer 如何设置 Offset、如何幂等,都无法处理一条根本没有进入 Kafka 的事件。
所以我们需要把消息可靠性分成两部分。
生产侧可靠性:
如何保证业务数据变更后,对应事件不会因为进程崩溃而永久遗漏?
消费侧可靠性:
如何保证事件进入 Kafka 后,Consumer 能够恢复处理,并避免重复副作用?
我们前面学习过的 Transactional Outbox,主要就是用于处理第一类问题。
例如:
text
MySQL 本地事务
|
+-- 更新支付状态
|
+-- 写入 Outbox 事件记录
|
v
COMMIT
|
v
Relay 可靠发布 Kafka
这样业务状态与待发布事件记录可以在同一个数据库事务中提交。
但 Relay 仍然可能重复发布消息,所以 Consumer 仍需要幂等。
这也说明:
Transactional Outbox 与 Consumer 幂等不是相互替代,而是分别解决消息链路两端的可靠性问题。
关于 Outbox 的事务边界、Relay 崩溃窗口和消息重复发布,我们会在第十一篇分布式事务专题继续深入,这里先建立联系。
十四、用一个完整故障场景串起来
最后再回到最初的支付成功事件。
text
Payment Service
|
v
MySQL 支付记录
|
v
Kafka payment-events
|
v
Order Consumer
|
v
MySQL 更新订单
假设系统采用:
text
Producer acks=all
Topic 有多个副本
Consumer 手动提交 Offset
MySQL 事务内实现业务幂等
正常流程:
text
1. Payment Service 确认支付成功
2. 支付事件被可靠发布到 Kafka
3. Kafka 将事件存入 Partition
4. Order Consumer 读取事件
5. MySQL 事务执行订单状态更新
6. MySQL COMMIT 成功
7. Consumer 提交下一条 Offset
现在分别看几个故障窗口。
| 故障位置 | 可能出现什么情况 | 需要什么机制 |
|---|---|---|
| Producer 发出消息后响应丢失 | 不确定 Kafka 是否写入 | 有限重试、Producer 幂等 |
| Broker Leader 故障 | 尚未安全复制的消息可能丢失 | 合理的副本、ISR 与 ACK 配置 |
| Consumer 读取后崩溃 | 消费进度未推进,后续重新读取 | Consumer Group 恢复 |
| MySQL 提交成功、Offset 未提交 | 重复执行业务的风险 | 业务幂等、唯一约束 |
| Offset 已提交、MySQL 未成功 | 业务处理被跳过 | 正确的提交顺序 |
| 消息一直处理失败 | 分区积压,后续事件受影响 | 有界重试、DLQ、异常恢复 |
| 数据库变更成功、Kafka 未发布 | 下游收不到事件 | Outbox 等可靠投递设计 |
你会发现,Kafka 的可靠性并不是依靠某个参数解决的。
acks=all 解决的是 Producer 对 Broker 写入确认的问题。
Consumer Offset 解决的是消费进度管理问题。
Consumer Group 解决的是消费任务分配与恢复问题。
MySQL 唯一约束和幂等解决的是重复业务副作用问题。
Transactional Outbox 解决的是数据库业务变更与待发布事件之间的可靠衔接问题。
它们分别保护消息链路中的不同环节。
十五、总结:Kafka 的可靠性边界到底在哪里?
通过这一篇,我们把 Kafka 的消息可靠性从生产端一直梳理到了业务执行端。
最重要的是理解以下几个结论。
第一,ACK 不等于业务成功。
Producer 收到 ACK,只说明 Kafka 按对应机制确认了消息写入,不代表 Consumer 已完成业务操作。
第二,提交 Offset 不会删除消息。
Offset 记录消费者组的消费位置,消息是否保留主要由 Kafka 的日志保留与清理策略决定。
第三,先执行业务再提交 Offset,可能重复消费。
因为业务系统和 Kafka 的消费进度不是天然同一个事务。
第四,先提交 Offset 再执行业务,可能遗漏业务操作。
如果提交成功后 Consumer 崩溃,未完成的操作可能不再通过正常消费流程重试。
第五,幂等不是让消息永远不重复,而是让重复消息不会产生重复业务副作用。
这也是为什么 MySQL 事务、业务唯一键和状态约束如此重要。
第六,Kafka 的 Exactly-once 能力存在明确的作用范围。
它不能自动保证外部 MySQL、支付接口或者短信系统的副作用都只生效一次。
最后,把整篇文章的核心思想总结成一句话:
可靠的 Kafka 消费不是确保每条消息永远只被读取一次,而是保证消息可以在故障后安全恢复处理,同时让重复执行不会破坏业务正确性。
理解了这一点,Kafka 就不再只是一个"发送消息的中间件",而是分布式系统中用于解耦、缓冲和恢复异步处理的重要基础设施。
至此,Kafka 的两篇核心博客已经完成。
下一篇进入 MySQL,从事务、隔离级别、MVCC 和锁开始,理解数据库如何在并发读写时保证数据正确性。
