业务系统性能改造手册 · 异步拆链
划清同步与异步的边界
以订单接口的响应承诺为界,区分响应前必须成立的业务不变量和允许延迟完成的副作用,并用状态、输入快照和切分实验验证后移工作的真实收益与代价。
03-03 已经让同步工作在可解释的线程和连接预算内运行。再往后压缩延迟,常见做法是给通知方法加上 @Async,让接口早点返回。
代码少了几行等待,业务承诺却可能在不知不觉中改变。原来接口成功意味着订单、库存和通知都已完成;改造后,它可能只意味着某个任务被放进当前进程的线程池。进程在下一秒退出,调用方仍拿到了“成功”。
在小编看来,异步边界不是线程边界,而是承诺边界。先写清接口返回时必须成立的事实、尚未完成的状态和后续失败由谁负责,才能判断哪段工作允许后移。
一、按业务结果重画订单主链路
先不要按 Controller、Service、Mapper 的类名拆链。一次订单提交可以按可观察的业务结果画成这样:
1客户端提交订单
2 │
3 ├─ 校验请求身份、商品与提交条件
4 ├─ 确定价格、库存处理方式和订单号
5 ├─ 持久化订单与响应前状态
6 ├─ 触发后续处理
7 │ ├─ 发送用户通知
8 │ ├─ 同步外部系统
9 │ └─ 生成非关键派生数据
10 └─ 返回响应,并提供后续状态的查询方式这只是示例骨架。真实系统中的库存预占、优惠确认、支付单创建和审计记录分别处于哪一侧,要由业务承诺和失败后果决定,不能照着名称分类。
为每个动作补一张链路清单:
1orderSubmissionStep:
2 name: ${STEP_NAME}
3 inputOwner: ${REQUEST_DATABASE_OR_DOWNSTREAM}
4 readsAndWrites: ${RESOURCES}
5 transactionBoundary: ${BOUNDARY_OR_NONE}
6 observedP50P95P99: ${DURATIONS}
7 responseCurrentlyWaitsForIt: ${TRUE_OR_FALSE}
8 factEstablishedWhenSuccessful: ${BUSINESS_FACT}
9 failureBeforeResponse: ${RESPONSE_AND_STATE}
10 failureAfterResponse: ${VISIBLE_STATE_AND_OWNER}
11 cancellableBeforeStart: ${TRUE_FALSE_WITH_REASON}
12 reversibleAfterStart: ${TRUE_FALSE_OR_COMPENSATION_REQUIRED}cancellable 和 reversible 不是一回事。任务尚未开始时可以从队列取消,不代表它已经调用外部系统后还能撤销;线程被中断,也不代表数据库提交或远端副作用会自动回滚。只要动作可能越过不可逆点,就要在边界设计里标出失败责任。
二、先定响应前的不变量,再讨论后移
判断一个动作能否移出主链路,可以连续问三个问题:
- 接口返回成功时,调用方据此会立即做什么?
- 这个动作稍后失败,之前的成功响应是否仍然诚实?
- 用户或系统能否看到未完成状态,并知道下一步是等待、查询还是人工处理?
下面的决策表不是通用答案。它展示的是订单案例需要怎样记录判断,${...} 部分必须由真实业务负责人确认。
| 候选动作 | 响应前需要成立的事实 | 后移后的可见状态 | 延迟失败的业务后果 | 当前判断 |
|---|---|---|---|---|
| 请求身份与提交条件校验 | 未通过时不得创建订单 | 无 | 错误订单已被接受 | 保持同步 |
| 订单记录与对外订单号 | 调用方需要获得可查询的稳定标识 | ${ORDER_ACCEPTED_STATE} | 返回的订单无法查询 | 保持同步或先可靠落盘 |
| 价格/库存处理 | 取决于接口是否承诺价格和库存已经确定 | ${PENDING_CONFIRMATION_STATE} | 订单可能随后被拒绝或改价 | 待业务确认 |
| 用户通知 | 通常不改变订单核心事实,但可能受合规约束 | ${NOTIFICATION_PENDING_OR_NOT_EXPOSED} | 用户晚收到或收不到通知 | 满足补救条件后可后移 |
| 报表与派生索引 | 通常不影响本次提交结果 | ${PROJECTION_LAG_STATE} | 查询视图暂时落后 | 能容忍延迟时可后移 |
“通知”也不天然适合异步。验证码、风控告警、依法必须留存的审计动作,失败影响与普通短信不同。表里的关键列是成功响应所声明的事实,而不是动作看起来是否耗时。
有些系统返回成功,只承诺“订单已受理”;另一些系统承诺“库存已锁定、价格已确认”。两种接口都可以成立,但切分位置不同。若产品仍把两者显示成同一个“下单成功”,技术上增加一个中间状态也没有解决语义歧义。
三、把“稍后完成”写进状态和响应
异步化会在原来的成功/失败之间增加状态。最小状态图应该覆盖响应时刻和延迟失败:
1SUBMITTING
2 ├─ 核心校验或持久化失败 ──→ REJECTED
3 └─ 响应前事实成立 ───────→ ACCEPTED
4 ├─ 后续动作处理中 ─→ PROCESSING
5 ├─ 全部完成 ───────→ COMPLETED
6 └─ 无法完成 ───────→ FOLLOW_UP_FAILED状态名只是占位。真正要确认的是:谁可以转换状态、哪个状态允许查询或取消、失败是否改变订单核心结果,以及用户看到什么文案。一个订单已经生效但短信发送失败,不一定应该把整个订单标成失败;反过来,库存确认失败却仍显示“处理中”,也会把业务拒绝藏起来。
3.1 201、202 和业务状态不能混用
若订单资源已经创建,后续只有不影响订单成立的通知尚未完成,响应可以描述已创建资源及通知状态。若接口只接受了请求,核心处理尚未完成,HTTP 202 Accepted 才符合“已接收但尚未完成”的语义。RFC 9110 还指出,HTTP 不会在异步完成后重新发送一个状态码;202 的响应表示应描述当前状态,并指向状态监视方式。RFC 9110,15.3.3
因此,改成 202 不是工作的结束。至少要给出稳定操作标识、当前状态和查询入口:
1asyncResponseContract:
2 httpStatus: ${201_OR_202_BASED_ON_PROMISE}
3 operationId: ${STABLE_OPERATION_ID}
4 orderId: ${ORDER_ID_IF_ALREADY_CREATED}
5 currentState: ${ACCEPTED_PROCESSING_OR_COMPLETED}
6 completedFacts:
7 - ${FACT_ALREADY_TRUE_AT_RESPONSE_TIME}
8 pendingFacts:
9 - ${FACT_NOT_YET_GUARANTEED}
10 statusResource: ${QUERY_URI_OR_EQUIVALENT}
11 nextCheck: ${CLIENT_POLL_PUSH_OR_BUSINESS_FLOW}
12 terminalStates: ${COMPLETED_REJECTED_FOLLOW_UP_FAILED}
13 cancellation:
14 allowedStates: ${STATES}
15 meaning: ${BEST_EFFORT_OR_GUARANTEED_BEFORE_POINT}
16 retentionAndVisibility: ${WHO_CAN_QUERY_FOR_HOW_LONG}接口若继续返回 200/201,也要避免字段名称暗示尚未兑现的事实。success: true 太模糊;orderCreated: true、inventoryStatus: PENDING 更接近调用方真正可以依赖的内容。
3.2 异步输入应是可重建的快照
后移的任务不能依赖请求线程稍后仍然存在。HttpServletRequest、ThreadLocal 中的登录态、打开的 JPA 实体、当前事务里的未提交对象,以及可被调用方继续修改的集合,都不适合作为异步输入。
输入快照应保留后续决策所需的稳定事实:
1deferredActionInput:
2 operationId: ${STABLE_ID}
3 orderId: ${ORDER_ID}
4 actionType: ${NOTIFY_SYNC_PROJECTION_OR_OTHER}
5 sourceVersion: ${ORDER_VERSION_OR_EVENT_VERSION}
6 actorAndTenant: ${MINIMUM_AUTHORIZED_CONTEXT}
7 immutableFacts: ${FIELDS_NEEDED_BY_THE_ACTION}
8 createdAt: ${TIMESTAMP}
9 contractVersion: ${SCHEMA_VERSION}只传 orderId,执行时重新读取最新数据,可能让任务处理到另一版状态;复制整份请求,又可能把敏感字段和无关数据带进更长生命周期。选择 ID + 版本,还是业务事实快照,取决于后续动作需要“执行时最新值”还是“提交时已确认值”。这项决定要写进动作契约。
四、换线程只能验证切口,不能承担交付承诺
Spring 的 @Async 会把方法调用提交给 TaskExecutor,调用方可以立即返回;但 void 异步方法的异常无法传回原调用方,默认只是记录日志。代理模式下,同类内部调用还不会被拦截。Spring Framework:Task Execution and Scheduling
另一个容易误判的点是事务。Spring 命令式 @Transactional 常用线程绑定资源,事务上下文不会传播到方法中新启动的线程。Spring Framework:声明式事务实现
所以这段代码不能证明订单提交与通知任务形成了可靠交接:
1@Transactional
2public OrderResponse submit(SubmitOrder command) {
3 Order order = orderRepository.save(createOrder(command));
4 notificationService.sendAsync(order.getId());
5 return OrderResponse.accepted(order.getId());
6}可能出现的窗口很直接:异步任务先于事务提交读取订单;事务随后回滚但任务已经产生外部副作用;任务进入内存队列后进程退出;队列拒绝或异步执行失败,却无法改变已经返回的响应。
本篇只把实现切口收窄成两个职责:
1record SubmitOrder(String requestId) {}
2
3interface OrderAcceptance {
4 AcceptedOrder accept(SubmitOrder command);
5}
6
7record AcceptedOrder(
8 String operationId,
9 long orderId,
10 String state,
11 java.util.List<DeferredAction> deferredActions) {}
12
13record DeferredAction(
14 String operationId,
15 long orderId,
16 long sourceVersion,
17 String actionType) {}OrderAcceptance 负责让响应前不变量成立,DeferredAction 描述已经确认需要后续完成的动作。示例没有实现投递,也不代表可以把返回的列表直接扔进线程池。数据库提交与后续动作登记怎样避免丢失、重复执行如何避免重复副作用,留到 04-02;持续失败、退避和积压处置属于 04-03。
五、切分实验要同时测响应和最终完成
改造前后的压测仍沿用统一环境、请求组合和数据快照。主要变量只改同步/异步边界,不能顺手更换 SQL、线程池和下游延迟。
1syncAsyncBoundaryExperiment:
2 experimentId: ${EXPERIMENT_ID}
3 fixedConditions:
4 applicationAndDependencies: ${VERSIONS}
5 datasetAndRequestMix: ${REFERENCES}
6 offeredLoad: ${MODEL}
7 downstreamBehavior: ${LATENCY_AND_FAILURE_PROFILE}
8 variants:
9 - boundary: ${ALL_SYNCHRONOUS_OR_CANDIDATE_SPLIT}
10 responseP50P95P99: ${DURATIONS}
11 completedThroughput: ${RATE}
12 responseErrorCounts: ${COUNTS}
13 acceptedOperations: ${COUNT}
14 terminalOperations: ${COUNT_BY_STATE}
15 acceptanceToTerminalP95P99: ${DURATIONS}
16 pendingAgeP95P99: ${DURATIONS}
17 incorrectOrInvisibleStates: ${COUNT_AND_EVIDENCE}
18 applicationDatabaseDownstreamCost: ${REFERENCES}
19 failureChecks:
20 processStopsAfterAcceptance: ${OBSERVED_STATE}
21 downstreamFailsAfterAcceptance: ${OBSERVED_STATE}
22 clientRetriesAfterUnknownResponse: ${OBSERVED_STATE}
23 cancelBeforeAndAfterIrreversiblePoint: ${OBSERVED_RESULTS}
24 decision:
25 removableCriticalPathTime: ${MEASURED_DURATION}
26 newConsistencyWindow: ${MEASURED_DURATION}
27 acceptedOperationalCost: ${EVIDENCE}
28 keepOrRollback: ${DECISION_AND_REASON}响应 P99 下降只是第一项结果。acceptedOperations 最终是否进入约定的终态、从受理到终态用了多久、失败是否对用户和运维可见,同样属于验收。改造后只是把下游调用移出计时窗口,却没有记录最终完成,报表会得到一份好看的接口延迟和一批无人负责的失败。
故障实验应停在本篇边界验证:证明进程退出、下游失败和取消发生时,响应契约没有撒谎,状态可以被发现。可靠投递方案、去重实现与重试参数不在本轮一起引入,否则无法区分边界选择和交付机制各自造成的变化。
六、哪些工作暂时不要后移
下面几类动作应保留在同步路径,或者先修改业务承诺再重新评估:
- 返回成功就必须成立、失败后不能接受延迟拒绝的业务事实。
- 调用方拿到响应后会立即依赖,系统却没有中间状态和查询入口的动作。
- 后续执行依赖请求线程、未提交事务或短生命周期上下文,尚未整理出稳定输入快照的动作。
- 外部副作用不可逆,重复、超时和未知结果目前无法核对的动作。
- 省下的关键路径耗时没有测量依据,却会新增状态、监控、值班和恢复工作的动作。
回退不只是关掉异步开关。已经处于 ACCEPTED 或 PROCESSING 的操作需要按旧契约继续完成或明确终止,新请求才能切回同步路径;否则同一状态会同时被两套流程解释。回退前还要确认调用方是否已经依赖新的状态字段和查询入口。
边界划清后的交付物很具体:一张主链路图指出响应时刻,一张决策表记录每个动作的承诺与失败后果,一份响应契约说明已完成和未完成的事实。只有这三份材料自洽,后续才值得讨论如何可靠投递。否则,把方法换到另一条线程,只是让失败晚一点被看见。