Java 基础体系 · 第 86/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Spring 事务深入:传播、隔离、代理、自调用和事件边界

Spring 事务问题通常不是“有没有加上 @Transactional”这么简单。一个事务至少涉及四个对象:

  1. 事务边界:从哪里开始,到哪里结束。
  2. 物理事务:数据库连接上真正执行的 BEGINCOMMITROLLBACK
  3. Spring 代理:谁拦截方法调用并创建、加入或挂起事务。
  4. 数据库并发控制:隔离级别、锁、MVCC、约束和异常。

如果只把事务理解成一个注解,就很容易误判以下情况:

  • 内层方法声明了 REQUIRES_NEW,但实际没有新事务;
  • 设置了 REPEATABLE_READ,却仍然遇到业务上的写偏差;
  • 方法上有 @Transactional,自调用时却完全不生效;
  • @TransactionalEventListener 执行了,但事件对应的数据没有成功提交;
  • 外层事务回滚了,内层方法却无法独立保留结果;
  • JPA 方法返回正常,但提交阶段才抛出约束异常。

下面从物理事务、传播、隔离、代理、自调用和事件边界逐层建立完整模型。

一、先建立事务模型:逻辑事务不等于物理事务

1. 物理事务

物理事务是数据库连接上的原子工作单元。以 JDBC 为例,典型过程是:

Connection connection = dataSource.getConnection();
connection.setAutoCommit(false);

try {
    // 在同一个 connection 上执行多条 SQL
    connection.commit();
} catch (Throwable ex) {
    connection.rollback();
    throw ex;
} finally {
    connection.close();
}

数据库保证的是:在该连接对应的物理事务中,成功提交的修改整体可见;回滚则撤销该物理事务尚未提交的修改。

Spring 并不直接替代数据库的事务能力。它主要负责:

  • 获取事务资源,例如 JDBC Connection 或 JPA EntityManager
  • 将资源绑定到当前线程;
  • 决定何时开启、提交、回滚、挂起和恢复事务;
  • 将方法调用异常转换成提交或回滚决策;
  • 在事务完成前后触发同步回调。

对于基于 JDBC 的同步调用,事务资源通常通过线程上下文绑定。当前线程中的 DAO 使用同一个数据源时,能够取得当前事务关联的连接。这个模型意味着:事务上下文通常随线程传播,而不是随普通 Java 方法参数传播

响应式事务是另一套模型,依赖 Reactor 上下文而不是传统的线程绑定;不能把同步事务的所有线程假设直接套到响应式代码中。

2. 逻辑事务

Spring 的“事务传播”首先描述的是逻辑事务关系,而不是简单地描述连接数量。

例如:

@Service
public class OrderService {

    @Transactional
    public void createOrder() {
        paymentService.reserve();
        orderRepository.save(...);
    }
}

@Service
public class PaymentService {

    @Transactional
    public void reserve() {
        // ...
    }
}

如果两个方法都使用默认的 REQUIRED

  • 外层 createOrder() 首次创建物理事务;
  • 内层 reserve() 加入该物理事务;
  • 内层返回时不会提交;
  • 外层正常结束时,整个物理事务才提交;
  • 任意一方导致该物理事务回滚,双方修改都会回滚。

可以形式化地表示为:

T_outer = physical transaction P
T_inner(REQUIRED) joins P
commit(T_inner) 不是物理提交
commit(T_outer) => commit(P)
rollback(T_inner) => mark P rollback-only

这里的关键状态是 rollback-only。内层代码可能捕获了异常并正常返回,但如果事务管理器已经把当前事务标记为只能回滚,外层最终提交时仍然会失败,常见表现是:

UnexpectedRollbackException

其因果链如下:

  1. 外层开启物理事务 P
  2. 内层加入 P
  3. 内层发生符合回滚规则的异常;
  4. 内层异常被上层捕获,方法表面上继续执行;
  5. Spring 仍将 P 标记为 rollback-only;
  6. 外层正常返回,Spring 尝试提交;
  7. P 不能提交,只能回滚;
  8. 外层看到 UnexpectedRollbackException 或类似提交失败。

因此,“捕获异常”不等于“恢复事务”。

二、@Transactional 到底做了什么

一个典型的 @Transactional 调用可以抽象为:

调用代理
  -> 解析事务属性
  -> 判断当前是否已有事务
  -> 按传播行为创建、加入、挂起或拒绝
  -> 调用目标方法
  -> 根据返回或异常决定提交、回滚或标记 rollback-only
  -> 恢复被挂起的事务
  -> 返回调用方

伪代码如下:

