Skip to content

Spring 事务商业场景训练营

Spring 事务不能只背 @Transactional、传播行为和事务失效原因。商业项目里真正要会的是:能把订单、库存、采集批次、失败日志、补偿记录放进正确事务边界;能解释声明式事务为什么依赖代理;能知道异常被 catch 后为什么不回滚;能在一个方法里用编程式事务精确控制提交范围;能处理 REQUIRES_NEW、自调用、跨线程、批处理这些真实问题。

训练目标:每个场景都要能写代码、解释事务边界、说明不这样会怎样、知道怎么排查、最后能转成面试标准回答。

训练总流程

mermaid
flowchart TD
    A["确定业务一致性边界"] --> B["选择声明式或编程式事务"]
    B --> C["配置传播行为和回滚规则"]
    C --> D["执行业务 SQL"]
    D --> E{"是否异常"}
    E -- "否" --> F["提交事务"]
    E -- "是" --> G["按规则回滚或标记 rollbackOnly"]
    G --> H["排查事务是否经过代理"]
    F --> I["整理面试回答"]
    H --> I

训练一:订单创建和库存扣减

场景

创建订单时要同时写订单、扣库存、写库存流水。这三步必须在一个事务里:订单写了但库存没扣,会超卖;库存扣了但订单没写,会产生脏库存。

声明式事务 Demo

java
@Service
public class OrderService {

    private final OrderMapper orderMapper;
    private final InventoryMapper inventoryMapper;
    private final InventoryFlowMapper inventoryFlowMapper;

    public OrderService(OrderMapper orderMapper,
                        InventoryMapper inventoryMapper,
                        InventoryFlowMapper inventoryFlowMapper) {
        this.orderMapper = orderMapper;
        this.inventoryMapper = inventoryMapper;
        this.inventoryFlowMapper = inventoryFlowMapper;
    }

    @Transactional(rollbackFor = Exception.class)
    public void createOrder(CreateOrderCommand command) {
        orderMapper.insert(command.toOrder());

        int affected = inventoryMapper.deduct(command.getSkuId(), command.getCount());
        if (affected != 1) {
            throw new IllegalStateException("库存不足");
        }

        inventoryFlowMapper.insert(command.toInventoryFlow());
    }
}

库存扣减 SQL:

sql
update inventory
set stock = stock - #{count}
where sku_id = #{skuId}
  and stock >= #{count}

原理解释

外部调用 orderService.createOrder() 时,调用的是 Spring 代理对象。代理先通过事务管理器获取连接,关闭自动提交,把连接绑定到当前线程。Mapper 执行 SQL 时从当前线程拿到同一个连接。方法正常返回就提交,抛出符合规则的异常就回滚。

mermaid
flowchart TD
    A["Controller 调用代理对象"] --> B["TransactionInterceptor"]
    B --> C["获取 Connection 并开启事务"]
    C --> D["insert order"]
    D --> E["update inventory"]
    E --> F["insert inventory_flow"]
    F --> G{"方法是否抛出回滚异常"}
    G -- "否" --> H["commit"]
    G -- "是" --> I["rollback"]

不这样会怎样

错误做法后果
三个 SQL 不在一个事务订单、库存、流水可能不一致
库存先查再扣并发下可能超卖
不检查影响行数库存不足仍然创建订单
只写 @Transactional 不抛异常业务失败但事务提交

训练二:异常被 catch 后为什么不回滚

错误示例

java
@Transactional
public void importAsset(AssetDTO dto) {
    try {
        assetMapper.insert(dto.toEntity());
        relationMapper.insert(dto.toRelation());
    } catch (Exception e) {
        log.error("导入失败", e);
    }
}

这段代码如果 relationMapper.insert() 失败,但异常被 catch 且没有重新抛出,事务代理看到方法“正常返回”,就会提交 assetMapper.insert()

正确写法一:重新抛出异常

