Spring 事务商业场景训练营
Spring 事务不能只背 @Transactional、传播行为和事务失效原因。商业项目里真正要会的是:能把订单、库存、采集批次、失败日志、补偿记录放进正确事务边界;能解释声明式事务为什么依赖代理;能知道异常被 catch 后为什么不回滚;能在一个方法里用编程式事务精确控制提交范围;能处理 REQUIRES_NEW、自调用、跨线程、批处理这些真实问题。
训练目标:每个场景都要能写代码、解释事务边界、说明不这样会怎样、知道怎么排查、最后能转成面试标准回答。
训练总流程
flowchart TD
A["确定业务一致性边界"] --> B["选择声明式或编程式事务"]
B --> C["配置传播行为和回滚规则"]
C --> D["执行业务 SQL"]
D --> E{"是否异常"}
E -- "否" --> F["提交事务"]
E -- "是" --> G["按规则回滚或标记 rollbackOnly"]
G --> H["排查事务是否经过代理"]
F --> I["整理面试回答"]
H --> I训练一:订单创建和库存扣减
场景
创建订单时要同时写订单、扣库存、写库存流水。这三步必须在一个事务里:订单写了但库存没扣,会超卖;库存扣了但订单没写,会产生脏库存。
声明式事务 Demo
@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:
update inventory
set stock = stock - #{count}
where sku_id = #{skuId}
and stock >= #{count}原理解释
外部调用 orderService.createOrder() 时,调用的是 Spring 代理对象。代理先通过事务管理器获取连接,关闭自动提交,把连接绑定到当前线程。Mapper 执行 SQL 时从当前线程拿到同一个连接。方法正常返回就提交,抛出符合规则的异常就回滚。
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 后为什么不回滚
错误示例
@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()。
正确写法一:重新抛出异常
@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;
}
}正确写法二:标记只回滚
@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());
}
}原理图
flowchart TD
A["业务抛异常"] --> B{"是否被 catch"}
B -- "否" --> C["异常传到事务代理"]
C --> D["代理判断需要回滚"]
D --> E["rollback"]
B -- "是" --> F["方法正常返回"]
F --> G["代理认为成功"]
G --> H["commit"]训练三:失败日志为什么常用 REQUIRES_NEW
场景
采集任务导入失败时,主事务要回滚,但失败日志必须保留,否则排查时什么都没有。
错误示例
@Transactional
public void importBatch(List<AssetDTO> rows) {
try {
doImport(rows);
} catch (Exception e) {
failLogMapper.insert(FailLog.from(e));
throw e;
}
}failLogMapper.insert() 和主事务在同一个事务中。最后抛异常回滚,失败日志也被回滚。
正确写法
@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));
}
}主流程:
@Transactional(rollbackFor = Exception.class)
public void importBatch(List<AssetDTO> rows) {
try {
doImport(rows);
} catch (Exception e) {
failLogService.saveFailLog(e);
throw e;
}
}原理
REQUIRES_NEW 会挂起外层事务,开启一个独立新事务。失败日志提交后,再回到外层事务,外层事务可以继续回滚。
flowchart TD
A["外层导入事务"] --> B["发生异常"]
B --> C["调用失败日志服务"]
C --> D["挂起外层事务"]
D --> E["开启新事务保存日志"]
E --> F["提交日志事务"]
F --> G["恢复外层事务"]
G --> H["外层事务回滚"]训练四:自调用为什么事务失效
错误示例
@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 代理对象,事务拦截器不会执行。
正确做法
- 把事务方法拆到另一个 Service。
- 从 Spring 容器拿代理对象调用。
- 调整事务边界,把事务加在外部入口方法上。
推荐拆分:
@Service
public class AssetWriteService {
@Transactional
public void saveAsset(AssetDTO dto) {
assetMapper.insert(dto.toEntity());
}
}flowchart TD
A["同类 this 调用"] --> B["绕过代理"]
B --> C["事务失效"]
D["调用另一个 Service"] --> E["经过代理对象"]
E --> F["事务生效"]训练五:编程式事务控制批处理
场景
导入 10 万条采集数据。如果一个大事务包住全部数据,失败回滚成本高,锁和 undo/redo 压力也大。更合理的是分批提交,每批 500 或 1000 条。
TransactionTemplate Demo
@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 中。新线程拿不到这个上下文。
@Transactional
public void importAsync(List<AssetDTO> rows) {
assetBatchMapper.insertBatch(rows);
CompletableFuture.runAsync(() -> {
auditMapper.insert(AuditLog.imported(rows.size()));
});
}异步线程里的 auditMapper.insert() 不在外层事务里。
flowchart TD
A["主线程开启事务"] --> B["Connection 绑定主线程"]
B --> C["提交异步任务"]
C --> D["异步线程执行 SQL"]
D --> E["拿不到主线程事务连接"]
E --> F["独立事务或自动提交"]解决方式:
- 不要把必须原子一致的 SQL 放到异步线程。
- 异步任务自己声明事务。
- 主事务提交后发 MQ 或应用事件,让异步逻辑最终一致执行。
训练七:事务提交后再发消息
如果在事务未提交时就发 MQ,消费者可能先查库,发现数据还没提交。
可以使用事务同步回调:
@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 是否命中索引 |
面试标准回答
Spring 事务本质是 Spring 对数据库事务的统一抽象。声明式事务通过 AOP 代理和 TransactionInterceptor 在方法外层开启、提交或回滚事务;编程式事务通过 TransactionTemplate 或 PlatformTransactionManager 在代码中控制边界。常规 Service 写操作优先用 @Transactional,批处理分批提交、部分代码需要事务、捕获异常但仍需回滚时可以用编程式事务。事务失效常见原因是自调用绕过代理、异常被 catch、方法不是 public、异常类型不触发回滚、跨线程、数据库不支持事务等。传播行为里 REQUIRED 是加入外层事务,REQUIRES_NEW 是挂起外层并新开事务,常用于失败日志和补偿记录独立提交。关联知识点
| 知识点 | 入口 |
|---|---|
| Spring 事务原理 | Spring 事务 |
| Spring AOP | Spring AOP |
| Spring 扩展点 | Spring 核心扩展点 |
| Spring 面试 | Spring 面试知识点 |
