Skip to content
Published on

Exactly-once 是幻觉吗:支付与消息传递的正确性

分享
Authors

引言 — "恰好一次"的诱惑

第一次接触消息队列或支付系统时,几乎所有人都会许下同一个愿望:"希望每条消息都能恰好被处理一次。" 支付应该只被扣一次款,邮件应该只发一次,库存应该只被扣减一次。扣两次款,客户就会愤怒;一次都没扣,公司就要亏钱。所以我们渴望 exactly-once(恰好一次)。

但是学习分布式系统之后,很快就会遇到一句令人困惑的话:"exactly-once 投递是不可能的。" 这句话看起来正面否定了我们的愿望。可与此同时,像 Kafka 这样的系统却又宣称支持 "exactly-once semantics"。到底哪个才对?

本文的目标就是厘清这团迷雾。核心在于一个区分:exactly-once「投递(delivery)」不可能,但 exactly-once「处理(processing)」可以达成。 把这句话彻底拆解开来,支付与消息传递的正确性该如何设计,也就清楚了。

如果想亲眼看看与这些概念相连的消息流,可以在本站的消息队列演练场里可视化体验。

投递保证的三个等级

当一个消息系统说它会"投递消息"时,这份保证其实分为三个等级。精确区分这三者,是所有讨论的起点。

At-most-once(最多一次)。 消息要么被投递一次,要么根本不被投递。绝不会有重复,但可能丢失。发送方是"发出去就不管,也不确认"的方式。快速、简单,但只适用于可以承受丢失的数据(例如大量的指标采样、部分日志)。

At-least-once(至少一次)。 消息一定会被投递至少一次。不会丢失,但可能重复。 因为发送方会一直重传,直到收到确认应答(ack)。这是大多数实用消息系统的默认行为。用"可能重复"换取"绝不丢失"。

Exactly-once(恰好一次)。 消息恰好被投递一次,既不丢失也不重复。听起来最理想,但正如接下来会看到的,在"投递"这个层面上纯粹地达成这一点,原理上是不可能的。

  at-most-once : 0 或 1 次   (可能丢失,不会重复)
  at-least-once: 1 次以上    (不会丢失,可能重复)
  exactly-once : 恰好 1 次   (不丢失也不重复 — 理想状态)

这里有一个重要的直觉:at-most-once 和 at-least-once 是完全相反的权衡。一个宁可容忍丢失也要消灭重复,另一个宁可容忍重复也要消灭丢失。而现实中可靠的系统几乎总是选择 at-least-once,因为丢失通常远比重复更致命。那么剩下的问题就是:"该怎么让那份重复变得无害?"

为什么 exactly-once 投递不可能

exactly-once 投递不可能的原因,并不是什么深奥的定理,而是源自一个极其简单的情形:网络会失败,而失败是无法区分的。

来看发送方 A 向接收方 B 发送消息并等待 ack 的情形。A 没有收到 ack。但 A 绝对无法区分下面两种情况。

  情况 1: 消息没有到达 B
    A --消息--X (丢失)     B
    -> B 没有收到。A 应该重传。

  情况 2: 消息到达了,但 ack 丢失了
    A --消息--> B (已处理!)
    A <--ack--X (丢失)
    -> B 已经收到了。如果 A 重传,就会重复!

在 A 看来,这两种情况完全一样:"我发了一条消息,但没有收到 ack。" 这时 A 陷入了两难。

  • 如果重传:情况 1 得到解决,但在情况 2 中,B 会收到两次消息(重复)。→ at-least-once
  • 如果不重传:情况 2 是安全的,但在情况 1 中,消息会永远丢失。→ at-most-once

也就是说,"ack 没来时要不要重传"这一个选择,就决定了 at-least-once 和 at-most-once 的分野。而介于两者之间的那个完美点——"既不丢失也不重复的投递"——由于这种根本性的模糊,在投递本身这个层面根本无法存在。只要网络随时可能断开,发送方就没有办法确切知道"对方是否收到了"。

两个将军问题 — 共识的不可能性

