Skip to content
第 58 / 250 章后端⏱ 10 分钟阅读

第 58 章:事务管理

学习目标

  • 掌握 @Transactional 的正确打开方式
  • 理解事务传播机制的 7 种行为
  • 识别事务自调用、try-catch 等常见坑
  • 了解分布式事务的几种解法

一、为什么需要事务?

事务的 ACID

  • Atomic(原子性):要么全成,要么全不成
  • Consistency(一致性):数据从一个一致状态到另一个一致状态
  • Isolation(隔离性):并发事务互不干扰
  • Durability(持久性):提交后永久生效
sql
-- 事务就是把多条 SQL 打包成一个原子操作
BEGIN;
UPDATE account SET money = money - 100 WHERE id = 1;
UPDATE account SET money = money + 100 WHERE id = 2;
COMMIT;       -- 或 ROLLBACK

二、@Transactional 基础

java
@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());

        // ④ 任何一步抛异常 → 全部回滚
    }
}

关键参数

java
@Transactional(
    propagation = Propagation.REQUIRED,        // ① 传播行为(默认)
    isolation = Isolation.DEFAULT,             // ② 隔离级别(默认跟随数据库)
    timeout = 30,                              // ③ 超时时间(秒)
    readOnly = false,                          // ④ 是否只读(可优化)
    rollbackFor = Exception.class              // ⑤ 哪些异常回滚
)

⑤ 为什么必须写 rollbackFor = Exception.class

@Transactional 默认只在遇到 RuntimeExceptionError 时回滚,对受检异常 Exception(如 IOException不回滚

这是 Spring 的历史包袱。实际开发中几乎所有业务异常都是 RuntimeException 子类(比如自定义的 BusinessException),所以问题不大;但你还是要写 rollbackFor = Exception.class 求个安心。

三、事务传播机制(重点)

传播机制:当一个事务方法调用另一个事务方法时,事务该如何传播?

java
@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 怎么选?

java
// 场景:下单成功后,无论如何都要记录操作日志
@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:自调用(最常见!)

java
@Service
public class OrderServiceImpl {

    // ❌ 自调用:绕过 Spring 代理,事务不生效!
    public void placeOrder() {
        createOrder();              // 直接调用本类方法,this 调用不走代理
    }

    @Transactional
    public void createOrder() {
        orderMapper.insert(new Order());
        // 抛异常时,数据库照样写入成功,因为根本没有事务!
    }
}
java
// ✅ 方案 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 吞掉异常

java
@Transactional
public void placeOrder() {
    try {
        inventoryService.deduct();
        accountService.deduct();
    } catch (Exception e) {     // ❌ 把异常吃了
        log.error("下单失败", e);
    }
    // 事务不会回滚!因为 Spring 看到的是"方法正常返回"
}
java
// ✅ 正确做法
@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:异常类型不匹配

java
@Transactional                          // 默认只回滚 RuntimeException
public void placeOrder() throws IOException {
    inventoryService.deduct();
    throw new IOException("IO 错误");    // ❌ 受检异常,默认不回滚
}
java
// ✅ 显式声明
@Transactional(rollbackFor = Exception.class)   // 所有异常都回滚

坑 4:异步 / 多线程失效

java
@Transactional
public void batchProcess(List<Item> items) {
    items.parallelStream().forEach(item -> {
        // ❌ 多线程环境,事务不生效
        // 事务是绑定在 ThreadLocal 上的,新线程里是空的
        processItem(item);
    });
}
java
// ✅ 方案:每个任务自己开事务
public void batchProcess(List<Item> items) {
    items.parallelStream().forEach(item -> {
        itemService.processOne(item);      // 单独走 @Transactional 方法
    });
}

坑 5:多数据源事务

java
// ❌ 一个 @Transactional 不能跨多个数据源
@Transactional
public void syncData() {
    masterMapper.insert(...);      // 主库
    slaveMapper.insert(...);       // 从库 → 报错:无法跨数据源管理事务
}
java
// ✅ 用 ChainedKafkaTransactionManager 或 Seata 等分布式事务方案

五、事务隔离级别

sql
-- MySQL 默认 REPEATABLE READ(可重复读)
-- Oracle 默认 READ COMMITTED(读已提交)
隔离级别脏读不可重复读幻读性能
READ UNCOMMITTED最高
READ COMMITTED
REPEATABLE READ(MySQL 默认)✅(InnoDB 用间隙锁部分解决)
SERIALIZABLE最低
java
@Transactional(isolation = Isolation.READ_COMMITTED)   // 自定义隔离级别
public List<Order> listRecent() {
    return orderMapper.selectRecent();
}

99% 场景下不需要动隔离级别。让数据库用默认的就好。真出现并发问题,先优化 SQL 和加锁,而不是改隔离级别。

六、分布式事务

问题:订单服务和库存服务是两个独立应用,各自有数据库。本地事务管不了跨服务的数据一致性。

方案 1:Seata AT 模式(强一致)

xml
<dependency>
    <groupId>io.seata</groupId>
    <artifactId>seata-spring-boot-starter</artifactId>
    <version>2.0.0</version>
</dependency>
java
@GlobalTransactional     // ① 全局事务注解
public void placeOrder(OrderDTO dto) {
    orderService.create(dto);          // 远程调用:订单服务
    inventoryService.deduct(dto);      // 远程调用:库存服务
    // 任一失败 → 全部回滚(基于 undo_log 日志)
}

方案 2:MQ 最终一致性(推荐)

java
// ① 订单服务:本地事务里插入消息
@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 而不是自己造轮子

七、只读事务优化

java
@Transactional(readOnly = true)   // ① 提示数据库这是只读事务
public List<Order> listOrders(Long userId) {
    return orderMapper.selectByUserId(userId);
}

好处

  • MySQL 会跳过事务日志(undo log)的生成
  • 一些 ORM 框架会跳过脏检查
  • 提示数据库可以走只读副本

MyBatis-Plus 链式查询自动只读? 不会。readOnly = true 只是个提示,它不会真的阻止你执行写操作

八、事务调试技巧

开启事务日志

yaml
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 [...]

排查事务不生效

java
// ① 确认注解被 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 章:数据库设计规范

本站基于 VitePress 构建 · 由 Codebook 团队维护