3、Exactly-Once语义:消息投递语义与幂等性设计

聊到数据一致性,有个概念绕不开——Exactly-Once。说白了就是每条数据只处理一次,不多不少刚刚好。但理想很丰满,现实很骨感。分布式系统里网络会抖、节点会挂、消息会重试,要做到 Exactly-Once 其实是个系统工程。

我刚开始做实时数仓那会儿,觉得这玩意儿不就是个配置项吗?后来被线上数据翻倍的问题折腾了几个通宵,才真正明白它背后的门道。今天咱们就把消息投递语义、幂等性设计、事务性写入这三块掰开揉碎聊清楚。

3.1 三种消息投递语义

消息投递语义定义了生产者和消费者之间传递数据的保证级别。常见的就三种:At-Most-Once、At-Least-Once、Exactly-Once。

语义 含义 数据丢失 数据重复 典型场景
At-Most-Once 最多一次,发完就不管了 可能 不会 日志采集、监控指标
At-Least-Once 至少一次,失败就重试 不会 可能 订单通知、支付回调
Exactly-Once 精确一次,不丢不重 不会 不会 金融交易、库存扣减

3.1.1 At-Most-Once:发完就忘

生产者把消息发出去,不管消费者收没收到,直接完事。这种模式性能最高,但代价是可能丢数据。

我见过不少 IoT 场景用这个模式。传感器每秒上报几千条温度数据,丢几条无所谓,反正下一轮还有。但你要是做交易系统,千万别碰这个——丢一条订单数据可能就是事故。

注意:At-Most-Once 不保证消息必达。如果你的业务对数据完整性有要求,请直接跳过这个选项。

3.1.2 At-Least-Once:失败就重试

这是大多数消息队列的默认语义。生产者发送消息后,等待 ACK。如果超时或收到 NACK,就重试。这样能保证消息不丢,但可能重复。

举个例子:你给 Kafka 发了一条消息,broker 写盘成功但 ACK 在网络里丢了。生产者没收到 ACK,于是重发。结果就是 broker 里存了两条一模一样的消息。

我早期做的一个风控系统就踩过这个坑。重复消息导致同一笔交易被拦截了两次,用户投诉说"你们系统是不是有病"。嗯,从那以后我养成了一个习惯——只要用 At-Least-Once,下游必须做幂等。

3.1.3 Exactly-Once:精确一次

Exactly-Once 是终极目标。它保证每条消息只被处理一次,既不会丢也不会重。但实现起来需要上下游协同,不是单靠消息队列就能搞定的。

Kafka 在 0.11 版本引入了幂等生产者和事务 API,配合消费者端的去重,才能实现端到端的 Exactly-Once。Flink 的 Checkpoint 机制也是这个思路——把状态和偏移量一起做快照,故障恢复时从 Checkpoint 回滚,保证每条数据只处理一次。

核心观点:Exactly-Once 不是某个组件的特性,而是整个数据链路的设计目标。生产者、消息队列、消费者三方必须协同配合。

3.2 幂等性设计

幂等性,说白了就是同一个操作执行多次,结果和执行一次一样。这是实现 Exactly-Once 的基石。

你想想看,分布式系统里网络超时、重试、重复消费都是家常便饭。如果每个操作都是幂等的,那重复执行就不可怕了。

3.2.1 幂等的几种实现方式

  • 唯一主键约束:数据库表里设置唯一索引,重复插入直接报错或忽略。这是最朴素也最有效的方式。
  • 去重表:维护一张去重表,记录已经处理过的消息 ID。新消息来的时候先查一下,处理过就跳过。
  • 状态机校验:每个业务操作都有状态流转。比如订单状态从"待支付"到"已支付",如果已经是"已支付",重复的支付请求直接忽略。
  • 版本号/乐观锁:更新数据时带上版本号,版本号不匹配就说明数据已经被更新过了,放弃本次操作。

我个人习惯用去重表 + 唯一主键的组合。去重表负责过滤重复消息,唯一主键兜底防止并发写入。这套方案在多个项目里验证过,效果很稳。

3.2.2 幂等设计的避坑指南

经验之谈:我曾经在一个实时计算项目里,用 Redis 做去重。结果 Redis 集群挂了,去重失效,下游数据库被重复数据撑爆了。后来我把去重逻辑改成了 MySQL 唯一索引 + 本地缓存双重校验,才算彻底解决。

