# 订单可靠性：幂等、未知态与对账

> “恰好一次”并不存在。对于下单这种不可逆的动作，这个不可能性会逼出一套具体设计：在发送之前就生成的幂等键、一个显式的未知状态，以及用对账代替盲目重试。

- Canonical (HTML): https://kaguc.com/blog/order-reliability-zh/
- Date: 2026-08-28


*English version: [Order reliability: idempotency, unknown states, and reconciliation](/blog/order-reliability/)。本文是[交易类 App 架构](/blog/trading-app-architecture-zh/)系列第二篇。*

你点了提交，请求发了出去，然后什么都没回来。

这就是问题的全部。它没有干净的解法，只有一个选择：你愿意承担哪一种失败。忽略这一点的客户端会带着一个只在网络糟糕时才出现的缺陷上线，而那恰恰是钱最要紧的时候。

## 你必须绕着设计的那个不可能性

当一个请求超时，从客户端看，两种情况完全无法区分：服务端根本没收到，或者服务端已经执行了但应答丢了。没有任何一条你能发出的消息、也没有任何一个你能调的超时值，可以把这两种情况分开。这个信息在你这一侧并不存在。

所以你只能选：

**至多一次。** 从不重试。你永远不会重复下单，但有时会悄无声息地漏掉用户要下的那一单。对涉及钱的场景，这不可接受，因为漏单是不可见的，用户是从行情里发现的。

**至少一次。** 一直重试直到被确认。你永远不会丢单，但有时会下两次。单独看同样不可接受，但这一种是**可以修的**。

你选至少一次，再用**幂等键**消除重复：客户端为这次“意图”生成一个标识，服务端用它识别重复。第一次到达时执行；此后携带同一个键的每一次到达，都直接返回最初的结果，不再执行。重试因此在构造上就是安全的。“恰好一次”不是在传输层实现的（在那里它不可能），而是在**语义层**实现的，代价只是一份仔细的簿记。

## 在发送之前生成键，并且落盘

正是这个细节，区分了“能用的设计”和“看起来一模一样但不能用的设计”。

这个键必须在**第一次网络调用之前生成并写入持久化存储**，作为创建订单意图的一部分。不是在请求构造器里，也不是等到要重试时才补。

原因在于你要防的那种故障。如果 App 在请求途中被杀死，重启后你必须能问一句“那一单到底成没成”，而你能问的前提是，这次尝试的身份在崩溃中活了下来。一个在发送时才在内存里生成的键会随进程一起消失，于是你的恢复路径会造出一个**新的**键，服务端会正确地把它当作一笔**新订单**。你等于用更复杂的步骤实现了重复提交。

所以顺序是：创建意图，连同它的键和待定状态一起落盘，然后再发送。之后的每一次重试，无论在本进程还是下一个进程里，都复用这个键。

## 超时是一种状态，不是一种失败

最常见的建模错误是把它当布尔值：提交了，或者没提交。而现实里有第三个值。

把订单建成一个状态机，其中有一个显式的**未知**态，在超时或语义不明的错误时进入。未知的含义是：某个动作可能发生了也可能没有，而客户端没有资格去猜。

重要的是在未知态里做什么：**既不盲目重试，也不报告失败，而是去问。** 用幂等键查询订单状态，让权威答案来消解这个歧义。只有当服务端确认它从未见过这个请求时，你才重发。

盲目重试只有在你的幂等保证端到端严丝合缝时才安全，其中包括跨 App 重启、跨服务端发布。而“先查询再决定”在任何情况下都安全。对一个不可逆的动作，应当优先选择那个安全性不依赖于别处保证完美的设计。

超时就告诉用户“失败”，是同一类错误的反方向版本：用户会手动再提交一次，于是重复单变成由你的界面造成的，而不是网络层。一个诚实的待定状态并不是更差的体验，它是唯一真实的那个。

## 服务端是唯一真相

本地的“已发送”不等于“已受理”，“已受理”不等于“已成交”。只有那个持有钱的端点才能作出这些判断。这就是端到端原则，用在一个它真正有分量的领域里。

