SpringBoot事务优化与状态机实战

SpringBoot事务优化:从大事务瘦身到并发安全与状态机实战

用途:代码CR、AI评审、团队编码参考

核心主题:@Transactional大事务优化,晚开事务、早提交事务,区分哪些逻辑放事务内、哪些放事务外,附带正反案例、坑点、两种业务模式代码;深入探讨事务内外数据一致性风险、并发覆盖问题及原子更新方案;最后引入状态机模式统一管理订单状态流转与审批流。


一、博客原文:SpringBoot事务优化------非数据库逻辑移出事务

1.1 文档目标

解决业务开发中非常常见问题:方法上加 @Transactional,方法内部混杂参数校验、普通查询、RPC调用、数据组装、业务计算,导致事务开启过早、事务执行时间过长、行锁持有时间久,引发锁超时、死锁、主从延迟、数据库连接占用等线上问题。

核心原则:晚开事务,早提交事务

能放到事务外面的逻辑,全部挪到事务外面;事务内部尽量只保留数据库写操作。

1.2 重要前置认知

  1. @Transactional 标记在方法上,整个方法为一个事务边界;
  2. 在同一个带 @Transactional 方法内部做 for 循环分批,只是拆分SQL,不会拆分事务,锁不会释放,仍然是大事务
  3. 只有新开启独立事务,才会释放锁;但拆分独立事务,就丢失整体原子性,业务二选一。

1.3 哪些逻辑可以挪到事务外面(全部优先外移)

✅ 可以放到事务外部:

  1. 参数校验、参数预处理、业务前置判断
  2. RPC调用、HTTP远程调用(网络IO,耗时不可控)
  3. 普通不带锁 select 查询(不需要行锁)
  4. 对象拼装、数据转换、复杂业务计算、集合处理

1.4 哪些逻辑必须留在事务内部(不能挪出去)

❌ 不能放到事务外部:

  1. select ... for update 悲观锁查询:行锁依赖事务,事务外 for update 不会加锁;
  2. 需要保证原子性的写库操作:insert / update / delete;

注意:for update 之后,中间不要插入RPC、网络调用,紧跟着执行更新,尽快提交事务。

1.5 场景一:强原子场景(整体要全部成功/全部失败)

反例:错误写法(禁止直接复制上线)

问题:一进入方法就开启事务,RPC、普通查询、数据组装全部在事务内,锁持有时间包含网络耗时。

java 复制代码
@Service
public class OrderService {

    @Autowired
    private OrderMapper orderMapper;
    @Autowired
    private UserMapper userMapper;
    @Autowired
    private ItemMapper itemMapper;
    @Autowired
    private UserRpc userRpc;

    /**
     * 【坏示例】事务包裹全部逻辑
     */
    @Transactional(rollbackFor = Exception.class)
    public void createOrder(Long orderId, Long userId){
        //1.参数校验
        if(orderId == null || userId == null){
            throw new RuntimeException("参数非法");
        }

        //2.普通查询DB(普通select,不需要锁行)
        UserPO userPo = userMapper.selectById(userId);
        if(userPo == null){
            throw new RuntimeException("用户不存在");
        }

        //3.RPC远程调用!网络IO,耗时不确定
        UserDTO userDTO = userRpc.getUserInfo(userId);

        //4.复杂数据拼凑、业务计算
        OrderPO orderPO = buildOrderPo(orderId, userDTO);

        //5.普通查询一批业务数据
        List<ItemPO> itemList = itemMapper.selectByOrderId(orderId);

        //6.真正写数据库操作
        orderMapper.insert(orderPO);
        for(ItemPO item : itemList){
            itemMapper.updateById(item);
        }
    }

    private OrderPO buildOrderPo(Long orderId, UserDTO userDTO) {
        OrderPO po = new OrderPO();
        po.setOrderId(orderId);
        po.setUserName(userDTO.getUserName());
        return po;
    }
}

风险点:RPC网络慢的时候,事务一直挂住,行锁长时间不释放,其他线程发生锁等待超时。

正例:改造方案

要点:

  1. 外层入口方法不加 @Transactional
  2. 参数校验、rpc、普通查询、对象组装全部放在外层;
  3. 全部前置逻辑执行完毕,数据准备就绪,再调用带 @Transactional 的内部方法;
  4. 事务内部只保留数据库读写;
  5. 需要自注入代理对象调用事务方法,不能使用 this 调用,否则AOP事务失效。
java 复制代码
@Service
public class OrderService {

