消息投递语义: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 💡 选择建议 • 日志/监控/非关键指标 → 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 是「不丢不重刚刚好」。选哪个,取决于你的业务愿意为「不丢」和「不重」付出多少代价。