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 场景用这个模式。传感器每秒上报几千条温度数据,丢几条无所谓,反正下一轮还有。但你要是做交易系统,千万别碰这个——丢一条订单数据可能就是事故。
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 开始支持事务。它的核心思路是:
- 生产者开启事务,生成一个全局唯一的 TransactionalId。
- 发送消息时标记为"未提交"状态。
- 消费者端设置隔离级别为 read_committed,只能读到已提交的消息。
- 生产者提交事务后,消息才对外可见。
下面是一个简单的 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();
}
3.3.3 Flink 的两阶段提交
Flink 的 Exactly-Once 依赖 Checkpoint 机制和两阶段提交。它的工作流程是这样的:
- Flink 定期做 Checkpoint,把算子的状态和偏移量一起保存。
- Checkpoint 完成时,触发预提交(pre-commit)阶段,把数据写入外部系统的临时区域。
- 所有算子都预提交成功后,进入提交(commit)阶段,把临时数据变成正式数据。
- 如果某个算子预提交失败,整个 Checkpoint 回滚,恢复到上一个成功状态。
这套机制保证了 Flink 应用在故障恢复时,每条数据只处理一次。但前提是外部系统(比如 Kafka、HDFS)也支持事务性写入。
3.4 知识体系总览
下面这张图把本章的核心知识点串起来了。你可以看到消息投递语义、幂等性、事务性写入三者之间的关系:
从图上可以看得很清楚:幂等性设计和事务性写入是 Exactly-Once 的两大支柱。消息投递语义定义了生产者和消费者之间的契约,而幂等和事务则是实现这个契约的具体手段。
我的建议:刚开始做流批一体项目时,别一上来就追求端到端的 Exactly-Once。先保证幂等,再逐步引入事务。步子迈大了容易扯着蛋——我见过太多团队在事务上花了好几个月,最后发现业务根本不需要那么强的保证。