对客户端有两个推论：

**乐观更新必须被标注为乐观的。** 立刻显示一笔待定订单是好的；把它显示成“已下单”则是一个谎言，而你偶尔要当着一个已经据此行动的人的面把它收回。

**每次重连和每次冷启动都要对账。** 拉取权威的订单列表，让它覆盖本地状态。不要做聪明的合并。客户端那份副本是缓存，而一个会和源头争辩的缓存，只是一个带持久化的 bug。

## 顺序：认版本，不认到达时间

同一笔订单的状态更新可能乱序到达，原因包括重试、多通道、重连。如果你无脑应用最后到达的那条，一条陈旧的“提交中”就会覆盖掉新鲜的“已成交”，于是界面把一笔已完成的订单显示成在途。

给每笔订单一个由**服务端**分配的单调递增版本号或序列号，并丢弃任何版本不比当前更新的更新。按版本做最后写入胜出，绝不按到达时间。客户端时钟不能替代它：它是错的，而且两个方向都会错。

同时要让非法迁移发出声音。如果你收到“已成交 → 提交中”，那不是一个需要渲染的状态，而是一个需要记录的缺陷。默默接受不可能的迁移，是数据 bug 演变成用户截图的常见路径。

## 存事件，而不只是当前状态

保留一份只追加的记录：意图已创建、已提交、已确认、已成交，并由它推导出当前状态。

你需要它有三个理由，其中只有一个和监管有关。它让客服和工程能对“09:31:04 这个 App 到底做了什么”给出真实答案；它把对账变成一次比较，而不是一次猜测；它还能把复盘从考古变成阅读。

原地修改一个状态字段，恰好销毁了你在最需要它的那天所需要的信息。

## 什么时候可以砍掉这些

以上全部，都是为**不可逆性**付的价钱。把不可逆性拿掉，它就坍缩了。

如果动作是可逆的，比如保存草稿、点赞、改偏好设置，那么一个带重试的普通请求就是正确的；幂等键加状态机加事件日志加对账，是拿着机器去找问题。

采用这套设计的触发条件不是“我们要处理数据”，而是“**存在一个用户无法自行撤销的动作**”：支付、下单、转账、不可逆的账户变更。有这样的动作时，整套都要建；没有的时候，一个都别建。


## 参考

- E. A. Akkoyunlu, K. Ekanadham, R. V. Huber，["Some constraints and tradeoffs in the design of network communications"](https://doi.org/10.1145/800213.806523)，*SOSP* 1975——两军问题：在不可靠信道上达成一致是不可能的，因此恰好一次投递也不可能。
- P. Helland，["Idempotence Is Not a Medical Condition"](https://queue.acm.org/detail.cfm?id=2187821)，*ACM Queue* 10(4)，2012——至少一次 + 幂等处理，以及为什么超时必须被建模为未知状态。
- J. H. Saltzer, D. P. Reed, D. D. Clark，["End-to-End Arguments in System Design"](https://web.mit.edu/Saltzer/www/publications/endtoend/endtoend.pdf)，*ACM TOCS* 2(4)，1984——保证只在能够验证它的端点上才是真的。
- L. Lamport，["Time, Clocks, and the Ordering of Events in a Distributed System"](https://lamport.azurewebsites.net/pubs/time-clocks.pdf)，*CACM* 21(7)，1978——按逻辑版本排序，永远不按墙钟或到达顺序。
- Stripe，[Idempotent requests](https://docs.stripe.com/api/idempotent_requests)；IETF HTTPAPI 工作组，["The Idempotency-Key HTTP Header Field"](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/)——客户端铸造幂等键在生产中的形态。
- M. Fowler，["Event Sourcing"](https://martinfowler.com/eaaDev/EventSourcing.html)，2005——从追加式事件记录推导当前状态。

---

*上一篇：[实时数据管线](/blog/realtime-data-pipeline-zh/) · 下一篇：[本地优先与增量同步](/blog/local-first-sync-zh/)*

