事务原来很简单——SpringBoot中如何正确使用事务

前言

最近在某社区看到一句扎心的话:"2025年的程序员,不是在优化简历,就是在准备优化简历的路上。"

我们就像在写一段没有事务保护的代码------一个异常就可能让所有努力付诸东流。

当潮水退去,才知道谁在裸泳。

最近review代码时,发现不少同事对Spring事务的理解还停留在"加个@Transactional就完事"的阶段。

这让我想起自己刚入行时,也曾在事务的坑里摔得鼻青脸肿。

今天趁着周日(单双休的休),把这些年积累的事务心得整理成文。

耐心看完,你一定有所收获。

正文

好了,咱们开始聊正事儿,从事务的基本概念说起吧!

什么是事务?

事务,英文名叫 Transaction。我特意查了下,这词儿来自拉丁语"transactio",意思是"完成"或者"处理"。在计算机的世界里,它指的是一组操作,要么全干完,要么全不干,就像个打包好的整体。

为了更好理解,咱们拿银行转账举个例子。你想给爸妈转1000块钱,这事儿分两步走:

  • 第一步:你的账户扣掉1000元;
  • 第二步:爸妈的账户加上1000元。

这俩步骤必须一起成功,不然就乱套了。

为啥这么说呢?想象一下,要是你的钱扣了,爸妈那边却没收到,这1000块不就凭空没了?反过来,要是你没扣钱,爸妈账户却多了1000,银行岂不是白赚了?(开玩笑,便宜谁也不能便宜银行啊!)

所以,任何一个步骤出错,另一边都得跟着停下来。

这时候,事务就派上用场了。它能保证这两步要么全成,要么全不干。

在数据库里,这种"非黑即白"的特性有个专业名字,叫原子性(Atomicity)。

它是事务的四大特性之一,简称 ACID

  • 原子性(Atomicity):操作要么全做,要么全不做,没中间状态。
  • 一致性(Consistency):事务得让数据库从一个正常状态,稳稳当当过渡到另一个正常状态。
  • 隔离性(Isolation):多个事务一块儿跑时,互相不能干扰。
  • 持久性(Durability):一旦事务提交,数据就永久保存,丢不了。

正式定义来了:事务就是一组操作的集合,要么全部成功,要么全部失败。简单吧?

这个概念在现实中超重要,像银行转账、电商下单,哪儿都少不了。

数据库里的事务还能在系统崩了的时候,保持数据不乱。要么一切顺利完成(叫提交事务),要么就像啥也没干(叫回滚事务)。

看看这个流程图就明白了:

graph LR A[开始事务] --> B[操作1] B --> C[操作2] C --> D[操作3] D --> E{是否全部成功?} E -->|是| F[提交事务] E -->|否| G[回滚事务]

SpringBoot中的事务管理

说完了概念,我们来讲讲应用。

SpringBoot提供了好几种方式来管理事务,最常用也最简单的就是通过 @Transactional 注解实现。

@Transactional 实际是利用了 TransactionManager 进行事务的管理,这里暂且按下不表,知道有这回事即可。

classDiagram class UserService { +@Transactional +transferMoney() } class TransactionManager UserService --> TransactionManager : 使用

@Transactional 注解的使用

@Transactional 注解可以用在方法上或类上,用来声明事务。

它的基本用法非常简单:

java 复制代码
@Service
public class UserService {
    @Transactional
    public void transferMoney(String fromAccount, String toAccount, BigDecimal amount) {
        // 先查你的账户是否正常
        // 再查你父母的账户是否正常
        // 你的账户扣1000
        // 父母账户加1000
    }
}

注释里的所有操作都被包含在一个事务中,就这么简单。

那这样就结束了?显然不是。

这里面还有不少的注意事项,稍不注意就会踩坑。同样先按下不表,继续往下看。

知道了事务最简单的用法,还得回头来了解下事务中两个很重要的要点,一个叫传播行为 ,一个叫隔离等级

事务的传播行为

想象一下,事务就像个保护罩,把操作裹起来,确保要么全成,要么全挂。

而传播行为(Propagation Behavior),讲的是一个罩着保护罩的方法 A,去调用另一个方法 B 时,这罩子咋传过去的问题。

就像接力赛,接力棒(事务)是直接递给 B,还是 B 自己拿根新的,或者干脆不拿?

