后端业务状态流转与事务一致性

订单类系统最危险的接口往往不是查询,而是“下一步”。一次状态变化可能同时修改主记录、支付记录、历史快照、子订单和过程日志。只要其中一步失败,数据就可能进入前端无法表达的中间状态。
1. 把状态变化写成业务动作
不要提供一个允许前端任意传 orderStatus 的通用接口。更安全的做法是为动作建立明确方法:
approve(orderId, action);reject(orderId, action);startFlight(orderId, action);confirmDelivered(orderId, action);refundCancel(exceptionId, action);方法名表达意图,参数只包含该动作允许输入的内容,目标状态由 Service 内部确定。
2. 状态流转有三个要素
每个动作都应回答三个问题:
| 要素 | 示例 |
|---|---|
| 前置状态 | 只有待审核订单才能审核通过 |
| 动作副作用 | 写审核人、时间、意见和过程日志 |
| 目标状态 | 审核通过后进入待调度 |
如果只检查目标状态,就会漏掉非法跳转。即使最终都要进入“已送达”,从待支付直接送达和从飞行中送达也不是同一件事。
3. 事务边界围绕业务动作
事务不应该机械地包住所有 Service 方法,而应覆盖一次业务动作必须同时成立的数据:
@Transactional(rollbackFor = Exception.class)public void pay(Long orderId) { insertPaymentRecord(orderId); updateOrderStatus(orderId, "WAIT_AUDIT"); insertProcessLog(orderId, "PAY");}读取列表通常可以使用只读事务;退款、重派、设置默认地址等多步写操作则需要回滚能力。
4. 日志属于业务结果
过程日志不是调试日志。它会被用户进度页和管理端详情读取,因此属于业务数据。订单已变成飞行中却没有 START_FLIGHT 记录,功能虽然“成功”,业务链路却不完整。
系统运行日志回答“程序发生了什么”,业务过程日志回答“订单发生了什么”,两者不要混用。
5. 幂等分层处理
常见的防重复手段包括:
- 终态检查:已关闭工单不能再次处理;
- 结果关联检查:存在
resultOrderId就不能再次生成重派单; - 稳定业务编号:快照按
reportNo更新或新增; - 数据库唯一约束:用户
openId、业务单号等最终由数据库兜底; - 条件更新:订阅额度只在
availableCount > 0时扣减。
synchronized 只能限制当前 JVM 实例,部署多个实例后仍需要数据库约束或分布式控制。
6. 第三方调用放在哪里
数据库事务无法回滚已经发给第三方的请求。若在事务中先调用外部平台、再写数据库,数据库失败时外部动作可能已经完成;反过来也可能出现数据库提交后消息发送失败。
需要先区分主事实:
- 订单状态、支付记录等核心数据应优先保证本地一致;
- 微信通知等附加动作可以记录失败并补偿;
- 真正启动飞行等不可轻易补偿的动作,需要设计请求号、状态确认和对账机制。
7. 条件更新把状态规则下沉到数据库
Service 中的状态判断让错误提示更友好,但真正写入时还可以再次限制旧状态:
int rows = orderMapper.updateStatusIfMatch( orderId, "WAIT_DISPATCH", "DELIVERING", taskId, updateTime);if (rows == 0) { throw new ServiceException( "订单状态已变化,请刷新后重试");}对应 SQL:
update hy_delivery_orderset order_status = #{targetStatus}, current_task_id = #{taskId}, update_time = #{updateTime}where order_id = #{orderId} and order_status = #{expectedStatus} and del_flag = '0'如果两个请求都读取到 WAIT_DISPATCH,只有第一个能把它更新为 DELIVERING。第二个执行时 where 条件已不成立,受影响行数为 0。
这是一种轻量乐观并发控制。另一种做法是在表中增加 version:
update hy_delivery_orderset ..., version = version + 1where order_id = #{orderId} and version = #{oldVersion}状态条件更适合只关心有限状态转换的业务,版本号更适合多个字段都可能被并发编辑的场景。
8. Spring 事务代理实际包住了什么
下面的注解并不是让方法内部每一行自动变成“不可分割指令”:
@Transactional(rollbackFor = Exception.class)public HyExceptionTicket refundCancel(...) { insertPaymentRecord(...); updateOrderStatus(...); updateTicketStatus(...); insertProcessLog(...);}Spring 在 Service Bean 外创建代理。Controller 调用代理方法时,代理先从事务管理器获取数据库连接并关闭自动提交,然后执行真实方法:
Controller -> TransactionInterceptor 开启事务 -> ServiceImpl.refundCancel -> 多个 Mapper 共用当前线程绑定的连接 -> 正常返回:commit -> 抛出匹配异常:rollback因此事务能否生效取决于调用是否经过代理。下面这种同类内部调用可能绕过代理:
public void outer() { inner(); // this.inner(),不是通过 Spring 代理调用}
@Transactionalpublic void inner() { // 事务可能不会按预期创建}可以把 inner 移到独立 Service,或把事务边界直接放在外部入口方法上。项目开启了 exposeProxy,虽然可以显式获取代理,但通常重新设计职责更清楚。
9. catch 异常为什么可能导致提交
@Transactional(rollbackFor = Exception.class)public void process() { mapper.insertA(); try { mapper.insertB(); } catch (Exception e) { log.error("insert B failed", e); }}异常被方法内部捕获且没有再次抛出,事务代理看到的是“方法正常返回”,于是会提交 insertA。如果 B 失败意味着整个业务失败,应重新抛出异常:
} catch (Exception e) { throw new ServiceException("处理失败", e);}也可以显式标记当前事务回滚,但让异常自然传播通常更易读。只有当 B 属于可忽略的附加动作时,才应该捕获后继续,并在代码中说明降级原因。
10. 事务传播用于表达动作嵌套
Spring 默认传播行为是 REQUIRED:外层已有事务就加入,没有就新建。订单 Service 调用过程日志 Service 时,两者因此通常共享同一事务。
createOrder(开启事务) -> insert order -> processLogService.insert(加入当前事务) -> 一起提交或回滚如果日志方法使用 REQUIRES_NEW,它会暂停外层事务并独立提交。之后订单回滚,日志却可能留下“订单创建成功”的错误记录。
独立事务适合必须保留的失败审计,但不适合描述尚未提交的业务事实。选择传播行为之前,要先确定内外两份数据是否必须共同成功。
11. 外部调用不能依赖数据库回滚
下面的写法看起来在事务里,实际只能回滚数据库:
@Transactionalpublic void startFlight(Order order) { Object djiTask = djiClient.createTask(...); orderMapper.updateStatus(order.getId(), "DELIVERING");}大疆任务创建成功后,如果数据库更新失败,回滚不会向大疆自动发送删除任务请求。反过来先提交数据库,再调用第三方,又可能出现本地显示配送中但平台没有任务。
可以引入本地状态机和补偿:
本地任务 CREATING -> 提交事务 -> 使用业务请求号调用第三方 -> 成功:本地任务 READY,订单进入 DELIVERING -> 失败:本地任务 FAILED,允许人工或定时重试请求号在第三方支持时作为幂等键;不支持时则需要在重试前查询平台是否已创建对应任务。
12. 用例测试应该覆盖哪些状态
状态流转测试不能只验证成功路径。以支付为例,至少应覆盖:
UNPAID + 未超时 -> PENDING_AUDIT,新增一条支付记录UNPAID + 已超时 -> CANCELED,不新增成功支付记录PENDING_AUDIT + 重试 -> 状态不变,不重复新增支付记录其他用户订单 -> 拒绝访问,数据库无变化写日志失败 -> 订单和支付记录全部回滚异常处置则要覆盖已关闭工单、重复重派、退款金额为空、过程日志插入失败和并发更新受影响行数为 0。
测试关注的是状态与副作用组合,而不是只断言 Service 返回了非空对象。
13. 小结
可靠的状态流转不是多写几个 if。动作接口限制意图,前置条件限制路径,事务保证本地数据一起成功,幂等阻止重复执行,过程日志留下可追溯结果。五者组合起来,复杂流程才有稳定基础。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!




