    @Autowired
    private OrderMapper orderMapper;
    @Autowired
    private UserMapper userMapper;
    @Autowired
    private ItemMapper itemMapper;
    @Autowired
    private UserRpc userRpc;

    //自注入,拿到Spring代理对象,不能this调用
    @Autowired
    private OrderService selfService;

    /**
     * 外层入口方法:不加事务,执行全部前置逻辑
     */
    public void createOrder(Long orderId, Long userId){
        //----------全部挪到事务外面开始----------
        //1.参数校验
        if(orderId == null || userId == null){
            throw new RuntimeException("参数非法");
        }

        //2.普通查询(普通select,不加锁,可以放事务外)
        UserPO userPo = userMapper.selectById(userId);
        if(userPo == null){
            throw new RuntimeException("用户不存在");
        }

        //3.RPC远程调用,网络IO,放在事务外面!!
        UserDTO userDTO = userRpc.getUserInfo(userId);
        if(userDTO == null){
            throw new RuntimeException("RPC获取用户信息失败");
        }

        //4.业务对象拼装、复杂计算
        OrderPO orderPO = buildOrderPo(orderId, userDTO);

        //5.普通查询业务数据,放到事务外面
        List<ItemPO> itemList = itemMapper.selectByOrderId(orderId);
        if(CollectionUtils.isEmpty(itemList)){
            throw new RuntimeException("没有待处理明细");
        }
        //----------全部挪到事务外面结束----------

        //所有校验、RPC、查询、组装全部完成,再调用事务方法做写库
        selfService.createOrderDbOperate(orderPO, itemList);
    }

    /**
     * 内层事务方法:只保留数据库写操作,逻辑尽量少
     * 仍然是一个完整事务,保证insert/update原子性
     */
    @Transactional(rollbackFor = Exception.class)
    public void createOrderDbOperate(OrderPO orderPO, List<ItemPO> itemList){
        orderMapper.insert(orderPO);

        // 如果循环更新明细:先按数据库主键排序,规避死锁
        List<Long> sortedItemPks = itemList.stream()
                .map(ItemPO::getId)
                .sorted(Comparator.naturalOrder())
                .collect(Collectors.toList());

        //分批:仅拆分in参数,仍然同一个事务,锁不会释放
        List<List<Long>> batches = Lists.partition(sortedItemPks, 300);
        for(List<Long> batch : batches){
            itemMapper.batchUpdateByIds(batch);
        }
    }

    private OrderPO buildOrderPo(Long orderId, UserDTO userDTO) {
        OrderPO po = new OrderPO();
        po.setOrderId(orderId);
        po.setUserName(userDTO.getUserName());
        return po;
    }
}

改造收益:

  1. 校验、RPC失败直接在外层抛出,事务根本不会打开,不占用数据库连接;
  2. 事务开启时机延后,事务内部只有DB操作,执行速度快,锁持有时间大幅缩短;
  3. insert/update仍然在同一个事务,保证业务原子性,不会出现部分成功部分失败。

1.6 另一种实现:编程式事务 TransactionTemplate

不想自注入代理对象,可以使用 TransactionTemplate 手动控制事务边界,适合定时任务、MQ消费场景。

java 复制代码
@Service
public class OrderService {
    @Autowired
    private TransactionTemplate transactionTemplate;
    @Autowired
    private OrderMapper orderMapper;
    @Autowired
    private ItemMapper itemMapper;
    @Autowired
    private UserRpc userRpc;

    public void createOrder(Long orderId, Long userId){
        //【事务外】执行参数校验、rpc、普通查询、组装数据,省略...

        //所有前置工作完成,再开启事务执行写库
        transactionTemplate.execute(status -> {
            orderMapper.insert(orderPO);
            List<List<Long>> batches = Lists.partition(sortedItemPks, 300);
            for(List<Long> batch : batches){
                itemMapper.batchUpdateByIds(batch);
            }
            return null;
        });
    }
}

1.7 场景二:允许部分成功,不需要整体原子性

比如数据修复、后台清理任务,可以一部分批次成功,一部分失败,支持断点续跑。

处理方式:每一批次独立事务,每批执行完立刻提交释放锁。

java 复制代码
@Autowired
private TransactionTemplate transactionTemplate;

public void batchRepairBizData(List<String> bizIdList){
    //事务外:业务id查询映射主键
    List<BizEntity> entityList = mapper.selectByBizIds(bizIdList);
    //主键排序,防止死锁
    List<Long> sortedPkList = entityList.stream()
            .map(BizEntity::getId)
            .sorted()
            .collect(Collectors.toList());

    List<List<Long>> batches = Lists.partition(sortedPkList, 300);
    for(List<Long> batch : batches){
        //每一批独立事务,执行完直接提交释放锁
        transactionTemplate.execute(status ->{
            mapper.batchUpdate(batch);
            return null;
        });
    }
}

