跳到主要内容

第 17 章 交易系统:当业务开始涉及金钱

副题:状态机、事务、幂等、重试、对账——把"不能出错"从愿望变成机制

第 15 章我们跟着一次请求走通了全链路,案例产品在阶段 1 上线了:内容社区跑了起来,有人发帖、有人评论。期间社区先经历了一轮用户增长,也扛过了第一波性能危机(那是上一章的故事),系统站稳之后,业务走向了收钱——阶段 3 开始。本章跟着案例经历交易上线的第一周:三个事故,五道防线,一条从同步走向异步的认知链。

本章要建立的认知

  1. 交易系统的正确性不靠"仔细",靠机制——每类错误都有一道对应的防线:状态机、事务、幂等、重试、对账;
  2. 这些防线不是孤立的技巧,它们解决的是同一个事实:网络不可靠、用户会连点、请求会重复——错误是环境的必然,不是态度的疏漏;
  3. 交易系统是"同步到异步"认知链的起点:单体 → 同步调用 → 问题出现 → 异步 → 消息 → 服务拆分 → 事件驱动——本章走到"消息"为止,第 26 章回收。

17.1 上线的第一周

阶段 3 的故事从一次产品决策开始:内容社区的数据不错,用户开始问"能不能在上面买东西"。于是产品经理提了一个需求:"上架商品,支持'立即购买'。"开发两周,上线。

先解释一个看着有点简陋的设计:为什么是"立即购买"而不是购物车?第 4 章砍需求时,我们已经用复杂度预算把这个决定做掉了——购物车意味着"订单 = 多个商品 + 金额重新计算",优惠券意味着"金额计算有规则引擎",每个都是新的状态组合和对账场景,所以第一版只做一单一件的立即购买。这里只补一句交易语境的说明:"立即购买"不是产品偷懒,是让交易链路先用最简形态跑通——购物车、优惠、秒杀这些复杂度,留给交易验证成功之后的版本(第 6 章建模时为阶段 3 预留的订单/支付对象,现在到了启用的时候)。

第一周,客服(其实就是开发者自己)收到三起投诉。

投诉一:扣了两次款。 用户小明看中一款二手相机,点"立即购买",页面卡了一下,他又点了一次。结果收到两条扣款短信、两个订单。

投诉二:付了钱,订单还挂着。 用户小红下单后跳转支付,钱扣了,但回 App 一看,订单还停在"待支付"。"我钱都付了,你们订单没动!"

投诉三:超卖。 一个卖家上架了 10 件限量手办,赶上平台第一次小促销,叠加论坛有人推荐,一小时涌进大量订单。结算时发现:10 件库存,卖出去 32 件。卖家发不了货,用户退款维权,投诉炸了。

如果你第一次接触交易系统,第一反应可能是:这三个问题都是 bug,修掉就好——重复点击加个防抖、支付回调重查一次、库存判断写严一点。

这个反应,正是交易系统最容易踩的坑。这三个问题不是 bug,是规律

  • 用户会连点——因为网络有延迟,界面没响应,人的本能是再点一次;
  • 支付回调会丢——因为网络会抖动、渠道会故障、服务器会重启;
  • 并发会超卖——因为"检查库存够不够"和"扣减库存"之间,隔着不可控的时间差。

把三个投诉背后的机制拆开看,是三个根源:

  • 投诉一是请求重复:同一个下单意图,被发送了两次。防抖能挡住"人类手速",挡不住"网络重试"——客户端在网络超时后会自动重发,浏览器也可能重复提交表单。只要链路里存在任何一次重试,重复请求就是必然。
  • 投诉二是投递丢失:支付渠道的通知在网络上丢了。网络协议只保证"尽力而为",不保证"一定送达"——数据包会丢、服务器会重启、进程会崩溃。只要依赖任何一次外部通知,丢失就是必然。
  • 投诉三是检查与修改的缝隙:"看看库存够不够"和"扣掉库存"之间隔着时间,两个请求都能通过检查。只要存在"先查后改",并发下的超卖就是必然。

三类问题,三个根源:重复、丢失、缝隙。交易系统的所有机制,都是冲着这三个根源去的——记住这一点,本章的防线清单就很好理解。为了方便后面引用,把它写成一张错误清单

根源典型场景后果
重复用户连点、网络重试、回调重放、消息重投重复扣款、重复订单、重复发货
丢失回调丢失、通知丢失、任务丢失订单卡死、状态永不更新
缝隙先查后改、检查与修改分离超卖、负数库存、双重处理

17.9 的对应表,就是这张错误清单的"机制版答案"——读完全章再回来看一眼这张清单,看每一行是否都有了着落。

网络不可靠、用户不可控、并发不可避免——这三件事是交易系统的环境,不是意外。**在交易系统里,"仔细一点"不是解决方案,机制才是。**本章就来看这些机制:每一类错误,对应一道防线。

为什么交易系统不能像内容社区那样"先上线再说"?因为两类业务的错误成本完全不同。内容错了——标题打错、评论误删——改回来就行,损失的是体验;交易错了——多扣了钱、订单丢失、超卖无法发货——改回来要付出真金白银,而且不一定改得回来:多扣的钱要退款流程,丢了的订单要找证据,超卖要挨个协商。错误成本的不同,决定了系统设计的不同:内容系统可以容忍"事后补救",交易系统必须"事前设防"。