java
@Transactional(rollbackFor = Exception.class)
public void importAsset(AssetDTO dto) {
    try {
        assetMapper.insert(dto.toEntity());
        relationMapper.insert(dto.toRelation());
    } catch (Exception e) {
        log.error("导入失败", e);
        throw e;
    }
}

正确写法二:标记只回滚

java
@Transactional
public ImportResult importAsset(AssetDTO dto) {
    try {
        assetMapper.insert(dto.toEntity());
        relationMapper.insert(dto.toRelation());
        return ImportResult.success();
    } catch (Exception e) {
        TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
        return ImportResult.fail(e.getMessage());
    }
}

原理图

mermaid
flowchart TD
    A["业务抛异常"] --> B{"是否被 catch"}
    B -- "否" --> C["异常传到事务代理"]
    C --> D["代理判断需要回滚"]
    D --> E["rollback"]
    B -- "是" --> F["方法正常返回"]
    F --> G["代理认为成功"]
    G --> H["commit"]

训练三:失败日志为什么常用 REQUIRES_NEW

场景

采集任务导入失败时,主事务要回滚,但失败日志必须保留,否则排查时什么都没有。

错误示例

java
@Transactional
public void importBatch(List<AssetDTO> rows) {
    try {
        doImport(rows);
    } catch (Exception e) {
        failLogMapper.insert(FailLog.from(e));
        throw e;
    }
}

failLogMapper.insert() 和主事务在同一个事务中。最后抛异常回滚,失败日志也被回滚。

正确写法

java
@Service
public class FailLogService {

    private final FailLogMapper failLogMapper;

    public FailLogService(FailLogMapper failLogMapper) {
        this.failLogMapper = failLogMapper;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveFailLog(Exception e) {
        failLogMapper.insert(FailLog.from(e));
    }
}

主流程:

java
@Transactional(rollbackFor = Exception.class)
public void importBatch(List<AssetDTO> rows) {
    try {
        doImport(rows);
    } catch (Exception e) {
        failLogService.saveFailLog(e);
        throw e;
    }
}

原理

REQUIRES_NEW 会挂起外层事务,开启一个独立新事务。失败日志提交后,再回到外层事务,外层事务可以继续回滚。

mermaid
flowchart TD
    A["外层导入事务"] --> B["发生异常"]
    B --> C["调用失败日志服务"]
    C --> D["挂起外层事务"]
    D --> E["开启新事务保存日志"]
    E --> F["提交日志事务"]
    F --> G["恢复外层事务"]
    G --> H["外层事务回滚"]

训练四:自调用为什么事务失效

错误示例

java
@Service
public class AssetService {

    public void importOne(AssetDTO dto) {
        saveAsset(dto);
    }

    @Transactional
    public void saveAsset(AssetDTO dto) {
        assetMapper.insert(dto.toEntity());
    }
}

importOne() 内部用 this.saveAsset() 调用,没有经过 Spring 代理对象,事务拦截器不会执行。

正确做法

  1. 把事务方法拆到另一个 Service。
  2. 从 Spring 容器拿代理对象调用。
  3. 调整事务边界,把事务加在外部入口方法上。

推荐拆分:

java
@Service
public class AssetWriteService {
    @Transactional
    public void saveAsset(AssetDTO dto) {
        assetMapper.insert(dto.toEntity());
    }
}
mermaid
flowchart TD
    A["同类 this 调用"] --> B["绕过代理"]
    B --> C["事务失效"]
    D["调用另一个 Service"] --> E["经过代理对象"]
    E --> F["事务生效"]

训练五:编程式事务控制批处理

场景

导入 10 万条采集数据。如果一个大事务包住全部数据,失败回滚成本高,锁和 undo/redo 压力也大。更合理的是分批提交,每批 500 或 1000 条。

TransactionTemplate Demo

java
@Service
public class AssetImportService {

    private final TransactionTemplate transactionTemplate;
    private final AssetMapper assetMapper;

