- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 引言 — "恰好一次"的诱惑
- 投递保证的三个等级
- 为什么 exactly-once 投递不可能
- 两个将军问题 — 共识的不可能性
- 不谈投递,谈处理 — 思路的转变
- 幂等性 — 做多少次结果都一样
- 去重 — 记住已经见过的东西
- 事务性发件箱 — 把处理和发布合而为一
- Kafka 的 exactly-once 到底保证了什么
- 支付系统中的实战
- 实践指南小结
- 结语
- 参考资料
引言 — "恰好一次"的诱惑
第一次接触消息队列或支付系统时,几乎所有人都会许下同一个愿望:"希望每条消息都能恰好被处理一次。" 支付应该只被扣一次款,邮件应该只发一次,库存应该只被扣减一次。扣两次款,客户就会愤怒;一次都没扣,公司就要亏钱。所以我们渴望 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 也是建立在这些原理之上的。它是在封闭的流处理内部,用幂等生产者和事务实现了这些概念,而一旦有外部副作用介入,我们就必须再次用幂等性和去重来守住边界。
所以实务上的智慧,不是去努力"恰好被投递一次",而是设计成"无论到达多少次,都只留下一次效果"。这个视角的转变,正是让支付与消息传递的正确性从幻觉变成工程学的关键。
参考资料
- "Two Generals' Problem" (概念整理): https://en.wikipedia.org/wiki/Two_Generals%27_Problem
- Confluent, "Exactly-Once Semantics in Apache Kafka": https://www.confluent.io/blog/exactly-once-semantics-are-possible-heres-how-apache-kafka-does-it/
- Apache Kafka 官方文档, "Delivery Semantics": https://kafka.apache.org/documentation/#semantics
- Stripe, "Idempotent Requests": https://docs.stripe.com/api/idempotent_requests
- Martin Kleppmann, "Designing Data-Intensive Applications" (第 9 章、第 11 章)