17.2 第一道防线:订单状态机

先处理一个看起来最不起眼、实际上最基础的问题:订单到底是什么?

在数据库里,订单是一行记录:order_no、buyer_id、product_id、amount、status……但业务上,订单不是"一行数据",它有一个生命周期:刚创建时是"待支付",支付成功变"已支付",发货后变"已发货",完成交易变"已完成";如果支付失败或超时,则进入"失败"或"已取消"。

图17-1 一级#5首现 图稿占位
订单状态机
订单的一生是状态转移的集合,非法转移必须被拒绝

把生命周期显式地写出来,就是状态机——它定义了三件事:有哪些状态、哪些转移是合法的、每个转移的触发条件。案例的订单状态机如下:

支付成功 ──> PAID ──发货──> SHIPPED ──收货──> COMPLETED
/
创建 ──> PENDING
\
支付失败 ──> FAILED
超时/用户取消 ──> CANCELED

写成转移表更严格:

当前状态事件目标状态合法?
PENDING支付成功(回调)PAID
PENDING支付失败(回调)FAILED
PENDING超时/用户取消CANCELED
PAID发货SHIPPED
SHIPPED确认收货COMPLETED
CANCELED支付成功(回调)PAID❌ 非法

这张表最值钱的是最后一行:**CANCELED 状态下收到支付成功回调,是非法转移,必须被拒绝。**你可能会问:订单都取消了,钱怎么还会付进来?会的——用户取消订单的同时,支付渠道的扣款回调正在路上;网络把两个事件的时间顺序打乱了。状态机的作用就是让系统面对这种乱序时有一个明确的态度:按状态说话,不按时间顺序猜测。

代码上,状态机通常长这样(示意,不追求完整工程):

ORDER_STATUS_TRANSITIONS = {
"PENDING": {"pay_success": "PAID", "pay_fail": "FAILED", "cancel": "CANCELED"},
"PAID": {"ship": "SHIPPED"},
"SHIPPED": {"complete": "COMPLETED"},
"FAILED": {},
"CANCELED": {},
"COMPLETED": {},
}

def transit(order, event):
allowed = ORDER_STATUS_TRANSITIONS[order.status]
if event not in allowed:
raise IllegalTransition(order.status, event) # 非法转移:拒绝
order.status = allowed[event]

为什么状态机是第一道防线?因为后面的每一道防线都要拿状态当判断依据:幂等判断要看"订单是否已处理"(17.5),回调处理要看"订单现在是什么状态"(17.6),对账要看"状态与钱是否匹配"(17.7)。状态乱了,后面所有机制都会跟着乱。先立状态机,再谈其他——这是交易系统的第一纪律。

案例的第一版代码其实没有状态机——订单表里有一个 status 字段,代码里到处直接 UPDATE。第一周的三起事故之外,又出了一次乱序事故:一个用户取消订单后,支付回调才到,代码没检查状态,直接把订单更新成 PAID——订单显示已支付,但用户已经退款了,两边对不上。那次事故之后,状态机才被立起来。这个故事想说明的是:状态机不是"多写几行代码",而是一种约束——它把"状态可以随意改"变成"状态只能在合法路径上走"。没有约束之前,每个开发者都是状态机的维护者,但没有人知道全部规则;有了转移表,规则只有一份,写在代码里。

状态机的价值可以数出三条,后面都会用到。第一,状态是判断的依据:订单能不能取消、回调该怎么处理、能不能发货,全部取决于当前状态——没有状态机,这些判断散落在各处代码里,互相矛盾;有了状态机,判断只有一个入口。第二,非法转移是系统的防火墙:乱序事件(取消后回调才到)会被拒之门外,而不是产生一个矛盾状态——"已取消但已支付"这种状态一旦存在,后面的对账、退款全都无法处理。第三,状态机是团队的语言:产品说"订单已取消",开发知道它对应 CANCELED 且不可逆;客服查订单,看状态就知道下一步该干什么。一份转移表,同时是代码约束、系统防火墙和团队词典。

状态机的代价也要说清:每加一个状态或事件,转移表要改、所有依赖状态的判断要跟着核对——状态机把"随意改"的成本,换成了"每次扩展都要过一遍规则"的成本,这是它该付的代价。

这里要澄清一个常见误解:状态机不是要你把状态存在某个特殊的地方——数据库里仍然只是一个 status 字段,状态机是加在 status 上的规则:所有更新 status 的代码都必须经过转移表。规则在代码层,状态在存储层,各司其职。

还有一个实现层面的问题:CANCELED 是谁触发的?状态机只定义了转移,不定义谁来发事件。"支付失败"由回调触发,"已发货"由卖家操作触发,但"超时取消"没有自然事件——它需要一个定时机制:要么用延迟任务(下单时登记"30 分钟后检查"),要么用定时扫描(定期扫出超时的 PENDING 订单批量转 CANCELED)。案例用的是定时扫描——它和 17.6 的主动查询是同一个机制(定时扫表 + 批量处理),一套代码两处复用。超时取消时要注意顺序:先转状态,再在同一个事务里释放库存(17.4 的"下单时扣 + 超时释放"),顺序反了就会出现"库存释放了订单还在"的缝隙。

