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

2126 字
11 分钟
后端业务状态流转与事务一致性

订单类系统最危险的接口往往不是查询,而是“下一步”。一次状态变化可能同时修改主记录、支付记录、历史快照、子订单和过程日志。只要其中一步失败,数据就可能进入前端无法表达的中间状态。

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_order
set 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_order
set ..., version = version + 1
where 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 代理调用
}
@Transactional
public 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. 外部调用不能依赖数据库回滚#

下面的写法看起来在事务里,实际只能回滚数据库:

@Transactional
public 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。动作接口限制意图,前置条件限制路径,事务保证本地数据一起成功,幂等阻止重复执行,过程日志留下可追溯结果。五者组合起来,复杂流程才有稳定基础。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
后端业务状态流转与事务一致性
https://firefly.cuteleaf.cn/posts/backend-state-transition-transaction/
作者
Yore
发布于
2026-07-25
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
Yore
Hello, I'm Yore.
公告
欢迎来到Yore的博客!本站基于原创作者夏叶的博客网站模版创作。
分类
标签
最新动态
站点统计
文章
51
分类
2
标签
124
总字数
94,115
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
EdgeOne Pages
博客版本
Firefly v6.15.0
文章许可
CC BY-NC-SA 4.0