中间件

Kafka / MQ 核心原理:可靠性、幂等与积压治理

从分区副本、生产消费可靠性到业务幂等、事务边界和消息积压,系统理解 Kafka。

难度:深入更新:2026-07-16

一、Kafka 的核心模型

Kafka 的逻辑层次是:

Topic
  ↓
Partition(有序追加日志)
  ↓
Replica(Leader + Followers)

生产者向分区 Leader 写入,消费者从分区拉取。消费者组内,一个分区同一时刻最多分配给一个消费者实例,因此:

消费组内的有效并行度受分区数上限约束。

不同消费者组各自维护消费进度,可以独立消费同一 Topic。

二、为什么 Kafka 吞吐高

  • 顺序追加日志,减少随机 I/O;
  • 批量发送和压缩,提高网络与磁盘利用率;
  • 页缓存充分利用操作系统缓存;
  • 分区允许横向并行;
  • 拉取模型让消费者按自身能力批量消费;
  • 网络协议和数据传输路径针对批处理优化。

吞吐高不代表单条消息一定延迟最低,也不代表可靠性配置可以省略。linger.ms、batch、压缩和 ACK 都是在延迟、吞吐与可靠性之间取舍。

三、生产端如何保证可靠

ACK

  • acks=0:不等待确认,吞吐高但可能静默丢失;
  • acks=1:Leader 写入即确认,Leader 故障时未同步副本的数据可能丢失;
  • acks=all:等待 ISR 中满足要求的副本确认,可靠性更高。

通常还应配合:

  • 合理的 min.insync.replicas
  • 禁止不受控的非同步副本选主;
  • 发送失败重试和回调告警;
  • 业务级超时与降级。

幂等生产者

幂等生产者使用 Producer ID、Epoch 和分区内序列号识别重试产生的重复写入,使单个生产者会话在单分区内的重试不会重复落日志。

它解决的是生产者到 Kafka 的重复写入,不等于解决消费者重复执行业务,也不等于跨系统 exactly-once。

四、Broker 如何保证副本可靠

每个分区由 Leader 接收读写,Follower 从 Leader 同步。ISR 表示当前与 Leader 保持同步的副本集合。

可靠性需要组合配置:

副本数 >= 3
acks = all
min.insync.replicas >= 2
合理的选主策略

如果只设置 acks=all,但 ISR 中只剩 Leader,一个副本确认仍可能满足要求;所以必须同时理解 min.insync.replicas

副本复制通常是异步推进的,Kafka 在可用性、吞吐和数据安全之间做工程权衡,并非跨机房强一致数据库。

五、消费端为什么常见“至少一次”

典型安全顺序是:

拉取消息
  ↓
执行业务
  ↓
业务成功后提交 offset

如果业务成功后、offset 提交前进程崩溃,重启后会再次消费,因此是至少一次语义。

如果先提交 offset 再执行业务,进程随后崩溃,则消息可能永久跳过。因此关键业务通常选择“先处理再提交 + 业务幂等”。

Kafka 消息有没有业务 messageId

Kafka 原生定位一条日志记录的身份通常是:

topic + partition + offset

Kafka 不会自动为业务生成通用 messageIdeventId。如果需要跨 Topic、重试 Topic、数据库和日志统一追踪,应由业务在消息信封中生成:

{
  "eventId": "uuid",
  "eventType": "BILL_CONFIRMED",
  "aggregateId": "bill-1001",
  "occurredAt": "2026-07-16T10:00:00+08:00",
  "payload": {}
}

六、消费幂等怎么做

常见方案:

唯一键或幂等表

eventId 或业务请求号建立唯一索引,在同一个数据库事务中记录消费和更新业务数据。重复消息触发唯一键冲突后返回已处理结果。

业务状态机

使用条件更新限制合法状态迁移:

UPDATE bill
SET status = 'PAID'
WHERE id = :id
  AND status = 'PAYING';

重复执行时受影响行数为 0,不会重复推进。

Redis 去重

适合可容忍最终一致、丢失风险可控的非核心场景。资金和结算不能只依赖有 TTL 的 Redis Key 作为最终幂等依据。

七、Kafka 事务与 exactly-once 的边界

Kafka 事务可以把多分区写入和消费 offset 提交放进同一个 Kafka 事务,典型用于:

消费 Kafka A
  ↓
处理
  ↓
写入 Kafka B
  +
提交 A 的 offset

下游使用 read_committed 时只读取已提交事务消息。

但这类 exactly-once 主要覆盖 Kafka 内部的“读—处理—写”。如果处理中还写 MySQL、调用支付接口或发送 HTTP 请求,就已经跨出 Kafka 事务边界,需要数据库幂等、Outbox、CDC 或业务补偿。

Kafka 事务也不要与 RocketMQ 的“事务消息/半消息”机制混为一谈,它们解决问题的路径不同。

八、数据库与 Kafka 如何保持一致

错误的直觉方案

更新数据库
→ 发送 Kafka

数据库提交后发送失败,会丢事件。反过来先发送再提交,数据库失败时又会出现无效事件。

Transactional Outbox

在同一个数据库事务中写业务表和 Outbox 表:

数据库事务:
  更新业务数据
  插入 Outbox 事件
  ↓
提交
  ↓
后台任务或 CDC 发布到 Kafka

发布过程可能重复,因此消费者仍要幂等。Outbox 提供的是可恢复的最终一致性,不是让数据库和 Kafka 共享一个全局事务。

CDC

通过 binlog 捕获数据库已提交变更并发布事件,可降低业务代码双写复杂度。需要治理表结构变化、事件语义、重放和重复投递。

九、顺序消息

Kafka 只保证单分区内有序。要保证同一订单、账户或账单的事件顺序,应使用稳定 Key 让它们进入同一分区:

key = accountId

全局有序通常意味着只使用一个分区,会牺牲并行度。多数业务真正需要的是聚合维度有序,而不是全局有序。

即使分区有序,也要处理重试、异步线程池和下游并行处理带来的业务乱序,可结合版本号和状态机拒绝旧事件。

十、重试与死信

Kafka Broker 原生保存日志,并不会自动替业务创建“重试队列”和“死信队列”。工程中通常创建普通 Topic:

business-topic
business-retry-1m
business-retry-10m
business-dlq

由应用或框架负责:

  • 记录重试次数和下一次执行时间;
  • 区分可重试异常与永久异常;
  • 超过阈值进入 DLQ;
  • 提供人工查看、修复和重放能力。

不要无限立即重试,否则会阻塞正常消息并放大下游故障。

十一、消息积压怎么处理

先判断瓶颈:

生产突然增加?
消费者实例不足?
分区数不足?
单条业务处理太慢?
数据库、缓存或远程接口变慢?
频繁 Rebalance?
毒消息反复失败?

治理步骤

  1. 看 Consumer Lag、消费速率、分区分布和失败率;
  2. 优化单条处理,使用批量查询、批量写和连接池;
  3. 在原消费者组内增加消费者,直到接近分区数;
  4. 并行度仍不足时评估扩分区,并检查顺序和 Key 分布;
  5. 把慢外部依赖隔离、限流或异步化;
  6. 将毒消息转入重试/DLQ,避免卡住主链路;
  7. 恢复后控制追赶速度,避免把数据库二次打垮。

不能简单创建一个临时消费者组“帮原组消费”,因为不同组拥有独立 offset;临时组读完不会推进原组的消费进度,还可能重复执行业务。真正协助应加入同一个消费者组,或设计受控的数据转移方案。

十二、Rebalance

消费者实例加入、退出,订阅变化或分区变化都可能触发 Rebalance。期间分区重新分配,可能出现短暂停顿。

治理方向包括:

  • 避免消费线程被长时间阻塞导致心跳异常;
  • 合理配置 poll 批次与最大处理间隔;
  • 使用静态成员或增量协作式分配减少抖动;
  • 把不可控慢调用移出核心 poll 循环,但要正确管理 offset 和背压。

十三、速记结论

生产可靠看 ACK、ISR 和幂等生产者;消费可靠看处理与 offset 的顺序;业务不重复靠幂等键和状态机;Kafka exactly-once 主要覆盖 Kafka 内部链路,跨数据库仍要 Outbox/CDC 和幂等;积压先找瓶颈,扩容必须尊重分区并行度和消费者组语义。

参考资料

DISCUSSION

评论与讨论

留下你的想法