1.8 高频疑问

Q1:事务外面查询的数据,进到事务内部会不会被其他线程修改?

会,会出现不可重复读。

  • 如果只是基础参数校验,允许短暂不一致,放事务外没问题。
  • 如果需要基于当前行数据做修改(扣库存等),不能在外面查询,必须放到事务内部使用 select ... for update 加悲观锁。

Q2:我在外层抛出异常,会不会回滚?

外层没有开启事务,直接抛出异常,不会触发任何回滚,也不会占用数据库连接 ;只有进入带 @Transactional 方法之后,才会开启事务。

Q3:this调用事务方法会发生什么?

this.createOrderDbOperate(),绕过SpringAOP代理,@Transactional 完全失效,不会开启事务。

解决:使用自注入 selfService 或者使用 TransactionTemplate 编程式事务。

1.9 CR检查清单(给AI评审/人工代码审查使用)

  • RPC、HTTP远程调用是否已经移出事务;
  • 参数校验、普通无锁查询、对象组装、业务计算是否放在事务外部;
  • select ... for update 悲观锁查询是否保留在事务内部;
  • for update 后面不允许插入RPC/网络IO,紧跟着写库;
  • 如果循环更新同一张表多行,是否做数据库主键升序排序;
  • 批量操作是否做分批(建议200-500条每批);
  • 事务方法是否使用代理对象调用,避免this调用;
  • 区分业务是【强原子(一个大事务)】还是【允许部分成功(多独立小事务)】。

1.10 总结

  1. 非数据库逻辑全部挪到事务外面;事务内尽量只保留写库和必要悲观锁查询。
  2. 强原子业务不能拆分事务,只能做事务瘦身,缩短事务执行时间,缓解锁超时风险。
  3. 允许部分成功的业务,拆分为多批独立小事务,可以真正释放锁,降低数据库压力。
  4. 同一个事务内的分批,只是拆分SQL,不会释放锁,不会拆分事务边界

二、深度评审:正例代码中隐藏的数据一致性风险

2.1 问题定位

在博客的"正例"代码中,createOrder 方法在事务外部查询了 itemList,然后将这个列表传递给了事务方法 createOrderDbOperate 进行更新。

java 复制代码
// 【事务外】查询业务数据
List<ItemPO> itemList = itemMapper.selectByOrderId(orderId);

// ... 其他逻辑 ...

// 【事务内】更新业务数据
selfService.createOrderDbOperate(orderPO, itemList);

2.2 风险分析

  1. 时间窗口 :在 itemList 被查询出来之后,到 createOrderDbOperate 事务真正开始执行并更新这些记录之前,存在一个时间窗口。
  2. 并发修改 :如果在这期间,有另一个线程或请求修改了 itemList 中的某条记录(例如,更新了库存或状态),那么当前事务持有的 itemList 对象中的状态就是过期的
  3. 数据覆盖 :事务方法会使用这个过期的对象去执行 update 操作,从而覆盖掉其他线程刚刚提交的、正确的修改,导致数据丢失。

2.3 修正建议

对于需要更新的数据,应该在事务内部重新查询,或者使用 select ... for update 加悲观锁来确保读取到的是最新数据并锁定它,防止并发修改。

java 复制代码
@Transactional(rollbackFor = Exception.class)
public void createOrderDbOperate(OrderPO orderPO, List<Long> itemIds){
    orderMapper.insert(orderPO);
    
    // 【修正】在事务内部,根据ID重新查询最新的数据
    // 或者使用 select ... for update 来加锁
    List<ItemPO> freshItemList = itemMapper.selectByIds(itemIds);
    
    // 使用 freshItemList 进行后续的业务逻辑和更新
    // ...
}

2.4 代码评审总结

尽管存在上述关键问题,这篇博客的整体方向和大部分建议都非常出色:

  • 核心原则正确:提出的"晚开事务,早提交事务"以及"非数据库逻辑全部移出事务"是优化数据库性能、减少锁等待和死锁的黄金法则。
  • 场景划分清晰:明确区分了"强原子性业务"(需要一个大事务)和"允许部分成功业务"(可拆分为多个小事务),并给出了不同的处理方案,非常实用。
  • 坑点提示到位
    • 指出了在同一个事务方法内循环分批并不会释放锁
    • 强调了 this 调用会导致 @Transactional 注解失效的问题。
    • 提到了对同一张表进行批量更新时,按主键排序可以有效避免死锁。
  • 提供了多种方案 :除了使用 @Transactional 注解,还介绍了使用 TransactionTemplate 进行编程式事务管理,增加了方案的灵活性。

