投递语义
这一课你将能做到
- 用自己的话解释 at-most-once / at-least-once / exactly-once 各在哪一步丢、在哪一步重,并能画出时序
- 实现「去重表 + 唯一索引」的消费幂等骨架,说清
eventId 的两种生成规则
- 画出 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」
时序四步:
- poll 到 offset=10
- 发邮件成功
- 进程崩溃(位点未提交)
- 重启再 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 规则
- 生产者 UUID:简单通用,重放历史时每次生成新 id(重放会再执行一次副作用,需配合业务键)
- 业务自然键:
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 行在);最多延迟到达
步骤 5:打开幂等生产者(回忆边界)
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, "true");
props.put(ProducerConfig.ACKS_CONFIG, "all"); // 幂等默认要求
// 原理:每个 Producer 有 PID,每条消息带分区内序列号
// Broker 发现 (PID, partition, seq) 重复 → 拒绝重复追加
// 边界:只管"写入日志不重复",不管"消费处理两次"
步骤 6:给副作用分类
| 副作用 | 重复执行后果 | 策略 |
|---|
| Redis 删缓存 | 通常无害(下次 miss 重载) | 直接重试 |
| 发邮件/短信 | 骚扰用户 | 去重表 / 邮件商幂等键 |
| 扣库存/扣款 | 资金事故 | 事务状态机 + 幂等键,不可裸调 |
结论写一句:幂等成本按副作用严重度分配,不是所有消费者都要上重炮。
讲清楚要点
- 因为「处理」与「提交位点」跨系统不原子,所以崩溃窗口必然存在——默认按会重复设计,这是模型属性不是 bug。
- 因为唯一索引能在并发下挡双插,所以去重表是最实用的 exactly-once「业务近似」:处理结果等价于只执行一次。
- 因为MySQL 与 Kafka 是两个系统,所以没有一键端到端 EOS;Outbox/Inbox 是几十年验证过的补丁,不是过度设计。
- 因为幂等生产者只管生产路径(PID+seq 去重),所以消费侧幂等永远不可省略——两层各管各的。
- 因为先提交再处理会丢,所以关键业务禁止「自动提交 + 无补偿」裸奔;日志/埋点类才允许 at-most-once。
- 因为Kafka 事务擅长 Kafka 内部拓扑(consume-process-produce 一笔提交),所以「Kafka ↔ MySQL」的一致性叙事仍以 Outbox + 业务幂等为主,Kafka 事务 API 不是银弹。
你可能会懵的地方
-
「官方宣传 exactly-once,我是不是已经 EOS 了?」
问边界:是「写入日志不重复」(幂等生产者做到了)、「Kafka 流内处理不丢不重」(事务 API 做到了),还是「邮件绝不发两次」(要业务幂等,Kafka 管不到)。面试答 EOS 必先答边界。
-
「去重表会不会无限膨胀?」
会。按时间归档/分区表定期清理;或用业务自然键 + 状态字段(订单表直接有 notified=true),不单独建表。关键是唯一性保证,不是表长什么样。
-
「Outbox 是不是过度设计?」
看 faliure cost:通知晚一分钟无害 → afterCommit + 对账够用;漏发一次要赔钱 → Outbox 是最便宜的保险。用故障代价反推方案等级。
-
「重复消费时事务怎么写才对?」
同一个 DB 事务内做「查去重 → 插去重 → 业务写」三步;对外副作用(发邮件)选可幂等的 API 或「先记意图、异步执行」。切忌「先发邮件再插去重记录」——中间崩溃下次再发。
-
「消息重放历史时幂等会失效吗?」
用 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();
卡点急救
- 同一订单处理了两次:查三处——eventId 是否真的相同(UUID 每次新生成?)、检查与插入是否同事务、唯一索引建了没有(
SHOW INDEX 验证)。
- outbox 积压不消费:发送器任务是否在跑、发送失败是否标记重试、Kafka 是否可达;outbox 表要加「状态 + 重试次数」字段,失败的退避重试并告警。
- 「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 + 对账补偿任务。
自测三问
- 画出 at-least-once 下「处理成功但 commit 前崩溃」的四步时序。
- 给出
TrackPublished 的 eventId 生成规则,并说明并发双消费如何被唯一索引挡住。
- 为何 Redis 删缓存重复执行通常无害,而扣库存不行?各用一句话点破。
小结一句话
默认按 at-least-once 设计:幂等键 + 唯一索引挡重复,(关键路径)Outbox 挡双写不一致;「exactly-once」先问边界再谈实现。
延伸