Java 技术
#Spring#Transaction#Propagation#Isolation#REQUIRES_NEW#Deadlock

Spring @Transactional 传播行为与隔离级别的交互陷阱:订单创建与库存扣减中的幻读与死锁

本文剖析 Spring 声明式事务中传播行为(特别是 REQUIRES_NEW)与数据库隔离级别的组合效应。通过订单创建与库存扣减的跨服务事务编排场景,解释 Spring 事务管理器如何映射传播行为到 JDBC 连接、保存点与事务同步,并分析不同隔离级别下的锁行为与异常路径。读者将获得避免事务边界错误和性能问题的决策矩阵,并理解为何默认隔离级别可能不适用于高并发写操作。

在微服务架构中,一个常见的业务操作是“创建订单并扣减库存”。订单服务负责生成订单记录,库存服务负责减少对应商品的可用数量。为了保证数据一致性,这两个步骤通常需要在一个数据库事务中完成。然而,当业务规则要求订单创建必须成功、库存扣减失败时不能回滚订单时,开发者往往会引入 Spring 的 REQUIRES_NEW 传播行为,将库存扣减包装在一个独立的新事务中。

这种看似自然的做法,在特定的数据库隔离级别下会引入隐蔽的并发问题:幻读导致库存超卖,或者死锁导致事务失败。问题的根源在于,Spring 的声明式事务抽象掩盖了底层 JDBC 连接、保存点和锁行为的复杂性。本文将以订单创建与库存扣减为贯穿场景,剖析传播行为与隔离级别的交互机制,并给出可操作的决策矩阵。

场景定义与初始实现

考虑一个简化的订单服务:OrderService.createOrder() 方法负责插入订单记录,并调用 InventoryService.deductStock() 扣减库存。初始实现如下:

@Service
public class OrderService {
    @Autowired
    private InventoryService inventoryService;

    @Transactional
    public void createOrder(Order order) {
        // 插入订单
        orderDao.insert(order);
        // 扣减库存
        inventoryService.deductStock(order.getProductId(), order.getQuantity());
    }
}

@Service
public class InventoryService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void deductStock(Long productId, int quantity) {
        // 检查库存并扣减
        int updated = inventoryDao.deduct(productId, quantity);
        if (updated == 0) {
            throw new InsufficientStockException();
        }
    }
}

OrderService.createOrder() 使用默认传播行为 REQUIRED,如果当前存在事务则加入,否则新建。InventoryService.deductStock() 使用 REQUIRES_NEW,总是新建一个事务,挂起当前事务(如果存在)。这样,即使库存扣减失败抛出异常,订单记录也不会回滚,因为订单事务被挂起,库存事务独立回滚。

这种设计满足了“订单创建必须成功”的业务需求,但忽略了数据库隔离级别对并发操作的影响。

传播行为如何映射到 JDBC 连接与保存点

Spring 的事务管理抽象依赖于 PlatformTransactionManager,其核心实现 DataSourceTransactionManager 将事务映射到 JDBC 连接。当 @Transactional 注解的方法被调用时,Spring 的 AOP 代理会拦截方法调用,并根据传播行为决定如何处理事务资源。

对于 REQUIRED,Spring 会检查当前线程是否已绑定事务资源(一个 ConnectionHolder)。如果存在,则复用该连接;否则从 DataSource 获取新连接,开启自动提交关闭模式,并绑定到线程。

对于 REQUIRES_NEW,Spring 总是从 DataSource 获取一个新的 JDBC 连接,并开启新事务。同时,它会将当前线程上原有的事务资源(包括连接和保存点)挂起,保存到一个“挂起资源”栈中。当新事务完成后,Spring 会恢复被挂起的事务资源。

OrderService.createOrder() 执行时,Spring 为订单事务获取连接 conn1。当调用 inventoryService.deductStock() 时,由于传播行为是 REQUIRES_NEW,Spring 获取另一个连接 conn2,开启新事务,并将 conn1 挂起。库存扣减在 conn2 上执行,完成后提交或回滚。然后恢复 conn1,订单事务继续执行。

关键点在于,两个事务使用不同的数据库连接,因此它们拥有独立的数据库会话和锁上下文。这导致隔离级别的行为变得更加复杂,因为两个事务之间无法利用单连接内的事务隔离机制。

隔离级别与锁行为

数据库隔离级别定义了事务之间可见性和锁的持有方式。以 MySQL InnoDB 为例,其默认隔离级别为 REPEATABLE READ,通过一致性非锁定读和间隙锁(Gap Lock)来防止幻读。但在 REQUIRES_NEW 场景下,由于两个事务使用不同连接,隔离级别的实际效果会偏离直觉。