17.3 订单状态机与支付状态:两套模型

订单状态机和支付状态是两套东西,这个区分要单独说——它是交易系统建模里最常见的困惑点。订单状态机(业务视角)和支付状态(渠道视角):订单状态机管的是"这笔交易进行到哪一步"(待支付→已支付→已发货→已完成);支付记录有自己的状态(待支付/成功/失败),它描述的是"钱在渠道那边发生了什么"。两者通过 order_id 关联,但语义独立:订单 CANCELED 时,支付记录可能还是"待支付"或"成功"——所以回调处理要查的是支付状态,更新的是订单状态,中间靠 17.6 的状态前置检查衔接。支付状态同样该用状态机管(待支付 → 成功/失败 → 退款中 → 已退款),和订单状态机同构,各自独立演进。把两套状态合并成一张表,是交易系统建模最常见的错误之一。

17.4 第二道防线:事务

超卖事故的根源在哪儿?拆开"下单"这个动作看:

1. 检查库存:SELECT stock FROM product WHERE id = 101; -- 得到 10
2. 扣减库存:UPDATE product SET stock = 9 WHERE id = 101;
3. 创建订单:INSERT INTO order ...;

问题出在第 1 步和第 2 步之间。两个用户同时下单时,可能都读到 stock = 10,都认为自己买到了,然后各自扣减、各自建单——10 件库存就卖出了 2 件。并发越高,偏差越大,32 件就是这么来的。

更糟的是中间崩溃:库存扣了,订单没建成——用户钱付了(或者库存没了)但订单不存在。库存和订单这两步,要么都成功,要么都失败,不能只做一半。

这就是事务(transaction)要解决的问题。数据库的事务保证一件事:一组操作要么全部提交(commit),要么全部回滚(rollback),不存在中间状态——这就是原子性。下单的代码应该长这样:

BEGIN; -- 开启事务
UPDATE product SET stock = stock - 1
WHERE id = 101 AND stock > 0; -- 条件更新:扣减的前提是还有库存
IF 影响行数 = 0:
ROLLBACK; -- 库存不足,整体回滚
RETURN "库存不足";
INSERT INTO order (order_no, buyer_id, product_id, amount, status)
VALUES (...); -- 创建订单
COMMIT; -- 都成功,一起提交

事务的保证可以拆成四件事,就是常说的 ACID:原子性(Atomicity)——一组操作要么全成要么全无,这是事务最核心的承诺;一致性(Consistency)——事务执行前后,数据都满足约束(库存不能为负、金额必须为正);隔离性(Isolation)——并发事务互不干扰,两个事务同时扣库存,不会互相读到中间状态;持久性(Durability)——提交成功的数据不会丢,即使数据库崩溃。这四个性质里,本章主要靠原子性和隔离性吃饭:原子性保证"扣库存+建订单"不拆家,隔离性保证"两个请求同时扣"不乱套。如第 14 章所述,它们的实现原理(日志、锁、MVCC)是数据库自己的事——这里把它们当作"数据库替你兜住的承诺"即可。

这段代码有两个关键点。

第一,扣库存用条件更新,而不是先查再扣UPDATE ... WHERE stock > 0 让数据库在行级锁内完成"检查+扣减"——同一时刻只有一个事务能扣这行库存,后面的请求会排队或冲突。先 SELECT 再 UPDATE 是先查后改,中间永远有缝隙;条件更新把检查和修改合并成一个原子操作,缝隙没了。这是防超卖的第一层机制。

第二,扣库存和建订单在同一个事务里。要么都提交,要么都回滚——不会出现"库存没了订单没建"或"订单建了库存没扣"。第二层机制。

防超卖其实有三个方案,要对比一下,因为它们是同一类问题的三种思路:

  • 条件更新(本章用的):UPDATE ... WHERE stock > 0,把检查和修改合并成一个原子操作。简单、快,适合"库存"这种单行资源。
  • 悲观锁SELECT ... FOR UPDATE,先锁住这行,处理完再释放。逻辑直白,但锁的时间长,并发吞吐低。
  • 乐观锁:UPDATE 时带上版本号,UPDATE ... WHERE id = ? AND version = ?,影响行数为 0 说明被并发改过,重试或报错。适合"改了之后冲突少"的场景。

三个方案都是"检查与修改之间不留缝隙"的不同实现。库存这种高争用、单行资源,条件更新最合适;如果业务是"读多改少、冲突罕见"(比如更新用户资料),乐观锁更划算。这也是一个可以用第 5 章九步模型走的小决策——评价维度是冲突概率和吞吐要求。

那事务能不能解决所有问题?不能。事务有它的边界:锁意味着并发下降(大家都在排队等同一行);长事务意味着数据库压力大;而且有些操作根本无法放进一个事务——比如调用第三方支付渠道,你不可能把"渠道扣款"放进本地数据库事务里(渠道不在你的数据库里)。所以事务解决的是"你控制范围内的多步操作"的一致性,控制范围之外的事,要靠后面的防线。