Object invoke(MethodInvocation invocation) {
    TransactionStatus status = transactionManager.getTransaction(attribute);

    try {
        Object result = invocation.proceed();

        if (status.isRollbackOnly()) {
            transactionManager.rollback(status);
        } else {
            transactionManager.commit(status);
        }

        return result;
    } catch (Throwable ex) {
        if (rollbackOn(ex)) {
            transactionManager.rollback(status);
        } else {
            transactionManager.commit(status);
        }
        throw ex;
    }
}

这不是 Spring 的源码,而是用于理解控制流的简化模型。实际实现还会处理事务同步器、保存点、挂起资源、事务名称、超时、只读标记和不同事务管理器的差异。

默认回滚规则

在常见的 @Transactional 配置下:

  • RuntimeExceptionError 默认触发回滚;
  • 受检异常 Exception 默认不一定触发回滚;
  • 可以使用 rollbackFornoRollbackFor 明确配置。
@Transactional(rollbackFor = IOException.class)
public void importFile(Path path) throws IOException {
    // IOException 也会触发回滚
}

受检异常不默认回滚不是数据库规则,而是 Spring 的事务拦截规则。数据库并不知道 Java 异常类型;最终是否回滚取决于事务拦截器如何处理异常。

以下写法常常造成误解:

@Transactional
public void create() {
    try {
        repository.save(...);
        callRemoteSystem();
    } catch (Exception ex) {
        log.error("failed", ex);
    }
}

这里可能发生两种不同结果:

  1. 异常没有触发事务状态的 rollback-only,方法正常返回,数据库修改被提交;
  2. 异常已经让事务进入 rollback-only,方法虽然捕获异常,外层提交时仍失败。

如果业务意图是“失败就回滚”,应显式抛出异常,或明确使用回滚规则;如果业务意图是“记录失败但保留主事务”,则需要设计独立的事务边界,而不是只捕获异常。

三、事务传播:一个注解如何改变事务边界

传播行为回答的问题是:

当前方法被调用时,如果调用线程已经存在事务,这个方法应该加入、创建、挂起还是拒绝当前事务?

1. REQUIRED:加入现有事务,否则创建

REQUIRED 是默认传播行为:

@Transactional(propagation = Propagation.REQUIRED)
public void update() {
}

状态转换:

调用前状态 方法行为 物理事务
无事务 创建事务 新建一个
有事务 加入事务 复用当前事务

它适合一个业务用例需要整体成功或整体失败的场景。

@Transactional
public void placeOrder() {
    orderRepository.insertOrder();
    inventoryService.decrease();
    auditService.record();
}

如果三个服务都使用 REQUIRED,它们通常共享同一个物理事务。auditService 的数据库写入不会在它自己返回时单独提交。

2. REQUIRES_NEW:挂起当前事务,创建独立事务

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void recordAudit() {
}

调用流程是:

外层事务 P1
  -> 挂起 P1
  -> 创建内层事务 P2
  -> 执行内层方法
  -> 提交或回滚 P2
  -> 恢复 P1
  -> 外层继续执行

它适合需要与外层结果独立的场景,例如保存审计记录、失败通知或重试状态:

@Service
public class OrderApplicationService {

    private final AuditService auditService;

    @Transactional
    public void createOrder() {
        orderRepository.save(...);

        try {
            inventoryService.decrease();
        } catch (RuntimeException ex) {
            auditService.recordFailure("inventory failed"); // REQUIRES_NEW
            throw ex;
        }
    }
}

@Service
public class AuditService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void recordFailure(String message) {
        auditRepository.save(message);
    }
}

如果 P1 最后回滚,P2 已经提交的审计记录仍然可以保留。

但是它有两个重要限制。

第一,REQUIRES_NEW 不是“同一个连接上的嵌套事务”

它通常需要为 P2 获取另一个数据库连接。连接池容量不足时,外层事务占用连接,内层事务又等待新连接,可能出现阻塞甚至死锁式资源耗尽。

假设:

  • 并发线程数为 N
  • 每个线程先持有一个外层连接;
  • 每个线程执行 REQUIRES_NEW 时还需要一个连接。

那么仅从连接数看,至少需要接近 2N 个可用连接,才能避免这种基本资源竞争。实际容量还要考虑其他 SQL、异步任务和管理连接;不能把这个关系当作通用性能公式,但它说明了风险来源。

第二,外层事务看不到尚未提交的内层数据

P2 提交后,数据库中的数据才对其他事务可见;但外层 P1 已经开始时使用了自己的隔离视图或锁状态。外层是否能立即读到 P2 的结果,取决于数据库和隔离级别,不能假设“内层提交后外层一定刷新可见”。

3. NESTED:在当前物理事务中建立保存点