常见的传播行为有这些:

  1. REQUIRED(默认值)

    • 如果当前已经有事务,那么方法B就加入这个已有的事务。

    • 如果当前没有事务,那么就给方法B新建一个事务。

    • 简单说:有就加入,没有就新建,大家尽量在一个"罩子"里。

      graph TD A[方法A] -->|无事务| B[新建事务] C[方法B] -->|已有事务| D[加入事务]
  2. REQUIRES_NEW

    • 不管当前有没有事务,它总会为自己创建一个全新的事务。

    • 如果当前已经有事务,那么原来的事务会先"暂停"一下,等方法B这个新事务执行完了,再"恢复"执行。

    • 简单说:我就是要单干,不管外面有没有"罩子",我自己必须有一个新的。

    • 这通常用在一些需要独立提交或回滚的日志记录、或者不希望影响外部事务的操作上。

      graph TD A[当前事务] --> B[挂起] B --> C[新建事务]
  3. NESTED

    • 如果当前有事务,就在嵌套事务内执行
    • 如果没有,就新建一个事务
    • 比较复杂,用得少
  4. SUPPORTS

    • 如果当前有事务,那我就加入你。
    • 如果当前没事务,那就算了,我也不创建新的,就在没有事务的环境下运行。
    • 简单说:有"罩子"我就蹭,没"罩子"我就裸奔。
    • 适合查询这种可有可无事务的场景。
  5. NOT_SUPPORTED

    • 以非事务方式执行
    • 坚决不带事务跑,有事务就先挂起
    • 一句话:别烦我,我不玩事务。
  6. NEVER

    • 以非事务方式执行
    • 如果当前有事务,就抛出异常
    • "有事务?滚 😂!"
  7. MANDATORY

    • 必须在一个已有的事务中执行
    • 否则抛出异常
    • "没有大腿抱?那我不干了!😡"

事务的隔离级别

隔离级别是干啥的?

简单说,就是解决多个事务一块跑时互相干扰的问题,比如脏读、不可重复读、幻读这些麻烦。

当多个事务同时操作数据库时,为了避免数据错乱,就需要设定一些规则来隔离它们,这就是事务的隔离级别(Isolation Level) 。隔离级别越高,数据越安全,但并发性能可能越差(因为限制更多了)。

并发事务可能引发以下问题(按严重程度递增):

  • 脏读 (Dirty Read) :一个事务读到了另一个事务尚未提交的修改。就像偷看了别人还在草稿阶段、可能随时会删掉的内容。
  • 不可重复读 (Non-Repeatable Read) :在一个事务内,两次读取同一行数据,结果却不一样。因为期间有其他事务提交了对这行数据的修改。就像你反复确认一个信息,结果每次都不一样。
  • 幻读 (Phantom Read) :在一个事务内,两次执行同样的范围查询,第二次查询看到了第一次没看到的新行 。因为期间有其他事务插入了符合条件的新数据。就像你数人数,数了两遍发现多出来几个人。
graph LR A[脏读] --> B[不可重复读] --> C[幻读]

Spring支持的标准隔离级别:

级别 脏读 不可重复读 幻读 性能
READ_UNCOMMITTED 最好
READ_COMMITTED ×
REPEATABLE_READ × × 一般
SERIALIZABLE × × × 最差
  1. READ_UNCOMMITTED(读未提交)

    • 隔离级别最低,几乎没有隔离。
    • 可能发生脏读、不可重复读、幻读。
    • 性能最好,但数据最不安全。
    • 类比:可以随便看别人正在写的草稿。
  2. READ_COMMITTED(读已提交)

    • 保证只能读到已经提交的数据,解决了脏读问题。
    • 但还可能发生不可重复读和幻读。
    • 这是大多数数据库(如 Oracle, SQL Server, PostgreSQL)的默认级别
  3. REPEATABLE_READ(可重复读)

    • 保证在一个事务内多次读取同一数据时,结果总是一致的,解决了不可重复读问题。
    • 但仍可能发生幻读(理论上,但 MySQL InnoDB 通过 MVCC 和间隙锁解决了幻读)。
    • 这是 MySQL 的默认隔离级别
  4. SERIALIZABLE(串行化)

    • 隔离级别最高,强制事务串行执行(一个接一个),避免了所有并发问题。
    • 但性能最差,因为失去了并发性。
    • 类比:大家排队,一个一个来。