还有两类场景要特别小心。调用外部系统:支付渠道扣款不可能放进本地事务,所以"扣库存+调渠道+建订单"这三步只能拆开,中间任何一步失败都要靠补偿(退款、恢复库存)而不是回滚——这是本章第一次碰到"控制范围外的一致性"。你可能会想到分布式事务(两阶段提交):它理论上能把跨系统操作捆成一个事务,但协调成本高、可用性差、实现复杂,交易系统的工程实践是放弃它,改用最终一致性 + 对账兜底——这也决定了本章必然走向 17.7 的对账和 17.8 的异步。长事务:一个事务开着十几分钟不提交,数据库的锁、日志、连接全被占着,生产事故多半这么来。事务的正确用法是"短、小、快":只包住必须一起成功的操作,包完立刻提交。

关于事务的完整原理(ACID、隔离级别、锁),如第 14 章所述——这里只需要记住它的用途:把"必须一起成功"的操作捆成一组

最后补一个下单设计里的关键决策:**库存什么时候扣?**两个选择:下单时扣(预占库存),或支付成功时扣。下单时扣的好处是防超卖最彻底——订单建了库存就没了,后面不会出现"付了钱没货";代价是库存可能被"占着不付"的订单锁死(用户下单不支付,库存一直挂着),所以要配超时释放:PENDING 超时转 CANCELED 时,事务里同时把库存加回去。支付时扣的好处是库存不浪费,坏处是支付成功的那一刻才扣库存,高峰期的并发风险更大。案例选的是下单时扣 + 超时释放——对"不能超卖"的优先级更高。这也是一个九步小决策:约束是"超卖不可接受",代价是"需要超时释放机制",正好和状态机的 CANCELED 转移联动。

17.5 第三道防线:幂等

回到投诉一:小明连点两次"立即购买",产生两个订单、两次扣款。事务能解决吗?不能——两个请求各自开事务、各自扣库存、各自建单,都是"合法"的,数据库看不出它们是同一个人的同一次购买。

问题的本质是:同一个业务操作,被请求了两次。网络层不保证"恰好一次"——用户连点会重发、客户端会重试、渠道会重放、消息会重投。交易系统面对重复请求的态度只有一种:同一个操作执行两次,效果必须和执行一次相同。这个性质叫幂等(idempotency)。

幂等怎么实现?最常用的两招。

先感受一下幂等的直觉:数学里 f(f(x)) = f(x) 叫幂等函数——同一个输入算两次,结果不变。HTTP 方法里 GET 天然幂等(读十次和读一次结果一样),POST 天然不幂等(发十次会创建十条)。"把 POST 变成幂等"是交易系统的必修课:让"创建订单"这个 POST 无论执行几次,效果都等于执行一次。注意幂等不是"锁"——它不管两个不同请求的先后,只管同一个请求的重复。

**第一招:唯一键。**给业务操作一个唯一标识,让数据库拒绝第二次。订单的 order_no 就是为这个设计的(第 7 章接口清单里就约定过"POST /v1/orders 带幂等键、客户端生成 order_no"——当时说是接口侧的起点,现在到了实现侧):下单请求带着客户端生成的 order_no,数据库对它建唯一约束——第二次插入同一 order_no 时,数据库报唯一键冲突,系统捕获冲突,返回第一次创建的订单:

尝试插入订单(order_no, ...) # order_no 有唯一约束
捕获 唯一键冲突:
返回 按 order_no 查到的已有订单 # 不重复建

用户连点两次,第一次插入成功,第二次撞唯一键——返回同一个订单,而不是创建第二个。扣款自然也只有一次(第二次请求根本没走到扣款逻辑,它拿到的是已存在的订单)。

这里有个前置问题:**order_no 从哪来?**两个选择:客户端生成(下单前先向服务端要一个预下单号,或客户端自己生成 UUID),或服务端生成。客户端生成的优点是连点两次的请求天然带同一个 order_no(都属于同一次下单动作);服务端生成的缺点是两次请求各自生成单号,反而挡不住重复。所以案例选的是:客户端在下单动作开始时生成 order_no,后续所有重试都带同一个单号——这保证"重复请求"和"原始请求"能被识别成同一个业务操作。

**第二招:状态前置检查。**有些操作没有天然的唯一键(比如"更新订单状态"),就用状态来挡:只有当前状态是 PENDING 的订单才能被支付回调推进到 PAID;订单已经是 PAID,回调再来一次,检查发现状态不对,直接忽略(下面是只演示状态前置的简化版,完整的回调处理见 17.6):

def handle_pay_callback(order, pay_result):
if order.status != "PENDING":
return already_processed(order) # 状态前置:重复回调直接忽略
order.status = "PAID"

状态前置方案有一个经典翻车方式:检查写错顺序。第一版代码有人写成"先更新状态,再检查状态"——更新完再查当然永远是 PAID,重复回调被当成了新事件,状态被反复推进。这类 bug 的本质是把检查放在了修改之后。交易系统的代码审查里,"检查是否在修改之前、且与修改在同一个原子单元内",是最常被盯的一行。

图17-2 图稿占位
幂等判断
同一个请求到达两次,机制只放行一次

幂等这条防线要单独多说几句,因为它解决的场景最多:用户连点(投诉一)、客户端网络重试、支付渠道回调重放、后面消息队列的消息重投(17.8)——四个来源,同一个问题,同一道防线。记住一个判断标准:凡是可能被重复执行的写操作,都必须幂等;不确定会不会重复,按"一定会重复"来设计。