REPEATABLE READ 下的幻读风险

REPEATABLE READ 级别,InnoDB 使用快照读(Snapshot Read)保证同一事务内多次读取结果一致。但在库存扣减场景中,业务逻辑通常使用当前读(SELECT ... FOR UPDATE)来锁定行并检查库存。当库存服务在 REQUIRES_NEW 事务中执行 SELECT ... FOR UPDATE 时,它会锁定符合条件的行。

然而,订单事务(conn1)在插入订单后,可能还需要读取库存快照以生成订单详情。由于 conn1REPEATABLE READ 下使用快照读,它读取到的是事务开始时的库存数据,而不是库存扣减后的最新数据。这会导致订单记录中的库存快照与实际扣减不一致,但这不是幻读,而是快照隔离的预期行为。

真正的幻读风险出现在并发场景下。假设两个订单事务同时执行,都插入订单并调用库存扣减。库存扣减使用 REQUIRES_NEW,因此每个订单事务的库存扣减都在独立的新事务中执行。如果库存扣减使用当前读锁定行,那么后执行的事务会等待先执行的事务释放锁。但若库存扣减逻辑包含范围查询(例如,检查库存是否大于零),则可能因为间隙锁导致死锁。

间隙锁导致的死锁

考虑以下执行序列:

  1. 事务 T1(订单 A)在 conn2 上执行 SELECT * FROM inventory WHERE product_id = 100 FOR UPDATE,锁定 product_id=100 的行。
  2. 事务 T2(订单 B)在另一个 conn2 上执行相同的 SELECT ... FOR UPDATE,由于 T1 持有行锁,T2 等待。
  3. T1 的库存扣减事务提交,释放锁。
  4. T2 获得锁,继续执行。

这看起来正常,但若库存表有多个 product_id,且事务涉及范围查询,情况会更复杂。例如,库存扣减可能先查询所有库存记录(SELECT * FROM inventory WHERE quantity > 0 FOR UPDATE),这会锁定所有符合条件的行以及它们之间的间隙。两个并发事务可能各自持有部分间隙锁,并等待对方释放,导致死锁。

REPEATABLE READ 隔离级别下,InnoDB 的间隙锁是为了防止幻读,但在 REQUIRES_NEW 导致的多连接场景下,间隙锁的持有时间跨多个事务,死锁概率增加。

隔离级别与传播行为的交互矩阵

为了帮助决策,下表总结了常见隔离级别与 REQUIRES_NEW 组合在订单-库存场景下的行为。假设数据库为 MySQL InnoDB,库存扣减使用当前读。

隔离级别传播行为库存可见性锁行为幻读风险死锁风险适用场景
READ_UNCOMMITTEDREQUIRES_NEW可读取未提交的库存扣减无行锁(除非显式加锁)高(脏读)极少使用,数据一致性要求极低
READ_COMMITTEDREQUIRES_NEW只能读取已提交的库存扣减行锁,无间隙锁可能(非重复读)允许不可重复读,但避免脏读
REPEATABLE_READREQUIRES_NEW快照读:事务开始时的库存;当前读:最新已提交行锁 + 间隙锁快照读无幻读,当前读有幻读可能默认级别,需注意间隙锁死锁
SERIALIZABLEREQUIRES_NEW所有读均为当前读,范围锁表级或范围锁很高强一致性,但并发极低

从表中可以看出,REPEATABLE READ 结合 REQUIRES_NEW 时,死锁风险较高,而幻读风险取决于是否使用当前读。在库存扣减这种高并发写操作中,默认隔离级别可能不是最佳选择。

死锁诊断与 JFR 可观测性

在生产环境中,死锁往往难以复现。JDK 提供的 jstack 工具可以打印线程堆栈和死锁信息,但 Spring 事务的线程绑定特性使得死锁通常表现为数据库死锁,而非 JVM 死锁。数据库死锁会被 InnoDB 检测到,并回滚其中一个事务,错误日志中会记录 Deadlock found when trying to get lock

通过 Spring 的 DataSourceTransactionManager 日志(设置 org.springframework.jdbc.datasource 为 DEBUG 级别),可以观察到连接获取和释放的时机。结合 JDBC 层的日志,可以追踪每个事务使用的连接 ID。在 MySQL 中,使用 SHOW ENGINE INNODB STATUS 可以查看最近一次死锁的详细信息,包括涉及的事务和锁。