三、事务内外数据一致性:为什么"查出来再更新"有坑

3.1 核心误区

很多人认为:事务里面查出来的数据,在事务内进行操作,就一定是安全的。事实上,内存中的更新,并不等于数据库中的更新,更不等于后续的查询能立刻"看"到这个更新。

这涉及到数据库底层的事务隔离级别MVCC(多版本并发控制)机制 。以 MySQL 默认的 REPEATABLE READ(可重复读)隔离级别为例,事务内部"查不到刚更新的数据"通常有以下两个根本原因:

3.2 原因一:内存更新未落盘,快照读回退

在 Spring 的 @Transactional 事务中,整个方法执行期间,数据修改(UPDATE)只是记录在内存或 Undo Log 中,直到方法完全执行完毕,事务才会提交(Commit)

如果你在同一个事务中,先执行了更新,紧接着又执行了一个普通的 SELECT 查询(快照读),由于事务还没提交,数据库为了保证"可重复读"的特性,可能会让后续的查询基于事务开始时的"历史快照"去读取数据,而不是去读取内存中尚未提交的修改。这就导致了你明明在代码里更新了对象,但查出来的结果依然是旧数据。

3.3 原因二:并发场景下的"行锁导致快照回退"(最诡异的坑)

这是实际开发中极易踩坑的场景。假设在并发请求下:

  • 请求 A 在事务中更新了某条数据,并生成了事务快照。
  • 请求 B 恰好也更新了同一条数据(即使值没变),这会对该行加一个排他锁(X锁),并生成一个新的未提交版本。
  • 此时,请求 A 再次执行查询。由于请求 B 把数据锁住了,请求 A 的快照读不会去等待锁释放,而是直接去读取该锁生效前的最后一个已提交版本。
  • 结果:请求 A 读到的竟然是极早期的旧版本数据!这在 MySQL 中是符合规范的设计,但在业务上表现为"数据明明更新了,查出来却是旧值"。

3.4 解决方案

方案一:调整隔离级别为 READ COMMITTED(推荐)

在方法上指定 @Transactional(isolation = Isolation.READ_COMMITTED)。在这个级别下,每次查询都会生成新的快照,读取查询时刻最新的已提交数据,从根源上避免了快照回退的问题。

方案二:使用 SELECT ... FOR UPDATE(强一致场景)

将后续的普通查询改为"当前读"(加锁查询)。这会强制数据库读取最新版本的数据,如果遇到其他事务的锁,它会乖乖等待,而不是去读历史版本。

方案三:拆分事务(架构优化)

将"更新"和"查询"拆分为两个独立的方法。先调用更新方法(事务自动提交),再调用查询方法(开启新事务)。这样查询时,更新的数据已经真实落盘了。


四、并发覆盖问题:丢失更新与三种解决方案

4.1 问题本质

你描述的场景在数据库理论中被称为**"丢失更新"(Lost Update) "覆盖更新"**。即使你把查询放进了事务内部,如果仅仅依靠普通的 SELECT 查询,然后基于查出来的对象在内存中计算后执行 UPDATE,依然无法避免并发覆盖的问题。因为普通的 SELECT 只是读取了某个时间点的"快照",并没有阻止其他线程在这个窗口期内修改同一条数据。

换句话说:

同时 table1 里面 id=1 的数据,事务外查出来的是 v1 版本,事务内查出来的是 v2 版本,实际数据库有可能是 v3 版本,等你更新后是 v4 版本------这种情况怎么解?

要彻底解决这种"查询后更新"的并发冲突,核心思路是控制并发行为,确保你的更新操作要么基于最新的数据,要么直接拒绝过期的操作。以下是业界标准的三种解决方案:

4.2 方案一:悲观锁 SELECT ... FOR UPDATE(强一致性首选)

执行流程: BEGIN; SELECT * FROM table1 WHERE id = 1 FOR UPDATE; UPDATE table1 SET ... WHERE id = 1; COMMIT;

原理解析: 当请求 A 执行 FOR UPDATE 时,数据库不仅会查出当前最新的数据(比如 v3 版本),还会给这条记录加上一把排他锁(X锁)。此时,如果请求 B 也要修改这条数据,在执行 FOR UPDATE 时就会被阻塞等待,直到请求 A 提交事务并释放锁。

