1. Exactly Once 语义概述
各位同学好,我是老赵。今天咱们聊聊消息中间件里最让人头疼、也最绕不开的一个概念——Exactly Once 语义。
说实话,我入行头三年,一直觉得这东西就是个理论玩具。直到有一次线上事故,订单系统重复扣款,财务对账差了 80 多万……嗯,从那以后,我再也不敢小看它了。
1.1 什么是 Exactly Once
Exactly Once,直译就是「恰好一次」。它描述的是:一条消息从生产者发出,到消费者处理完毕,在整个链路中,无论发生什么故障,最终的效果都像只执行了一次。
你想想看,这其实是个非常强的承诺。网络会抖、机器会挂、磁盘会坏,要在这种环境下做到「不多不少刚刚好」,难度可想而知。
核心定义:Exactly Once 不是保证「消息只被传输一次」,而是保证「消息的处理效果只生效一次」。
举个例子。你转账 100 块,系统收到请求后扣款、记账、发通知。如果扣款成功了但记账超时,生产者重试,就会再扣一次——这就是典型的「非 Exactly Once」。真正的 Exactly Once 要保证:无论重试多少次,最终账户只被扣 100 块。
1.2 为什么需要 Exactly Once
我经常被问到:我的业务允许丢几条消息,或者多处理几次,有必要上 Exactly Once 吗?
我的回答是:看场景。但下面这几种情况,你躲不开。
- 金融交易:扣款、转账、结算,多一次少一次都是事故。我在某支付公司见过因为重复消费导致用户余额变成负数的案例,修复花了整整两周。
- 库存扣减:电商大促时,一个商品库存 100 件,如果消息重复处理,可能卖出 120 件。超卖带来的客诉和赔偿,够你喝一壶的。
- 计费与账单:云厂商按调用量计费,多统计一次就多收一次钱,用户不投诉才怪。
- 日志审计:监管要求每条操作记录必须唯一且完整,重复或丢失都算合规事故。
我的经验:如果你在做一个「数据对不上就要赔钱」的系统,别犹豫,直接上 Exactly Once。如果只是日志收集、监控告警这类场景,At Most Once 反而更合适——省心、省资源。
1.3 三种语义对比
在讲 Exactly Once 之前,咱们得先搞清楚它的两个「兄弟」——At Most Once 和 At Least Once。这三者的关系,说白了就是:一个丢数据,一个重复数据,一个既要又要还要。
| 语义 | 含义 | 典型行为 | 适用场景 | 代价 |
|---|---|---|---|---|
| At Most Once | 最多一次 | 发完即忘,不重试 | 日志、监控、传感器数据 | 可能丢消息 |
| At Least Once | 至少一次 | 失败就重试,直到成功 | 通知、订单创建 | 可能重复处理 |
| Exactly Once | 恰好一次 | 去重 + 幂等 + 事务 | 金融、库存、计费 | 性能开销大、实现复杂 |
我简单解释一下这三者的区别。
At Most Once:生产者把消息发出去,不管 Broker 收没收到,也不管消费者处理没处理完。说白了就是「发完拉倒」。好处是快,坏处是丢数据你都不知道。我见过有人用 UDP 做消息队列,就是这个模式。
At Least Once:这是大多数消息中间件的默认语义。生产者发消息后,必须等到 Broker 确认(ACK)才算完。如果没收到 ACK,就重试。消费者处理完也要回 ACK,没回就重新投递。好处是不丢数据,坏处是——你想想看,重试必然导致重复。我在项目中遇到过,Kafka 默认就是 At Least Once,消费者没做好幂等,重复消费直接导致数据库主键冲突。
Exactly Once:它要解决的就是 At Least Once 带来的重复问题。怎么做?核心三板斧:幂等生产者 + 事务性写入 + 幂等消费者。后面几章我会详细拆解,这里先有个印象。
注意:Exactly Once 不是银弹。它需要 Broker、生产者和消费者三方配合,任何一方掉链子都做不到。我曾经在一个项目里,Broker 端配了 Exactly Once,但消费者没做幂等,结果该重复还是重复。所以,链路中的每一环都要对齐。
1.4 知识体系总览
下面这张图,是我梳理的 Exactly Once 落地全貌。你可以把它当成整个课程的地图。
这张图展示了 Exactly Once 落地的完整链路。从上到下依次是:语义定义 → 三大支柱(幂等生产者、事务性写入、幂等消费者)→ 实现技术 → 主流中间件支持 → 落地挑战。后面的章节,我会沿着这条线,一个一个给你讲透。
1.5 一个简单的代码示例
光说不练假把式。咱们先看一段伪代码,感受一下三种语义在生产者端的差异。
// At Most Once:发完即忘
producer.send("topic", message);
// 不关心结果,不重试
// At Least Once:等待确认,失败重试
while (true) {
try {
RecordMetadata meta = producer.send("topic", message).get();
break; // 收到 ACK,跳出循环
} catch (Exception e) {
// 重试,但可能导致重复
log.warn("发送失败,重试中...");
}
}
// Exactly Once:幂等 + 事务
producer.initTransactions();
producer.beginTransaction();
try {
// 开启幂等
producer.enableIdempotence(true);
producer.send("topic", message);
// 同时写入 offset 到事务
producer.sendOffsetsToTransaction(offsets, groupId);
producer.commitTransaction();
} catch (Exception e) {
producer.abortTransaction();
// 事务回滚,保证不丢不重
}
你看,代码量从 1 行涨到 20 行,复杂度肉眼可见地上升。这就是 Exactly Once 的代价——用更复杂的逻辑,换取更强的数据一致性。
我的建议:刚开始接触 Exactly Once 时,别急着上事务。先从幂等生产者入手,配合消费者的去重逻辑,大部分场景都能覆盖。事务的开销比你想象的大得多,后面我会专门讲什么时候该用、什么时候不该用。