消息投递语义:At Most Once、At Least Once、Exactly Once 的定义与对比
聊分布式消息系统,绕不开一个核心问题:消息到底能投递几次?
我刚开始接触消息中间件时,总觉得这问题有点钻牛角尖。不就是发一条消息嘛,成功就是成功,失败就是失败,哪有那么多说法?后来在生产环境被坑了几次,才明白——投递语义这三个字,直接决定了你的系统是「能用」还是「靠谱」。
说白了,消息投递语义就是生产者和消费者之间的一份契约。它约定了消息从发送到被消费,到底能重复多少次。今天咱们就把三种语义掰开揉碎,看看它们各自是什么、怎么用、以及我踩过的坑。
三种语义,一张表说清楚
先给个全景图。三种语义的核心差异,其实就一句话:消息会不会丢?会不会重复?
| 语义 | 消息会丢吗 | 消息会重复吗 | 典型场景 |
|---|---|---|---|
| At Most Once | 可能丢 | 不会重复 | 日志、监控、非关键指标 |
| At Least Once | 不会丢 | 可能重复 | 订单通知、短信发送、任务调度 |
| Exactly Once | 不会丢 | 不会重复 | 金融交易、支付扣款、库存扣减 |
嗯,这张表你得刻在脑子里。每次设计消息链路时,先问自己:我的业务能容忍丢消息,还是能容忍重复消息?
At Most Once:最多一次
这是最「佛系」的语义。发送方把消息发出去,不管对方收没收到,直接完事。不重试、不确认、不补偿。
优点很明显:性能高、延迟低、实现简单。
缺点也很致命:消息可能丢失。
我在项目中见过一个典型的例子——用户行为日志采集。用户点击了一个按钮,埋点系统把事件发到 Kafka。如果发送失败,直接丢弃。为什么可以这样?因为丢几条日志不影响业务,但日志量巨大,每条都保证送达,成本扛不住。
核心原则:At Most Once 适用于「丢了无所谓,但别给我重复」的场景。
实现方式也简单:发送即忘。生产者发完消息,不等待 ACK,不处理异常。消费者收到消息就处理,不保存偏移量,不幂等。
// 伪代码:At Most Once 发送
producer.send(topic, message);
// 发完就走,不管结果
你想想看,这种模式在什么场景下能用?非关键指标、实时监控、日志归档。丢了 0.1% 的数据,业务不受影响,但性能能提升一个量级。
At Least Once:至少一次
这是生产环境最常用的语义。发送方确保消息至少被送达一次,如果失败就重试。
优点:消息不会丢。
缺点:消息可能重复。
为什么会重复?我举个例子。生产者发消息到 Broker,Broker 写盘成功,返回 ACK。但网络抖了一下,ACK 没到生产者手里。生产者超时重试,又发了一次。结果就是——Broker 收到了两条一模一样的消息。
我曾经在一个短信发送系统里遇到过这个问题。用户触发「发送验证码」,系统把指令发到消息队列。消费者拿到指令后调用短信网关。结果有一次,生产者重试导致两条指令,消费者发了两次短信。用户收到了两条一模一样的验证码……虽然业务上没问题(验证码以最新为准),但用户体验很差。
避坑指南:At Least Once 必须配合幂等性使用。如果消费者不幂等,重复消息就是灾难。
实现方式:生产者开启 ACK 机制,超时重试。消费者处理完消息后提交偏移量,但提交时机要小心——先处理后提交,否则消息可能丢失。
// 伪代码:At Least Once 消费
while (true) {
Message msg = consumer.poll();
try {
process(msg); // 先处理
consumer.commit(); // 后提交
} catch (Exception e) {
// 不提交,下次重新拉取
}
}
说白了,At Least Once 是用重复换不丢。大部分业务系统都能接受偶尔的重复,但不能接受丢数据。所以它成了默认选择。
Exactly Once:恰好一次
这是分布式消息系统的圣杯。消息不丢、不重复,每条消息恰好被处理一次。
听起来很完美,对吧?但实现起来,代价极高。
Exactly Once 不是单一技术能搞定的,它需要端到端的保障:
- 生产者到 Broker:事务消息或幂等生产者
- Broker 存储:持久化 + 副本同步
- Broker 到消费者:事务消费或幂等消费
- 消费者处理:幂等操作 + 原子提交
我在一个支付对账系统里实践过 Exactly Once。用户发起退款,系统生成退款指令,通过消息队列下发到账务系统。账务系统必须保证:一条退款指令,最终只执行一次扣款。
怎么做到的?核心思路是去重 + 幂等。
// 伪代码:Exactly Once 消费(幂等方案)
Message msg = consumer.poll();
String msgId = msg.getId();
// 检查去重表
if (dedupTable.exists(msgId)) {
// 已处理过,直接跳过
consumer.commit();
return;
}
// 执行业务逻辑(幂等)
process(msg);
// 记录去重 + 提交偏移量(原子操作)
dedupTable.save(msgId);
consumer.commit();
但这里有个坑:去重表和偏移量提交必须原子。如果去重表写成功了,但偏移量提交失败,下次重启会重新消费这条消息。去重表里已经有记录了,所以不会重复处理——嗯,这其实已经做到了 Exactly Once。
我的经验:真正的 Exactly Once 在分布式环境下几乎不可能 100% 保证。实际工程中,我们追求的是业务层面的 Exactly Once——通过幂等设计,让重复消息在业务上不产生副作用。
三种语义的对比与选择
讲完定义,咱们看看实际项目中怎么选。
我个人的习惯是:先看业务容忍度,再看性能要求。
- 能丢消息吗?能 → At Most Once。不能 → 往下看。
- 能容忍重复吗?能 → At Least Once。不能 → Exactly Once。
- 性能要求极高?At Most Once > At Least Once > Exactly Once。
这张决策流程图,是我多年实践总结出来的:
三种语义的实现代价
选型时还有一个关键因素:实现复杂度。
| 维度 | At Most Once | At Least Once | Exactly Once |
|---|---|---|---|
| 实现难度 | 低 | 中 | 高 |
| 性能开销 | 低 | 中 | 高 |
| 网络开销 | 1次 | 1~N次(重试) | 2次(事务) |
| 存储开销 | 无 | 偏移量 | 事务日志 + 去重表 |
| 幂等要求 | 不需要 | 建议有 | 必须 |
你看,Exactly Once 的代价是全方位的。所以我在实际项目中,只在核心资金链路用 Exactly Once,其他场景用 At Least Once + 幂等就够了。
一句话总结:At Most Once 是「丢了就丢了」,At Least Once 是「宁可重复不能丢」,Exactly Once 是「不丢不重刚刚好」。选哪个,取决于你的业务愿意为「不丢」和「不重」付出多少代价。