《Spring Boot 入门》14.1 @Transactional 基本用法

没有事务时,扣库存、记流水、扣额度会各自提交,中途失败就留下不一致数据。本节用图书借阅场景演示这个问题,再讲 @Transactional 加在方法还是类上、rollbackFor 默认只回滚运行时异常的坑、readOnly 与 timeout 的作用,以及 TransactionTemplate 编程式事务的适用场景。

本节目标:看清没有事务时数据会怎样不一致,掌握 @Transactional 的放置位置、rollbackFor 默认回滚规则的坑、readOnly 与 timeout 的用途,以及 TransactionTemplate 编程式事务的适用场景。
适用版本:Spring Boot 4.1.x(Java 21)

14.1 @Transactional 基本用法

第 12、13 章把数据存进了数据库,也把查询写顺了。但一个业务动作往往要改好几张表:借一本书,要扣图书库存、要写一条借阅流水、还要扣读者的可借额度。这三步只要中间一步失败,前两步若已经生效,数据库就处于一种「说不清」的状态。本节先把这个状态演示出来,再讲 Spring Boot 给的第一件工具——@Transactional。

14.1.1 一个没有事务的借阅流程

沿用第 12、13 章的图书领域模型,再加两个实体:借阅流水 BorrowRecord 与读者 Reader。读者身上有一个 quota 字段,表示还能借几本。借书的 Service 长这样:

package com.example.library.service;

import org.springframework.stereotype.Service;

import com.example.library.domain.Book;
import com.example.library.domain.BorrowRecord;
import com.example.library.domain.Reader;
import com.example.library.repository.BookRepository;
import com.example.library.repository.BorrowRecordRepository;
import com.example.library.repository.ReaderRepository;

@Service
public class BorrowService {

    private final BookRepository bookRepository;
    private final BorrowRecordRepository recordRepository;
    private final ReaderRepository readerRepository;

    public BorrowService(BookRepository bookRepository,
                         BorrowRecordRepository recordRepository,
                         ReaderRepository readerRepository) {
        this.bookRepository = bookRepository;
        this.recordRepository = recordRepository;
        this.readerRepository = readerRepository;
    }

    public void borrow(Long readerId, Long bookId) {
        // 步骤 1:扣库存
        Book book = bookRepository.findById(bookId).orElseThrow();
        if (book.getStock() <= 0) {
            throw new IllegalStateException("库存不足: " + bookId);
        }
        book.setStock(book.getStock() - 1);
        bookRepository.save(book);

        // 步骤 2:写借阅流水
        BorrowRecord record = new BorrowRecord(readerId, bookId);
        recordRepository.save(record);

        // 步骤 3:扣读者额度
        Reader reader = readerRepository.findById(readerId).orElseThrow();
        reader.setQuota(reader.getQuota() - 1);
        readerRepository.save(reader);
    }
}

这段代码没有任何事务注解。Spring Data JPA 的 save() 在方法返回时会各自把改动刷进数据库(简单保存通常在一次 save 内就提交了)。于是三个步骤是三次独立的提交。

14.1.2 中间失败会留下什么

假设 borrow_record 表上有一条唯一约束 (reader_id, book_id),同一个人不能重复借同一本书。某次调用中,步骤 1 成功,步骤 2 违反约束抛出异常:

2026-10-02T10:12:41.220+08:00 ERROR 51230 --- [nio-8080-exec-1] c.e.library.service.BorrowService        : borrow failed
org.springframework.dao.DataIntegrityViolationException: could not execute statement
    [Unique index or primary key violation: "PUBLIC.CONSTRAINT_INDEX_4 ON PUBLIC.BORROW_RECORD(READER_ID, BOOK_ID)"]
	at org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:322)
	...

步骤 2 抛异常,方法直接中断,步骤 3 根本没执行。此刻数据库里的状态是:

表期望实际
book库存 -1库存 -1(已提交)
borrow_record新增一条没有新增(插入失败)
reader额度 -1额度没变(步骤 3 未执行)

书少了一本,却没有对应的借阅记录;读者额度也没扣。这就是数据不一致:一次借书动作被拆成了半成品。若这是库存扣减,长此以往会出现「库存对不上流水」的对账事故。

14.1.3 事务要保证的核心:原子性

事务(Transaction)把「多个数据库写操作」打包成一个不可分割的整体:要么全部成功提交,要么全部失败回滚,不允许停留在中间状态。这依赖数据库本身的能力,而不是 Java 代码的 try-catch——try-catch 只能捕获异常,无法把已经提交的步骤 1「撤销」。

关系型数据库事务有四个特性,合称 ACID:

特性含义本场景的体现
原子性(Atomicity)全做或全不做三步要么都生效,要么都不生效
一致性(Consistency)事务前后数据满足约束库存数始终等于「总数 - 在借数」
隔离性(Isolation)并发事务互不干扰两个人同时借最后一本,只能一个成功
持久性(Durability)提交后永久保存提交后断电也不丢

入门阶段最需要理解的是原子性:它正是「加一个注解就能解决问题」的原因。隔离性留到 14.2。