最鲜明地展示这种模糊性的经典问题,是两个将军问题(Two Generals' Problem),一个揭示分布式共识根本困难的思想实验。

两位将军各自驻扎在一座山丘上,两山之间的山谷里有敌军。两支军队只有同时进攻才能获胜。只要有一方单独进攻,就会失败。将军们通过派信使穿过山谷来通信,但信使可能被敌军抓获而消失。

  将军 A: "黎明时进攻" --信使--> 将军 B
    (如果信使被抓? A 不知道 B 是否收到)

  将军 B 收到并回复 "同意!" --信使--> 将军 A
    (如果这个信使被抓? B 不知道 A 是否收到了自己的同意)

  A 收到同意后回复 "确认!" --信使--> ...
    (这个也可能被抓。确认的确认的确认,没完没了...)

核心在于:无论交换多少条确认消息,发送方永远无法确定最后一条消息是否送达——因为最后一名信使也有可能被抓获。所以两位将军永远无法通过有限次的消息交换,达成"我们双方都确切知道"这种共同知识(common knowledge)。这是一个已被证明的不可能性。

两个将军问题给我们的教训很明确。在不可靠的信道上,"确定的、一次性的共识"是不可能的。所以我们不应该去追求确定性,而要找到一条在另一个层面解决问题的迂回之路。这条迂回之路,就是接下来要讲的内容。

不谈投递,谈处理 — 思路的转变

这里发生了决定性的转变。既然 exactly-once 投递不可能,那就换一个目标。我们真正想要的,不是"消息恰好被投递一次",而是"其结果恰好被反映一次"。

客户想要的并不是"支付消息恰好穿越网络一次",而是"扣款恰好只发生一次"。这两者是不同的。即使消息在网络上传了两次,只要扣款只发生一次,客户就会满意。

这就是 exactly-once processing(恰好一次处理)的思路。让投递保持 at-least-once(也就是允许重复),转而在接收端把重复无害地吸收掉,使最终效果恰好是一次。

  放弃: exactly-once 投递   (原理上不可能)
  采用: at-least-once 投递  (可能重复,但不丢失)
        + 在接收端吸收重复
        = exactly-once 处理  (最终效果是一次)

这个转变的美妙之处在于,不去强行做不可能的事,而是用可能实现的东西组合出想要的结果。 这个组合中的核心要素有三个:幂等性、去重、事务性发件箱。我们逐一来看。

幂等性 — 做多少次结果都一样

exactly-once 处理最重要的武器是幂等性(idempotency)。一个操作是幂等的,意味着做一次和做多次,结果是一样的。

用几个例子找找感觉。

  • "把余额设置为 200" → 幂等。无论执行多少次,结果都是余额 200。
  • "从余额里扣除 100" → 不幂等。执行两次,就会扣掉 200。
  • "把这笔订单标记为'已支付'状态" → 幂等。无论做多少次,状态都只有一个'已支付'。

关键在于,即使发生重复投递,只要处理是幂等的,重复就会自动变得无害。 所以设计 exactly-once 处理的第一步,就是"尽可能让操作幂等"。

问题出在"扣除 100"这类本质上不幂等的操作上。支付和库存扣减大多属于这一类。这种情况下,如果无法把操作本身改成幂等的,就必须"记住是否已经处理过"来过滤重复。这就是下一个要素——去重。

去重 — 记住已经见过的东西

把本质上不幂等的操作变成幂等的标准技巧,就是去重(deduplication)。思路很简单:给每条消息(或每个请求)附上一个唯一标识符,接收端记住"已经处理过的标识符列表",一旦同样的标识符再次出现,就直接忽略。

  给请求附上唯一键:  idempotency-key: "abc-123"

  接收端处理:
    IF "abc-123" 是否已经处理过?
      YES -> 什么都不做,返回上次的结果 (吸收重复)
      NO  -> 处理它,并记录 "abc-123 已处理"

支付 API 中常见的幂等键(idempotency key)正是这个东西。客户端在支付请求中带上一个唯一键发送,服务端就用这个键来判断是否重复。即使因为网络问题客户端重传了同一个请求,服务端看到同样的键,就会明白"啊,这个已经处理过了",不再创建新的扣款,而是返回上一次的结果。

这里有一个微妙但决定性的地方。"已处理的记录"和"实际处理"必须原子性地一起发生。 如果处理完了,却在留下"已处理"记录之前挂掉,下一次重试就会再处理一遍。反过来,如果只留下了记录,却在处理之前挂掉,处理本身就会丢失。所以去重必须和处理绑定在同一个事务里,这正是第三个要素登场的地方。

事务性发件箱 — 把处理和发布合而为一

前面一篇文章中讲过的发件箱模式(outbox pattern),在这里再次扮演核心角色。在 exactly-once 处理中,发件箱把两件事原子性地绑在一起:一是"消息处理的结果(状态变更)",二是"完成了这次处理的记录(以及如果需要,接下来要发布的下一条消息)"。

典型的消费者 exactly-once 处理流程是这样的:

  接收消息 (消息带有唯一 id)
    |
    v
  在同一个本地事务中:
    BEGIN
      IF 这个消息 id 已经在已处理表里 -> 什么都不做,结束
      业务处理 (例如状态变更)
      INSERT 已处理的消息 id             -- "已经见过"的记录
      INSERT INTO outbox (下一个事件)    -- 如果需要,写入下一条消息
    COMMIT   -- 处理、记录、发布要么全部一起生效,要么全部不生效

这个结构的力量在于,"已处理的记录"和"实际处理"位于同一个数据库的同一个事务里,永远不可能出现偏差。 事务一旦提交,三者全部生效;一旦失败,三者全部作废。即使中途挂掉,状态也始终一致。而发布依然是 at-least-once 的(发件箱模式本来就是这样),但只要下一个消费者也具备同样的幂等/去重结构,整条链路就能在每一环都维持"恰好一次的效果"。

总结一下,exactly-once 处理是三个要素的组合:

要素作用
at-least-once 投递防止丢失(容忍重复)
幂等性 / 去重让重复变得无害
事务性发件箱把处理和记录原子性地绑在一起

Kafka 的 exactly-once 到底保证了什么

说到这里经常会有人问:"可是 Kafka 不是支持 exactly-once 吗?" 没错。只是需要准确了解它到底意味着什么。

Kafka 的 exactly-once semantics(EOS)并不是靠魔法打破了物理定律,而是把上面那些原理在协议层面实现出来。核心是两个维度。

  • 幂等生产者(idempotent producer):生产者给每条消息附上一个序列号,broker 检查这个序列号,从而在 broker 日志这一层消除因重传导致的重复记录。也就是说,即使生产者因为网络问题重传,日志里也只会留下一份。
  • 事务(transactions):跨多个分区的写入,以及消费位移(offset)的提交,被绑定成一个原子事务。这样一来,"读取消息(consume) → 处理 → 写出结果(produce) → 提交位移"这整条链路,要么全部一起确定,要么全部一起取消。

这里有一个决定性的条件。Kafka 的 EOS 真正成立,是在"从 Kafka 读、写回 Kafka这个封闭的世界"之内——因为处理、位移提交和输出全都进入了 Kafka 事务。可一旦处理过程对外部系统(例如调用支付网关、发送邮件、写入另一个数据库)产生了副作用,那个外部效果就在 Kafka 事务之外了。

  Kafka EOS 能完整覆盖的情况:
    读 Kafka -> 处理 -> 写 Kafka (+位移)  <- 全部在一个事务里

  EOS 自动覆盖不到的情况:
    读 Kafka -> 调用外部支付 API  <- 这次调用在 Kafka 之外!
    -> 这里依然需要幂等键 / 去重

所以即便是 Kafka 的 exactly-once,一旦出现外部副作用,最终还是要靠我们前面看到的幂等性和去重来守住这道边界。Kafka EOS 很强大,但不是万能的——它的强大,是在"封闭流处理"这个前提条件之内。误解这个前提,就会陷入"用了 Kafka,重复的问题就此终结"这种危险的错觉。

支付系统中的实战

把这些原理应用到最敏感的领域——支付上。因为在支付里,重复就等于钱,所以 exactly-once 处理的原则格外重要。

  • 要求每笔支付请求都带上幂等键。 让客户端生成一个唯一键(通常基于订单 id),附在支付请求里,服务端用这个键拦截重复扣款。这是唯一可靠的方法,能防止客户端在网络超时后重试时造成二次扣款。
  • 把"已扣款的记录"和"扣款"本身原子性地绑在一起。 外部支付网关调用发生在事务之外,所以这一点很微妙。常见的模式是:先记录"请求已发起"(pending),收到网关响应后再确定最终状态,中间的重试则靠幂等键来判断。
  • Webhook 接收也要做到幂等。 支付网关发来的支付完成 webhook 通常是 at-least-once 的,同一个事件可能会到达好几次。webhook 的处理也必须基于事件 id 做去重。
  • 建立对账(reconciliation)机制。 无论设计得多么周全,分布式系统总有可能出现偏差。定期把自己的记录和支付方的记录做对账、找出不一致之处,是最后一道安全网。

核心信息是这样的:在支付领域,"exactly-once"不是网络会像变魔法一样自动保证的东西,而是我们通过幂等键、去重和对账创造出来的性质

实践指南小结

把前面的内容压缩一下。

要警惕任何承诺"exactly-once 投递"的系统。 纯粹的 exactly-once 投递原理上不可能。这类承诺通常要么是把"at-least-once + 去重"换了个说法,要么只在某个特定的封闭条件下成立。务必确认清楚这个条件。

把 at-least-once 当作默认选择接受下来。 丢失通常比重复更致命。所以可靠的系统会选择 at-least-once,把剩下的重复问题留在处理层解决。

尽可能把操作设计成幂等的。 "设置"胜过"增减"。如果本质上无法幂等,就用唯一标识符去重。

把去重和实际处理绑定在同一个事务里。 如果"已经见过的记录"和"处理"出现偏差,exactly-once 就会崩塌。发件箱模式提供的正是这种原子性。

把对账作为最后一道安全网。 尤其是在支付这类重复即致命的场景里,通过定期比对捕捉不一致的流程必不可少。

结语

"exactly-once 是幻觉吗?"这个问题的答案是"是,也不是"。exactly-once 投递是幻觉。只要网络可能断开、失败又无法区分,正如两个将军问题所说,不可靠信道上确定的共识就是不可能的。但 exactly-once 处理不是幻觉。接受 at-least-once 投递,用幂等性和去重让重复变得无害,再用事务性发件箱把处理和记录原子性地绑在一起,就真的能构建出最终效果恰好一次的系统。

就连 Kafka 的 exactly-once 也是建立在这些原理之上的。它是在封闭的流处理内部,用幂等生产者和事务实现了这些概念,而一旦有外部副作用介入,我们就必须再次用幂等性和去重来守住边界。

所以实务上的智慧,不是去努力"恰好被投递一次",而是设计成"无论到达多少次,都只留下一次效果"。这个视角的转变,正是让支付与消息传递的正确性从幻觉变成工程学的关键。

参考资料