上一章把测试和安全补到了 RESTful 服务外面:我们已经会验证输入、权限和错误响应,也会用集成测试检查一条订单链路能不能跑通。但有一类问题,单个接口的测试全部通过也挡不住——订单服务、库存服务和支付服务分别做对了自己的事,组合起来却留下了三份互相矛盾的状态。
先看三个很像、处理方式却完全不同的现场。第一个现场里,支付服务已经扣款,返回响应时网络断了,订单服务只看见超时,不知道钱到底扣没扣;第二个现场里,支付服务明确拒绝支付,订单服务却在释放库存时再次遇到故障;第三个现场里,同一条“订单已创建”事件被送了两次,库存消费者没有识别重复,结果预留了两次。
这些现场共同暴露了拆分服务的代价:以前在一次本地事务里完成的动作,现在散落在多个进程、多个数据存储和多次网络传输中。代码耦合少了,失败状态却多了;系统真正要解决的,也从“函数有没有返回”变成了“各服务分别做过什么,以及怎样让它们重新收敛”。

整章最需要记住的一句话是:超时是未知,不是失败。超时只说明调用方在期限内没有拿到回执,不能证明下游没有执行。看到超时就退款、释放库存或重新扣款,都可能把一次通信故障放大成业务错误。
这一章不把“编排”“Saga”“Outbox”当成要背的名词,而是从订单链路的故障倒推它们为什么存在。我们会依次追问:系统怎样知道自己走到哪一步,怎样安全重试,怎样补偿已经发生的副作用,又怎样把暂时不一致的状态诚实地交给客户端。
教学项目中的创建订单流程很直接:订单服务先创建库存预留,再创建支付,最后把订单标记为完成。这个顺序把业务意图写得很清楚,压缩后的主干如下:
const reservationResponse = await requestJson(
`${urls.inventory}/reservations`,
{
method: 'POST',
headers: {
'x-trace-id': traceId,
'idempotency-key': `inventory:${order.id}`,
},
body: {
orderId: order.id,
productId: order.productId,
quantity: order.quantity,
},
}
);
order.reservationId = reservationResponse.body.id;
const payment = await chargeWithRetry(
order,
traceId,
paymentMode
);
order.paymentId = payment.id;
order.status = 'COMPLETED';这段代码很容易读,因为流程的控制权集中在订单服务里:库存预留成功后才会支付,支付成功后才会完成订单,业务顺序一眼就能看出来。问题也藏在同一个地方——每个 await 都跨过了一次网络边界,返回值不再只有“成功”和“抛异常”两种可能,至少还多了下面几种:
请求根本没有离开订单服务。
请求到达库存服务,但库存服务还没处理就崩溃了。
库存已经预留,成功响应却在路上丢失了。
响应到达订单服务,但订单服务还没记录结果就重启了。
下游处理得很慢,调用方先触发自己的超时。
从调用方看到的现象看,后四种都可能只是一条“没有收到响应”,但从业务结果看,它们完全不同。调用方只有通信结果,没有下游事务的全知视角,所以任何恢复动作都必须先承认这一层信息缺口。
如果订单、库存、支付都在同一个数据库里,我们可以在一个本地事务中更新三张表;任何一步失败,数据库都能回滚还没提交的修改。拆成独立服务后,订单数据库的事务管不到库存数据库,更管不到外部收单系统,即使订单服务回滚了自己的状态,支付服务里已经完成的扣款也不会自动消失。
因此,“订单写入成功”只能证明订单服务自己的本地事务成功,不能证明整个业务流程已经一致。每个服务只能对自己持有的数据负责,跨服务的完整结果必须由流程状态、幂等记录、查询或补偿共同拼出来。
支付拒绝是一种明确失败:支付服务返回业务错误后,订单服务知道没有产生扣款,可以开始释放库存。支付超时却只是未知结果,它可能表示请求没有执行,也可能表示扣款已经完成但回执丢失,两者看起来都是“调用没有成功返回”,恢复路径却不能相同。
这时直接走“支付失败”的补偿路径并不安全,因为顾客的钱可能已经扣走;反过来,直接创建一笔新的支付也不安全,因为第一次可能已经成功,第二次会造成重复扣款。所以恢复动作必须建立在可核对的业务标识上,而不是建立在“我没有收到响应”的直觉上。
项目故意让支付服务先保存支付,再延迟第一次响应。这个故障注入不是生产接口的一部分,它只是把“业务已提交、调用方却不知道”的时间窗口显露出来,关键顺序如下:
const payment = {
id: `pay-${String(++paymentSequence).padStart(4, '0')}`,
orderId: body.orderId,
amount: body.amount,
currency: body.currency || 'CNY',
status: 'CAPTURED',
};
payments.set(payment.id, payment);
idempotency.set(key, payment.id);
// 支付已经记录,第一次回执才开始延迟。
if (paymentMode ===
订单服务给一次支付尝试设置了 180 毫秒超时,支付服务却在 350 毫秒后才返回,所以订单服务会先超时。注意这个时间顺序:超时发生时,支付记录已经存在,状态已经是 CAPTURED;订单服务如果把超时翻译成“扣款失败”,就把通信现象错误地翻译成了业务事实。
为了在未知结果下继续核对,订单服务把支付幂等键固定为订单 ID。这个键代表同一次扣款意图,不会随着连接超时或客户端重发而变化,因此两次请求仍能落到同一笔业务记录上,相关请求头如下:
headers: {
'x-trace-id': traceId,
'idempotency-key': `payment:${order.id}`,
}第一次调用超时后,第二次仍然发送 payment:ord-0002,没有为网络重试制造新的业务身份。支付服务收到这个键时,会先检查它是否已经对应一笔支付:
if (idempotency.has(key)) {
const existing = payments.get(idempotency.get(key));
return sendJson(
res,
200,
paymentRepresentation(existing),
{
'idempotency-replayed': 'true',
}
);
}命中后,支付服务返回原来的支付资源,不再创建第二笔支付。这一步把“重发一次 HTTP 请求”变成了“重新询问同一个业务动作的结果”,调用次数可以增加,业务副作用却仍然只有一份。

运行场景脚本后,终端会把尝试次数和重放标记一起打印出来。我们不只看最终订单是否完成,还要确认第二次调用究竟创建了新支付,还是读取了第一次已经提交的结果:
=== 场景二:支付成功但第一次回执丢失 ===
{
"httpStatus": 201,
"orderId": "ord-0002",
"status": "COMPLETED",
"paymentAttempts": 2,
"secondAttemptReplayedOriginalPayment": true
}paymentAttempts: 2 说明订单服务确实发起了两次调用,secondAttemptReplayedOriginalPayment: true 说明第二次拿到的是原支付记录。最后只有一个 paymentId,订单安全进入 COMPLETED;这里安全的不是“重试”这个动作本身,而是同一业务键让两次网络调用落到了同一笔支付上。
如果每次重试都生成一个新键,支付服务会把它们当成两个业务动作,因此幂等键应该代表“这一次扣款意图”,而不是“这一次网络发送”。它还需要和请求内容绑定:同一个键第一次表示订单 A、金额 199 元,第二次却表示订单 B、金额 999 元时,服务端应拒绝冲突,而不是悄悄返回第一次的结果。
生产实现还要持久化幂等记录,设置与业务追溯期相匹配的保留时间,并让多个服务实例共享它。教学项目把记录放在单进程内存中,重启后会清空,这足以演示语义,却不足以承担真实支付;真实系统还要处理记录过期、参数摘要冲突和并发首请求等边界。
只有下游明确支持幂等时,调用方才能把有副作用的重试当成安全动作。下游没有幂等能力时,超时后更稳妥的做法是按订单号查询、进入待核对状态或交给对账流程,而不是盲目再发一次扣款。
订单服务现在扮演一个简单编排器:它知道库存要先于支付,也知道支付拒绝后需要释放库存。同步编排的第一个好处是流程集中,读订单服务的代码就能看清主路径和错误路径;第二个好处是容易给客户端即时结果,只要调用链在超时预算内完成,一次请求就能拿到完成订单。
第三个好处是业务规则有明确归属,“支付失败要释放库存”由订单流程负责,不需要让库存服务猜测为什么释放。代价同样直接:订单服务必须知道库存和支付的协议、错误模型与调用顺序,调用链越长,延迟越容易叠加;任一依赖变慢,订单请求都会被拖住。
编排器还必须保存流程状态。它如果在“库存已预留、支付未开始”之间崩溃,重启后不能依赖已经消失的调用栈来决定下一步,而要从持久化状态判断应该继续支付、查询结果还是执行补偿。
一个可恢复的编排器不能只记录“成功”或“失败”,它至少要保留下面这些信息。只有把步骤、业务请求和补偿进度一起持久化,重启后的执行者才能从确定位置继续,而不是重新猜测整条流程:
流程实例 ID,对应哪一笔订单。
当前状态,已经完成了哪些步骤。
每一步的业务请求 ID 与幂等键。
上一次尝试时间、次数和错误分类。
可以执行的补偿动作及其状态。
需要人工处理时的原因与上下文。
这些信息不能只放在调用栈里,因为进程重启后调用栈会消失,库存预留和支付扣款等业务副作用却仍然存在。简单流程可以自己维护一张流程状态表;当分支增多、持续时间变长或包含人工审批时,再考虑工作流引擎,让它处理持久化状态、定时器、重试和恢复。
工作流引擎不会替团队决定“退款是否等于撤销”这类业务语义,也不会自动判断超时后的支付究竟成功还是失败。它保存的是执行进度和恢复线索,正向动作、补偿规则与不可逆边界仍然要由领域逻辑明确给出。
另一种做法是让订单服务发布“订单已创建”事件,库存服务订阅后预留库存,再发布“库存已预留”,支付服务接着根据事件创建支付。没有一个服务通过同步调用掌握完整流程,每个服务只响应自己关心的事实,这种方式常被称为事件协作。
事件协作降低了服务之间的直接地址依赖,也能借助消息通道吸收短时流量峰值;然而流程并没有消失,只是分散到了事件定义、消费者和状态转换里。直接耦合减少之后,团队必须接住重复投递、乱序、积压和状态追踪等新问题。

当一笔订单卡住时,你不能只打开订单服务的代码找答案,还要知道事件有没有写出、消息有没有投递、哪个消费者处理过、哪个状态仍在等待。事件协作不是“更松耦合,所以更简单”,它只是拿直接调用的耦合,换来了消息语义、重复处理、顺序和全链路观察的复杂度。
消息系统为了减少消息丢失,常见做法是只有收到消费者确认,才把一条消息视为处理完成。如果消费者已经修改了业务数据,确认回执却在路上丢失,消息系统无法知道处理结果,只能再次投递;这和支付回执丢失是同一种结构,动作可能完成,确认结果却没有到达。
因此,至少一次交付保证的是“消息不会因为一次投递失败就被轻易放弃”,并不等于“业务只执行一次”。消费者必须把重复投递当成正常输入,从事件身份、本地事务和副作用接口三个层面准备好重放路径。
这里要先区分两个概念。同一条消息因为确认超时被再次投递,通常仍然带着同一个 eventId;生产者因发布超时重新生成一条消息,却描述同一个业务事实时,则可能带着不同的消息 ID。只按消息系统分配的 ID 去重,未必能挡住第二种重复,因此事件还应携带稳定的业务标识,例如:
{
"eventId": "evt-8f17",
"eventType": "OrderCreated",
"orderId": "ord-0002",
"orderVersion": 1,
"occurredAt": "2026-08-18T10:20:00Z",
"data": {
"productId": "keyboard-001",
"quantity": 1,
"amount": 199,
"currency": "CNY"
}
}eventId 用于识别同一事件的重复投递,orderId + orderVersion 则帮助消费者判断同一订单变更是否被重复发布,或者某个旧事件是否晚到。两层标识解决的问题不同:前者面向投递,后者面向业务事实和版本顺序。
一个库存消费者可以维护 processed_events 表,把“见过哪条事件”变成可持久化、可并发约束的数据。完整处理步骤如下:
消费者收到事件后开启本地事务,先尝试插入 eventId。eventId 上有唯一约束,并与消费者名称共同界定处理范围,同一条事件再次送到这个消费者时,会在业务修改发生前被识别。
如果 eventId 已存在,说明这条事件已经处理过。消费者不再扣减库存,也不重复发布后续事件,只记录一次重放结果并确认消息,让消息系统停止继续投递。
如果 eventId 是新的,消费者校验事件版本和当前库存,在同一个本地事务里创建与 orderId 绑定的预留,同时保存处理记录。最后一起提交这两份数据,避免只留下预留或只留下去重记录。
本地事务提交后再确认消息,不能把确认放在业务提交之前。即使确认回执丢失导致再次投递,eventId 的唯一约束也会挡住重复副作用,并让消费者安全返回已有结果。
对应的伪代码把去重记录和库存预留放进同一个数据库事务。这里的关键不是先执行哪一行,而是两份数据要么一起提交,要么一起回滚,从而不给进程崩溃留下只完成一半的窗口:
await database.transaction(async (tx) => {
const inserted = await tx.processedEvents.insertIfAbsent({
eventId: event.eventId,
consumer: 'inventory-service',
});
if (!inserted) return;
await tx.reservations.create({
orderId: event.orderId,
productId: event.data.productId,
quantity: event.data.quantity,
});
});如果先查 eventId、后扣库存,中间没有本地事务,两个消费者实例可能同时查到“未处理”,然后各扣一次;如果先扣库存、后记录 eventId,进程在两步之间崩溃,重投时仍会再扣一次。去重记录与业务修改必须原子提交,才能让“已经处理”与真实副作用始终保持一致。
“恰好一次”通常有明确的作用范围。即使消息基础设施在某个区域、某种订阅或某段流处理中提供更强保证,数据库写入、外部 HTTP 调用和生产者重复发布仍可能落在保证范围之外。面向钱、库存、权益这类副作用时,幂等消费者仍然值得保留。
事件协作还有一个更早的问题:订单服务要更新订单,还要向消息系统发布事件,而这两步不在同一个事务里。先写数据库再发消息,可能出现订单已创建、事件却没发出去;先发消息再写数据库,又可能让消费者处理一个最终不存在的订单。只要故障恰好落在两次写入之间,无论调整顺序都不可靠,这就是双写裂缝。
Outbox 的思路很朴素:在订单服务自己的数据库里增加一张待发布事件表,创建订单时把业务记录和待发事件放进同一个本地事务。这样不需要让订单数据库和消息系统参加同一个分布式事务,两次关键写入仍能一起提交:
BEGIN;
INSERT INTO orders (
id,
status,
amount,
version
) VALUES (
'ord-0002',
'PROCESSING',
199,
1
);
INSERT INTO outbox_events (
event_id,
aggregate_id,
aggregate_version,
event_type,
payload,
published_at
) VALUES (
'evt-8f17',
'ord-0002'
数据库事务提交成功,订单与待发事件会同时存在;事务回滚,两者都不存在。另一个发布器持续扫描尚未发布的 Outbox 记录,把它们送到消息通道,因此即使订单进程在提交后立即重启,事件仍然留在数据库里等待发送。

发布器把消息发成功后,还要把 published_at 标记为已发布。如果消息已进入通道,发布器却在标记前崩溃,它重启后会再次发送同一事件。因此,Outbox 通常提供至少一次发布,而不是天然的只发一次,这正是上一节要设计幂等消费者的原因。
Outbox 和幂等消费需要一起使用,前者守住“不能漏”,后者守住“重复不能产生第二份副作用”。把职责拆开后,每一层分别处理自己能观察到的失败:
Outbox 防止业务数据已经提交,事件却永久漏掉。
稳定的 eventId 让重发仍能代表同一个事件。
幂等消费者防止重复投递变成重复副作用。
有了 Outbox 表,问题并没有结束。发布器停摆时,未发布记录会持续积压,团队需要监控最老未发布事件的年龄,而不只是看表里有多少行;同一订单的事件还要按版本处理,否则 OrderCompleted 比 OrderCreated 更早到达时,按到达顺序更新会让状态倒退。
可以按聚合 ID 分区,在事件中携带版本号,并让消费者拒绝旧版本覆盖新版本。已发布记录也需要归档或清理,否则 Outbox 会无限增长;积压告警、顺序约束和数据清理,都是事件协作新增的日常运维成本。
回到支付被明确拒绝的现场:库存已经预留,支付服务明确表示没有完成扣款,订单服务因此需要调用库存服务释放预留。这里有足够证据选择补偿路径,项目里的补偿函数很短:
async function releaseReservation(order, traceId) {
if (!order.reservationId) return;
await requestJson(
`${urls.inventory}/reservations/${order.reservationId}`,
{
method: 'DELETE',
headers: { 'x-trace-id': traceId },
}
);
}订单创建流程捕获支付错误后,把订单设为拒绝,再尝试释放库存。如果释放动作也失败,它不会假装库存已经恢复,而是留下待补偿标记,代码如下:
order.status = 'REJECTED';
order.failure = error.code || error.kind || 'PAYMENT_FAILED';
try {
await releaseReservation(order, traceId);
} catch {
order.failure = `${order.failure}_COMPENSATION_PENDING`;
}这是一段简化的 Saga:流程由多个本地动作组成,后续动作失败时,再通过新的业务动作修正已经发生的效果。它不是命令库存数据库撤销一个跨库事务,而是向库存服务发出“释放这笔预留”的新请求,所以补偿也有自己的网络调用、状态转换和失败可能。
数据库回滚发生在提交之前,外部世界通常看不到中间结果;补偿却发生在前一步已经提交之后,其他请求可能已经观察甚至使用了这个状态。例如,优惠券使用后再补偿,不一定能恢复原有效期;机票取消可能产生手续费;已经发出的通知更无法从收件人的视野里删除。
因此,每个正向动作都要提前定义补偿语义,不能等到线上故障发生后才临时发明“反向接口”。设计时至少要回答三个问题:
它是否可以补偿?
补偿的业务含义是什么?
到了哪个时间点后,它不可逆?
把不可逆动作尽量放在必要校验之后,可以减少无法恢复的半成品。如果动作无法完全撤销,补偿的目标就应是恢复到一个业务可接受的状态,而不是假装时间能够倒流。
库存服务可能在释放预留时不可用,这时不能把订单简单标成 REJECTED,仿佛库存已经恢复。项目用 COMPENSATION_PENDING 暂时留下故障信号;更完整的实现还会持久化补偿任务,记录尝试次数、下一次重试时间、最后错误和人工处理状态,以便进程重启后继续推进。
补偿接口本身也要幂等,否则补偿重试可能把库存加回两次。库存服务的释放逻辑已经体现这一点:预留若是 RELEASED,再次释放不会重复增加库存,而是返回原资源并标记重放。
if (reservation.status === 'RELEASED') {
return sendJson(
res,
200,
reservationRepresentation(reservation),
{ 'idempotency-replayed': 'true' }
);
}
reservation.status = 'RELEASED';
stock.set(
reservation.productId,
stock.get(reservation.productId) + reservation.quantity
);场景脚本在创建订单前后各读取一次鼠标库存,用同一条业务链验证支付拒绝后的库存是否真正恢复。这里不能只断言订单返回 422,还要核对库存副作用是否被补偿:
=== 场景三:支付失败后用补偿操作释放库存 ===
{
"httpStatus": 422,
"problemCode": "PAYMENT_DECLINED",
"stockBefore": 3,
"stockAfter": 3
}支付服务给出了明确的 PAYMENT_DECLINED,所以流程可以确定地进入补偿;库存先从 3 变成 2,释放预留后又回到 3,说明补偿动作完成。如果支付只是超时,这条路径就不能直接照搬,因为超时仍然是未知,而不是支付失败的证据。
只用 SUCCESS 和 FAILED 两个状态,装不下分布式流程。在支付回执丢失的那几百毫秒里,订单既不能说支付失败,也不能假装已经完成,它需要一个能够表达未知、还能指向下一步核对动作的状态。一个更完整的订单状态机可以这样设计:
PROCESSING
├─ 库存预留成功 → INVENTORY_RESERVED
├─ 库存明确失败 → REJECTED
└─ 库存超时 → INVENTORY_UNKNOWN
INVENTORY_RESERVED
├─ 支付成功 → COMPLETED
├─ 支付明确拒绝 → COMPENSATING
└─ 支付超时 → PAYMENT_UNKNOWN
COMPENSATING
├─ 库存释放成功 → REJECTED
└─ 释放失败 → COMPENSATION_PENDING
PAYMENT_UNKNOWN
├─ 查询到成功 → COMPLETED
├─ 查询到失败 → COMPENSATING
└─ 长期未知 → MANUAL_REVIEW
教学项目为了突出主线,只持有 PROCESSING、COMPLETED、REJECTED,并把补偿待处理放进 failure 字段。生产流程通常需要把 INVENTORY_RESERVED、PAYMENT_UNKNOWN、COMPENSATING 等中间状态也持久化,否则重启后的执行者无法判断哪些副作用已经发生,更无法安全恢复。
PAYMENT_UNKNOWN → COMPLETED 不能由定时器到点自动完成,它需要支付查询结果、支付回执事件或对账记录作为证据。PAYMENT_UNKNOWN → COMPENSATING 同样不能只因为等待时间够长,因为等待只会让未知持续得更久,不会把未知变成失败;只有支付方明确确认未扣款,或业务规则提供了另一种可验证结论,系统才能开始释放库存。
状态表不应只存一个枚举值,还要记录造成当前状态的证据和下一步动作,让恢复进程不必猜测上一次执行到了哪里。例如:
{
"orderId": "ord-0002",
"status": "PAYMENT_UNKNOWN",
"version": 3,
"paymentRequestKey": "payment:ord-0002",
"lastTransition": "PAYMENT_TIMEOUT",
"nextAction": "QUERY_PAYMENT",
"retryAt": "2026-08-18T10:20:05Z"
}paymentRequestKey 让查询仍然指向同一笔支付,lastTransition 说明为什么进入未知状态,nextAction 和 retryAt 则让调度器知道接下来何时做什么。这些字段共同把一次内存中的控制流,变成进程重启后仍可恢复的业务流程。
订单暂时处于 PAYMENT_UNKNOWN,可以是一种可接受的中间不一致,前提是系统有明确的收敛路径。团队至少要把下面这些问题落实成程序、数据和告警,而不是只在架构图上写“最终一致”:
谁负责继续查询支付结果。
多久重试一次,最多自动重试多久。
查询和补偿怎样保持幂等。
卡住后怎样告警并进入人工处理。
客户端看到什么状态,是否允许继续操作。
如果没有这些约束,“最终一致”往往只是“希望它有一天自己变好”。真正的最终一致性有目标状态、有恢复动作、有截止条件,也能度量还有多少流程尚未收敛;当自动恢复超过边界时,它还要能把足够上下文交给人工处理。
中间状态不是缺陷。把“不知道支付结果”明确写成 PAYMENT_UNKNOWN,比错误地写成 FAILED 更可靠。诚实的状态让对账、重试、客服处理和客户端提示都能围绕同一个事实工作。
订单和支付要不要由一个中心编排器控制,不能只看哪张架构图更漂亮,而要看流程的顺序约束、失败恢复方式和团队能承担的运维成本。同一种业务在不同规模、不同合规要求下可能得到不同答案,模式只能帮助比较,不能替代判断。
当流程需要集中掌握顺序和补偿时,通常更偏向编排。团队可以从下面几个信号判断集中控制是否值得:
步骤顺序严格,后一步依赖前一步的返回数据。
补偿路径复杂,需要从一个位置看清流程进度。
业务要求客户端尽快拿到确定结果。
参与服务不多,调用链和超时预算仍然可控。
团队希望集中审计每次状态转换。
教学项目的“预留库存 → 支付 → 完成订单”适合先用编排讲清楚,因为流程短、补偿关系明确,读者能直接看到支付拒绝后释放库存。若以后加入人工审核、分期支付或多仓调拨,编排器的状态和恢复逻辑也会随之变重,需要重新评估实现方式。
当多个下游只需要获知事实,且允许在不同时间完成自己的工作时,通常更偏向事件协作。判断时可以观察下面这些信号:
多个下游只需要知道事实,不需要阻塞主请求。
新消费者会持续加入,例如积分、通知、分析。
生产者和消费者的吞吐差异很大,需要消息通道缓冲。
某个消费者短时不可用时,主流程仍可接受延后处理。
团队愿意投入事件治理、积压监控和链路追踪。
“订单已完成”之后触发积分、邮件和报表,通常没有必要让订单请求同步等待它们,这些动作更适合通过事件启动。即使某个消费者暂时不可用,主交易仍可完成,消息积压可以在服务恢复后继续消化。
同一条业务流程里,可以让核心交易由编排器控制,让非关键扩展通过事件协作。按一致性要求分层之后,控制关系可以表达成:
同步编排:库存预留 → 支付 → 订单完成
事件协作:订单完成 → 积分、通知、分析、推荐更新这不是折中凑合,而是按一致性要求划分控制方式:钱和库存需要明确、可审计的恢复路径,通知与分析允许稍后完成。关键不在于全系统统一采用某一种模式,而在于每条边界都能解释自己对延迟、失败和一致性的承诺。
订单和支付拆开并不天然正确。如果业务量和团队都很小,两者永远一起发布,把它们留在一个服务里可能更省成本;如果支付要独立接入多家收单渠道、接受单独审计并服务多条业务线,拆开才更有意义。边界取决于业务变化和组织责任,不取决于“微服务数量越多越先进”的想象。
拆得过粗,代码和数据变化会互相牵制;拆得过细,一次下单可能跨过十几个网络边界,团队又会把大量时间花在超时、重试和对账上。边界没有模板答案,每拆出一个服务,都应该能说明它换来了什么独立性,以及团队愿意承担哪些新故障。
写路径拆开后,读路径也会出现新问题。一个订单详情页不再能从单个数据库读取全部信息,它可能同时需要:
订单服务里的商品、金额和订单状态。
库存服务里的预留状态。
支付服务里的扣款或退款状态。
物流服务里的配送进度。
让浏览器分别调用四个服务,会暴露内部边界,也会把重试、权限和部分失败处理推给前端。BFF 可以面向某一类客户端聚合这些数据:移动端只返回轻量摘要,网页端则组合更完整的操作记录,让客户端需求与领域服务边界之间有一层明确的适配。
BFF 可以并行请求订单、库存和支付,减少串行等待造成的总延迟。并行只改变等待方式,不改变每个服务拥有自己状态的事实:
const [order, reservation, payment] = await Promise.allSettled([
getOrder(orderId),
getReservation(orderId),
getPayment(orderId),
]);并行读取减少了等待时间,但三个结果可能来自不同时间点。订单服务刚写成 COMPLETED,支付读副本还没同步,BFF 就可能读到“订单已完成、支付处理中”;这不一定是 BFF 拼装错了,而是分布式读取正在暴露真实的时间差。

BFF 不应该把这种差异悄悄抹平,更不能为了页面好看伪造一个统一状态。更诚实的做法是在聚合结果里保留每个子状态、更新时间和部分错误,让客户端知道这是一份带时间边界的视图:
{
"orderId": "ord-0002",
"summary": "PAYMENT_CONFIRMING",
"order": {
"status": "PROCESSING",
"version": 2,
"updatedAt": "2026-08-18T10:20:00Z"
},
"reservation": {
"status": "RESERVED",
"updatedAt": "2026-08-18T10:19:59Z"
},
"payment": {
"status": "UNKNOWN"
这里的 summary 是面向界面的解释,不是覆盖各领域服务事实的新真相。订单服务仍然拥有订单状态,支付服务仍然拥有支付状态;BFF 可以解释和组合这些事实,却不应成为另一套无人负责、还可能与源数据冲突的核心状态机。
物流服务暂时不可用时,订单详情是否完全失败,并没有统一答案。如果用户只是查看已购商品,可以返回订单和支付信息,同时把物流标成暂不可用;如果用户正在申请退款,而退款资格依赖签收状态,就不能忽略物流失败。BFF 要按具体操作区分“可缺失数据”和“关键数据”,而不是一律 Promise.allSettled 后返回 200。
BFF 也不应接管核心交易规则。它可以组合表述、裁剪字段、并行读取,但订单能否取消、支付能否退款,仍应由拥有该规则的领域服务判断;否则为了方便页面而复制的规则,会很快与真正的业务约束发生偏差。
AI 推理服务放进订单组合时,最有用的经验仍然来自同一个母题:长耗时调用会扩大未知结果窗口。例如,订单服务需要生成一份风险说明或商品归类建议,推理可能排队几十秒,也可能因为输入较大产生明显成本;让客户端一直占住一次 HTTP 请求,会同时增加连接超时、网关超时和重复提交的概率。
更合适的方式是把推理任务建模为资源。客户端负责提交一次可去重的任务,服务端负责保存任务状态和结果位置,双方不需要用一条长连接假装推理仍是一次普通函数调用。
客户端先提交一个带稳定幂等键的风险评估任务,键中包含订单和输入版本,确保重试仍然指向同一份计算意图。请求可以这样表达:
POST /risk-assessments
Idempotency-Key: risk:ord-0002:v1
Content-Type: application/json
{
"orderId": "ord-0002",
"modelVersion": "risk-2026-08",
"inputVersion": 1
}服务验证输入、权限和配额后,尽快返回一个可查询的操作资源,而不是等待模型完成。响应同时告诉客户端下一次应该查询哪个 URL:
HTTP/1.1 202 Accepted
Location: /operations/op-9102
Retry-After: 2
Content-Type: application/json
{
"id": "op-9102",
"status": "QUEUED",
"links": {
"self": "/operations/op-9102"
}
}客户端根据 Location 随后读取操作状态。这个查询只读取已有任务,不会再次消耗一次推理配额:
GET /operations/op-9102任务完成后,操作资源从 QUEUED 或 RUNNING 转入终态。成功终态再给出结果资源链接,让任务进度与业务结果保持两个清楚的资源边界:
{
"id": "op-9102",
"status": "SUCCEEDED",
"result": {
"href": "/risk-assessments/ra-2048"
}
}
创建任务的响应也可能丢失,所以提交任务同样需要幂等键。客户端超时后使用同一个键重试,服务端应返回原来的 operationId,而不是再次启动一次昂贵推理;如果客户端已经拿到 operationId,后续只查询这个资源,不要重新提交任务。
超时在这里仍然是未知,不是失败。未知的可能不只是业务状态,还可能是一笔已经发生的计算成本,因此服务端既要能按业务键找回原任务,也要记录任务是否已经排队、开始推理或生成结果。
操作资源至少要记录输入版本、模型版本、状态、错误类别和结果位置,这样订单规则变化或模型升级后,团队仍能知道某个结果由什么输入和模型产生。限流和配额应在创建任务前检查,避免先消耗昂贵资源再告诉客户端超额;模型给出的概率、候选项或解释可以放进结果资源,但不要让不稳定的模型输出越过确定性的支付与库存规则。
AI 服务在这里就是一个长耗时、高成本依赖,它需要遵守同样的资源、幂等、状态和可观测约束,不需要成为整个订单流程的中心。真正有用的设计不是给订单流程塞进更多“智能”术语,而是让昂贵任务可以查询、可以去重、可以失败,也可以被独立限流和审计。
上一章的单元测试、接口测试和权限控制仍然有效,但组合流程还需要故障场景测试。测试目标要从“每个接口单独返回正确”推进到“部分失败发生后,整条业务最终停在正确状态”,至少覆盖下面这些情况:
支付明确拒绝后,库存最终恢复。
支付已成功但第一次回执丢失时,只产生一笔支付。
同一个事件重复投递时,消费者只产生一次业务副作用。
Outbox 发布器在发送后、标记前重启时,事件可以重发且不会重复扣库存。
补偿接口重复调用时,状态与数量不会再次变化。
支付长期未知时,订单进入待核对而不是错误地失败。
BFF 某个非关键依赖不可用时,返回带警告的部分视图。
AI 任务提交响应丢失时,同一个幂等键返回原任务。
这些测试关心的不是某个函数抛没抛异常,而是整个业务最终停在哪个状态,以及状态是否能在重试、重启和重复消息之后继续收敛。只有把故障注入到服务边界上,幂等、补偿和恢复逻辑才真正接受过验证。
客户端有权创建订单,不代表订单服务可以不受限制地调用任何支付操作,服务之间仍要验证身份和权限,并使用最小权限凭证。事件中也不要塞入支付凭证、个人敏感信息或完整模型输入,再让所有订阅者都能读取;幂等键和 trace ID 用于关联请求,同样不应该携带用户隐私。
BFF 聚合多个服务后,权限应按当前用户重新判断,不能因为 BFF 能访问内部数据就全部返回给前端。服务组合扩大了数据流动范围,安全检查也必须跟着每一次调用、发布和聚合继续传播,不能只守住最外层网关。
当支付回执丢失时,只看订单日志会看到一次超时,只看支付日志却会看到一次成功扣款,两边都没有撒谎,只是各自看见了不同片段。系统需要把同一个追踪上下文穿过网关、订单、库存和支付,也要把它带进消息属性与补偿任务,场景脚本因此能用同一个 trace ID 串起四段记录:
inventory-service POST /reservations 201
payment-service POST /payments 201
order-service POST /orders 201
api-gateway POST /api/orders 201有了这条链,我们才能追问:支付用了多久,订单为什么发起第二次尝试,第二次是否命中幂等记录,补偿又在哪一步卡住。日志、指标和链路追踪在微服务里不是加分项;没有它们,团队只知道“订单没对上”,有了它们,才有机会把未知缩小到某次调用、某条事件和某次状态转换。
服务会升级,事件会新增字段,状态机会增加分支,BFF 也会调整聚合方式,每一次演进都可能破坏另一个服务的假设。接下来要解决的不是再背一批架构名词,而是让契约、版本、追踪和兼容性一起工作;当一个字段改变、一条事件晚到或一个服务变慢时,系统应当能回答影响了谁、流程停在哪里,以及应该继续、补偿还是转人工。
做到这一步,我们才真正接受了微服务的交换条件:拆分带来独立演进,也要求团队认真管理网络造成的未知、状态造成的时间差,以及恢复动作本身的失败。下一章讨论 API 演进时,这些问题会继续出现,只是焦点会从“这次流程怎样收敛”转向“服务变化时,已有消费者怎样不被突然打断”。