14.1.4 加上 @Transactional

在方法上加 @Transactional,Spring 就会为这次调用开启一个数据库事务:

package com.example.library.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class BorrowService {

    private final BookRepository bookRepository;
    private final BorrowRecordRepository recordRepository;
    private final ReaderRepository readerRepository;

    public BorrowService(BookRepository bookRepository,
                         BorrowRecordRepository recordRepository,
                         ReaderRepository readerRepository) {
        this.bookRepository = bookRepository;
        this.recordRepository = recordRepository;
        this.readerRepository = readerRepository;
    }

    @Transactional
    public void borrow(Long readerId, Long bookId) {
        Book book = bookRepository.findById(bookId).orElseThrow();
        if (book.getStock() <= 0) {
            throw new IllegalStateException("库存不足: " + bookId);
        }
        book.setStock(book.getStock() - 1);
        bookRepository.save(book);

        BorrowRecord record = new BorrowRecord(readerId, bookId);
        recordRepository.save(record);

        Reader reader = readerRepository.findById(readerId).orElseThrow();
        reader.setQuota(reader.getQuota() - 1);
        readerRepository.save(reader);
    }
}

执行过程变成:

  1. 进入 borrow 前,Spring 通过代理向 PlatformTransactionManager 申请一个事务,从连接池借一条连接,并设置 autoCommit = false;
  2. 三个步骤都在同一条连接、同一个事务里执行,任何一次 save 都不会立即提交;
  3. 方法正常返回时,代理提交事务;方法抛出符合回滚规则的异常时,代理回滚事务。

同样跑一次重复借阅,这次步骤 2 失败后,步骤 1 的库存扣减会被一起回滚,数据库回到调用前的状态。

14.1.5 加在类上还是方法上

@Transactional 既能标在方法上,也能标在类上:

@Transactional
public class BorrowService {
    // 类内所有 public 方法都带上事务
}
放置位置作用范围适用场景
方法上仅该方法类里只有部分方法需要事务,最常用
类上类内所有 public 方法整个 Service 都是写操作,省去逐个标注
类上 + 方法上方法上的配置覆盖类上的类上给默认值,个别方法改 readOnly 或 rollbackFor

方法级配置优先级高于类级。一个常见的用法是类上标 @Transactional(readOnly = true) 作为默认(大多数是查询),再给写方法单独标 @Transactional 覆盖。

14.1.6 rollbackFor:最容易踩的坑

这是入门阶段最常出事故的一条规则:@Transactional 默认只在抛出 RuntimeException(运行时异常)或 Error 时回滚;受检异常(Checked Exception)默认不回滚。

看这个例子。假设借书前要调用一个外部系统做资格校验,校验失败时抛出一个受检异常:

@Transactional
public void borrow(Long readerId, Long bookId) throws IOException {
    Book book = bookRepository.findById(bookId).orElseThrow();
    book.setStock(book.getStock() - 1);
    bookRepository.save(book);

    // 调用外部接口,失败时抛出受检异常
    if (!remoteChecker.check(readerId)) {
        throw new IOException("remote check failed");
    }

    BorrowRecord record = new BorrowRecord(readerId, bookId);
    recordRepository.save(record);
}

IOException 是受检异常。按照默认规则,它不会触发回滚:方法抛出后,事务被提交,步骤 1 的库存扣减留在了库里——你以为失败会回滚,实际却提交了,问题比完全没有事务更隐蔽。

对照表如下:

抛出的异常默认是否回滚原因
RuntimeException 及其子类是默认回滚规则
Error 及其子类是默认回滚规则
受检异常(IOException、SQLException 等)否不在默认规则内
未捕获的异常(被 try-catch 吞掉)否根本没抛到代理层

修正方式是把回滚范围显式放大到 Exception:

@Transactional(rollbackFor = Exception.class)
public void borrow(Long readerId, Long bookId) throws IOException {
    // ...
}

rollbackFor = Exception.class 覆盖了所有异常类型(Exception 是所有受检异常与运行时异常的父类,Error 则始终回滚)。项目里养成统一写 rollbackFor = Exception.class 的习惯,可以规避绝大多数「异常了却没回滚」的问题。若反过来想排除某类异常,可以用 noRollbackFor。

14.1.7 readOnly = true

对纯查询方法,可以声明 readOnly = true:

@Transactional(readOnly = true)
public List<Book> search(String keyword) {
    return bookRepository.findByTitleContaining(keyword);
}

它的含义是「本事务只读,不做写操作」。收益有两层:

  • 语义表达:明确告诉读代码的人,这个方法不会改数据;
  • 性能优化:Hibernate 会跳过脏检查(不再为每个受管实体在提交前做快照对比),JDBC 层也可能把连接设为只读、由数据库走只读路径。

需要提醒的是,readOnly = true 只是提示,不是强制约束。它不会阻止你误写,因此不能当作安全保障;真写错了,行为取决于驱动与数据库。它是「声明意图 + 顺带优化」,不是「只读锁」。

