CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Kafka 消息队列入门/课时/投递语义
RawBlame
课时目录12 节

Kafka 消息队列入门

  1. 01消息队列是什么
  2. 02核心概念
  3. 03Docker 运行 Kafka
  4. 04生产者
  5. 05消费者组
  6. 06Spring Kafka
  7. 07消息格式
  8. 08投递语义
  9. 09Kafka Streams 入门
  10. 10监控与 lag
  11. 11Kafka vs RabbitMQ
  12. 12综合练习

投递语义

幂等、事务、重复消费处理。

30 分钟

投递语义

这一课你将能做到

  1. 用自己的话解释 at-most-once / at-least-once / exactly-once 各在哪一步丢、在哪一步重,并能画出时序
  2. 实现「去重表 + 唯一索引」的消费幂等骨架,说清 eventId 的两种生成规则
  3. 画出 Outbox 两步(同库事务写 outbox → 异步发 Kafka),对比「事务后直接 send」的风险边界

为什么要学

Kafka 面试里最容易被问倒的词就是 exactly-once:很多人把「开了个配置」当成「系统不丢不重」,一上生产就翻车。工程真相是:

  • 大多数业务系统落地在 at-least-once + 幂等消费
  • 「MySQL 写成功 + Kafka 发出去」是两个系统两步操作,没有一键原子——Outbox 是经典补丁
  • 幂等生产者只解决生产侧重复,消费侧重复要靠业务键

人话:宁可「多做一次但结果一样」,也别「少做一次钱少了」——这是分布式系统投递的第一性原则。

先建立图像

at-most-once : 先 commit 位点 → 再处理
               崩溃在处理中?消息跳过 → 可能丢(日志场景可接受)

at-least-once: 先处理 → 再 commit
               崩溃在 commit 前?重启后重投 → 可能重复 ← 默认按这个设计

exactly-once : 事务 API + 幂等存储,边界内不丢不重
               Kafka 事务擅长「读 Topic → 写 Topic」链路
               跨 MySQL 仍要业务幂等/Outbox 兜底
语义含义实现要点代价
at-most-once尽量不重,可能丢先提交位点关键业务不可接受
at-least-once尽量不丢,可能重先处理再提交 + 幂等消费方要做幂等
exactly-once边界内不丢不重Kafka 事务 / 幂等生产者 / 业务幂等复杂度与延迟
Outbox 模式:
  同一 DB 事务:UPDATE orders + INSERT outbox_events   ← 原子
  独立发送器:轮询 outbox → producer.send → 标记已发
  → 根治「库成功消息失败」与「消息成功库回滚」两类不一致

跟着做

步骤 1:在纸上画「处理成功但未 commit」

时序四步:

  1. poll 到 offset=10
  2. 发邮件成功
  3. 进程崩溃(位点未提交)
  4. 重启再 poll → 又从 offset=10 开始 → 邮件再发一次

笔记结论:这就是 at-least-once;邮件接口必须幂等或去重,否则用户收两封。

步骤 2:落地去重表幂等骨架

@Entity
@Table(name = "processed_events",
       indexes = @Index(name = "uk_event_id", columnList = "eventId", unique = true))
public class ProcessedEvent {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String eventId;
    private Instant processedAt;
    // 构造器/getter 省略
}

@Service
@RequiredArgsConstructor
public class OrderCreatedHandler {
    private final ProcessedEventRepo processedEventRepo;
    private final NotifyService notifyService;

    @Transactional
    public void handleOrderCreated(OrderCreatedEvent evt) {
        // 已处理:直接返回(重复投递安全跳过)
        if (processedEventRepo.existsByEventId(evt.eventId())) {
            return;
        }
        notifyService.sendEmail(evt);                                  // 副作用
        processedEventRepo.save(new ProcessedEvent(evt.eventId(), Instant.now()));
    }
}