最后划一下幂等的边界:幂等管的是"同一个请求重复到达",管不了"两个不同请求同时争抢同一资源"——两个用户同时买最后一件库存,幂等判断都会通过,最后靠的是 17.4 的事务(条件更新)。防线分工要清楚:事务管并发,幂等管重复;把幂等当并发工具用(或者反过来),都会在某个场景下漏事。

幂等还有一个配套动作:重放测试。上线前把同一个下单请求(同一个 order_no)发两次,断言只产生一个订单;把同一个回调发三次,断言订单状态只被推进一次。这类测试不需要什么框架,压测工具或脚本就能做,但它能提前暴露"唯一键没建对""状态前置检查顺序写反"这类问题——交易系统的测试里,幂等重放是成本最低、收益最高的一项(第 18 章会展开测试体系)。

17.6 第四道防线:回调、重试与主动查询

投诉二:小红付了钱,订单停在"待支付"。钱在支付渠道那边扣成功了,但我们的系统不知道。

支付渠道通知商家的方式,是回调(callback):用户付款成功后,渠道向商家服务器发起一个 HTTP 请求(带着支付结果和签名),商家收到后更新订单状态。回调是异步的——渠道不会等你处理完再返回给用户,它把通知"投递"给你,处理是商家自己的事。

一个自然的疑问:为什么支付渠道不用更"实时"的方式通知,比如长连接推送?因为回调的本质是投递协议——渠道要对海量商家做通知,长连接要求商家侧保持常驻连接,成本和可靠性都不如"HTTP 回调 + 重试"简单;而且回调的语义对渠道最省事:发出去,对方确认,完事。技术上更实时的方案存在,但交易系统选方案的标准是可靠性优先于实时性——回调 + 重试 + 轮询兜底,实时性损失几秒,可靠性有完整保障。

回调机制有一个直白的现实:投递不保证送达。渠道的回调可能因为网络抖动丢失、可能因为你的服务器刚好在重启而失败。所以回调协议普遍带重试:渠道会按策略反复重发回调(几分钟、几小时、几天),直到商家确认收到。

重试之外还有一个前置问题:怎么确认回调是真的?渠道的通知经过公网,任何人都有可能伪造一个"支付成功"的请求打到你的回调接口——伪造成功,你就发货了,钱没到。所以回调协议普遍带签名:渠道用私钥对回调内容签名,商家用渠道公钥验签,验不过直接丢弃。验签是回调的第一道闸,状态机是第二道(17.2 的非法转移),幂等是第三道(17.5 的重复处理)——一个回调处理函数叠三道防线,这就是交易系统的常态。

重试还有一个要防的反噬:重试风暴。渠道重发回调,如果你的处理接口响应慢,渠道会更快地重发,回调越积越多,把接口打爆。所以回调接口的响应协议要明确:处理成功返回"SUCCESS"(渠道停止重试),验签失败或重复返回"ALREADY"(渠道不再重试),只有处理失败才返回错误(渠道按退避策略重试)。渠道侧的重试间隔通常带指数退避(1 分钟、5 分钟、30 分钟……),商家侧要做好承接——幂等 + 快速响应,就是回调风暴的两块盾牌。

一个完整的回调处理长这样(示意):

def handle_pay_callback(request):
if not verify_signature(request): # 1. 验签:假的直接丢弃
return "INVALID"
order = get_order(request.order_no) # 2. 查订单
if order is None or order.status != "PENDING":
return "ALREADY_PROCESSED" # 3. 幂等/状态前置:重复回调忽略
if request.amount != order.amount:
raise AmountMismatch() # 4. 金额一致性:回调金额必须等于订单金额
transit(order, "pay_success") # 5. 状态机内更新(非法转移会被拒绝)
return "SUCCESS"

第四步的金额一致性检查很容易被漏:回调说支付成功,但金额和订单对不上(渠道 bug、篡改、优惠计算错误)——不检查就更新,订单状态对、钱不对,只能等对账兜底。能早挡的,别等对账。

重试带来的新问题:同一个回调可能收到很多次。这正好回到 17.5——回调处理必须幂等:验签通过后,检查订单状态,只有 PENDING 才能转 PAID,重复回调直接返回"已处理"。渠道重试 + 商家幂等,一对组合把"回调会丢"和"回调会重"两个问题同时解决。

但重试也不保证一定送达。所以还有第二层兜底:主动查询。商家定期(比如每 5 分钟)主动向渠道查询"这些 PENDING 订单到底付了没"——"你不通知我,我去问你"。查到已支付,就本地补一次状态更新(同样走幂等)。

这里可以做一个取舍总结:回调为主、轮询兜底,是支付通知的标准形态。纯回调的问题是投递不可靠,纯轮询的问题是延迟和成本(每 5 分钟查一次全量 PENDING,渠道接口压力大)。所以两者的分工是:回调负责"及时"(用户付完几秒内更新),轮询负责"兜底"(回调丢了也能在 5 分钟内收敛)。这个"主 + 兜底"的双通道模式,在后面很多地方会反复出现——缓存与数据库、消息队列与对账,都是同一个思路。