选择哪个隔离级别,需要在数据一致性和系统性能之间做权衡。

通常 READ_COMMITTEDREPEATABLE_READ 是比较常用的折中选择。

编程式事务管理

虽然 @Transactional 注解用起来爽,但有时我们需要更精细地控制事务的边界,比如在同一个方法内,部分代码需要事务,部分不需要,或者需要根据条件动态决定是否开启事务。

这时,编程式事务管理就派上用场了。

java 复制代码
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.DefaultTransactionDefinition;

@Service
public class ManualTransactionService {

    @Autowired
    private PlatformTransactionManager transactionManager;

    public void transferMoneyManually() {
        // 定义事务属性,比如隔离级别、传播行为等(这里用默认)
        TransactionDefinition def = new DefaultTransactionDefinition();
        // 手动开启事务
        TransactionStatus status = transactionManager.getTransaction(def);
        System.out.println("手动开启事务...");

        try {
            // --- 这里是你的业务逻辑 ---
            System.out.println("执行业务操作 1...");
            System.out.println("执行业务操作 2...");
            // 假设这里可能出错
            // if (System.currentTimeMillis() % 2 == 0) {
            //     throw new RuntimeException("模拟手动事务中发生异常!");
            // }
            // --- 业务逻辑结束 ---

            // 如果一切顺利,手动提交事务
            transactionManager.commit(status);
            System.out.println("手动提交事务成功!");
        } catch (Exception e) {
            // 如果出现任何异常,手动回滚事务
            transactionManager.rollback(status);
            System.err.println("手动回滚事务!原因: " + e.getMessage());
            // 记得把异常抛出,让上层知道出错了
            throw e;
        }
    }
}

这个过程就像开手动挡汽车,虽然麻烦点,但控制感更强。

它的执行流程大致如下:

sequenceDiagram participant Service participant TransactionManager participant DB Service->>TransactionManager: getTransaction() TransactionManager->>DB: 开启事务 Service->>DB: 执行SQL alt 成功 Service->>TransactionManager: commit() TransactionManager->>DB: 提交 else 失败 Service->>TransactionManager: rollback() TransactionManager->>DB: 回滚 end

注意事项

  1. 方法可见性:只对 public 方法生效

    • @Transactional 加在 privateprotectedpackage-private 方法上是无效的,且 Spring 不会报错(静默失败)。

    • 原理:Spring 事务是基于 AOP 代理实现的,非 public 方法无法被代理类有效拦截。

    • 记住:事务方法必须是公开的(public)!

      graph LR A[方法可见性] --> B(public); A --> C(private); A --> D(protected); A --> E(package-private); B -- @Transactional --> F[✔ 生效]; C -- @Transactional --> G[✘ 无效]; D -- @Transactional --> H[✘ 无效]; E -- @Transactional --> I[✘ 无效];
  2. 异常处理:默认只认 RuntimeExceptionError

    • 默认情况下,只有当方法抛出 RuntimeExceptionError 时,事务才会回滚。
    • 如果你抛出的是受检异常 (Checked Exception,比如 IOException, SQLException),事务不会回滚!
    • 得用 @Transactional(rollbackFor=Exception.class) 才管用
    • 或者 rollbackFor = {SpecificException.class, ...} 指定特定异常回滚
    • 也可以用 noRollbackFor 来指定哪些异常不回滚
  3. 自调用问题:同一个类内部调用会失效

    • 在一个 Service 类里面,一个没有 @Transactional 注解的方法 A 调用同一个类里面另一个有 @Transactional 注解的方法 B,方法 B 的事务不会生效。
    • 因为 Spring AOP 代理是基于目标对象的代理实例。当你在类内部直接调用 this.methodB() 时,是直接调用原始对象的方法,绕过了代理对象,自然事务拦截器就没机会工作了。
    • 解决方案
      • 注入自己代理对象:通过 ApplicationContext 获取自身的代理 Bean,再用代理对象调用。
      • 将事务方法移到另一个 Bean 中,通过 Bean 注入调用。
      • 使用 AspectJ (配置更复杂)。
  4. 事务超时:防止长时间锁定资源

    • 可以通过 @Transactional(timeout = 10) 设置事务超时时间(单位秒)。如果事务执行时间超过设定值,会自动回滚并抛出异常。
    • 有助于防止某个事务长时间占用数据库连接或锁,影响系统性能。
  5. 只读事务:优化查询性能

    • 对于只有查询操作的方法,可以设置 @Transactional(readOnly = true)
    • 这会告诉数据库这是一个只读操作,数据库可以进行一些性能优化,比如不记录回滚日志。同时也能在某些隔离级别下防止误操作(如尝试更新)。
  6. 多数据源事务:需要特殊处理