同一 eventId 第二次进来直接跳过;并发重复靠唯一索引挡住双插(一个事务会因唯一键冲突回滚,重试后读到已处理记录)。注意检查+插入+副作用必须在同一事务,拆开就有窗口。

步骤 3:定 eventId 规则

两种合法方案写进笔记:

  1. 生产者 UUID:简单通用,重放历史时每次生成新 id(重放会再执行一次副作用,需配合业务键)
  2. 业务自然键:orderId + ":OrderCreated" 或 orderId + ":" + eventType + ":" + version——重放可推导出同一 id,天然防重放重复

推荐:有明确业务实体的事件用自然键;通用事件用 UUID。

步骤 4:对比「事务后 send」与 Outbox

方案 A:afterCommit 发送(上一课写法,实验够用)
  @Transactional 写 orders
  afterCommit → kafkaTemplate.send
  风险窗口:提交后、发送前进程崩溃 → 事件丢了
  兜底:对账任务补发(扫「已支付但未通知」)

方案 B:Outbox(生产关键路径)
  同一事务写 orders + outbox_events
  定时任务/CDC 读 outbox → send → 标记 sent
  事件永不丢(outbox 行在);最多延迟到达

实验流量用 A;涉及钱、库存、合规的上 B。

步骤 5:打开幂等生产者(回忆边界)

props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, "true");
props.put(ProducerConfig.ACKS_CONFIG, "all"); // 幂等默认要求
// 原理:每个 Producer 有 PID,每条消息带分区内序列号
// Broker 发现 (PID, partition, seq) 重复 → 拒绝重复追加
// 边界:只管"写入日志不重复",不管"消费处理两次"

步骤 6:给副作用分类

副作用重复执行后果策略
Redis 删缓存通常无害(下次 miss 重载)直接重试
发邮件/短信骚扰用户去重表 / 邮件商幂等键
扣库存/扣款资金事故事务状态机 + 幂等键,不可裸调

结论写一句:幂等成本按副作用严重度分配,不是所有消费者都要上重炮。

讲清楚要点

  1. 因为「处理」与「提交位点」跨系统不原子,所以崩溃窗口必然存在——默认按会重复设计,这是模型属性不是 bug。
  2. 因为唯一索引能在并发下挡双插,所以去重表是最实用的 exactly-once「业务近似」:处理结果等价于只执行一次。
  3. 因为MySQL 与 Kafka 是两个系统,所以没有一键端到端 EOS;Outbox/Inbox 是几十年验证过的补丁,不是过度设计。
  4. 因为幂等生产者只管生产路径(PID+seq 去重),所以消费侧幂等永远不可省略——两层各管各的。
  5. 因为先提交再处理会丢,所以关键业务禁止「自动提交 + 无补偿」裸奔;日志/埋点类才允许 at-most-once。
  6. 因为Kafka 事务擅长 Kafka 内部拓扑(consume-process-produce 一笔提交),所以「Kafka ↔ MySQL」的一致性叙事仍以 Outbox + 业务幂等为主,Kafka 事务 API 不是银弹。

你可能会懵的地方

  1. 「官方宣传 exactly-once,我是不是已经 EOS 了?」 问边界:是「写入日志不重复」(幂等生产者做到了)、「Kafka 流内处理不丢不重」(事务 API 做到了),还是「邮件绝不发两次」(要业务幂等,Kafka 管不到)。面试答 EOS 必先答边界。

  2. 「去重表会不会无限膨胀?」 会。按时间归档/分区表定期清理;或用业务自然键 + 状态字段(订单表直接有 notified=true),不单独建表。关键是唯一性保证,不是表长什么样。

  3. 「Outbox 是不是过度设计?」 看 faliure cost:通知晚一分钟无害 → afterCommit + 对账够用;漏发一次要赔钱 → Outbox 是最便宜的保险。用故障代价反推方案等级。

  4. 「重复消费时事务怎么写才对?」 同一个 DB 事务内做「查去重 → 插去重 → 业务写」三步;对外副作用(发邮件)选可幂等的 API 或「先记意图、异步执行」。切忌「先发邮件再插去重记录」——中间崩溃下次再发。

  5. 「消息重放历史时幂等会失效吗?」 用 UUID 的事件会(每次重放新 id)。要支持重放就用业务自然键做 eventId,重放推导出同一键 → 去重表照常拦截。