@Transactional(propagation = Propagation.NESTED)
public void tryPartially() {
}

概念上:

物理事务 P
  -> 创建保存点 S
  -> 执行内层操作
  -> 内层失败:回滚到 S
  -> 外层仍可继续
  -> 最终由 P 统一提交

它与 REQUIRES_NEW 的区别是:

特征 NESTED REQUIRES_NEW
是否新建物理事务
是否独立提交
回滚范围 回滚到保存点 回滚整个内层事务
外层是否暂停
典型实现基础 JDBC savepoint 新连接和新事务

NESTED 依赖事务管理器和底层资源支持。它不是所有 JPA 环境都可移植支持的通用能力。常见的 JDBC 事务管理器可以使用保存点;JpaTransactionManager 对嵌套事务的支持有特定限制,通常需要允许 JDBC 保存点,并不能把它理解成 JPA 标准层面的普遍嵌套事务。

因此,使用前应确认:

  • 使用的 PlatformTransactionManager
  • 数据源和 JDBC 驱动是否支持保存点;
  • ORM 是否在同一资源上协调;
  • 配置是否允许嵌套事务;
  • 测试是否真的验证了“只回滚到保存点”。

4. SUPPORTSNOT_SUPPORTEDMANDATORYNEVER

这些传播行为更像边界约束:

@Transactional(propagation = Propagation.SUPPORTS)
  • 有事务:加入事务;
  • 无事务:以非事务方式执行。
@Transactional(propagation = Propagation.NOT_SUPPORTED)
  • 有事务:挂起事务;
  • 无事务:非事务执行。

它可以用于不应占用当前事务连接的操作,但如果方法中执行了数据库写操作,就需要清楚地知道这些写入可能处于自动提交模式或由其他机制管理。

@Transactional(propagation = Propagation.MANDATORY)
  • 有事务:加入;
  • 无事务:直接抛出异常。

它适合强制要求调用方已经建立业务事务的底层组件。

@Transactional(propagation = Propagation.NEVER)
  • 有事务:抛出异常;
  • 无事务:正常执行。

它适合明确禁止在事务中运行的操作,但实际使用频率通常低于前几种。

5. 传播行为的选择不能脱离业务语义

以下两个需求看起来都叫“记录日志”,事务语义却不同:

订单创建失败时,审计记录也必须消失
    -> REQUIRED

订单创建失败时,失败审计必须保留
    -> REQUIRES_NEW,或事务外可靠消息

订单主流程中某一步失败,只撤销这一步,后续继续
    -> NESTED 或显式补偿,不能只写 REQUIRED

写入完成后通知其他系统
    -> 不能仅依赖同一事务中的普通事件监听器

传播行为不是性能开关,也不是异常处理开关,而是对原子性范围的声明。

四、隔离级别:数据库并发读取看到了什么

传播决定事务如何组合;隔离决定并发事务之间能看到什么。

设两个事务为 T1T2,数据库操作按照时间顺序交错执行。隔离级别限制的是某些交错调度是否允许产生特定现象。

1. 脏读

初始库存为 10

T1: UPDATE stock SET quantity = 0
T2: SELECT quantity  -> 0
T1: ROLLBACK

T2 读到了 T1 尚未提交的数据 0。最终数据库库存仍然是 10,所以 T2 读到的是无效中间状态,这叫脏读。

2. 不可重复读

T1: SELECT quantity -> 10
T2: UPDATE quantity = 8
T2: COMMIT
T1: SELECT quantity -> 8

同一事务两次读取同一行,结果不同,称为不可重复读。

3. 幻读

假设读取条件是 quantity < 5

T1: SELECT COUNT(*) WHERE quantity < 5 -> 3
T2: INSERT a row with quantity = 2
T2: COMMIT
T1: SELECT COUNT(*) WHERE quantity < 5 -> 4

第二次读取多出了一行匹配记录,这类范围结果变化称为幻读。

现代数据库经常使用 MVCC、间隙锁、谓词锁或其他实现策略。规范层面对隔离现象的约束与具体数据库实现并不完全等价,因此不能只看 Java 枚举名就推断所有锁行为。

4. Spring 的隔离级别配置

@Transactional(isolation = Isolation.REPEATABLE_READ)
public Order loadAndUpdate(long id) {
    // ...
}

常见级别可抽象为:

Spring 枚举 典型目标
DEFAULT 使用数据库默认级别
READ_UNCOMMITTED 允许更弱隔离,可能脏读
READ_COMMITTED 避免读取未提交数据
REPEATABLE_READ 加强同一行的重复读取一致性
SERIALIZABLE 以更强并发约束换取更低并发度