JFR(JDK Flight Recorder)可以监控 JDBC 连接事件,但无法直接捕获数据库内部锁等待。不过,通过自定义 JFR 事件,可以在 Spring 事务边界记录连接 ID 和时间戳,从而在事后分析中关联数据库死锁日志。

替代方案与权衡

面对 REQUIRES_NEW 带来的复杂性和风险,有几种替代方案可以考虑。

方案一:使用 REQUIRED 并调整业务逻辑

将库存扣减的传播行为改为 REQUIRED,使其与订单事务共享同一连接。这样,库存扣减的锁在订单事务提交前一直持有,避免了跨连接的死锁,但违背了“订单必须成功”的业务需求。可以通过补偿事务(Saga 模式)或最终一致性来解决:订单创建后,异步扣减库存,如果扣减失败,则标记订单为异常并人工处理。

方案二:降低隔离级别为 READ_COMMITTED

将数据库隔离级别设置为 READ_COMMITTED,可以消除间隙锁,从而大幅降低死锁概率。但代价是可能出现不可重复读和幻读。在库存扣减场景中,如果使用 SELECT ... FOR UPDATE 当前读,READ_COMMITTED 下仍然会锁定行,但不会锁定间隙。这要求业务逻辑能够容忍非锁定读的不一致。

方案三:使用乐观锁

在库存表中增加版本号字段,扣减时使用 UPDATE inventory SET quantity = quantity - ?, version = version + 1 WHERE product_id = ? AND version = ?。如果更新影响行数为 0,则重试或失败。乐观锁避免了行锁持有时间过长的问题,但需要处理重试逻辑,并且在高冲突场景下重试开销大。

方案四:调整事务边界

将订单创建和库存扣减拆分为两个独立的顶层事务,通过消息队列保证最终一致性。订单服务创建订单并发送消息,库存服务消费消息并扣减库存。这种方式完全消除了嵌套事务,但增加了系统复杂度和延迟。

贯穿场景的流程与异常路径

以下 Mermaid 流程图展示了订单创建与库存扣减在 REQUIRES_NEW 下的完整执行路径,包括正常流程和异常路径。

flowchart TD
    A[开始 createOrder] --> B[获取连接 conn1,开启事务 T1]
    B --> C[插入订单记录]
    C --> D{调用 deductStock}
    D --> E[挂起 T1,获取连接 conn2,开启事务 T2]
    E --> F[执行 SELECT ... FOR UPDATE 检查库存]
    F --> G{库存充足?}
    G -- 是 --> H[扣减库存 UPDATE]
    H --> I[提交 T2]
    I --> J[恢复 T1]
    J --> K[提交 T1]
    K --> L[结束]
    G -- 否 --> M[抛出异常,回滚 T2]
    M --> N[恢复 T1]
    N --> O{T1 是否捕获异常?}
    O -- 是 --> P[订单已插入,T1 继续]
    P --> K
    O -- 否 --> Q[异常传播,回滚 T1]
    Q --> L

在图中,关键点是 T1 和 T2 使用不同连接,因此 T2 的回滚不影响 T1 已插入的订单。但若 T1 在 T2 失败后执行了其他数据库操作,这些操作仍然在 T1 的上下文中,可能依赖于 T2 的结果,导致数据不一致。

不适用场景与版本边界

REQUIRES_NEW 并非在所有情况下都有效。如果底层数据库不支持保存点或多连接事务,Spring 会降级处理。例如,在 JTA 环境下,REQUIRES_NEW 可能要求 XA 事务支持。此外,如果方法调用是自调用(this.method()),AOP 代理不会拦截,传播行为失效。

在 Spring 6.x 和 Spring Boot 3.x 中,@Transactional 的行为与早期版本基本一致,但底层可能使用 TransactionManager 的不同实现(如 JdbcTransactionManager)。资料中未提及特定版本的行为变更,因此本文描述适用于 Spring Framework 5.x 和 6.x 的主流版本。

结论

Spring 的 REQUIRES_NEW 传播行为在隔离级别为 REPEATABLE READ 时,由于使用多个 JDBC 连接,间隙锁导致的死锁风险显著增加。在订单-库存场景中,这种组合虽然满足了业务上的独立性需求,但牺牲了并发性能和稳定性。开发者应根据业务对一致性的要求,权衡使用 REQUIRED 配合补偿机制、降低隔离级别、乐观锁或最终一致性架构。在生产环境中,必须通过数据库死锁日志和 Spring 事务日志进行可观测性诊断,避免盲目使用默认配置。

资料来源

  1. Spring Framework Reference: Transaction Management
  2. Understanding Spring Transaction Propagation