    public AssetImportService(TransactionTemplate transactionTemplate,
                              AssetMapper assetMapper) {
        this.transactionTemplate = transactionTemplate;
        this.assetMapper = assetMapper;
    }

    public void importLargeFile(List<AssetDTO> rows) {
        List<List<AssetDTO>> batches = Lists.partition(rows, 500);
        for (List<AssetDTO> batch : batches) {
            transactionTemplate.executeWithoutResult(status -> {
                for (AssetDTO row : batch) {
                    assetMapper.insert(row.toEntity());
                }
            });
        }
    }
}

为什么适合编程式事务

声明式事务加在 importLargeFile() 上,会把整个文件放进一个大事务。编程式事务可以精确包住每一批。

做法优点风险
一个大事务全部成功或失败锁久、日志大、回滚慢
每条一个事务失败影响小提交太频繁,性能差
每批一个事务性能和风险折中要设计失败批次补偿

训练六:跨线程事务为什么失效

事务资源绑定在线程上。当前线程开启事务后,连接保存在 TransactionSynchronizationManager 的 ThreadLocal 中。新线程拿不到这个上下文。

java
@Transactional
public void importAsync(List<AssetDTO> rows) {
    assetBatchMapper.insertBatch(rows);

    CompletableFuture.runAsync(() -> {
        auditMapper.insert(AuditLog.imported(rows.size()));
    });
}

异步线程里的 auditMapper.insert() 不在外层事务里。

mermaid
flowchart TD
    A["主线程开启事务"] --> B["Connection 绑定主线程"]
    B --> C["提交异步任务"]
    C --> D["异步线程执行 SQL"]
    D --> E["拿不到主线程事务连接"]
    E --> F["独立事务或自动提交"]

解决方式:

  1. 不要把必须原子一致的 SQL 放到异步线程。
  2. 异步任务自己声明事务。
  3. 主事务提交后发 MQ 或应用事件,让异步逻辑最终一致执行。

训练七:事务提交后再发消息

如果在事务未提交时就发 MQ,消费者可能先查库,发现数据还没提交。

可以使用事务同步回调:

java
@Transactional
public void createOrder(CreateOrderCommand command) {
    Order order = orderMapper.insert(command.toOrder());

    TransactionSynchronizationManager.registerSynchronization(
        new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                mqTemplate.send("order.created", order.getOrderNo());
            }
        }
    );
}

更可靠的商业方案仍然是本地消息表或事务消息,因为 afterCommit 里进程宕机仍可能导致消息没发。

事务失效排查清单

现象排查点
注解没生效是否从外部经过代理对象调用
回滚没发生异常是否被 catch,异常类型是否符合回滚规则
部分 SQL 提交是否跨线程、是否用了多个数据源、是否不在同一事务管理器
REQUIRES_NEW 没独立提交是否同类自调用,是否调用了代理对象
批处理很慢事务是否过大,是否逐条提交,是否缺批量写
锁等待严重事务边界是否过大,SQL 是否命中索引

面试标准回答

text
Spring 事务本质是 Spring 对数据库事务的统一抽象。声明式事务通过 AOP 代理和 TransactionInterceptor 在方法外层开启、提交或回滚事务;编程式事务通过 TransactionTemplate 或 PlatformTransactionManager 在代码中控制边界。常规 Service 写操作优先用 @Transactional,批处理分批提交、部分代码需要事务、捕获异常但仍需回滚时可以用编程式事务。事务失效常见原因是自调用绕过代理、异常被 catch、方法不是 public、异常类型不触发回滚、跨线程、数据库不支持事务等。传播行为里 REQUIRED 是加入外层事务,REQUIRES_NEW 是挂起外层并新开事务,常用于失败日志和补偿记录独立提交。

关联知识点

知识点入口
Spring 事务原理Spring 事务
Spring AOPSpring AOP
Spring 扩展点Spring 核心扩展点
Spring 面试Spring 面试知识点