适用场景: 写多读少、冲突概率高、对数据强一致性要求极高的场景(如金融资金扣减、秒杀库存扣减)。

4.3 方案二:乐观锁------版本号机制(高并发轻量级方案)

如果你不想在数据库层面加锁影响性能,可以使用乐观锁。核心思想是:更新时校验数据是否被别人改过。

执行流程:

  1. 在表中增加一个 version 字段。
  2. 事务内查询:SELECT id, data, version FROM table1 WHERE id = 1;(假设读出 version = 3)
  3. 事务内更新:UPDATE table1 SET data = 'new_data', version = version + 1 WHERE id = 1 AND version = 3;

原理解析: 如果在你查询到更新的时间窗口内,请求 B 已经修改了这条数据,数据库里的 version 已经变成了 4。当你的 UPDATE 语句执行时,由于 WHERE version = 3 条件不匹配,影响行数(Affected Rows)将返回 0

后续处理: 应用层捕获到影响行数为 0 后,就知道数据发生了冲突,此时可以选择抛出异常提示用户"数据已变更",或者重新查询最新数据并重试业务逻辑。

4.4 方案三:原子更新------增量操作与条件判断(最轻量、最推荐)

如果你的业务逻辑允许,永远不要基于"旧快照"进行绝对值覆盖,而是使用相对值(增量)冲正。

执行流程: 将"读-判-改"合并为一条带条件的 SQL。例如扣减库存:UPDATE table1 SET stock = stock - 1 WHERE id = 1 AND stock >= 1;

原理解析: 这种写法根本不需要先 SELECT 查出旧值,而是让数据库自己在执行更新时去判断条件是否成立。执行后同样检查影响行数,为 0 则说明库存不足或已被扣完。

适用场景: 逻辑简单、判断条件能直接写进 WHERE 子句的场景(如余额扣减、状态流转)。这种方式彻底消除了中间态,简单且高效。

4.5 方案选择建议

方案 一致性强度 性能影响 适用场景
悲观锁 FOR UPDATE 强一致性 有锁竞争,并发受限 金融扣减、秒杀库存
乐观锁版本号 最终一致性 无锁,冲突时重试 冲突概率低的场景
原子更新 WHERE条件 强一致性 无锁,最轻量 加减操作、简单状态流转

五、原子更新实战:状态流转的标准化写法

5.1 错误做法:应用层"读-改-写"(存在并发覆盖风险)

java 复制代码
// 1. 查出订单(假设查出状态为 1-待发货)
Order order = orderMapper.selectById(orderId); 

// 2. 内存中判断状态
if (order.getStatus() == OrderStatus.PENDING_SHIPMENT) {
    // 3. 在这期间,如果有另一个线程把订单取消了(状态变成了 9-已取消)
    // 这里的更新依然会执行,导致状态从 9 被覆盖回 2!
    order.setStatus(OrderStatus.SHIPPED); 
    orderMapper.updateById(order); 
}

5.2 正确做法:数据库层"条件原子更新"

1. 定义状态枚举(清晰管理状态机)

java 复制代码
public enum OrderStatus {
    PENDING_PAYMENT(0, "待支付"),
    PENDING_SHIPMENT(1, "待发货"),
    SHIPPED(2, "已发货"),
    CANCELED(9, "已取消");
    
    private final int code;
    private final String desc;
    
    OrderStatus(int code, String desc) {
        this.code = code;
        this.desc = desc;
    }
    
    // 省略构造方法和 getter
}

2. 编写 Mapper 层的原子更新 SQL

java 复制代码
// 返回值必须是 int(受影响的行数)
int shipOrder(@Param("orderId") Long orderId, 
              @Param("fromStatus") int fromStatus, 
              @Param("toStatus") int toStatus);

对应的 MyBatis XML:

xml 复制代码
<update id="shipOrder">
    UPDATE t_order
    SET status = #{toStatus}, 
        update_time = NOW()
    WHERE id = #{orderId} 
      AND status = #{fromStatus}  <!-- 【核心】把状态校验放在 WHERE 里 -->
</update>

3. 业务 Service 层调用

