@Transactional 是 Spring 里最容易被「加上就完事」的注解:方法上写了,就以为一定在事务里。实际上代理、异常类型、传播行为、提交时机,每一处都能让事务静默失效。这篇用发布文章和下单两个场景,把事务边界拆成可验证的规则。示例基于 Spring Boot 3.3 / Java 21,为独立实现,不是本仓库代码。

1. 注解加了,事务却没生效

最典型的一段代码长这样:publish 里调用同类的 updateStatus,两个方法都标了 @Transactional,作者以为内层用了 REQUIRES_NEW。

@Service
public class ArticleService {
    @Transactional
    public void publish(Long id) {
        updateStatus(id);        // 自调用:不经过代理,注解形同虚设
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void updateStatus(Long id) {
        // 期望独立事务,实际加入外层事务
    }
}

它不会报错,测试也可能过,因为大多数时候外层事务恰好覆盖了内层逻辑。一旦外层回滚,本应独立提交的状态变更一起丢了;或者内层想独立提交审计记录,却发现根本没提交。这类 bug 的排查成本极高,因为代码「看起来完全正确」。

还有三种同样隐蔽的失效:注解加在 private / final 方法上、异常被 catch 后没再抛出、抛的是 checked exception。下面逐个拆。

2. 代理机制:自调用为什么一定失效

Spring 的事务是 AOP 实现的:容器给你注入的不是 ArticleService 本身,而是一个代理对象。代理在方法调用前后开启、提交或回滚事务,再调用真正的目标对象。

外部调用走的是 caller → proxy(开启事务)→ target.publish();自调用走的是 target.publish() → target.updateStatus(),中间没有 proxy。自调用发生在目标对象内部,this.updateStatus() 直接命中目标方法,代理没有机会介入。所以内层的传播行为、只读标记、回滚规则全部不生效。

修复方式有三种,按推荐程度排序:

  1. 拆类。把需要独立事务的逻辑放到另一个 Bean,跨 Bean 调用必然经过代理。这也是职责划分更清晰的做法。
  2. 注入自身代理。用 ObjectProvider<ArticleService> 或 @Lazy 注入自己,调用 self.updateStatus(id)。能解决问题,但容易让依赖关系变得可疑。
  3. AopContext.currentProxy()。需要 @EnableAspectJAutoProxy(exposeProxy = true),代码里出现强制类型转换,不推荐作为常规手段。

拆类是最干净的:

@Service
@RequiredArgsConstructor
public class ArticlePublishService {
    private final ArticleService articleService;
    private final AuditService auditService;

    @Transactional
    public void publish(Long id) {
        articleService.markPublished(id);          // 跨 Bean,走代理
        auditService.record("article.publish", id); // REQUIRES_NEW
    }
}

另外两个代理相关的坑:final 方法和 final 类无法被 CGLIB 代理,注解同样失效;private 方法根本不在代理的拦截范围内。Spring 6 默认使用 CGLIB,不再需要为「没有接口」单独配置,但也意味着上述限制依然存在。

判断某个调用到底走没走代理,最直接的办法是打开 logging.level.org.springframework.transaction=DEBUG,看日志里是否出现 Creating new transaction 或 Participating in existing transaction。自调用不会产生任何一条,这个方法比读代码可靠。

3. 传播行为:REQUIRED、REQUIRES_NEW、NESTED

传播行为回答的是「当前已经有一个事务时,新方法怎么办」。

传播行为 行为 适用场景
REQUIRED(默认) 加入现有事务;没有就新建 绝大多数业务方法
REQUIRES_NEW 挂起外层,开独立事务,提交后恢复外层 审计日志、失败记录等「必须留下」的写
NESTED 在外层事务内开 savepoint 局部失败可回滚、外层仍可继续

REQUIRES_NEW 最常用,也最容易误用:

@Service
@RequiredArgsConstructor
public class AuditService {
    private final AuditLogRepository auditLogRepository;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void record(String action, Long targetId) {
        auditLogRepository.save(AuditLog.of(action, targetId));
    }
}

它有三个代价,必须在设计时算进去:

  • 占用两条连接。外层事务的连接还没释放,内层又借一条。并发高时连接池会被瞬间翻倍消耗,REQUIRES_NEW 用多了就是连接池耗尽的捷径。
  • 看不到外层未提交的数据。内层是独立事务,读不到外层刚写入但未提交的行;如果内层逻辑依赖这些数据,会读到旧值甚至死锁。
  • 内层失败会向外传播。默认情况下,内层抛异常会把外层一起标记为回滚。如果希望「审计失败不影响主流程」,要在调用处 try/catch,并确认外层事务尚未被标记 rollback-only。

NESTED 只回滚到 savepoint,外层可以继续,但它依赖 JDBC savepoint,不是所有数据库和驱动都支持;JPA 场景下还要确认底层连接允许 savepoint。跨库、跨数据源时不要指望它,直接用 REQUIRES_NEW 或拆流程更可靠。

选传播行为时先问一句:这段逻辑失败,应该让谁一起失败?下单扣库存失败,订单主记录必须回滚,用 REQUIRED;扣库存成功但发券失败,业务上可能允许「券稍后补发」,那就把发券放到提交之后。不要为了「看起来独立」而滥用 REQUIRES_NEW,它把一次业务操作拆成了两个无法原子回滚的部分。

4. 只读事务不是性能开关

@Transactional(readOnly = true) 常被当成优化手段随手加。它真正做的事是:告诉 Hibernate 本次事务不需要脏检查,FlushMode 设为 MANUAL,从而少一次自动 flush。它能减少开销,但不会让查询变快一个数量级。

几个实际约束:

  • 只读事务仍然占用连接,长报表查询照样会拖住连接池。
  • 某些数据库驱动会把只读连接路由到从库,这时要确认主从延迟可接受。
  • 不要给写方法加 readOnly = true。Hibernate 可能跳过 flush,更新静默丢失,而且这种错误在开发环境很难复现。

我的习惯是:查询方法显式标 readOnly = true,写方法只写 @Transactional,不在类级别统一标注。类级别标注最容易被后来的写方法继承,变成隐藏的坑。

5. 事务里的远程调用

事务里最不该出现的是网络 I/O:调用第三方接口、发邮件、上传对象存储。原因很直接——数据库连接在事务期间一直被占用,远程调用耗时 3 秒,连接就被占 3 秒;超时重试时更糟,连接占用时间成倍增长。

常见做法是把副作用挪到提交之后:

@Transactional
public void publish(Long id) {
    Article article = articleRepository.findById(id).orElseThrow();
    article.publish();
    events.publishEvent(new ArticlePublishedEvent(article.getId(), article.getSlug()));
}

事件在事务内发布,监听器在提交后执行,写库和副作用就此解耦。下一节展开。

如果暂时不想引入事件机制,也可以用 TransactionSynchronizationManager.registerSynchronization(...) 的 afterCommit 回调,效果类似,但代码更命令式,且回调里抛出的异常不会影响已经提交的事务。

6. 回滚规则:默认只认运行时异常

Spring 的默认规则是:抛出 RuntimeException 或 Error 回滚,抛出 checked exception 提交。这和历史原因有关,但对业务代码很不直观——IOException、SQLException 这类受检异常反而不会回滚。

@Transactional(rollbackFor = Exception.class)
public void importArticles(Path file) throws IOException {
    // 读文件、解析、批量写入
}

两条实践建议:

  • 需要 checked exception 触发回滚时,显式写 rollbackFor = Exception.class,不要依赖记忆。
  • 方法内部 catch 后不再抛出,等于告诉 Spring「一切正常」,事务会照常提交。确实要回滚又不想抛异常时,调用 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。

还有一个反直觉的点:异常在方法返回后才抛出(比如提交时约束冲突),此时 @Transactional 方法内的 try/catch 根本捕获不到。约束冲突、乐观锁冲突都属于这一类,处理位置应该在调用方,而不是被调用的方法内部。

内层事务被标记回滚、外层又试图提交时,抛出的不是原始异常,而是 UnexpectedRollbackException。看到它不要先怀疑数据库,先找「内层 catch 了异常但没有继续抛」的地方——那才是真正的原因。

7. 领域事件:AFTER_COMMIT 才是正确时机

@TransactionalEventListener 把监听时机绑定到事务阶段,常用的是 AFTER_COMMIT:事务成功提交后才执行,回滚则不执行。这正好解决「写库成功才发通知」的问题。

public record ArticlePublishedEvent(Long articleId, String slug) {
}

@Component
@RequiredArgsConstructor
public class ArticlePublishedListener {
    private final SiteBuildClient siteBuildClient;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onPublished(ArticlePublishedEvent event) {
        try {
            siteBuildClient.requestBuild(event.slug());
        } catch (Exception ex) {
            log.warn("build request failed, slug={}", event.slug(), ex);
        }
    }
}

必须知道的边界:

  • 默认同步执行。监听器跑在提交事务的那个线程里,用户请求会等它结束。耗时操作要加 @Async,并配置独立的线程池。
  • 监听器抛异常不会回滚事务。事务已经提交了,异常只会往上冒泡,可能让调用方收到 500,但数据已经落库。所以监听器内部要自行兜底。
  • 没有事务时事件不会触发。如果发布方不在事务里,监听器默认被忽略;需要显式 fallbackExecution = true。
  • 顺序不保证。多个监听器之间不要假设执行顺序,有依赖就合并成一个监听器或引入消息队列。

如果副作用必须可靠(比如必须发出一条消息),事件机制还不够,要用 outbox 表:业务数据与消息记录在同一个事务里写入,再由独立任务扫描发送。这是「至少一次」语义,消费端需要幂等。

8. 乐观锁 @Version 与重试边界

并发编辑同一篇文章时,后提交的会覆盖先提交的。乐观锁用版本号检测冲突:

@Entity
@Getter
public class Article {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String title;
    @Version
    private long version;
}

更新时 Hibernate 会带上 WHERE id = ? AND version = ?,版本不匹配就抛 ObjectOptimisticLockingFailureException。注意这个异常通常在 flush 或提交时抛出,位置在业务方法之外,所以重试必须包住整个事务:

@Service
@RequiredArgsConstructor
public class ArticleEditFacade {
    private final ArticleEditService editService;

    @Retryable(retryFor = ObjectOptimisticLockingFailureException.class,
               maxAttempts = 3, backoff = @Backoff(delay = 50, multiplier = 2))
    public void rename(Long id, String title) {
        editService.rename(id, title);   // 每次重试都是全新事务
    }
}

@Service
@RequiredArgsConstructor
class ArticleEditService {
    @Transactional
    public void rename(Long id, String title) {
        articleRepository.findById(id).orElseThrow().rename(title);
    }
}

三个前提:@EnableRetry 已开启;重试方法本身不在事务里(否则重试的还是同一个事务);每次重试重新读取实体,不能复用已经处于持久态的旧对象。

重试次数要有上限和退避,且只对「可重试」的冲突重试。业务校验失败、参数错误重试多少次都没用,反而放大压力。如果冲突率很高,说明热点行设计有问题,应该考虑拆行、排队或改用悲观锁。

9. 大事务拆分

一次导入五千篇文章,如果整个循环包在一个事务里,会带来三个问题:锁持有时间长、undo log 膨胀、失败时全部回滚且无法定位。正确做法是按批提交:

public void importAll(List<ArticleRow> rows) {                 // TransactionTemplate 由构造器注入
    for (List<ArticleRow> chunk : partition(rows, 200)) {
        transactionTemplate.executeWithoutResult(status -> chunk.forEach(this::saveOne));
    }
}

用 TransactionTemplate 而不是 @Transactional,是因为事务边界要落在循环内部,注解表达不了「每 200 条一个事务」。拆分之后要接受一个事实:部分成功。接口要返回成功条数和失败明细,而不是简单的成功 / 失败;重试时要靠业务唯一键做幂等,否则会重复插入。

拆分粒度也不是越小越好。每条一个事务会让提交次数暴涨,磁盘 fsync 成为瓶颈。200–1000 条一批,按行大小和索引数量实测调整。

拆分之后,「部分成功」会暴露给调用方,幂等就成了硬要求。批量导入用 slug 或外部单号做唯一键,重复执行时走 upsert;删除类操作先查后删,避免第二批把第一批的结果再删一次。没有幂等保证的重试,比不重试更危险。

10. 踩坑清单

  1. 同类自调用,内层事务注解不生效。
  2. @Transactional 加在 private、final 方法或 final 类上。
  3. 抛 checked exception 却期望回滚,忘了 rollbackFor。
  4. catch 后吞掉异常,事务照常提交。
  5. 以为 REQUIRES_NEW 能看到外层未提交的数据。
  6. 在事务里调用 HTTP 接口、发邮件、上传文件。
  7. 监听器里做重活,把用户请求拖到超时。
  8. 在事务方法内部重试乐观锁,重试的仍是同一个事务。
  9. 整个导入包一个大事务,失败后无法定位到具体批次。
  10. 类级别 @Transactional(readOnly = true),写方法忘了覆盖。

11. 小结

@Transactional 的边界可以压缩成三句话:代理决定它生不生效,传播行为决定它和谁共享事务,异常类型决定它回不回滚。再加一条:副作用要放在提交之后,别让网络 I/O 决定数据库连接的占用时长。

验证事务是否真的生效,最可靠的办法不是读代码,而是打开 SQL 日志,看 BEGIN / COMMIT 出现的次数和位置。注解是声明,日志才是事实。

这个系列到这里:分层、认证、事务,是 Spring Boot 后端最容易「看起来会了、实际踩坑」的三块。下一篇回到 Go 侧,讲 GORM 在 SQLite、MySQL、PostgreSQL 之间的方言差异与迁移策略。