常见踩坑场景

  1. 异常被吃掉
java 复制代码
@Transactional
public void transfer() {
    try {
        // 业务代码
    } catch (Exception e) {
        // 异常被捕获,事务不会回滚
    }
}

正确做法 :要么在 catch 块里手动回滚(如果用编程式事务),要么重新抛出异常(或者包装成 RuntimeException 抛出),让 @Transactional 能捕获到。

  1. 错误配置隔离级别
java 复制代码
// 场景:统计报表,但用了最低隔离级别
@Transactional(isolation = Isolation.READ_UNCOMMITTED)
public Report generateReport() {
    // 这里读取的数据可能是其他事务未提交的"脏"数据
    List<Data> data = fetchData();
    // 基于可能不准确的数据生成报表...
    Report report = processData(data);
    return report;
}

反思

  • 除非你明确知道自己在做什么以及能接受脏读的后果,否则不要轻易使用 READ_UNCOMMITTED
  • 务必根据业务场景选择合适的隔离级别。
  1. 大事务问题
graph LR A[开始事务] --> B[查询1] B --> C[业务计算] C --> D[远程调用] D --> E[更新数据库] E --> F[提交事务]

问题

  • 事务持有数据库连接和锁的时间过长,严重影响并发性能。
  • 外部调用(HTTP、文件 IO)不受数据库事务控制,一旦它们失败,事务回滚了,但外部操作可能已经生效,导致状态不一致。

建议

  • 保持事务短小精悍:尽量只包含必要的数据库操作。
  • 将非事务性操作(如远程调用、发消息、写文件)移出事务边界。可以先完成数据库事务,成功后再执行这些操作(可能需要考虑最终一致性方案)。
  • 如果业务逻辑确实复杂,考虑拆分成多个小事务,或者使用分布式事务管理。

写在最后

事务教会我们一个朴素的真理:人生没有"部分提交",每个选择都应当全力以赴,即使失败也要优雅回滚。

就像Spring的事务管理,重要的不是永不犯错,而是知道何时该坚持,何时该放手。

程序员的生活何尝不是一场精心设计的事务?

我们熬夜写代码是begin,成功上线是commit,遇到bug时的回滚不过是下一次尝试的开始。

在这里,愿每一位同行者:

  • 思维清晰,逻辑永不宕机;
  • Bug 少少,灵感多多,发量稳固如山;
  • 在面对复杂系统和难解问题时,能保持耐心与智慧,最终找到那优雅的解决方案;

更重要的是,在敲代码之余,也能找到生活的平衡点,身体健康,心情愉悦,享受创造带来的成就,也拥抱生活赋予的温暖。

平安喜乐。

相关推荐
沐怡旸7 分钟前
【穿越Effective C++】条款4:确定对象使用前已先被初始化——C++资源管理的基石
c++·面试
天天摸鱼的java工程师33 分钟前
解释 Spring 框架中 bean 的生命周期:一个八年 Java 开发的实战视角
java·后端
往事随风去1 小时前
那个让老板闭嘴、让性能翻倍的“黑科技”:基准测试最全指南
后端·测试
李广坤1 小时前
JAVA线程池详解
后端
调试人生的显微镜1 小时前
深入剖析 iOS 26 系统流畅度,多工具协同监控与性能优化实践
后端
蹦跑的蜗牛1 小时前
Spring Boot使用Redis实现消息队列
spring boot·redis·后端
非凡ghost1 小时前
HWiNFO(专业系统信息检测工具)
前端·javascript·后端
非凡ghost1 小时前
FireAlpaca(免费数字绘图软件)
前端·javascript·后端
非凡ghost1 小时前
Sucrose Wallpaper Engine(动态壁纸管理工具)
前端·javascript·后端
间彧1 小时前
从零到一搭建Spring Cloud Alibbaba项目
后端