第 58 章:事务管理
学习目标
- 掌握
@Transactional的正确打开方式 - 理解事务传播机制的 7 种行为
- 识别事务自调用、try-catch 等常见坑
- 了解分布式事务的几种解法
一、为什么需要事务?
事务的 ACID:
- Atomic(原子性):要么全成,要么全不成
- Consistency(一致性):数据从一个一致状态到另一个一致状态
- Isolation(隔离性):并发事务互不干扰
- Durability(持久性):提交后永久生效
-- 事务就是把多条 SQL 打包成一个原子操作
BEGIN;
UPDATE account SET money = money - 100 WHERE id = 1;
UPDATE account SET money = money + 100 WHERE id = 2;
COMMIT; -- 或 ROLLBACK二、@Transactional 基础
@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {
private final OrderMapper orderMapper;
private final AccountService accountService; // ① 注入另一个 Service
private final InventoryService inventoryService;
// ② 关键注解:方法运行在事务里
@Transactional(rollbackFor = Exception.class)
public void placeOrder(OrderDTO dto) {
// 1. 扣库存
inventoryService.deduct(dto.getSkuId(), dto.getQuantity());
// 2. 创建订单
Order order = new Order();
orderMapper.insert(order);
// 3. 扣款
accountService.deduct(dto.getUserId(), dto.getAmount());
// ④ 任何一步抛异常 → 全部回滚
}
}关键参数
@Transactional(
propagation = Propagation.REQUIRED, // ① 传播行为(默认)
isolation = Isolation.DEFAULT, // ② 隔离级别(默认跟随数据库)
timeout = 30, // ③ 超时时间(秒)
readOnly = false, // ④ 是否只读(可优化)
rollbackFor = Exception.class // ⑤ 哪些异常回滚
)⑤ 为什么必须写
rollbackFor = Exception.class?
@Transactional默认只在遇到RuntimeException和Error时回滚,对受检异常Exception(如IOException)不回滚。这是 Spring 的历史包袱。实际开发中几乎所有业务异常都是
RuntimeException子类(比如自定义的BusinessException),所以问题不大;但你还是要写rollbackFor = Exception.class求个安心。
三、事务传播机制(重点)
传播机制:当一个事务方法调用另一个事务方法时,事务该如何传播?
@Transactional(propagation = Propagation.REQUIRED) // 默认
public void outer() {
inner();
}
@Transactional(propagation = Propagation.REQUIRES_NEW) // 独立事务
public void inner() { }| 传播行为 | 含义 | 典型场景 |
|---|---|---|
| REQUIRED(默认) | 已有事务就加入,没有就新建 | 绝大多数场景 |
| REQUIRES_NEW | 无论有没有,都开新事务,挂起外层 | 操作日志、审计独立提交 |
| NESTED | 在外层事务里开子事务(Savepoint) | 部分失败可回滚,整体可继续 |
| SUPPORTS | 有事务就用,没有就非事务跑 | 查询方法 |
| MANDATORY | 必须在已有事务里跑,否则抛异常 | 必须被事务包裹 |
| NEVER | 必须在没有事务的环境下跑 | 强制非事务 |
| NOT_SUPPORTED | 挂起外层事务,非事务跑 | 不希望被外层事务影响 |
REQUIRED vs REQUIRES_NEW vs NESTED 怎么选?
// 场景:下单成功后,无论如何都要记录操作日志
@Transactional
public void placeOrder() {
// ① 主业务事务
orderMapper.insert(order);
inventoryService.deduct();
accountService.deduct();
// ② 日志独立提交,不受主事务影响
logService.saveLog(); // @Transactional(REQUIRES_NEW)
}
// 场景:批量导入,每条独立失败不影响其他
@Transactional
public void batchImport(List<Item> items) {
for (Item item : items) {
try {
importService.importOne(item); // @Transactional(NESTED)
} catch (Exception e) {
// ③ 单条失败不影响外层
}
}
}REQUIRES_NEW 的代价:它会挂起外层事务,用一个新的数据库连接。频繁使用会让连接池爆掉。除非必要(如审计日志),否则优先用
REQUIRED。
四、@Transactional 的 5 大经典坑
坑 1:自调用(最常见!)
@Service
public class OrderServiceImpl {
// ❌ 自调用:绕过 Spring 代理,事务不生效!
public void placeOrder() {
createOrder(); // 直接调用本类方法,this 调用不走代理
}
@Transactional
public void createOrder() {
orderMapper.insert(new Order());
// 抛异常时,数据库照样写入成功,因为根本没有事务!
}
}// ✅ 方案 1:拆成两个 Service
@Service
public class OrderService { // 主调用方
private final OrderCreateService createService;
public void placeOrder() {
createService.createOrder(); // 跨 Service 调用,走 Spring 代理
}
}
@Service
public class OrderCreateService { // 事务方
@Transactional
public void createOrder() { ... }
}
// ✅ 方案 2:注入自己(AopContext)
@Service
public class OrderServiceImpl {
private final OrderService self; // 注入自己的代理
public OrderServiceImpl(@Lazy OrderService self) {
this.self = self;
}
public void placeOrder() {
self.createOrder(); // 通过代理调用
}
}
// ⚠️ 方案 3:用 TransactionTemplate 手动控制(最稳)
@Service
public class OrderServiceImpl {
private final TransactionTemplate tx;
public void placeOrder() {
tx.execute(status -> {
createOrder(); // 在 lambda 里调用,没有 this 问题
return null;
});
}
}坑 2:try-catch 吞掉异常
@Transactional
public void placeOrder() {
try {
inventoryService.deduct();
accountService.deduct();
} catch (Exception e) { // ❌ 把异常吃了
log.error("下单失败", e);
}
// 事务不会回滚!因为 Spring 看到的是"方法正常返回"
}// ✅ 正确做法
@Transactional
public void placeOrder() {
try {
inventoryService.deduct();
accountService.deduct();
} catch (Exception e) {
log.error("下单失败", e);
throw e; // ① 重新抛出,让 Spring 感知到
}
}
// ✅ 或者手动标记回滚
@Transactional
public void placeOrder() {
try {
inventoryService.deduct();
} catch (Exception e) {
TransactionAspectSupport.currentTransactionStatus()
.setRollbackOnly(); // ② 强制标记回滚
throw new BusinessException("库存不足");
}
}坑 3:异常类型不匹配
@Transactional // 默认只回滚 RuntimeException
public void placeOrder() throws IOException {
inventoryService.deduct();
throw new IOException("IO 错误"); // ❌ 受检异常,默认不回滚
}// ✅ 显式声明
@Transactional(rollbackFor = Exception.class) // 所有异常都回滚坑 4:异步 / 多线程失效
@Transactional
public void batchProcess(List<Item> items) {
items.parallelStream().forEach(item -> {
// ❌ 多线程环境,事务不生效
// 事务是绑定在 ThreadLocal 上的,新线程里是空的
processItem(item);
});
}// ✅ 方案:每个任务自己开事务
public void batchProcess(List<Item> items) {
items.parallelStream().forEach(item -> {
itemService.processOne(item); // 单独走 @Transactional 方法
});
}坑 5:多数据源事务
// ❌ 一个 @Transactional 不能跨多个数据源
@Transactional
public void syncData() {
masterMapper.insert(...); // 主库
slaveMapper.insert(...); // 从库 → 报错:无法跨数据源管理事务
}// ✅ 用 ChainedKafkaTransactionManager 或 Seata 等分布式事务方案五、事务隔离级别
-- MySQL 默认 REPEATABLE READ(可重复读)
-- Oracle 默认 READ COMMITTED(读已提交)| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 性能 |
|---|---|---|---|---|
| READ UNCOMMITTED | ✅ | ✅ | ✅ | 最高 |
| READ COMMITTED | ❌ | ✅ | ✅ | 高 |
| REPEATABLE READ(MySQL 默认) | ❌ | ❌ | ✅(InnoDB 用间隙锁部分解决) | 中 |
| SERIALIZABLE | ❌ | ❌ | ❌ | 最低 |
@Transactional(isolation = Isolation.READ_COMMITTED) // 自定义隔离级别
public List<Order> listRecent() {
return orderMapper.selectRecent();
}99% 场景下不需要动隔离级别。让数据库用默认的就好。真出现并发问题,先优化 SQL 和加锁,而不是改隔离级别。
六、分布式事务
问题:订单服务和库存服务是两个独立应用,各自有数据库。本地事务管不了跨服务的数据一致性。
方案 1:Seata AT 模式(强一致)
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>2.0.0</version>
</dependency>@GlobalTransactional // ① 全局事务注解
public void placeOrder(OrderDTO dto) {
orderService.create(dto); // 远程调用:订单服务
inventoryService.deduct(dto); // 远程调用:库存服务
// 任一失败 → 全部回滚(基于 undo_log 日志)
}方案 2:MQ 最终一致性(推荐)
// ① 订单服务:本地事务里插入消息
@Transactional
public void placeOrder(OrderDTO dto) {
// 创建订单
orderMapper.insert(order);
// 插入本地消息(和订单在同一个事务)
LocalMessage msg = new LocalMessage();
msg.setTopic("inventory.deduct");
msg.setPayload(JSON.toJSONString(dto));
localMessageMapper.insert(msg);
}
// ② 定时任务:扫表发送消息
@Scheduled(fixedDelay = 1000)
public void sendPendingMessages() {
List<LocalMessage> msgs = localMessageMapper.selectByStatus(0); // 待发送
for (LocalMessage msg : msgs) {
try {
mqClient.send(msg.getTopic(), msg.getPayload());
msg.setStatus(1); // 已发送
localMessageMapper.updateById(msg);
} catch (Exception e) {
log.error("发送失败", e);
}
}
}
// ③ 库存服务:消费消息,幂等扣库存
@RocketMQMessageListener(topic = "inventory.deduct")
public void onMessage(Message msg) {
OrderDTO dto = JSON.parseObject(msg.getBody(), OrderDTO.class);
inventoryService.deductIdempotent(dto.getSkuId(), dto.getQuantity());
// 扣库存前先查消息ID是否处理过(幂等)
}方案 3:本地消息表(最简单)
上面 MQ 方案的第一步就是本地消息表,不用 MQ 也能做:定时扫表 + 重试。
方案对比
| 方案 | 一致性 | 性能 | 复杂度 | 适用 |
|---|---|---|---|---|
| Seata AT | 强一致 | 中(加锁) | 中 | 金融、订单等强一致场景 |
| MQ 最终一致 | 最终一致 | 高 | 中 | 绝大多数业务 |
| 本地消息表 | 最终一致 | 高 | 低 | 中小项目、订单量不大 |
| TCC | 强一致 | 高 | 高 | 高性能要求的大厂 |
经验法则:
- 能用本地事务就别上分布式(拆服务要谨慎)
- 能用最终一致性就别追求强一致(用户体验几乎一样,工程复杂度天差地别)
- 真要强一致,上 Seata 而不是自己造轮子
七、只读事务优化
@Transactional(readOnly = true) // ① 提示数据库这是只读事务
public List<Order> listOrders(Long userId) {
return orderMapper.selectByUserId(userId);
}好处:
- MySQL 会跳过事务日志(
undo log)的生成 - 一些 ORM 框架会跳过脏检查
- 提示数据库可以走只读副本
MyBatis-Plus 链式查询自动只读? 不会。
readOnly = true只是个提示,它不会真的阻止你执行写操作。
八、事务调试技巧
开启事务日志
logging:
level:
org.springframework.transaction.interceptor: trace
org.springframework.transaction.support: trace输出:
[DEBUG] Creating new transaction with name [com.taskflow.OrderService.placeOrder]
[DEBUG] Acquired Connection [HikariProxyConnection@xxx] for JDBC transaction
[DEBUG] Participating in existing transaction
[DEBUG] Initiating transaction commit
[DEBUG] Committing JDBC transaction on Connection [...]排查事务不生效
// ① 确认注解被 Spring 代理了
AopUtils.isAopProxy(orderService); // 应该返回 true
// ② 确认方法被外部调用(不是 this 调用)
// ③ 确认异常被抛出(没被 catch 吞掉)
// ④ 确认 Bean 是通过 Spring 注入的(不是 new 出来的)九、本章小结
| 要点 | 关键 |
|---|---|
@Transactional | 默认 REQUIRED + 只回滚 RuntimeException |
必加 rollbackFor = Exception.class | 求个安心 |
| 传播机制 | REQUIRED 最常用;REQUIRES_NEW 用于独立提交 |
| 自调用 | 必踩坑,拆 Service 或用 TransactionTemplate |
| try-catch | 异常必须重新抛出,否则事务不回滚 |
| 多线程 | 事务绑定 ThreadLocal,新线程里是空的 |
| 分布式事务 | Seata 强一致 / MQ 最终一致(推荐) |
| 只读事务 | readOnly = true 提示数据库 |
| 排查 | 开 org.springframework.transaction debug 日志 |
动手练习
练习 1:基础题
实现转账功能:账户 A 减 100、账户 B 加 100。故意模拟异常(账户 B 不存在),验证 @Transactional 是否能回滚账户 A 的扣款。
练习 2:进阶题
实现下单操作:扣库存 + 创建订单 + 扣款。三个步骤任意一个失败全部回滚。用 TransactionTemplate 手动控制事务,避免自调用问题。
练习 3:思考题
下单成功后要记录操作日志(即使下单失败也要记录失败的日志)。如何设计事务传播机制?
下一章:第 59 章:数据库设计规范 →