DEFAULT 并不表示“最高级别”,而是交给数据库默认配置。

隔离级别必须满足两个边界。

边界一:只有新建物理事务时才真正设置

假设外层事务已经是 READ_COMMITTED

@Transactional(isolation = Isolation.READ_COMMITTED)
public void outer() {
    inner();
}

@Transactional(isolation = Isolation.SERIALIZABLE)
public void inner() {
}

如果 inner() 使用 REQUIRED 加入外层事务,它通常不能把已经开始的物理事务动态改成 SERIALIZABLE。内层的隔离声明不会自动改变外层物理事务。

如果需要严格禁止这种隐式冲突,可以考虑事务管理器的 validateExistingTransaction 配置,让加入现有事务时验证隔离级别、只读属性等是否兼容;具体行为取决于事务管理器。

边界二:隔离级别不等于业务不变量保护

库存扣减的业务不变量可能是:

quantity >= 0

单纯读取再更新:

@Transactional
public void decrease(long id, int amount) {
    int quantity = repository.findQuantity(id);

    if (quantity < amount) {
        throw new InsufficientStockException();
    }

    repository.updateQuantity(id, quantity - amount);
}

两个并发事务都读到 quantity = 10,各扣减 8,可能都通过校验。最终结果可能是覆盖更新,也可能违反业务预期。

更可靠的做法是让数据库原子地执行条件更新:

UPDATE inventory
SET quantity = quantity - :amount
WHERE id = :id
  AND quantity >= :amount;

然后检查影响行数:

int affected = jdbcTemplate.update("""
    UPDATE inventory
       SET quantity = quantity - ?
     WHERE id = ?
       AND quantity >= ?
    """, amount, id, amount);

if (affected != 1) {
    throw new InsufficientStockException();
}

这里的推导是:

  1. quantity >= amount 在同一条更新语句中判断;
  2. 更新和判断由数据库作为一个原子操作协调;
  3. 若并发竞争导致条件不成立,影响行数为 0
  4. Java 代码据此判定扣减失败。

还可以使用悲观锁:

SELECT quantity
FROM inventory
WHERE id = ?
FOR UPDATE;

或使用乐观锁:

UPDATE inventory
SET quantity = ?, version = version + 1
WHERE id = ? AND version = ?;

三者的语义不同:

  • 更高隔离级别:扩大数据库对并发调度的限制;
  • FOR UPDATE:明确锁住要修改的行;
  • 条件更新或版本号:把业务条件放入写操作中验证。

唯一约束、外键、检查约束也属于并发正确性的一部分。应用层的“先查询再插入”不能替代数据库唯一约束。

五、JDBC、JPA 与“提交时才失败”

1. JDBC 语句执行和事务提交

JDBC 方法返回成功,通常只表示驱动接受并执行了语句,不表示整个业务事务已经提交。真正提交发生在 Spring 方法返回后,由事务拦截器调用 commit()

所以异常可能在以下时刻出现:

  • SQL 执行时;
  • flush 时;
  • 提交时;
  • 提交后的事务同步回调中。

2. JPA 的持久化上下文

JPA 使用持久化上下文跟踪实体状态。典型状态包括:

  • new:尚未纳入持久化上下文;
  • managed:由当前持久化上下文管理;
  • detached:曾经被管理,但当前不再关联;
  • removed:标记为删除。
@Transactional
public void changeUser(long id) {
    User user = entityManager.find(User.class, id);
    user.setName("new-name");
}

如果 user 是 managed 实体,JPA 可以通过脏检查在 flush 时生成 UPDATE;代码中未显式调用 update 并不意味着没有数据库修改。

常见过程是:

find -> 返回 managed 实体
setName -> 只改变内存中的实体状态
flush -> JPA 比较状态并生成 SQL
commit -> 数据库提交

flush 不等于 commit

  • flush:把持久化上下文的变化同步到数据库;
  • commit:提交物理事务,使变化对其他事务稳定可见。

如果唯一约束、外键或非空约束在 flush 阶段被检查,异常可能在 repository 方法返回后、事务方法结束前出现;如果数据库或驱动延迟到提交检查,也可能在事务拦截器提交时出现。

因此,下面的测试不能只断言 save() 没有抛异常:

@Transactional
public void createDuplicate(String code) {
    repository.save(new Entity(code));
    // 约束异常可能在方法返回后的 flush/commit 阶段出现
}

需要在集成测试中让事务真正提交,或显式调用 flush() 验证数据库约束异常。

3. readOnly = true 的含义

@Transactional(readOnly = true)
public User load(long id) {
    return repository.findById(id).orElseThrow();
}