幂等设计有几个容易踩的坑:

  • 去重键的选择:不要用时间戳做去重键,同一毫秒可能有多条数据。建议用业务唯一 ID,比如订单号、交易流水号。
  • 去重表的过期时间:去重表不能无限膨胀。设置合理的 TTL,比如 7 天。超过 TTL 的消息如果重复出现,大概率是系统异常,需要人工介入。
  • 并发去重:高并发场景下,去重表本身可能成为瓶颈。可以用分布式锁 + 本地缓存做分层去重,减少对去重表的直接访问。

3.3 事务性写入

事务性写入解决的是多个操作要么全部成功,要么全部失败的问题。在流批一体架构里,事务性写入通常涉及跨系统协调——比如同时更新数据库和消息队列,或者同时写入 HDFS 和更新元数据。

3.3.1 分布式事务的经典模型

模型 原理 优点 缺点
XA 协议 两阶段提交(2PC) 强一致性 性能差,协调者单点
TCC Try-Confirm-Cancel 性能较好 业务侵入性强
事务消息 半消息 + 回调确认 解耦,适合异步场景 实现复杂
Saga 长事务 + 补偿 高可用,适合微服务 最终一致性

在流批一体场景里,我比较推荐事务消息和Saga。XA 太重了,不适合高吞吐的实时计算。TCC 对业务代码的改造太大,维护成本高。

3.3.2 Kafka 的事务性写入

Kafka 从 0.11 开始支持事务。它的核心思路是:

  1. 生产者开启事务,生成一个全局唯一的 TransactionalId。
  2. 发送消息时标记为"未提交"状态。
  3. 消费者端设置隔离级别为 read_committed,只能读到已提交的消息。
  4. 生产者提交事务后,消息才对外可见。

下面是一个简单的 Kafka 事务生产者示例:

Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("transactional.id", "my-transactional-id");
props.put("enable.idempotence", "true");

KafkaProducer<String, String> producer = new KafkaProducer<>(props);

// 初始化事务
producer.initTransactions();

try {
    // 开启事务
    producer.beginTransaction();
    
    // 发送消息
    producer.send(new ProducerRecord<>("topic-a", "key1", "value1"));
    producer.send(new ProducerRecord<>("topic-b", "key2", "value2"));
    
    // 提交事务
    producer.commitTransaction();
} catch (Exception e) {
    // 回滚事务
    producer.abortTransaction();
}
注意:Kafka 事务只保证生产者到 broker 之间的 Exactly-Once。消费者端如果自己维护偏移量,还需要配合幂等设计才能做到端到端的 Exactly-Once。

3.3.3 Flink 的两阶段提交

Flink 的 Exactly-Once 依赖 Checkpoint 机制和两阶段提交。它的工作流程是这样的:

  • Flink 定期做 Checkpoint,把算子的状态和偏移量一起保存。
  • Checkpoint 完成时,触发预提交(pre-commit)阶段,把数据写入外部系统的临时区域。
  • 所有算子都预提交成功后,进入提交(commit)阶段,把临时数据变成正式数据。
  • 如果某个算子预提交失败,整个 Checkpoint 回滚,恢复到上一个成功状态。

这套机制保证了 Flink 应用在故障恢复时,每条数据只处理一次。但前提是外部系统(比如 Kafka、HDFS)也支持事务性写入。

3.4 知识体系总览

下面这张图把本章的核心知识点串起来了。你可以看到消息投递语义、幂等性、事务性写入三者之间的关系:

Exactly-Once 知识体系 Exactly-Once 语义 幂等性设计 事务性写入 幂等实现方式 • 唯一主键约束 • 去重表 • 状态机 / 乐观锁 事务模型 • XA / 2PC • TCC • 事务消息 / Saga 消息投递语义(At-Most / At-Least / Exactly) 工程落地 Kafka 事务 + 幂等生产者 + Flink Checkpoint + 去重表

从图上可以看得很清楚:幂等性设计和事务性写入是 Exactly-Once 的两大支柱。消息投递语义定义了生产者和消费者之间的契约,而幂等和事务则是实现这个契约的具体手段。

我的建议:刚开始做流批一体项目时,别一上来就追求端到端的 Exactly-Once。先保证幂等,再逐步引入事务。步子迈大了容易扯着蛋——我见过太多团队在事务上花了好几个月,最后发现业务根本不需要那么强的保证。