主动查询的具体做法:定时任务扫出"创建超过 X 分钟仍 PENDING 且未超时取消"的订单,批量向渠道查询支付状态;查到已支付就补更新(走同一套回调处理逻辑,保证幂等);查到未支付就继续等,超过取消时限的转 CANCELED。查询间隔是权衡:间隔短,及时性好,但渠道接口压力大;间隔长,成本低,但用户等得久。案例选的是 5 分钟——一个对成本和体验都说得过去的默认值,具体数字每个团队可以有自己的九步论证。

图17-3 图稿占位
支付回调与重试时序
渠道重试 + 主动查询双兜底

这四道防线走下来,可以看到交易系统的设计风格:每一层都假设下一层会失效。状态机假设事件会乱序,事务假设操作会中断,幂等假设请求会重复,回调假设通知会丢失——每道防线都在为环境的不可靠兜底,而兜底本身又假设自己可能失效,所以防线是叠起来的,不是选一个。

17.7 最后一道防线:对账

防线叠了四层,是不是就万无一失了?还不是。考虑一个场景:支付回调到达时,订单已经 CANCELED(17.2 转移表里的非法转移场景)——用户取消了订单,但渠道那边钱其实扣了。这时订单状态和钱对不上:渠道认为这笔支付成功,本地订单是取消状态。

还有更隐蔽的:回调处理代码有 bug、主动查询漏了单、人工退款操作错了金额……任何机制都可能出错,而交易系统要求的是钱必须对得上

所以交易系统有最后一道防线:对账(reconciliation)。定期(比如每天)把支付渠道的对账单和本地订单/支付记录逐笔比对:每一笔渠道记录的支付,本地都有对应的订单,且金额一致;反之亦然。对不上的,就是长短款——长款(渠道有、本地没有,或金额多了)和短款(本地有、渠道没有)都要处理:补单、退款、人工核查。

对账的流程可以拆成四步。取数:每天定时拉取支付渠道的对账单(渠道通常提供 T+1 的对账文件),同时导出本地订单与支付记录;比对:以"订单号 + 金额"为键双向核对——渠道的每一笔,本地有没有?本地的每一笔,渠道有没有?金额是否一致?分类:对不上的分成几类——长款、短款、状态不一致(本地 PENDING 但渠道已支付);处理:长款要补单或退款,短款要查原因,状态不一致走补更新。处理的规则要预先定好——对账当天再想"这单怎么办",8 个人有 8 个主意。

对账的自动化程度也是设计决策:比对全自动,处理半自动。比对、分类、生成差异清单可以完全自动化(定时任务 + 脚本);但"差异怎么处理"——退款还是补单?要不要人工确认?——通常要人看一眼再执行,因为自动补偿本身也可能错(补偿错了比不补偿更糟)。所以对账流程的常见形态是:系统每天自动出差异清单,人工复核后执行处理,处理过程同样走状态机和幂等。对账的 SLA 也要定:差异清单多久出、多久处理完——这是"最终一致"的最终期限。

案例的第一次对账就抓到了长短款:一笔支付金额和订单金额差了 1 分钱——回调处理时用了浮点数运算,四舍五入丢了 1 分。那次事故的细节讲完:回调处理里有一行 amount = pay_amount / 100(把渠道返回的"分"转成"元"做展示),再用除法结果做后续判断——浮点数的除法会产生 0.1 + 0.2 != 0.3 这类误差,金额就在某个订单上差出 1 分。单看这一单,1 分钱无关痛痒;但对账系统眼里,"金额不一致"就是不一致,长短款清单里它和 1 万元的长款是同一优先级。那次之后,案例立了两条铁律:金额一律以"分"为单位的整数存储、禁止浮点数参与金额运算;展示层的"元"只在最后一步转换。这两条今天已经是交易系统的行业常识,但每一条都是被事故教出来的。

对账的价值不在"每天发现错误",而在"给'最终一致'一个落点"。17.8 会讲到,异步化之后的系统不是"每时每刻一致",而是"最终一致"——对账就是这个"最终"的检查点:机制保证的是"最终会对得上",而对账负责验证这一点,并在对不上时给出补偿路径(退款、补单、人工)。

这里把退款交代一句,免得悬着:退款是另一条需要同样防线体系的链路——退款状态机、退款幂等(同一笔退款不能退两次)、原路退回——它和支付的防线完全同构,本章不展开,只需要知道"补偿路径"不是随口一说,它带着自己的状态机和幂等。

17.8 从同步到异步:认知链条在此引出

防线讲完了,回头看一眼 17.6:支付回调处理,本质上是一次异步——渠道不等待你的处理结果,你收到通知后自己慢慢处理。为什么回调必须异步?因为同步意味着"渠道要等你的服务器处理完",而你的服务器可能正在重启、可能处理很慢、可能根本没收到——同步把两边的可用性绑在一起,异步则允许两边各自独立运转。

这个"同步 → 异步"的转变,是本章最重要的一条认知链的起点:

单体 → 同步调用 → 问题出现 → 异步 → 消息 → 服务拆分 → 事件驱动

把案例的下单链路放进去看。阶段 3 初期的下单是纯同步的:用户请求 → 扣库存 → 建订单 → 调支付渠道 → 等渠道返回 → 回给用户。同步的好处是简单直观,但问题随着交易量增长逐一暴露:

  • 慢依赖拖垮整个请求:支付渠道、短信通知、发票生成……任何一个慢,用户就卡在"下单中";
  • 峰值扛不住:促销日的下单洪峰,同步处理全部挤在请求线程里,线程池被打满,全站变慢;
  • 失败的连带:通知短信失败,要不要回滚订单?同步模型里,一个非关键环节的失败会污染整个主流程。

