第 17 章 交易系统:当业务开始涉及金钱
副题:状态机、事务、幂等、重试、对账——把"不能出错"从愿望变成机制
第 15 章我们跟着一次请求走通了全链路,案例产品在阶段 1 上线了:内容社区跑了起来,有人发帖、有人评论。期间社区先经历了一轮用户增长,也扛过了第一波性能危机(那是上一章的故事),系统站稳之后,业务走向了收钱——阶段 3 开始。本章跟着案例经历交易上线的第一周:三个事故,五道防线,一条从同步走向异步的认知链。
本章要建立的认知
交易系统的正确性,不靠人"仔细",靠机制兜底。状态机管流转、事务管原子性、幂等管重复、重试管失败、对账管最终一致——每类风险都有对应的防线。
这些防线应对的是同一个底层事实:网络不可靠、用户会连点、请求会重发。出错是分布式环境的必然,与态度无关。防线的设计目标,就是把"必然发生的错误"变成"可被系统自动消化的事件"。
而交易系统本身,也是架构认知升级的起点。它逼着你从单体同步调用,走向异步、消息、服务拆分和事件驱动——本章是这条认知链的第一环,后面的架构演进章节会不断回到它。
17.1 上线的第一周
阶段 3 从一个产品决策开始:内容社区数据不错,用户问"能不能买东西"。产品经理拍板:"上架商品,支持立即购买。"开发两周,上线。
为什么是"立即购买"而不是购物车?第 4 章砍需求时就用复杂度预算做过决定——购物车意味着多单多品、金额重算、优惠券规则,每个都是新的状态组合和对账场景。第一版只做一单一件,让交易链路用最简形态跑通。购物车、优惠、秒杀留给后面。
第一周,客服(也就是开发者自己)收到三起投诉。
投诉一:扣了两次款。 小明看中二手相机,点"立即购买",页面卡了一下,又点了一次。两条扣款短信,两个订单。
投诉二:付了钱,订单还挂着。 小红下单后跳转支付,钱扣了,回 App 一看,订单还在"待支付"。"我钱都付了,你们订单没动!"
投诉三:超卖。 卖家上架 10 件限量手办,赶上小促销,一小时卖出去 32 件。发不了货,退款维权,投诉炸了。
如果你是第一次接触交易系统,第一反应可能是:三个 bug,修掉就行——重复点击加个防抖、支付回调重查一次、库存判断写严一点。
这个反应,正是最大的坑。
这三个问题是规律,不是偶发的 bug。只要系统在线上跑,它们必然发生:
- 用户一定会连点——网络有延迟,界面没反馈,人的本能就是再点一次。防抖能挡住手速,挡不住网络超时后的自动重试。
- 支付回调一定会丢——网络会抖动,渠道会故障,服务器会重启。TCP 只保证"尽力而为",不保证"一定送达"。
- 并发一定会超卖——"检查库存"和"扣减库存"之间隔着时间差,两个请求都能通过检查。
把三个投诉拆开,根源只有三个:重复、丢失、缝隙。
| 根源 | 典型场景 | 后果 |
|---|---|---|
| 重复 | 用户连点、网络重试、回调重放 | 重复扣款、重复订单 |
| 丢失 | 回调丢失、通知丢失、任务丢失 | 订单卡死、状态永不更新 |
| 缝隙 | 先查后改、检查与修改分离 | 超卖、负数库存 |
网络不可靠、用户不可控、并发不可避免——这三件事是交易系统的环境,不是意外。
内容系统可以"先上线再说":标题打错、评论误删,改回来就行,损失的是体验。交易系统不行——多扣的钱要退款流程,丢了的订单要找证据,超卖要挨个协商。错误成本决定了系统设计:内容系统容忍事后补救,交易系统必须事前设防。
在交易系统里,"仔细一点"不是解决方案。机制才是。
17.2 第一道防线:订单状态机
先回答一个最基础的问题:订单到底是什么?
数据库里它是一行记录:order_no、buyer_id、amount、status……但业务上,订单有一个生命周期:刚创建时"待支付",付了钱变"已支付",发货后变"已发货",用户确认后变"已完成";如果超时没付,或者用户主动取消,就进入"已取消"。
把这个生命周期显式画出来,就是状态机:
支付成功 ──> PAID ──发货──> SHIPPED ──收货──> COMPLETED
/
PENDING ────┼── 支付失败 ──> FAILED
\
超时/取消 ──> CANCELED
写成转移表更严格:
| 当前状态 | 事件 | 目标状态 | 合法? |
|---|---|---|---|
| PENDING | 支付成功 | PAID | ✅ |
| PENDING | 支付失败 | FAILED | ✅ |
| PENDING | 超时/取消 | CANCELED | ✅ |
| PAID | 发货 | SHIPPED | ✅ |
| CANCELED | 支付成功 | PAID | ❌ |
最后一行最值钱:订单都取消了,支付回调才到,这是非法转移,必须拒绝。 你可能会问:都取消了,钱怎么还会付进来?会的——用户点取消的同时,支付渠道的扣款请求正在路上。网络把时间顺序打乱了。状态机的作用就是面对乱序时有一个明确态度:按状态说话,不按时间顺序猜测。
代码实现通常长这样:
TRANSITIONS = {
"PENDING": {"pay_success": "PAID", "pay_fail": "FAILED", "cancel": "CANCELED"},
"PAID": {"ship": "SHIPPED"},
"SHIPPED": {"complete": "COMPLETED"},
"FAILED": {},
"CANCELED": {},
}
def transit(order, event):
if event not in TRANSITIONS[order.status]:
raise IllegalTransition(f"{order.status} 不能 {event}")
order.status = TRANSITIONS[order.status][event]
为什么状态机是第一道防线?
因为后面每一道机制都要拿状态当判断依据:幂等要看"订单是否已处理",回调要看"现在是什么状态",对账要看"状态和钱是否匹配"。状态乱了,后面全乱。 先立状态机,再谈其他——这是交易系统的第一纪律。
案例第一版其实没有状态机——status 字段到处直接 UPDATE。后来出了一起乱序事故:用户取消后支付回调才到,代码没检查状态,直接把订单改成 PAID。结果订单显示已支付,但用户已经退款了,两边对不上。那次之后,状态机才被逼出来。
状态机是一种约束,不只是多写几行代码。 它把"状态可以随意改"变成"状态只能在合法路径上走"。没有约束之前,每个开发者都按自己的理解改状态,规则散落在各处;有了转移表,规则只有一份,写在代码里。
状态机的三条价值:
第一,状态是判断的唯一依据。 订单能不能取消、回调该怎么处理、能不能发货,全看当前状态。没有状态机,这些判断散落在各处,互相矛盾;有了状态机,判断只有一个入口。
第二,非法转移是防火墙。 乱序事件(取消后回调才到)会被拒之门外,而不是产生"已取消但已支付"这种矛盾状态。这种矛盾一旦存在,后面的对账、退款全都没法处理。
第三,状态机是团队语言。 产品说"订单已取消",开发知道对应 CANCELED 且不可逆;客服看状态就知道下一步该干什么。一份转移表,同时是代码约束、系统防火墙和团队词典。
澄清一个误解:状态机不是让你把状态存在特殊地方。 数据库里仍然只是一个 status 字段。状态机是加在字段上的规则——所有更新 status 的代码都必须经过转移表。规则在代码层,状态在存储层,各司其职。
代价也要说清: 每加一个状态或事件,转移表要改,所有依赖状态的判断要跟着核对。状态机把"随意改"的成本,换成了"每次扩展都要过一遍规则"的成本——这是它该付的代价。
最后提一个实现细节:超时取消谁触发?
状态机只定义转移,不定义谁来发事件。"支付成功"由回调触发,"发货"由卖家操作触发,但"超时取消"没有自然事件——它需要一个定时机制。案例用定时扫描:定期扫出超时的 PENDING 订单,批量转 CANCELED。
超时取消要注意顺序:先转状态,再在同一个事务里释放库存(17.4 会讲"下单扣库存,超时释放")。顺序反了,就会出现"库存释放了但订单还在"的缝隙。
17.3 订单状态机与支付状态:两套模型
这里有一个必须单独澄清的区分,它是交易系统建模里最常见的困惑点:订单状态和支付状态,是两套独立的账。
订单状态机管的是业务视角——"这笔交易进行到哪一步了":待支付 → 已支付 → 已发货 → 已完成。它跟着业务流程走。
支付状态管的是渠道视角——"钱在第三方支付那边发生了什么":待支付 → 成功/失败 → 退款中 → 已退款。它跟着银行/微信/支付宝的回调走。
两者通过 order_id 关联,但语义完全独立。举个例子:订单已经 CANCELED 了,支付记录可能还是"待支付"——因为用户点取消的同时,支付渠道的扣款请求可能还在路上。反过来,支付记录显示"成功",订单也可能因为超时而处于 CANCELED。
为什么不能合成一张表? 因为它们的演进节奏不同步。订单取消了,支付渠道不会自动知道;支付成功了,订单也可能因为网络乱序而拒绝接收。合成一张表,就会出现"一个状态字段要同时表达两件事"的混乱——status = CANCELED 到底是指订单取消了,还是指支付退款了?
回调处理时怎么衔接? 收到支付渠道的回调,先查支付记录的状态(确认钱到底发生了什么),再拿这个结果去触发订单状态机的转移——但转移前必须做状态前置检查(17.6):如果订单已经是 CANCELED,即使支付成功也要拒绝。这就是两套状态机之间的衔接点。
两套状态机是同构的(都有状态、事件、转移规则),但必须独立演进、独立存储。把支付状态和订单状态合并到一张表里,是交易系统建模最常见的错误之一。
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 件库存就这样超卖了。并发越高,偏差越大,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; -- 都成功,一起提交
这段代码有两个关键点:
第一,条件更新代替先查后扣。 UPDATE ... WHERE stock > 0 让数据库在行级锁内完成"检查+扣减"——同一时刻只有一个事务能碰这行库存,后面的请求排队或冲突。先 SELECT 再 UPDATE 中间永远有缝隙;条件更新把检查和修改合并成一个原子操作,缝隙没了。
第二,扣库存和建订单在同一个事务里。 要么都提交,要么都回滚——不会出现"库存没了订单没建"或"订单建了库存没扣"。
ACID 四个性质,这里主要靠前两个吃饭:
- 原子性:"扣库存 + 建订单"不拆家,要么全成要么全无
- 隔离性:两个请求同时扣,不会互相读到中间状态
一致性(数据满足约束)和持久性(提交不丢)是数据库兜底的承诺,第 14 章已经讲过原理,这里当作基础设施用即可。
防超卖其实有三个方案,同一类问题的三种思路:
| 方案 | 做法 | 适用场景 |
|---|---|---|
| 条件更新(本章用的) | UPDATE ... WHERE stock > 0 | 高争用、单行资源(库存) |
| 悲观锁 | SELECT ... FOR UPDATE 先锁再改 | 逻辑直白,但锁时间长、吞吐低 |
| 乐观锁 | UPDATE ... WHERE version = ? | 读多改少、冲突罕见(如用户资料) |
库存这种高并发单行资源,条件更新最合适;冲突少的场景,乐观锁更划算。
事务不是万能的,有两个边界要认清。
第一,外部系统调不进来。 支付渠道扣款不可能放进本地数据库事务——渠道不在你的数据库里。"扣库存 + 调渠道 + 建订单"这三步只能拆开,中间任何一步失败都要靠补偿(退款、恢复库存),而不是回滚。这是本章第一次碰到"控制范围外的一致性",也是后面走向对账和异步的根本原因。
第二,事务要短、小、快。 一个事务开着十几分钟不提交,数据库的锁、日志、连接全被占着,生产事故多半这么来。只包住必须一起成功的操作,包完立刻提交。
最后补一个关键决策:库存什么时候扣?
- 下单时扣(预占):防超卖最彻底,但可能被"占着不付"的订单锁死库存。需要配超时释放机制——订单
PENDING超时转CANCELED时,事务里同时把库存加回去。 - 支付成功时扣:库存不浪费,但高峰期并发风险更大。
案例选下单时扣 + 超时释放——对"不能超卖"的优先级更高。这也和第 6 章预留的 order/payment 表结构、17.1 的状态机 CANCELED 转移联动起来了。
17.5 第三道防线:幂等
回到投诉一:小明连点两次"立即购买",产生两个订单、扣了两次款。事务能解决吗?不能。 两个请求各自开事务、各自扣库存、各自建单,在数据库看来都是合法的,它看不出这是"同一个人的同一次购买"。
问题的本质:网络不保证"恰好一次"。 用户会连点、客户端会重试、渠道会重放、消息会重投——同一个业务操作,可能被系统收到多次。交易系统面对这种情况,只有一种正确态度:同一个操作执行两次,效果必须和执行一次相同。 这个性质叫幂等。
幂等的直觉
数学里,如果 f(f(x)) = f(x),就叫幂等函数——算两次和算一次结果一样。HTTP 里 GET 天然幂等(读十次结果不变),POST 天然不幂等(发十次建十条)。交易系统的必修课,就是把"创建订单"这种 POST 变成幂等:无论请求来多少次,只产生一个订单。
实现幂等最常用两招。
第一招:唯一键
给每个业务操作一个唯一标识,让数据库替我们挡掉重复。订单的 order_no 就是干这个的:下单请求带着 order_no,数据库对它建唯一约束。第二次插入同一个 order_no 时,数据库报唯一键冲突,系统捕获冲突,直接返回第一次创建的订单。
try:
插入订单(order_no, ...)
except 唯一键冲突:
return 按 order_no 查询已有订单 # 不重复建单,不重复扣款
用户连点两次:第一次插入成功,第二次撞唯一键——返回的是同一个订单,不是第二个。 扣款自然也只有一次,因为第二次请求根本没走到扣款逻辑。
order_no 从哪来? 必须客户端生成。下单动作开始时,前端就生成一个唯一单号,后续所有重试都带同一个号。如果让服务端生成,两次请求会拿到两个不同单号,唯一键就失效了。
第二招:状态前置检查
有些操作没有天然唯一键(比如"更新订单状态"),就用状态来挡:只有 PENDING 的订单才能被支付回调推进到 PAID。如果订单已经是 PAID,回调再来一次,检查发现状态不对,直接忽略。
def handle_pay_callback(order, pay_result):
if order.status != "PENDING":
return already_processed(order) # 重复回调,直接忽略
order.status = "PAID"
这里有一个经典翻车方式:检查写错顺序。 有人写成"先更新状态,再检查状态"——更新完再查当然永远是 PAID,重复回调被当成了新事件,状态被反复推进。交易系统的代码审查里,"检查是否在修改之前"是最常被盯的一行。
幂等管的是"重复",不是"并发"。
两个用户同时抢最后一件库存,幂等判断都会通过(这是两个不同的请求),最后靠的是事务和条件更新来防超卖。分工要清楚:幂等管重复请求,事务管并发争抢。 把两者混用,总会在某个场景下漏事。
幂等要配重放测试。
上线前把同一个下单请求(同一个 order_no)连发两次,断言只产生一个订单;把同一个回调发三次,断言订单状态只被推进一次。这类测试成本极低,但能提前暴露"唯一键没建对""状态检查顺序写反"的问题——交易系统里,幂等重放是成本最低、收益最高的测试项。
17.6 第四道防线:回调、重试与主动查询
投诉二:小红付了钱,订单还停在"待支付"。钱在支付渠道扣成功了,但系统不知道。
支付渠道怎么通知商家?回调(callback)。 用户付款成功后,渠道向商家服务器发一个 HTTP 请求,带上支付结果和签名。商家收到后更新订单状态。
为什么不用更实时的长连接?因为渠道要面对海量商家,长连接成本高、可靠性差。回调 + 重试 + 轮询兜底,实时性损失几秒,可靠性有完整保障——交易系统选方案,可靠性永远优先于实时性。
回调有一个直白的现实:不保证送达。 网络会抖动,服务器会重启,通知可能丢失。所以渠道普遍带重试策略:几分钟、几小时、甚至几天内反复重发,直到商家确认收到。
但重试也有反噬:重试风暴。如果商家处理慢,渠道会更快重发,回调越积越多,把接口打爆。所以回调接口的响应协议要明确:
- 处理成功 → 返回
"SUCCESS",渠道停止重试 - 重复或已处理 → 返回
"ALREADY",渠道不再重试 - 只有处理失败 → 返回错误,渠道按退避策略重试(1 分钟、5 分钟、30 分钟……)
商家侧两块盾牌:幂等 + 快速响应。
一个完整的回调处理,叠三道防线:
def handle_pay_callback(request):
# 第一道:验签。假的直接丢弃
if not verify_signature(request):
return "INVALID"
order = get_order(request.order_no)
# 第二道:幂等/状态前置。重复或已处理的直接忽略
if order is None or order.status != "PENDING":
return "ALREADY_PROCESSED"
# 第三道:金额一致性。回调金额必须等于订单金额
if request.amount != order.amount:
raise AmountMismatch()
# 过状态机(非法转移会被拒绝)
transit(order, "pay_success")
return "SUCCESS"
金额检查最容易被漏。 回调说支付成功,但金额和订单对不上——渠道 bug、优惠计算错误、甚至篡改。不检查就更新,订单状态对了、钱不对,只能等对账兜底。能早挡的,别等对账。
但重试也不保证一定送达。所以还有第二层兜底:主动查询。
商家定时(比如每 5 分钟)主动向渠道查询:"这些 PENDING 订单到底付了没?" 查到已支付,本地补一次状态更新(走同一套回调逻辑,保证幂等);查到未支付就继续等;超过时限的转 CANCELED。
"回调为主、轮询兜底"是标准形态。 纯回调不可靠,纯轮询成本高(每 5 分钟查一次全量 PENDING,渠道压力大)。分工是:回调负责及时(几秒),轮询负责收敛(几分钟内一定能对齐)。
主动查询的间隔是权衡:短了及时性好但渠道压力大,长了成本低但用户等得久。案例选 5 分钟——对成本和体验都说得过去的默认值。
四层防线叠在一起:
状态机假设事件会乱序,事务假设操作会中断,幂等假设请求会重复,回调 + 轮询假设通知会丢失。每一层都为下一层的失效兜底,而兜底本身又假设可能失效,所以防线是叠起来的,不是选一个。
17.7 最后一道防线:对账
防线叠了四层,也不是万无一失。想想这个场景:用户取消了订单,但支付渠道的扣款请求正在路上——回调到达时,订单已经是 CANCELED。渠道认为钱扣了,本地认为订单取消了,钱和订单对不上。
还有更隐蔽的:回调处理代码有 bug、主动查询漏了单、人工操作退错了金额……任何机制都可能出错,而交易系统只认一件事:钱必须对得上。
所以还有最后一道防线:对账(reconciliation)。
对账就四步:
- 取数:每天拉支付渠道的对账单,同时导出本地订单和支付记录
- 比对:以"订单号 + 金额"为键双向核对——渠道的每笔本地有没有?本地的每笔渠道有没有?金额一致吗?
- 分类:对不上的分几类——长款(渠道有、本地没有)、短款(本地有、渠道没有)、状态不一致(本地
PENDING但渠道已支付) - 处理:长款补单或退款,短款查原因,状态不一致走补更新
比对可以全自动,处理只能半自动。 差异清单系统每天自动出,但"退款还是补单"通常要人复核后再执行——自动补偿本身也可能错,补偿错了比不补偿更糟。
案例的第一次对账,抓到了 1 分钱。
回调处理里有一行:amount = pay_amount / 100,把渠道返回的"分"转成"元"。浮点数除法产生了误差——0.1 + 0.2 != 0.3 那种。某个订单上差出 1 分。
单看这一单,1 分钱无关痛痒;但对账系统眼里,"金额不一致"就是不一致,它和 1 万元的长款是同一优先级。那次之后,案例立了两条铁律:金额一律以整数"分"存储,禁止浮点数参与金额运算。 展示层的"元"只在最后一步转换。这两条今天已是行业常识,但每一条都是被事故教出来的。
对账的本质价值,是给"最终一致"一个检查点。
异步化后的系统**"最终会对得上",但不是每时每刻都对得上**——对账就是这个"最终"的验证:机制保证方向正确,对账负责验证结果,并在对不上时给出补偿路径(退款、补单、人工核查)。
退款也提一句:它是另一条同构的链路——退款状态机、退款幂等、原路退回——防线体系和支付完全对称。本章不展开,但要知道"补偿路径"不是随口一说,它带着自己的状态机和幂等。
17.8 从同步到异步:认知链条在此引出
防线讲完了,回到 17.6 的一个细节:支付回调本质上是异步的——渠道发完通知就走,不等你处理完。为什么必须这样?因为同步意味着"渠道要等你服务器处理完",而你的服务器可能在重启、可能在卡顿、可能根本没收到。异步让两边各自独立运转,互不拖累。
这个"同步 → 异步"的转变,是本章最重要认知链的起点:
单体 → 同步调用 → 问题暴露 → 异步 → 消息队列 → 服务拆分 → 事件驱动
阶段 3 初期的下单链路是纯同步的:
用户请求 → 扣库存 → 建订单 → 调支付渠道 → 等渠道返回 → 回给用户。
简单直观,但交易量一上来,三个问题逐一暴露:
- 慢依赖拖垮主流程:支付渠道、短信通知、发票生成——任何一个慢,用户就卡在"下单中"转圈
- 峰值扛不住:促销洪峰全挤在请求线程里,线程池被打满,全站变慢
- 失败不该连坐:通知短信发不出去,难道要回滚整个订单?同步模型里,非关键环节的失败会污染主流程
解法:把"必须立刻做的"和"可以稍后做的"拆开。 下单成功后,支付回调处理、发通知、开发票这些耗时环节从请求线程里拆出去,变成后台任务。请求线程只做核心事:扣库存、建订单、返回结果。
异步化的第一版实现很朴素:一张任务表。
task 表:任务类型、载荷、状态、重试次数、下次执行时间
下单流程把"发通知""处理回调"写进任务表,一个后台 worker 定时扫表,取到就执行,失败则更新重试次数。日订单千级时完全够用。
但任务表是异步的"自行车":能跑,别指望上高速。worker 要自己实现防重复领取、失败重试、重试上限,任务一多,轮询间隔和数据库压力开始打架,而且单 worker 没有故障转移——worker 挂了,任务就停摆了。
什么时候该上消息队列?三个信号出现一个就该考虑:
- 慢依赖出现:某个环节 P99 明显拖垮整条链路
- 峰值不可控:促销洪峰把请求线程打满
- 失败不该连坐:通知、发票失败不该让订单回滚
案例在阶段 3 末引入消息队列,因为三件事同时成了刚需。
消息队列把"任务"变成"消息",由队列系统保证三件事:
- 可靠投递:消息写入后不丢,消费者崩溃会重投
- 失败重试:消费失败按策略重试,超上限进死信队列人工处理
- 削峰填谷:生产快消费慢时,消息排队,把峰值压力摊平到时间轴上
异步化带来一个新概念:最终一致性。
用户付完款,App 上订单可能还显示"支付中"几秒钟——这是异步世界的默认契约,不是错误。系统保证在有限时间内(几秒到几分钟)状态收敛到正确值,不会永远不一致。
最终一致性是"中间状态有定义、收敛路径有保证、极端情况有兜底","数据随便乱"不算。 比如"支付中"的收敛路径很明确:回调到了转 PAID,超时未到转 CANCELED 并触发退款(17.7 的补偿路径)。三者缺一,才叫数据不一致。
产品层要做的,是让这个"支付中"状态可解释——给文案、给进度提示,而不是让用户误以为支付失败。
异步不能回避的代价:排查变难。
同步模型里,一次请求从头到尾在一个调用链里,出了错顺着堆栈找就行。异步之后,一个下单动作拆成好几个任务,分散在不同进程、不同时间执行。"用户说没收到通知"——可能是回调没来、任务没跑、消息丢了、worker 挂了,任何一个环节。
所以异步系统几乎必然配套链路追踪(给每个业务操作分配 trace_id,贯穿所有相关任务),这是后面要讲的可观测性的主要内容。排查复杂度,是异步化代价清单里最被低估的一项。
17.9 交易系统的全貌与代价
把这一章的所有防线串起来,就是一张"错误 → 防线"的映射表——也是本章的业务-技术对应表:
| 业务诉求 | 技术选择 | 为什么 | 代价/取舍 |
|---|---|---|---|
| 订单状态不能乱,事件乱序时有明确态度 | 订单状态机 | 状态是判断与处理的前提;非法转移必须被拒绝 | 新增状态/流程要改转移表,扩展有成本 |
| 扣库存和建订单不能只做一半 | 数据库事务 | 要么都成功要么都回滚,消除中间状态 | 行级锁让并发排队,长事务压库 |
| 用户连点/网络重试不能重复扣款 | 幂等(唯一键 + 状态前置) | 重复请求是环境常态,必须"执行两次等于一次" | 唯一键/状态判断本身要严谨,漏一处就破 |
| 回调丢失,订单不能永远卡在待支付 | 渠道重试 + 主动查询 | 投递不保证送达,双兜底才能闭合 | 轮询有延迟和成本,重试要防风暴 |
| 钱必须对得上 | 对账 | 机制再完善也可能不一致,对账是"最终一致"的检查点 | 周期性对账是持续成本,长短款处理要人盯 |
| 耗时操作不能拖垮下单主流程 | 异步化 | 慢依赖同步等待会拖死请求线程 | 引入最终一致性,中间状态"落后" |
| 异步任务多了谁来可靠管理 | 消息队列 | 任务表方案在规模下吃力 | 复杂度转移到队列运维与消息语义 |
这张表就是交易系统的完整画像。
这张表换个角度看,就是交易系统的纵深防御:外层是幂等和回调(防重复、防丢失),中间是状态机和事务(防乱序、防中断),底层是对账(防漏网)。故障或攻击要从外打到内,每一层都要撕开。纵深的意义在于:任何单层失效,都不会直接导致"钱错了"——它会被下一层接住,最坏情况落到对账,由人工兜底。
现在可以说说它的代价了——交易系统是全书复杂度最陡峭的一章,陡峭在三个地方:
第一,机制是叠加的,不是替换的。状态机、事务、幂等、重试、对账——每一层都假设下层会失效,所以层不能省。少了任何一层,都会在某个具体场景下出事:少了幂等,连点就重复扣款;少了主动查询,回调丢失订单就卡死;少了对账,1 分钱的 bug 永远不会被发现。复杂度是环境的真实成本。
第二,调试和测试的难度上了一个台阶。并发问题无法靠"多试几次"复现,回调乱序无法靠单测覆盖,对账问题要等一天才能发现。所以交易系统的测试必须专门设计,线上必须有可观测性兜底。
第三,交易系统是攻击者最喜欢的地方。钱和用户数据都在这里——支付成功页被刷、订单信息被爬、退款接口被恶意调用,都是真实世界的攻击场景。
这三条代价是提醒,不是劝退:交易系统的复杂度是业务逼出来的(第 2 章讲过,业务复杂度上升,旧方案无法承受,新技术出现)。内容社区可以"先上线再说",交易系统不行——因为内容错了可以改,钱错了要负责。理解了这一点,你就理解了为什么交易系统长成今天这个样子:"不能出错"这四个字,本身就值这么多道防线。
最后把交易系统放回整张地图:它是业务逻辑层的一次深化——同一套架构(单体、PostgreSQL、React + Node 栈),业务规则变复杂了一个量级:从"增删改查"变成"状态机 + 事务 + 幂等 + 对账"。这也印证了第 2 章的演进逻辑:阶段 3 的业务复杂度上升,逼出了阶段 1 不需要的机制——架构没换,同一层架构里长出了新的复杂性。架构演进(拆分、消息、事件驱动)要等到阶段 5 才发生。
回到 17.1 的三个投诉:现在可以回答它们各自的防线了——重复扣款由幂等接住,回调丢失由重试 + 主动查询接住,超卖由事务的条件更新接住。三个"仔细一点"解决不了的问题,防线体系给出了机制性的答案。如果你读完本章只带走一句话,那就是:交易系统的设计,就是给环境的每一种不可靠,配一道对应的防线;防线有代价,所以宁可让代价显式,也不让错误隐式。
本章小结
- 交易系统的正确性不靠态度靠机制:状态机管乱序,事务管中断,幂等管重复,重试+主动查询管丢失,对账管"最终一致"的检查——每类错误都有一道防线;
- 防线是叠起来的:每一层都假设下一层会失效,所以一层都不能省;代价是复杂度陡增、测试难、易被攻击;
- 交易系统是"同步 → 异步"认知链的起点:从支付回调的"不得不异步"开始,走向异步任务、消息队列、事件驱动;
- 金额以"分"为单位整数存储——对账抓到的第一个 bug,就是浮点数丢的 1 分钱。
下一章
交易系统上线了,机制也立起来了。但下一个问题立刻出现:这些机制怎么证明它们是对的?状态机的转移表怎么测?并发场景怎么复现?回调乱序、重复请求、对账差异——这些"防线的防线",靠什么守住?
下一章,交易上线后的质量承诺:测试体系——怎么证明"业务正确"不是一句口号。