readOnly 主要是事务属性提示:

  • 某些事务管理器会向 JDBC 连接设置只读标记;
  • ORM 可能采取不同的 flush 策略;
  • 数据库是否真正禁止写入取决于驱动和数据库。

它不等于 Java 层的编译期只读,也不能保证所有写操作都被阻止。若方法需要严格禁止写入,应依赖数据库权限、连接配置或明确的架构约束,而不能只依赖注解。

六、代理机制:注解为什么有时生效、有时不生效

1. Spring 常见的代理路径

Spring 声明式事务通常基于 AOP 代理:

调用方
  -> Spring 代理对象
       -> TransactionInterceptor
            -> 目标对象方法

代理可以是:

  • JDK 动态代理:通常基于接口;
  • CGLIB 子类代理:基于目标类。

具体采用哪一种取决于 Spring 配置和目标类型。代理拦截到方法调用后,才能读取 @Transactional 并执行事务逻辑。

因此,下面的说法不准确:

只要方法上有 @Transactional,执行该方法就一定有事务。

准确说法是:

必须通过能够识别该注解的事务代理调用该方法,事务拦截逻辑才会运行。

2. 自调用为什么绕过事务

@Service
public class ReportService {

    public void generate() {
        savePart();
    }

    @Transactional
    public void savePart() {
        repository.save(...);
    }
}

如果外部调用:

reportService.generate();

执行路径是:

外部对象引用
  -> ReportService 代理
       -> generate()
            -> this.savePart()

this.savePart() 是目标对象内部的普通 Java 调用,不会再次经过代理。因此 savePart() 上的事务注解不会触发新的事务边界。

这不是 Spring 特殊破坏了 Java,而是代理只能拦截“经过代理对象”的调用。

反例:

public void generate() {
    this.savePart(); // 仍然绕过代理
}

加上 this 不会改变问题。

3. 推荐的拆分方式

把事务边界放到另一个 Spring Bean:

@Service
public class ReportService {

    private final ReportWriter reportWriter;

    public ReportService(ReportWriter reportWriter) {
        this.reportWriter = reportWriter;
    }

    public void generate() {
        reportWriter.savePart();
    }
}

@Service
public class ReportWriter {

    private final ReportRepository repository;

    @Transactional
    public void savePart() {
        repository.save(...);
    }
}

调用路径变成:

ReportService 代理
  -> ReportService.generate()
       -> ReportWriter 代理
            -> TransactionInterceptor
                 -> ReportWriter.savePart()

这时内层调用确实经过了事务代理。

4. 其他代理边界

以下情况也必须检查代理是否能拦截:

  • private 方法无法作为常规外部代理事务入口;
  • final 方法不能被基于子类的代理正常覆盖拦截;
  • final 类无法被基于子类的代理扩展;
  • 直接使用 new Service() 创建对象,不会经过 Spring 容器代理;
  • 从同一个类内部调用另一个事务方法,仍然属于自调用;
  • 测试中直接实例化目标类,也会绕过容器。

可以通过注入自身代理调用,但这会增加结构复杂度:

@Service
public class JobService {

    private final JobService proxy;

    public JobService(JobService proxy) {
        this.proxy = proxy;
    }

    public void run() {
        proxy.runInNewTransaction();
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void runInNewTransaction() {
    }
}

这依赖容器的代理注入行为,容易形成循环依赖或让代码难以理解。通常拆分 Bean 更清晰。

对于需要织入同类内部调用的场景,可以使用 AspectJ 等编织方式,但这改变了代理模型和构建、运行时配置,不能把它当成默认行为。

5. TransactionTemplate:显式表达边界

对于动态事务边界、循环中部分提交、异常后继续处理等场景,TransactionTemplate 往往比隐藏的自调用更直接:

@Service
public class BatchService {

    private final TransactionTemplate transactionTemplate;
    private final ItemRepository repository;

    public BatchService(
            PlatformTransactionManager transactionManager,
            ItemRepository repository) {
        this.transactionTemplate = new TransactionTemplate(transactionManager);
        this.repository = repository;
    }