14.1.8 timeout

timeout 给事务设一个超时上限(单位秒):

@Transactional(timeout = 5, rollbackFor = Exception.class)
public void borrow(Long readerId, Long bookId) {
    // 超过 5 秒未完成,事务被回滚并抛 TransactionTimedOutException
}

当某一步卡住(比如慢 SQL、锁等待)超过 timeout 秒,Spring 会标记事务回滚,后续操作抛出 TransactionTimedOutException。它的作用是防止一次请求长时间占着连接与锁,在连接池资源紧张时尤其重要。

要注意 timeout 依赖底层 JDBC 驱动的支持(如 Statement.setQueryTimeout),并非所有数据库都能精确到毫秒;它是「兜底保护」,不能替代对慢 SQL 本身的优化。

14.1.9 TransactionTemplate:需要精细控制回滚点时

声明式事务适合「方法正常就提交、异常就回滚」这种粗粒度场景。但有些逻辑需要在方法中途主动决定回滚——比如校验失败但不想抛异常、只想把已做的改动撤销。这时可以用编程式事务 TransactionTemplate:

package com.example.library.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;

@Service
public class BorrowService {

    private final BookRepository bookRepository;
    private final BorrowRecordRepository recordRepository;
    private final TransactionTemplate txTemplate;

    public BorrowService(BookRepository bookRepository,
                         BorrowRecordRepository recordRepository,
                         TransactionTemplate txTemplate) {
        this.bookRepository = bookRepository;
        this.recordRepository = recordRepository;
        this.txTemplate = txTemplate;
    }

    public boolean borrow(Long readerId, Long bookId) {
        return txTemplate.execute(status -> {
            Book book = bookRepository.findById(bookId).orElseThrow();
            if (book.getStock() <= 0) {
                status.setRollbackOnly();   // 主动标记回滚,不抛异常
                return false;
            }
            book.setStock(book.getStock() - 1);
            bookRepository.save(book);
            recordRepository.save(new BorrowRecord(readerId, bookId));
            return true;
        });
    }
}

要点:

  • TransactionTemplate 由 Spring Boot 在检测到 PlatformTransactionManager 时自动配置,直接注入即可,无需手动创建;
  • execute 里是一段 TransactionCallback,返回 true 表示成功;
  • status.setRollbackOnly() 把事务标记为回滚,即使方法正常返回也会回滚——这是声明式事务做不到的精细控制。

选择建议:优先用声明式 @Transactional(代码干净、符合直觉);只有当回滚时机需要在方法内部按条件决定、或需要把一段逻辑包在事务里但不想拆成新方法时,才用 TransactionTemplate。

14.1.10 事务与数据库隔离的关系

上面所有讨论都建立在「数据库真的支持事务」之上。事务的隔离级别、并发读写行为,都是数据库层面的能力,Spring 只是把它包装成了注解。@Transactional(isolation = ...) 可以指定隔离级别,但它只是把参数透传给数据库连接,并不改变数据库本身的默认行为。

隔离级别的细节、七种传播行为的差异,放到下一节 14.2 展开。本节只需记住:事务的「原子性」由数据库保证,Spring 负责的是「什么时候开、什么时候提交或回滚」。

14.1.11 为什么 @Transactional 靠 AOP 代理生效

还有一个关键问题没解答:@Transactional 只是一个注解,Spring 凭什么知道要在方法前后加开启与提交?答案是 AOP 代理。

Spring 在容器启动时,会为带 @Transactional 的 Bean 生成一个代理对象。你注入的 BorrowService 实际上是代理,调用 borrow() 时先进入代理逻辑(开启事务),再调用真实对象的方法,最后按结果提交或回滚。这也是下一节 14.3「事务失效」的根源——只要调用绕过了代理,事务就不会生效。

至此,@Transactional 的基本用法已经完整:加在哪、回滚规则怎么配、readOnly 与 timeout 怎么用、需要精细控制时怎么退回编程式。接下来要解决的是「多个事务相遇时怎么办」——传播行为。

小结

  • 没有事务时,多次 save 各自提交,中途失败会留下「库存扣了、流水没记」这类不一致数据;事务的核心价值是原子性,全做或全不做。
  • @Transactional 可加在方法或类上,方法级配置覆盖类级;被标注的 public 方法由 Spring 生成的 AOP 代理包裹。
  • 默认只回滚 RuntimeException 与 Error,受检异常默认不回滚——这是最容易踩的坑,统一写 rollbackFor = Exception.class 可规避。
  • readOnly = true 表达只读意图并跳过脏检查,但只是提示、不是强制;timeout 限制事务最长耗时,防止长时间占用连接与锁。
  • 需要在方法内部按条件主动回滚时,用 TransactionTemplate 的 status.setRollbackOnly(),它比声明式更精细。
  • 事务的原子性由数据库保证,Spring 只是包装;隔离级别与并发行为是数据库层面的能力,下一节展开。

阅读导航:上一节:13.3 分页与排序 · 下一节:14.2 传播行为与隔离级别 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计