为什么无法实现精确一次的消息投递

前言

本文翻译自Brave New Geek的文章You Cannot Have Exactly-Once Delivery

我经常会惊讶地发现,许多人对分布式系统的行为存在根本性的误解。我自己也曾经持有许多这样的误解,所以我尽量不去贬低或忽视这些问题,而是试图去教育和启发大家,希望在不显得说教的前提下做到这一点。我之所以能够不断学习,也只是因为走在了前人铺就的道路上。回过头来看,人们会相信这些谬误并不奇怪,因为我曾经也信以为真。但当我尝试向别人解释某些设计决策和系统限制时,这仍然让我感到挫败。

在分布式系统中,你无法实现真正的"仅一次"消息投递(Exactly-Once Delivery)

浏览器和服务器?分布式。服务器和数据库?分布式。服务器和消息队列?分布式。在这些场景中,你无法实现真正的仅一次投递语义。

交付语义的权衡

正如我过去所描述的,分布式系统的核心在于权衡。消息投递语义本质上有三种:

  1. 至多一次(At-Most-Once)
  2. 至少一次(At-Least-Once)
  3. 仅一次(Exactly-Once)

其中,前两种是可行且被广泛使用的。如果你想较真地探讨问题,你可能会说"至少一次"也是不可能的,因为从严格意义上讲,网络分区(Network Partition)在时间上并不是严格有界的 。如果你的网络连接被无限期地中断,你就无法投递任何消息。然而,现实情况是:当网络连接无限期中断时,你面临的更大问题可能是打电话找你的网络服务商(ISP),因此我们通常认为**"至少一次"在实际应用中是可行的**。换句话说,我们在理论模型中假设网络分区的持续时间是有限的,尽管这个假设的界限可能是人为设定的。

那么问题来了:为什么仅一次投递是不可能的?

答案可以从**"两个将军问题"(Two Generals Problem)或更一般的 "拜占庭将军问题"(Byzantine Generals Problem)中找到。我之前曾深入研究过这个问题。此外,我们还必须考虑FLP 不可能性定理(FLP Impossibility Result)**,它表明:在存在失败进程的情况下,分布式系统中的进程无法在有限时间内达成一致性决定。


现实世界中的消息投递

假设我给你寄了一封信,并在信中要求你收到后给我打电话。然而,你并没有打电话。可能的原因有两个:

  1. 你真的不在乎我的信件
  2. 信件在邮寄过程中丢失了

这就是分布式系统的现实:

  • 我可以只寄一封信并希望你收到(至多一次)。
  • 我可以寄 10 封信,以确保你至少能收到一封(至少一次)。

然而,寄 10 封信并不会提供额外的保证,因为它不能确保你只会收到一封。在分布式系统中,我们通常依赖**确认机制(Acknowledgment, Ack)**来提高消息的可靠性,但这一过程同样可能出错:

  • 消息丢失
  • 确认(Ack)丢失
  • 接收方宕机
  • 网络太慢?我太慢?接收方太慢?

FLP 定理和"两个将军问题"不是复杂的设计问题,而是数学上的不可能性定理。

许多人会扭曲"投递"的定义,使其符合仅一次投递语义,或者干脆让这个术语变得毫无意义。例如:

  • **状态机复制(State Machine Replication)**就是一个典型的例子。

    • 通过原子广播协议(Atomic Broadcast) ,确保消息按照固定顺序可靠投递。
    • 但是,在网络分区或进程崩溃的情况下,我们无法 做到绝对可靠的投递和排序,除非引入大量的协调机制(Coordination),这会带来额外的**延迟(Latency)可用性(Availability)**的损失。
    • ZooKeeper 所依赖的Zab 协议 本质上是基于至少一次投递,并且通过**幂等性(Idempotency)**来确保一致性。

幂等性与分布式状态变更

"幂等性"是解决至少一次投递问题的关键。

如果一个操作是幂等的,那么无论它被执行一次还是多次,最终的结果都是相同的。例如:

  • 数据库的 UPSERT(更新或插入)
  • "设置状态"操作,而非"增量更新"

如果状态变更是非幂等的 ,那么额外的消息重复可能会导致不一致性,从而破坏仅一次投递的假象。


现有的消息队列如何保证消息投递?

每个主要的消息队列系统(MQ)都会自称提供某种"消息投递保证"。如果一个消息队列声称提供仅一次投递,那么:

  1. 它要么在欺骗你(误导营销)
  2. 要么它的开发者不了解分布式系统
RabbitMQ 的消息确认机制:

RabbitMQ 的**发布确认(Publisher Confirms)**文档是这样描述的:

在使用确认机制(Confirms)时,如果生产者因通道或连接失败而恢复,则需要重新传输所有尚未收到确认的消息。

由于网络故障等原因,确认消息可能无法送达生产者,这就可能导致消息重复 。因此,消费者应用程序需要执行去重或使用幂等性方式处理消息

这说明 RabbitMQ 只能提供"至少一次"投递语义

要实现伪"仅一次"投递,只能依赖:

  • 幂等性(Idempotency)
  • 去重机制(Deduplication)

伪"仅一次"投递的实现方式

在实践中,真正的仅一次投递是做不到的 ,但我们可以通过幂等性去重机制来模拟它:

  1. 设计幂等操作

    • 例如:用 set_status("PAID") 代替 increase_balance(100)
  2. 基于唯一 ID 进行去重

    • Kafka 允许消费者使用**幂等生产者 ID(Idempotent Producer ID)**进行去重
  3. 使用有序消息(Ordered Messages)

    • 例如:Kafka 的事务机制确保消息在同一个分区内保持顺序
  4. 使用 CRDT(Commutative Replicated Data Types)

    • 例如:在多个副本间同步无序但可合并的数据

结论

分布式系统中,不存在真正的"仅一次"投递

  • 你必须选择"至少一次"还是"至多一次"
  • 大多数情况下,我们选择"至少一次" ,然后依赖幂等性或去重机制来实现伪"仅一次"投递

所以,不要迷信某些消息队列声称的"仅一次"投递

设计分布式系统时,应接受异步带来的不确定性,并设计出能够容错的架构

参考

相关推荐
你们补药再卷啦37 分钟前
springboot 项目 jmeter简单测试流程
java·spring boot·后端
网安密谈1 小时前
SM算法核心技术解析与工程实践指南
后端
bobz9651 小时前
Keepalived 检查和通知脚本
后端
AKAMAI1 小时前
教程:在Linode平台上用TrueNAS搭建大规模存储系统
后端·云原生·云计算
盘盘宝藏1 小时前
idea搭建Python环境
后端·intellij idea
喵手1 小时前
Spring Boot 项目基于责任链模式实现复杂接口的解耦和动态编排!
spring boot·后端·责任链模式
大鹏dapeng1 小时前
使用gone v2 的 Provider 机制升级改造 goner/xorm 的过程记录
后端·设计模式·go
雷渊1 小时前
介绍一下RocketMQ的几种集群模式
java·后端·面试
讳疾忌医_note1 小时前
别再错用 C++ 线程池!正确姿势与常见误区大揭秘
后端
快乐源泉1 小时前
【设计模式】参数校验逻辑复杂,代码长?用责任链
后端·设计模式·go