    public void process(List<Long> ids) {
        for (Long id : ids) {
            transactionTemplate.executeWithoutResult(status -> {
                repository.process(id);
            });
        }
    }
}

这个例子明确表示:每次循环执行一个事务。若某次处理抛出异常,默认会回滚当前这次事务;是否继续下一项,还需要在循环外捕获异常并定义业务策略。

如果要配置传播或隔离:

transactionTemplate.setPropagationBehavior(
        TransactionDefinition.PROPAGATION_REQUIRES_NEW);
transactionTemplate.setIsolationLevel(
        TransactionDefinition.ISOLATION_READ_COMMITTED);

显式模板不是比注解“更高级”,而是让事务状态和控制流更可见。

七、事务事件:事件发布点不等于事件生效点

普通 Spring 事件:

publisher.publishEvent(new OrderCreated(orderId));

只表示事件已经交给事件发布机制处理。普通监听器可能在当前方法中立即执行:

@EventListener
public void onOrderCreated(OrderCreated event) {
    // 默认可以在发布点附近同步执行
}

如果发布事件的代码位于事务中,监听器执行时可能发生以下情况:

1. 事务修改订单
2. 发布 OrderCreated
3. 监听器读取订单或发送消息
4. 原事务后续失败
5. 数据库回滚

于是监听器已经产生副作用,但订单并不存在。这就是事件边界与事务边界不一致。

1. @TransactionalEventListener

Spring 提供事务同步事件监听器:

@Component
public class OrderEventHandler {

    @TransactionalEventListener(
            phase = TransactionPhase.AFTER_COMMIT)
    public void onOrderCreated(OrderCreated event) {
        notificationService.send(event.orderId());
    }
}

常见阶段:

  • BEFORE_COMMIT:提交前执行;
  • AFTER_COMMIT:提交成功后执行;
  • AFTER_ROLLBACK:回滚后执行;
  • AFTER_COMPLETION:提交或回滚完成后执行。

默认情况下,事务事件监听器需要存在事务。如果事件在没有事务的情况下发布,监听器通常不会执行;可以配置 fallbackExecution = true,但这会改变语义:

@TransactionalEventListener(
        phase = TransactionPhase.AFTER_COMMIT,
        fallbackExecution = true)
public void handle(OrderCreated event) {
}

开启后,非事务场景也可能执行监听器,但此时没有“提交成功”这个前提,必须确认这是否符合业务要求。

2. AFTER_COMMIT 的精确边界

业务方法
  -> 修改数据库
  -> publishEvent()
  -> 事务提交
  -> AFTER_COMMIT 监听器

AFTER_COMMIT 保证监听器被安排在事务成功提交之后,而不是保证监听器后续动作本身具有原子性。

例如:

@Transactional
public void createOrder() {
    orderRepository.save(order);
    publisher.publishEvent(new OrderCreated(order.id()));
}

如果提交失败:

  • AFTER_COMMIT 监听器不会以“提交成功”语义执行;
  • 订单也不应被视为已成功创建。

如果提交成功但监听器发送 HTTP 请求失败:

  • 数据库订单已经存在;
  • HTTP 调用不会自动回滚订单;
  • 监听器异常不能撤销已经完成的数据库提交。

这说明 AFTER_COMMIT 适合“提交后做动作”,但不能单独提供跨数据库与外部系统的原子性。

3. 事件监听器中的数据库写入

事务提交完成后,原事务已经结束。监听器中如果要写数据库,应该明确建立新的事务边界:

@Component
public class OrderProjectionHandler {

    private final ProjectionService projectionService;

    public OrderProjectionHandler(ProjectionService projectionService) {
        this.projectionService = projectionService;
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handle(OrderCreated event) {
        projectionService.update(event.orderId());
    }
}

@Service
public class ProjectionService {

    @Transactional
    public void update(long orderId) {
        // 新的物理事务
    }
}

不能把 AFTER_COMMIT 误解为“监听器自动拥有一个可提交的新事务”。事务同步回调执行时,底层资源的清理时机和可见状态还可能让代码看起来像处于旧上下文中,但原事务已经不能再提交新的修改。明确调用新的事务代理更安全。

4. @Async 会切断线程事务上下文

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(OrderCreated event) {
}

异步执行会切换线程。传统同步事务绑定在线程上,因此:

  • 原事务不会自动传播到异步线程;
  • 异步线程需要自己的事务,若有数据库写入,应在异步调用链上建立新事务;
  • 事件对象不能依赖已关闭的 JPA managed 实体;
  • 应传递不可变的 ID 或数据快照,而不是懒加载实体。

异步还改变错误处理方式:监听器异常通常无法像同步业务方法异常那样回传给已经结束的原始调用。必须通过重试、死信、状态表或监控记录失败。

5. 跨系统可靠性:事务事件不等于可靠消息

下面的实现仍有故障窗口:

1. 数据库事务提交成功
2. 进程在事件监听器执行前崩溃
3. 外部消息没有发送

也可能是:

1. 消息发送成功
2. 进程在记录发送状态前崩溃
3. 重试时再次发送

如果要求“数据库状态和待发送消息不丢失”,常见方案是 Outbox:

同一个数据库事务:
  orders 表插入订单
  outbox 表插入待发送事件

后台发布器:
  查询未发布 outbox
  发送消息
  成功后标记已发布
  失败则重试

其核心不是“事件监听器更强”,而是把必须同时持久化的数据放进同一个本地数据库事务中。发布到外部系统仍然需要幂等键、重复消费处理和失败重试。

八、一个完整的业务示例

下面的示例表达这样一个要求:

  • 订单、库存扣减必须整体成功或整体失败;
  • 库存扣减采用数据库条件更新,避免并发超卖;
  • 订单提交成功后再发送事件;
  • 事件处理失败不能回滚已经提交的订单;
  • 审计记录在主事务失败时仍然保留。
public record OrderCreated(long orderId) {}

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final InventoryService inventoryService;
    private final AuditService auditService;
    private final ApplicationEventPublisher publisher;

    public OrderService(
            OrderRepository orderRepository,
            InventoryService inventoryService,
            AuditService auditService,
            ApplicationEventPublisher publisher) {
        this.orderRepository = orderRepository;
        this.inventoryService = inventoryService;
        this.auditService = auditService;
        this.publisher = publisher;
    }

    @Transactional
    public long placeOrder(long productId, int amount) {
        long orderId = orderRepository.insertPending(productId, amount);

        try {
            inventoryService.decrease(productId, amount);
        } catch (RuntimeException ex) {
            auditService.recordFailure(orderId, ex.getMessage());
            throw ex;
        }

        publisher.publishEvent(new OrderCreated(orderId));
        return orderId;
    }
}

库存服务:

@Service
public class InventoryService {

    private final JdbcTemplate jdbcTemplate;

    public InventoryService(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Transactional
    public void decrease(long productId, int amount) {
        int affected = jdbcTemplate.update("""
            UPDATE inventory
               SET quantity = quantity - ?
             WHERE product_id = ?
               AND quantity >= ?
            """, amount, productId, amount);

        if (affected != 1) {
            throw new IllegalStateException("库存不足");
        }
    }
}

审计服务:

@Service
public class AuditService {

    private final AuditRepository repository;

    public AuditService(AuditRepository repository) {
        this.repository = repository;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void recordFailure(long orderId, String reason) {
        repository.insert(orderId, reason);
    }
}

提交后监听器:

@Component
public class OrderCreatedListener {

    private final NotificationService notificationService;

    public OrderCreatedListener(NotificationService notificationService) {
        this.notificationService = notificationService;
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handle(OrderCreated event) {
        notificationService.notifyCreated(event.orderId());
    }
}

通知服务:

@Service
public class NotificationService {

    private final NotificationRepository repository;

    public NotificationService(NotificationRepository repository) {
        this.repository = repository;
    }

    @Transactional
    public void notifyCreated(long orderId) {
        // 如果这里写本地数据库,使用新的事务
        // 如果调用外部系统,需要重试和幂等设计
        repository.recordNotificationAttempt(orderId);
    }
}

完整状态变化如下:

sequenceDiagram
    participant C as 调用方
    participant O as OrderService代理
    participant I as InventoryService代理
    participant A as AuditService代理
    participant DB as 数据库
    participant E as AFTER_COMMIT监听器

    C->>O: placeOrder()
    O->>DB: 开启物理事务 P1
    O->>DB: 插入订单
    O->>I: decrease()
    I->>DB: 加入 P1,条件扣减库存

    alt 库存不足
        I-->>O: 抛出异常
        O->>A: recordFailure()
        A->>DB: 挂起 P1,开启 P2
        A->>DB: 插入审计并提交 P2
        A-->>O: 返回
        O->>DB: 回滚 P1
        O-->>C: 抛出异常
    else 库存成功
        I-->>O: 返回
        O->>O: 发布 OrderCreated
        O->>DB: 提交 P1
        DB-->>E: 触发 AFTER_COMMIT
        E->>DB: 需要写库时开启新事务
        E-->>C: 原调用已完成
    end

在库存不足路径中:

  • 订单和库存修改属于 P1,一起回滚;
  • 审计属于 P2,独立提交;
  • AFTER_COMMIT 不执行,因为 P1 没有成功提交。

在成功路径中:

  • 事件在事务内发布,但监听器在 P1 成功提交后执行;
  • 监听器失败不会撤销订单;
  • 如果要求监听器动作最终一定成功,就需要持久化 outbox 或可靠任务状态。

九、常见失败表现与诊断方法

1. 事务注解存在,但数据库没有回滚

优先检查:

调用对象是否为 Spring 代理
方法是否发生自调用
对象是否通过 new 创建
异常是否符合回滚规则
是否抛出后又被吞掉
事务管理器是否绑定了正确的数据源

可以在关键位置记录:

TransactionSynchronizationManager.isActualTransactionActive()
TransactionSynchronizationManager.isCurrentTransactionReadOnly()
TransactionSynchronizationManager.getCurrentTransactionName()

这些 API 用于诊断当前线程是否存在 Spring 事务上下文,但“有事务上下文”仍不等于目标 SQL 一定使用了预期的数据源。多数据源场景还要检查事务管理器、路由数据源和连接获取链路。

2. 出现 UnexpectedRollbackException

通常意味着:

某个加入 REQUIRED 的内层逻辑触发了 rollback-only,
外层仍试图正常提交。

诊断重点不是只看最外层异常,而是查找最早导致事务被标记回滚的异常。常见修复方向:

  • 不要在同一个事务中捕获后假装成功;
  • 需要独立保留的数据使用 REQUIRES_NEW
  • 需要局部撤销时确认是否真的支持 NESTED
  • 将错误状态作为正常业务结果处理,而不是通过异常吞掉。

3. REQUIRES_NEW 导致连接池耗尽

表现可能是:

  • 内层调用长时间等待连接;
  • 请求线程堆积;
  • 数据库连接池活动数长期接近上限;
  • 线程转储中大量线程阻塞在连接获取。

诊断时同时观察:

  • 外层事务持续时间;
  • REQUIRES_NEW 调用次数和耗时;
  • 连接池活动、空闲、等待线程数;
  • 是否在循环中为每条记录开启独立事务。

REQUIRES_NEW 每次创建独立事务,不能无成本地嵌入长事务。

4. 设置隔离级别后问题仍然存在

需要区分三种问题:

  1. 配置没有经过代理,隔离级别根本未应用;
  2. 方法加入了已经存在的事务,内层隔离声明无法改变物理事务;
  3. 数据库隔离级别解决不了业务不变量,例如“余额不能为负”。

验证时不要只看注解,应该通过集成测试并发执行实际 SQL,观察:

  • 数据库实际隔离级别;
  • 是否使用了预期连接;
  • SQL 是否命中索引;
  • 是否出现锁等待;
  • 影响行数和版本号是否符合预期。

5. 事件监听器执行了,但查询不到数据

可能原因包括:

  • 使用普通 @EventListener,执行时事务尚未提交;
  • 监听器被配置为 BEFORE_COMMIT
  • 事件没有在事务中发布,却期待 AFTER_COMMIT
  • 异步监听器读取的是另一个事务视图;
  • JPA 实体已 detached,懒加载失败;
  • 写入事务实际上已回滚,但监听器没有按预期绑定到该事务。

事件对象最好携带稳定的数据快照:

public record OrderCreated(long orderId, String customerId) {}

而不是把依赖当前持久化上下文的实体对象直接传到异步或提交后处理阶段。

十、规范保证、实现行为与工程选择必须区分

规范和数据库保证

  • JDBC 提供连接、自动提交、提交、回滚和保存点等事务操作;
  • JPA 定义持久化上下文、实体状态、flush 等持久化语义;
  • 最终原子提交、锁和隔离效果由数据库事务实现决定;
  • 数据库约束是并发正确性的重要组成部分。

Spring 的框架行为

  • @Transactional 通常通过代理拦截;
  • REQUIRED 默认加入现有事务;
  • REQUIRES_NEW 挂起当前事务并建立独立事务;
  • 事务同步事件监听器依赖事务生命周期;
  • 具体能力还取决于使用的事务管理器和资源类型。

工程上的取舍

  • 事务方法应覆盖一个明确的业务原子操作,而不是盲目覆盖整个请求;
  • 外部 HTTP、消息发送和文件操作不会自动加入数据库事务;
  • 长事务会持有连接、锁和持久化上下文;
  • REQUIRES_NEW 要考虑连接池和失败重试;
  • NESTED 要先验证保存点支持;
  • 提交后动作要考虑进程崩溃、重复执行、幂等和补偿;
  • 跨系统一致性通常需要 outbox、重试和消费幂等,而不是继续扩大本地数据库事务。

Spring 事务的核心不是“给方法加注解”,而是明确回答五个问题:

哪个调用真正经过了代理?
哪个方法创建了物理事务?
哪些方法加入了同一个事务?
数据库允许哪些并发交错?
提交成功后,哪些副作用仍需要独立可靠地完成?

传播回答事务如何组合,隔离回答并发读取如何互相影响,代理决定声明是否真正被执行,自调用揭示了代理边界,事务事件则明确了数据库提交与后续副作用之间的时间边界。只有把这几层放在同一条调用链中分析,才能正确判断一次事务到底覆盖了什么、何时提交、何时回滚,以及失败后哪些结果仍然存在。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。