java 复制代码
public void shipOrder(Long orderId) {
    // 1. 执行原子更新,将 待发货(1) 变更为 已发货(2)
    int affectedRows = orderMapper.shipOrder(
        orderId, 
        OrderStatus.PENDING_SHIPMENT.getCode(), 
        OrderStatus.SHIPPED.getCode()
    );
    
    // 2. 根据影响行数判断结果
    if (affectedRows == 0) {
        // 说明 WHERE 条件没匹配上!
        // 原因只有两个:订单不存在,或者订单当前状态已经不是"待发货"了
        throw new BusinessException("发货失败:订单状态已变更,请刷新后重试");
    }
    
    // 3. 更新成功,继续后续逻辑(如发短信、扣减库存等)
    // sendShipmentSms(orderId);
}

5.3 为什么这种做法是安全的?

  1. 数据库引擎保证原子性 :在执行 UPDATE ... WHERE status = 1 时,MySQL 会对满足条件的行加排他锁(X锁)
  2. 并发拦截 :如果此时有另一个线程正在执行"取消订单"(UPDATE ... WHERE status = 1 SET status = 9),这两个 SQL 会竞争同一行的锁。谁先拿到锁,谁就能把状态改掉。
  3. 后到者自然失败 :当"取消订单"先拿到锁并把状态改为 9 并提交后,"发货"的 SQL 拿到了锁,此时它去匹配 WHERE status = 1,发现匹配不到,于是影响行数为 0
  4. 无需额外查询 :整个过程不需要SELECT 查一次数据库,直接一次 UPDATE 搞定,既保证了绝对的数据一致性,又减少了一次数据库 IO。

黄金公式 :只要涉及到状态流转,永远记住这个公式:

UPDATE table SET status = 新状态 WHERE id = ? AND status = 旧状态


六、状态机模式:统一管理订单状态流转

6.1 痛点:散落的 if-else

没有状态机的时候,每个方法里都要写一遍 if-else:

java 复制代码
// 以前:每个方法里都要写一遍 if-else
if (status == 1 || status == 2) { ... }
if (status != 9) { ... }
// 散落在十几个方法里,改一个状态要翻遍全项目

6.2 核心思路

把"哪些状态能流转到哪些状态"这个规则,从散落的 if-else 里抽出来,集中管理。

6.3 第一步:用枚举定义状态机

java 复制代码
public enum OrderStatus {
    PENDING_PAYMENT(0, "待支付"),
    PENDING_SHIPMENT(1, "待发货"),
    SHIPPED(2, "已发货"),
    COMPLETED(3, "已完成"),
    CANCELED(9, "已取消");

    private final int code;
    private final String desc;

    // 核心:每个状态声明自己能流转到哪些状态
    private final Set<OrderStatus> allowedTransitions;

    OrderStatus(int code, String desc) {
        this.code = code;
        this.desc = desc;
        this.allowedTransitions = new HashSet<>();
    }

    // 静态代码块集中配置流转规则
    static {
        PENDING_PAYMENT.allow(PENDING_SHIPMENT, CANCELED);
        PENDING_SHIPMENT.allow(SHIPPED, CANCELED);
        SHIPPED.allow(COMPLETED);
        // COMPLETED 和 CANCELED 没有后续流转,是终态
    }

    private void allow(OrderStatus... targets) {
        this.allowedTransitions.addAll(Arrays.asList(targets));
    }

    // 判断能否流转
    public boolean canTransitTo(OrderStatus target) {
        return allowedTransitions.contains(target);
    }

    // 省略 getter...
}

6.4 第二步:Service 层变得极其干净

java 复制代码
public void shipOrder(Long orderId) {
    Order order = orderMapper.selectById(orderId);
    
    // 校验状态流转是否合法
    OrderStatus currentStatus = OrderStatus.of(order.getStatus());
    if (!currentStatus.canTransitTo(OrderStatus.SHIPPED)) {
        throw new BusinessException("当前状态不允许发货");
    }
    
    // 原子更新(和之前一样,WHERE status = 旧状态)
    int rows = orderMapper.updateStatus(orderId, currentStatus.getCode(), OrderStatus.SHIPPED.getCode());
    if (rows == 0) {
        throw new BusinessException("操作失败,订单状态已变更");
    }
}

6.5 状态流转图

有了状态机之后,所有流转规则集中在枚举的 static 块里,一眼就能看清整个状态流转图:

复制代码
待支付 → 待发货 → 已发货 → 已完成
  ↓         ↓
已取消    已取消

而且如果以后要加一个新状态(比如"退款中"),只需要在枚举里加一行 allow,不用去翻各个 Service 里的 if-else 了。


七、审批流状态机:从线性审批到条件路由

7.1 为什么审批流是"地狱级"场景?