于是案例做了第一次异步化:下单成功后,支付回调处理、通知、发票这些耗时环节从请求线程里拆出去,变成后台任务——请求线程只做最核心的事(扣库存、建订单、返回结果),其余的事"稍后处理"。这就是异步任务:把"必须立刻做的"和"可以稍后做的"分开。

异步化的第一版实现很朴素:一张任务表(task 表:任务类型、载荷、状态、重试次数、下次执行时间),下单流程把"发通知""处理回调"这些任务写进表里,一个后台 worker 进程定时扫表,取到任务就执行,失败则更新重试次数。这套方案在小规模下完全够用,案例用它撑过了日订单千级的阶段。它的局限也很快暴露:worker 要自己实现"取任务、防重复领取、失败重试、重试上限",任务一多,轮询间隔和数据库压力开始打架,而且单 worker 没有故障转移——worker 挂了,任务就停摆了。任务表方案是异步的"自行车":能跑,但别指望它上高速。

什么时候该做异步化?三个信号:慢依赖出现——某个环节的 P99 明显拖垮整条链路,用户卡在下单页;峰值不可控——促销、活动导致请求洪峰,同步处理全部挤在请求线程里;失败不该连坐——通知、发票这类环节失败,不该让下单主流程一起失败。三者任一出现,就该把那个环节拆出去。异步化不是越早越好,判断标准是"它拖垮主流程的概率 × 代价"——和第 5 章九步模型"先问清楚再选"的分寸感是同一个。

异步带来一个新的问题:后台任务多了,谁来管理它们?谁保证任务不丢、失败会重试、顺序不乱?任务量上来之后——支付回调、通知、对账、补偿、各种消息——任务表方案开始吃力。这时就轮到消息队列登场:它把"任务"变成"消息",由队列系统保证三件事——可靠投递(消息写入后不会丢,消费者崩溃会重投)、失败重试(消费失败按策略重试,超过上限进死信队列人工处理)、削峰填谷(生产快消费慢时,消息在队列里排队,把峰值压力摊平到时间轴上)。案例在阶段 3 末引入消息队列,正是这三件事同时成为刚需的时候:任务量上来了,任务表扛不住,且促销洪峰需要削峰。

要不要上消息队列,是一个典型的决策问题(第 5 章的九步模型可以完整走一遍:业务目标、约束、候选方案、评价维度……)。案例的结论是:阶段 3 末,订单链路开始引入消息队列。但这里先不展开——因为消息队列、事件驱动、服务拆分,是阶段 5 的主角,第 26 章会完整回收这条认知链。现在只需要记住:交易系统是这条链的起点,从支付回调的"不得不异步"开始,系统一步步走向异步化、消息化、事件化。

还有一个必须澄清的概念:最终一致性。异步化之后,用户下单后不会立刻看到"支付成功"——回调要等渠道通知,通知要排队处理,所以"订单状态"和"用户感知"之间有一段时间差。这不是"数据错误",而是最终一致:系统保证在有限时间内(通常几秒到几分钟)状态会收敛到正确值,而不会永远不一致。最终一致性不是妥协,是异步世界的默认契约——代价是用户看到的中间状态可能短暂"落后",收益是整个系统不再被最慢的环节拖死。怎么向用户解释这种"落后"(订单状态文案、支付中的过渡态),是产品层的事,但机制层的保证(最终会一致)是交易系统的底线。

异步还有一个不能回避的代价:排查变难。同步模型里,一次请求从头到尾在一个调用链里,出了错顺着堆栈找就行;异步之后,一个下单动作被拆成好几个任务,分散在不同进程、不同时间执行,"用户说没收到通知"可能是回调没来、任务没跑、消息丢了、worker 挂了——任何一个环节。所以异步系统几乎必然配套链路追踪(给每个业务操作分配一个 trace_id,贯穿所有相关任务),这是第 25 章可观测性的主要内容。异步化的代价清单里,"排查复杂度"是最容易被低估的一项。

最终一致性在用户侧的观感是这样的:用户付完款,App 上订单可能还显示"支付中"几秒钟——这是异步处理中正常的中间状态,等回调处理完就变"已支付"。产品设计上要做的,是让这个"支付中"状态可解释(文案、进度提示),而不是让用户误以为支付失败。机制层面,"支付中"状态有明确的收敛路径:回调到了转 PAID,超时未到转 CANCELED 并触发退款(17.7 的补偿路径)。最终一致性不是"数据随便乱",而是"中间状态有定义、收敛路径有保证、极端情况有兜底"——三者缺一,才叫数据不一致。

17.9 交易系统的全貌与代价

把这一章的所有防线串起来,就是一张"错误 → 防线"的映射表——也是本章的业务-技术对应表:

业务诉求技术选择为什么代价/取舍
订单状态不能乱,事件乱序时有明确态度订单状态机状态是判断与处理的前提;非法转移必须被拒绝新增状态/流程要改转移表,扩展有成本
扣库存和建订单不能只做一半数据库事务要么都成功要么都回滚,消除中间状态行级锁让并发排队,长事务压库
用户连点/网络重试不能重复扣款幂等(唯一键 + 状态前置)重复请求是环境常态,必须"执行两次等于一次"唯一键/状态判断本身要严谨,漏一处就破
回调丢失,订单不能永远卡在待支付渠道重试 + 主动查询投递不保证送达,双兜底才能闭合轮询有延迟和成本,重试要防风暴
钱必须对得上对账机制再完善也可能不一致,对账是"最终一致"的检查点周期性对账是持续成本,长短款处理要人盯
耗时操作不能拖垮下单主流程异步化慢依赖同步等待会拖死请求线程引入最终一致性,中间状态"落后"
异步任务多了谁来可靠管理消息队列(引出,26 章回收)任务表方案在规模下吃力复杂度转移到队列运维与消息语义

这张表就是交易系统的完整画像。它的读法和第 5 章那张技术栈对应表一样:先看"业务诉求"列——每一行都是一次真实的事故或投诉;再看"为什么"列——那是本章正文;"代价/取舍"列——每一道防线都标了价。填不上"代价"的防线,说明还没想清楚。

现在可以说说它的代价了——交易系统是全书复杂度最陡峭的一章,陡峭在三个地方:

第一,机制是叠加的,不是替换的。状态机、事务、幂等、重试、对账——每一层都假设下层会失效,所以层不能省。少了任何一层,都会在某个具体场景下出事:少了幂等,连点就重复扣款;少了主动查询,回调丢失订单就卡死;少了对账,1 分钱的 bug 永远不会被发现。复杂度不是设计过度,是环境的真实成本。

这张表换个角度看,就是交易系统的纵深防御:外层是幂等和回调(防重复、防丢失),中间是状态机和事务(防乱序、防中断),底层是对账(防漏网)。故障或攻击要从外打到内,每一层都要撕开。纵深的意义在于:任何单层失效,都不会直接导致"钱错了"——它会被下一层接住,最坏情况落到对账,由人工兜底。

第二,调试和测试的难度上了一个台阶。并发问题无法靠"多试几次"复现,回调乱序无法靠单测覆盖,对账问题要等一天才能发现。所以交易系统的测试必须专门设计(第 18 章的测试体系会展开),线上必须有可观测性兜底(第 25 章)。

第三,交易系统是攻击者最喜欢的地方。钱和用户数据都在这里——支付成功页被刷、订单信息被爬、退款接口被恶意调用,都是真实世界的攻击场景(第 19 章的 Web 安全会展开)。

这三条代价不是劝退,而是提醒:交易系统的复杂度是业务逼出来的(第 2 章讲过,业务复杂度上升,旧方案无法承受,新技术出现)。内容社区可以"先上线再说",交易系统不行——因为内容错了可以改,钱错了要负责。理解了这一点,你就理解了为什么交易系统长成今天这个样子:不是开发者喜欢复杂,是"不能出错"这四个字,本身就值这么多道防线。

最后把交易系统放回整张地图(第 3 章的 Web 系统总架构):它不是一个新组件,而是业务逻辑层的一次深化——同一套架构(单体、PostgreSQL、React + Node 栈,第 5 章的技术栈决策在这里原样延续),业务规则变复杂了一个量级:从"增删改查"变成"状态机 + 事务 + 幂等 + 对账"。这也印证了第 2 章的演进逻辑:阶段 3 的业务复杂度上升,逼出了阶段 1 不需要的机制——不是架构换了,是同一层架构里长出了新的复杂性。架构演进(拆分、消息、事件驱动)要等到阶段 5 才发生,那是第 26 章的事。

回到 17.1 的三个投诉:现在可以回答它们各自的防线了——重复扣款由幂等接住,回调丢失由重试 + 主动查询接住,超卖由事务的条件更新接住。三个"仔细一点"解决不了的问题,防线体系给出了机制性的答案。如果你读完本章只带走一句话,那就是:交易系统的设计,就是给环境的每一种不可靠,配一道对应的防线;防线有代价,所以宁可让代价显式,也不让错误隐式。

这也是全书方法论的一次完整演练:第 5 章的决策模型在这里变成了具体的机制选择(扣库存时机、回调还是轮询、要不要消息队列),第 2 章的演进逻辑在这里有了一个完整的实例——交易系统不是凭空复杂的,它是业务"不能出错"四个字逐层逼出来的。

本章小结

  • 交易系统的正确性不靠态度靠机制:状态机管乱序,事务管中断,幂等管重复,重试+主动查询管丢失,对账管"最终一致"的检查——每类错误都有一道防线;
  • 防线是叠起来的:每一层都假设下一层会失效,所以一层都不能省;代价是复杂度陡增、测试难、易被攻击;
  • 交易系统是"同步 → 异步"认知链的起点:从支付回调的"不得不异步"开始,走向异步任务、消息队列、事件驱动——这条链在第 26 章回收;
  • 金额以"分"为单位整数存储——对账抓到的第一个 bug,就是浮点数丢的 1 分钱。

下一章

交易系统上线了,机制也立起来了。但下一个问题立刻出现:这些机制怎么证明它们是对的?状态机的转移表怎么测?并发场景怎么复现?回调乱序、重复请求、对账差异——这些"防线的防线",靠什么守住?

下一章,交易上线后的质量承诺:测试体系——怎么证明"业务正确"不是一句口号。