前言
本文翻译自Brave New Geek的文章You Cannot Have Exactly-Once Delivery
我经常会惊讶地发现,许多人对分布式系统的行为存在根本性的误解。我自己也曾经持有许多这样的误解,所以我尽量不去贬低或忽视这些问题,而是试图去教育和启发大家,希望在不显得说教的前提下做到这一点。我之所以能够不断学习,也只是因为走在了前人铺就的道路上。回过头来看,人们会相信这些谬误并不奇怪,因为我曾经也信以为真。但当我尝试向别人解释某些设计决策和系统限制时,这仍然让我感到挫败。
在分布式系统中,你无法实现真正的"仅一次"消息投递(Exactly-Once Delivery) 。
浏览器和服务器?分布式。服务器和数据库?分布式。服务器和消息队列?分布式。在这些场景中,你无法实现真正的仅一次投递语义。
交付语义的权衡
正如我过去所描述的,分布式系统的核心在于权衡。消息投递语义本质上有三种:
- 至多一次(At-Most-Once)
- 至少一次(At-Least-Once)
- 仅一次(Exactly-Once)
其中,前两种是可行且被广泛使用的。如果你想较真地探讨问题,你可能会说"至少一次"也是不可能的,因为从严格意义上讲,网络分区(Network Partition)在时间上并不是严格有界的 。如果你的网络连接被无限期地中断,你就无法投递任何消息。然而,现实情况是:当网络连接无限期中断时,你面临的更大问题可能是打电话找你的网络服务商(ISP),因此我们通常认为**"至少一次"在实际应用中是可行的**。换句话说,我们在理论模型中假设网络分区的持续时间是有限的,尽管这个假设的界限可能是人为设定的。
那么问题来了:为什么仅一次投递是不可能的?
答案可以从**"两个将军问题"(Two Generals Problem)或更一般的 "拜占庭将军问题"(Byzantine Generals Problem)中找到。我之前曾深入研究过这个问题。此外,我们还必须考虑FLP 不可能性定理(FLP Impossibility Result)**,它表明:在存在失败进程的情况下,分布式系统中的进程无法在有限时间内达成一致性决定。
现实世界中的消息投递
假设我给你寄了一封信,并在信中要求你收到后给我打电话。然而,你并没有打电话。可能的原因有两个:
- 你真的不在乎我的信件
- 信件在邮寄过程中丢失了
这就是分布式系统的现实:
- 我可以只寄一封信并希望你收到(至多一次)。
- 我可以寄 10 封信,以确保你至少能收到一封(至少一次)。
然而,寄 10 封信并不会提供额外的保证,因为它不能确保你只会收到一封。在分布式系统中,我们通常依赖**确认机制(Acknowledgment, Ack)**来提高消息的可靠性,但这一过程同样可能出错:
- 消息丢失?
- 确认(Ack)丢失?
- 接收方宕机?
- 网络太慢?我太慢?接收方太慢?
FLP 定理和"两个将军问题"不是复杂的设计问题,而是数学上的不可能性定理。
许多人会扭曲"投递"的定义,使其符合仅一次投递语义,或者干脆让这个术语变得毫无意义。例如:
-
**状态机复制(State Machine Replication)**就是一个典型的例子。
- 通过原子广播协议(Atomic Broadcast) ,确保消息按照固定顺序可靠投递。
- 但是,在网络分区或进程崩溃的情况下,我们无法 做到绝对可靠的投递和排序,除非引入大量的协调机制(Coordination),这会带来额外的**延迟(Latency)和可用性(Availability)**的损失。
- ZooKeeper 所依赖的Zab 协议 本质上是基于至少一次投递,并且通过**幂等性(Idempotency)**来确保一致性。
幂等性与分布式状态变更
"幂等性"是解决至少一次投递问题的关键。
如果一个操作是幂等的,那么无论它被执行一次还是多次,最终的结果都是相同的。例如:
- 数据库的 UPSERT(更新或插入)
- "设置状态"操作,而非"增量更新"
如果状态变更是非幂等的 ,那么额外的消息重复可能会导致不一致性,从而破坏仅一次投递的假象。
现有的消息队列如何保证消息投递?
每个主要的消息队列系统(MQ)都会自称提供某种"消息投递保证"。如果一个消息队列声称提供仅一次投递,那么:
- 它要么在欺骗你(误导营销)
- 要么它的开发者不了解分布式系统
RabbitMQ 的消息确认机制:
RabbitMQ 的**发布确认(Publisher Confirms)**文档是这样描述的:
在使用确认机制(Confirms)时,如果生产者因通道或连接失败而恢复,则需要重新传输所有尚未收到确认的消息。
由于网络故障等原因,确认消息可能无法送达生产者,这就可能导致消息重复 。因此,消费者应用程序需要执行去重或使用幂等性方式处理消息。
这说明 RabbitMQ 只能提供"至少一次"投递语义 。
要实现伪"仅一次"投递,只能依赖:
- 幂等性(Idempotency)
- 去重机制(Deduplication)
伪"仅一次"投递的实现方式
在实践中,真正的仅一次投递是做不到的 ,但我们可以通过幂等性 和去重机制来模拟它:
-
设计幂等操作:
- 例如:用
set_status("PAID")
代替increase_balance(100)
- 例如:用
-
基于唯一 ID 进行去重:
- Kafka 允许消费者使用**幂等生产者 ID(Idempotent Producer ID)**进行去重
-
使用有序消息(Ordered Messages) :
- 例如:Kafka 的事务机制确保消息在同一个分区内保持顺序
-
使用 CRDT(Commutative Replicated Data Types) :
- 例如:在多个副本间同步无序但可合并的数据
结论
分布式系统中,不存在真正的"仅一次"投递:
- 你必须选择"至少一次"还是"至多一次" 。
- 大多数情况下,我们选择"至少一次" ,然后依赖幂等性或去重机制来实现伪"仅一次"投递。
所以,不要迷信某些消息队列声称的"仅一次"投递 。
设计分布式系统时,应接受异步带来的不确定性,并设计出能够容错的架构。