审批流不仅状态多,而且流转条件极其复杂(比如:金额大于1万要总监批,小于1万部门经理就能批)。如果还用传统的 if-else,代码很快就会变成一坨无法维护的"意大利面条"。这时候,我们需要把状态机再升个级,引入**"动作(Action)""流转条件(Condition)"**的概念。

7.2 第一步:定义审批动作(Action)

java 复制代码
public enum ApprovalAction {
    SUBMIT("提交"),
    APPROVE("同意"),
    REJECT("驳回"),
    CANCEL("撤销");
    
    private final String desc;
    ApprovalAction(String desc) { this.desc = desc; }
}

7.3 第二步:定义审批状态(State)

java 复制代码
public enum ApprovalStatus {
    DRAFT("草稿"),
    MANAGER_APPROVING("部门经理审批中"),
    DIRECTOR_APPROVING("总监审批中"),
    APPROVED("审批通过"),
    REJECTED("已驳回");
    
    private final String desc;
    ApprovalStatus(String desc) { this.desc = desc; }
}

7.4 第三步:核心------流转规则配置(Rule)

把"动作"、"前置状态"、"目标状态"和"流转条件"绑定在一起。

java 复制代码
public class ApprovalRule {
    private ApprovalStatus from;
    private ApprovalAction action;
    private ApprovalStatus to;
    private Predicate<ApprovalContext> condition; // 核心:动态条件

    public static ApprovalRule of(ApprovalStatus from, ApprovalAction action, ApprovalStatus to) {
        ApprovalRule rule = new ApprovalRule();
        rule.from = from;
        rule.action = action;
        rule.to = to;
        rule.condition = ctx -> true; // 默认无条件
        return rule;
    }

    // 链式调用设置条件
    public ApprovalRule when(Predicate<ApprovalContext> condition) {
        this.condition = condition;
        return this;
    }
    
    // 省略 getter...
}

7.5 第四步:审批上下文(Context)

用来传递判断条件所需的业务数据(比如金额)。

java 复制代码
@Data
@Builder
public class ApprovalContext {
    private Long applyId;
    private BigDecimal amount; // 审批金额
    // ... 其他业务字段
}

7.6 第五步:状态机引擎(StateMachine)

集中管理所有规则,并负责执行流转。

java 复制代码
public class ApprovalStateMachine {
    
    // 集中注册所有的流转规则
    private static final List<ApprovalRule> RULES = new ArrayList<>();

    static {
        // 规则1:草稿 -> 提交 -> 经理审批中
        RULES.add(ApprovalRule.of(ApprovalStatus.DRAFT, ApprovalAction.SUBMIT, 
                    ApprovalStatus.MANAGER_APPROVING));
        
        // 规则2:经理审批中 -> 同意 -> (看金额)
        RULES.add(ApprovalRule.of(ApprovalStatus.MANAGER_APPROVING, ApprovalAction.APPROVE, 
                    ApprovalStatus.DIRECTOR_APPROVING)
                .when(ctx -> ctx.getAmount().compareTo(new BigDecimal("10000")) > 0)); 
        // 金额>1万,转总监
        
        RULES.add(ApprovalRule.of(ApprovalStatus.MANAGER_APPROVING, ApprovalAction.APPROVE, 
                    ApprovalStatus.APPROVED)
                .when(ctx -> ctx.getAmount().compareTo(new BigDecimal("10000")) <= 0)); 
        // 金额<=1万,直接通过
        
        // 规则3:经理审批中 -> 驳回
        RULES.add(ApprovalRule.of(ApprovalStatus.MANAGER_APPROVING, ApprovalAction.REJECT, 
                    ApprovalStatus.REJECTED));
        
        // ... 可以继续加总监审批的规则
    }

    /**
     * 核心执行方法
     */
    public static ApprovalStatus transit(ApprovalStatus currentStatus, 
                                          ApprovalAction action, 
                                          ApprovalContext context) {
        return RULES.stream()
                .filter(rule -> rule.getFrom() == currentStatus 
                        && rule.getAction() == action 
                        && rule.getCondition().test(context)) // 匹配动态条件
                .findFirst()
                .map(ApprovalRule::getTo)
                .orElseThrow(() -> new BusinessException("非法的审批操作或状态流转"));
    }
}

7.7 第六步:业务层调用(极其清爽)

