业务系统性能改造手册 · 异步拆链

划清同步与异步的边界

2026-08-053 min read异步拆链
摘要

以订单接口的响应承诺为界,区分响应前必须成立的业务不变量和允许延迟完成的副作用,并用状态、输入快照和切分实验验证后移工作的真实收益与代价。

03-03 已经让同步工作在可解释的线程和连接预算内运行。再往后压缩延迟,常见做法是给通知方法加上 @Async,让接口早点返回。

代码少了几行等待,业务承诺却可能在不知不觉中改变。原来接口成功意味着订单、库存和通知都已完成;改造后,它可能只意味着某个任务被放进当前进程的线程池。进程在下一秒退出,调用方仍拿到了“成功”。

在小编看来,异步边界不是线程边界,而是承诺边界。先写清接口返回时必须成立的事实、尚未完成的状态和后续失败由谁负责,才能判断哪段工作允许后移。

一、按业务结果重画订单主链路

先不要按 Controller、Service、Mapper 的类名拆链。一次订单提交可以按可观察的业务结果画成这样:

text
1客户端提交订单 23 ├─ 校验请求身份、商品与提交条件 4 ├─ 确定价格、库存处理方式和订单号 5 ├─ 持久化订单与响应前状态 6 ├─ 触发后续处理 7 │ ├─ 发送用户通知 8 │ ├─ 同步外部系统 9 │ └─ 生成非关键派生数据 10 └─ 返回响应,并提供后续状态的查询方式

这只是示例骨架。真实系统中的库存预占、优惠确认、支付单创建和审计记录分别处于哪一侧,要由业务承诺和失败后果决定,不能照着名称分类。

为每个动作补一张链路清单:

yaml
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}

cancellablereversible 不是一回事。任务尚未开始时可以从队列取消,不代表它已经调用外部系统后还能撤销;线程被中断,也不代表数据库提交或远端副作用会自动回滚。只要动作可能越过不可逆点,就要在边界设计里标出失败责任。

二、先定响应前的不变量,再讨论后移

同步与异步边界应按响应前必须成立的业务不变量划分而不是按代码耗时随意后移 判断一个动作能否移出主链路,可以连续问三个问题:

  1. 接口返回成功时,调用方据此会立即做什么?
  2. 这个动作稍后失败,之前的成功响应是否仍然诚实?
  3. 用户或系统能否看到未完成状态,并知道下一步是等待、查询还是人工处理?

下面的决策表不是通用答案。它展示的是订单案例需要怎样记录判断,${...} 部分必须由真实业务负责人确认。

候选动作响应前需要成立的事实后移后的可见状态延迟失败的业务后果当前判断
请求身份与提交条件校验未通过时不得创建订单错误订单已被接受保持同步
订单记录与对外订单号调用方需要获得可查询的稳定标识${ORDER_ACCEPTED_STATE}返回的订单无法查询保持同步或先可靠落盘
价格/库存处理取决于接口是否承诺价格和库存已经确定${PENDING_CONFIRMATION_STATE}订单可能随后被拒绝或改价待业务确认
用户通知通常不改变订单核心事实,但可能受合规约束${NOTIFICATION_PENDING_OR_NOT_EXPOSED}用户晚收到或收不到通知满足补救条件后可后移
报表与派生索引通常不影响本次提交结果${PROJECTION_LAG_STATE}查询视图暂时落后能容忍延迟时可后移

“通知”也不天然适合异步。验证码、风控告警、依法必须留存的审计动作,失败影响与普通短信不同。表里的关键列是成功响应所声明的事实,而不是动作看起来是否耗时。

有些系统返回成功,只承诺“订单已受理”;另一些系统承诺“库存已锁定、价格已确认”。两种接口都可以成立,但切分位置不同。若产品仍把两者显示成同一个“下单成功”,技术上增加一个中间状态也没有解决语义歧义。

三、把“稍后完成”写进状态和响应

异步化必须把稍后完成显式写进状态和响应并使用可重建的输入快照 异步化会在原来的成功/失败之间增加状态。最小状态图应该覆盖响应时刻和延迟失败:

text
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 不是工作的结束。至少要给出稳定操作标识、当前状态和查询入口:

yaml
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: trueinventoryStatus: PENDING 更接近调用方真正可以依赖的内容。

3.2 异步输入应是可重建的快照

后移的任务不能依赖请求线程稍后仍然存在。HttpServletRequest、ThreadLocal 中的登录态、打开的 JPA 实体、当前事务里的未提交对象,以及可被调用方继续修改的集合,都不适合作为异步输入。

输入快照应保留后续决策所需的稳定事实:

yaml
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:声明式事务实现

所以这段代码不能证明订单提交与通知任务形成了可靠交接:

java
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}

可能出现的窗口很直接:异步任务先于事务提交读取订单;事务随后回滚但任务已经产生外部副作用;任务进入内存队列后进程退出;队列拒绝或异步执行失败,却无法改变已经返回的响应。

本篇只把实现切口收窄成两个职责:

java
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、线程池和下游延迟。

yaml
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 最终是否进入约定的终态、从受理到终态用了多久、失败是否对用户和运维可见,同样属于验收。改造后只是把下游调用移出计时窗口,却没有记录最终完成,报表会得到一份好看的接口延迟和一批无人负责的失败。

故障实验应停在本篇边界验证:证明进程退出、下游失败和取消发生时,响应契约没有撒谎,状态可以被发现。可靠投递方案、去重实现与重试参数不在本轮一起引入,否则无法区分边界选择和交付机制各自造成的变化。

六、哪些工作暂时不要后移

下面几类动作应保留在同步路径,或者先修改业务承诺再重新评估:

  • 返回成功就必须成立、失败后不能接受延迟拒绝的业务事实。
  • 调用方拿到响应后会立即依赖,系统却没有中间状态和查询入口的动作。
  • 后续执行依赖请求线程、未提交事务或短生命周期上下文,尚未整理出稳定输入快照的动作。
  • 外部副作用不可逆,重复、超时和未知结果目前无法核对的动作。
  • 省下的关键路径耗时没有测量依据,却会新增状态、监控、值班和恢复工作的动作。

回退不只是关掉异步开关。已经处于 ACCEPTEDPROCESSING 的操作需要按旧契约继续完成或明确终止,新请求才能切回同步路径;否则同一状态会同时被两套流程解释。回退前还要确认调用方是否已经依赖新的状态字段和查询入口。

边界划清后的交付物很具体:一张主链路图指出响应时刻,一张决策表记录每个动作的承诺与失败后果,一份响应契约说明已完成和未完成的事实。只有这三份材料自洽,后续才值得讨论如何可靠投递。否则,把方法换到另一条线程,只是让失败晚一点被看见。