错法对照

错法问题正解
先 commitSync 再处理关键逻辑提交后崩溃 → 消息永久丢失先处理(幂等)再提交位点
依赖「Kafka 不会重复」不做去重at-least-once 必然可能重复eventId 唯一索引兜底
DB 成功、send 失败就当没发生该订单永远没有事件Outbox 或失败落补偿表 + 告警
Redis 删缓存的心态拿去扣库存重复扣减是资金事故不可重入操作用状态机 + 幂等键
去重检查与业务写拆成两个事务中间崩溃产生重复窗口同一事务内完成三步
// 错:at-most-once 心智用在扣库存
consumer.commitSync();
inventory.decrement(orderId); // 崩溃则丢,钱货不一致

// 对:at-least-once + 幂等
inventory.decrementIdempotent(orderId, evt.eventId());
consumer.commitSync();

卡点急救

  1. 同一订单处理了两次:查三处——eventId 是否真的相同(UUID 每次新生成?)、检查与插入是否同事务、唯一索引建了没有(SHOW INDEX 验证)。
  2. outbox 积压不消费:发送器任务是否在跑、发送失败是否标记重试、Kafka 是否可达;outbox 表要加「状态 + 重试次数」字段,失败的退避重试并告警。
  3. 「exactly-once 开关都开了还是重复」:确认重复发生在哪一段——Topic 里重复(生产侧问题)还是下游副作用重复(消费侧幂等问题)。用 console consumer 数同一 orderId 的消息条数定位。

面试常问

  • Kafka 的 exactly-once 是什么边界?怎么实现的? 背边界再背机制:①幂等生产者——每个 Producer 分配 PID,消息带分区内序列号,Broker 按 (PID, partition, seq) 去重,解决「重试导致重复写入」;②事务——跨分区/会话原子写,配合 isolation.level=read_committed 实现「消费-处理-生产」链路 EOS;③跨外部系统(MySQL 等)Kafka 管不到,必须业务幂等(去重表/状态机)或 Outbox。一句话总结:Kafka 内部 EOS 是配置问题,端到端 EOS 是架构问题。

  • 怎么保证消息不被重复消费(或重复了也无害)? 承认 at-least-once 前提下:下游幂等三板斧——唯一键拦截(去重表 eventId 唯一索引)、状态机校验(只允许合法状态流转,重复请求被状态挡住)、外部调用幂等(邮件商/支付接口传幂等键)。再补一句位点纪律:先处理后提交,重复交给幂等兜底。

  • 数据库和消息队列的双写不一致怎么解决? 反模式:事务里先写库再发消息(发送失败库已提交)、或先发消息再写库(库回滚消息已出去)。正解 Outbox:业务表与 outbox 表同事务落库,后台轮询/CDC 把 outbox 投递到 Kafka 后标记;下游幂等消费。退而求其次用 afterCommit + 对账补偿任务。

自测三问

  1. 画出 at-least-once 下「处理成功但 commit 前崩溃」的四步时序。
  2. 给出 TrackPublished 的 eventId 生成规则,并说明并发双消费如何被唯一索引挡住。
  3. 为何 Redis 删缓存重复执行通常无害,而扣库存不行?各用一句话点破。

小结一句话

默认按 at-least-once 设计:幂等键 + 唯一索引挡重复,(关键路径)Outbox 挡双写不一致;「exactly-once」先问边界再谈实现。

延伸

  • Kafka 官方文档 — 投递语义
  • 下一课「Kafka Streams 入门」:不写 poll 循环,在流上直接做过滤、聚合与窗口统计。
加载本节练习…
← 上一课消息格式下一课 →Kafka Streams 入门