java 复制代码
public void handleApproval(Long applyId, ApprovalAction action) {
    // 1. 查出申请单
    ApplyForm form = applyMapper.selectById(applyId);
    
    // 2. 构建上下文
    ApprovalContext context = ApprovalContext.builder()
            .applyId(applyId)
            .amount(form.getAmount())
            .build();
            
    // 3. 状态机计算下一个状态
    ApprovalStatus currentStatus = ApprovalStatus.of(form.getStatus());
    ApprovalStatus nextStatus = ApprovalStateMachine.transit(currentStatus, action, context);
    
    // 4. 原子更新(依然是老配方,保证并发安全)
    int rows = applyMapper.updateStatus(applyId, currentStatus.getCode(), nextStatus.getCode());
    if (rows == 0) {
        throw new BusinessException("审批失败:单据状态已变更,请刷新");
    }
    
    // 5. 触发后续动作(如发消息给总监)
    if (nextStatus == ApprovalStatus.DIRECTOR_APPROVING) {
        notifyDirector(form);
    }
}

7.8 这套方案的三大优势

  1. 业务逻辑零耦合 :Service 层根本不知道"1万块钱要总监批"这种业务规则,它只负责把 Context 扔给状态机,状态机吐出 NextStatus
  2. 规则一目了然 :所有的流转规则都在 static 块里,就像一张表格,新来的同事看一眼就知道整个审批流是怎么走的。
  3. 高并发安全 :最后依然落地到 UPDATE ... WHERE status = 旧状态,数据库兜底,绝对不出错。

如果你们公司的审批流极其复杂(比如支持任意节点驳回、会签、加签),那可能就需要引入轻量级的状态机框架(比如 Spring StateMachine 或 Cola StateMachine)了。但如果是常规的线性审批,上面这套纯 Java 方案绝对是性价比最高的!


八、全文总结

8.1 事务优化三层进阶

层级 核心策略 关键代码模式
第一层:事务瘦身 非DB逻辑移出事务,晚开早提交 自注入代理 + 拆分内外层方法
第二层:并发安全 避免丢失更新,保证数据一致性 原子更新 / 悲观锁 / 乐观锁
第三层:规则集中 状态机统一管理流转规则 枚举配置 + 条件路由 + 原子更新

8.2 核心口诀

  1. 非数据库逻辑全部挪到事务外面;事务内尽量只保留写库和必要悲观锁查询。
  2. 强原子业务不能拆分事务,只能做事务瘦身,缩短事务执行时间。
  3. 允许部分成功的业务,拆分为多批独立小事务,可以真正释放锁。
  4. 状态流转永远用原子更新:UPDATE ... SET ... WHERE id=? AND status=旧状态。
  5. 流转规则集中管理,用状态机替代散落的 if-else。

8.3 CR检查清单(完整版)

  • RPC、HTTP远程调用是否已经移出事务;
  • 参数校验、普通无锁查询、对象组装、业务计算是否放在事务外部;
  • select ... for update 悲观锁查询是否保留在事务内部;
  • for update 后面不允许插入RPC/网络IO,紧跟着写库;
  • 如果循环更新同一张表多行,是否做数据库主键升序排序;
  • 批量操作是否做分批(建议200-500条每批);
  • 事务方法是否使用代理对象调用,避免this调用;
  • 区分业务是【强原子(一个大事务)】还是【允许部分成功(多独立小事务)】;
  • 状态流转是否使用原子更新(WHERE 条件校验旧状态);
  • 流转规则是否集中管理(状态机/枚举配置),而非散落 if-else。
相关推荐
笃行3502 小时前
SQLServer数据迁移之后,那张报表还能不能秒出
后端
不可求~2 小时前
多模型路由怎么落地:把任务分流写进项目的最小实现
java·前端·数据库
LucianaiB2 小时前
别再被 AI 骗了:我把腾讯云 4 个 Skill 做成了个「AI 打假侦探」,过程的一个小配置坑惨了我
后端
mldong3 小时前
用 DDD 设计工作流引擎:聚合根与充血模型
java·架构
candyTong3 小时前
Claude Code 如何恢复一段会话
后端·架构·ai编程
mqiqe4 小时前
AgentScope Java Harness:4. 双层记忆系统 让 Agent 拥有真正的“长期大脑“
java·开发语言
伍一514 小时前
03-文件管理模块-一个看似简单的模块藏着多少细节
java·ruoyi·若依
gugucoding4 小时前
46. 【Java】JUC并发工具:让并发更简单
java·开发语言
En^_^Joy4 小时前
Django项目配置全攻略:settings配置文件
后端·python·django
砍材农夫4 小时前
spring-ai|Spring‑AI 2.0.0 新特性 + 入门教程
spring boot·spring·spring cloud