English version: Order reliability: idempotency, unknown states, and reconciliation。本文是交易类 App 架构系列第二篇。
你点了提交,请求发了出去,然后什么都没回来。
这就是问题的全部。它没有干净的解法,只有一个选择:你愿意承担哪一种失败。忽略这一点的客户端会带着一个只在网络糟糕时才出现的缺陷上线,而那恰恰是钱最要紧的时候。
你必须绕着设计的那个不可能性
当一个请求超时,从客户端看,两种情况完全无法区分:服务端根本没收到,或者服务端已经执行了但应答丢了。没有任何一条你能发出的消息、也没有任何一个你能调的超时值,可以把这两种情况分开。这个信息在你这一侧并不存在。
所以你只能选:
至多一次。 从不重试。你永远不会重复下单,但有时会悄无声息地漏掉用户要下的那一单。对涉及钱的场景,这不可接受,因为漏单是不可见的,用户是从行情里发现的。
至少一次。 一直重试直到被确认。你永远不会丢单,但有时会下两次。单独看同样不可接受,但这一种是可以修的。
你选至少一次,再用幂等键消除重复:客户端为这次“意图”生成一个标识,服务端用它识别重复。第一次到达时执行;此后携带同一个键的每一次到达,都直接返回最初的结果,不再执行。重试因此在构造上就是安全的。“恰好一次”不是在传输层实现的(在那里它不可能),而是在语义层实现的,代价只是一份仔细的簿记。
在发送之前生成键,并且落盘
正是这个细节,区分了“能用的设计”和“看起来一模一样但不能用的设计”。
这个键必须在第一次网络调用之前生成并写入持久化存储,作为创建订单意图的一部分。不是在请求构造器里,也不是等到要重试时才补。
原因在于你要防的那种故障。如果 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”,SOSP 1975——两军问题:在不可靠信道上达成一致是不可能的,因此恰好一次投递也不可能。
- P. Helland,“Idempotence Is Not a Medical Condition”,ACM Queue 10(4),2012——至少一次 + 幂等处理,以及为什么超时必须被建模为未知状态。
- J. H. Saltzer, D. P. Reed, D. D. Clark,“End-to-End Arguments in System Design”,ACM TOCS 2(4),1984——保证只在能够验证它的端点上才是真的。
- L. Lamport,“Time, Clocks, and the Ordering of Events in a Distributed System”,CACM 21(7),1978——按逻辑版本排序,永远不按墙钟或到达顺序。
- Stripe,Idempotent requests;IETF HTTPAPI 工作组,“The Idempotency-Key HTTP Header Field”——客户端铸造幂等键在生产中的形态。
- M. Fowler,“Event Sourcing”,2005——从追加式事件记录推导当前状态。