事务原来很简单——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 少少,灵感多多,发量稳固如山;
  • 在面对复杂系统和难解问题时,能保持耐心与智慧,最终找到那优雅的解决方案;

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

平安喜乐。

相关推荐
天天扭码2 分钟前
面试官:算法题”除自身以外数组的乘积“ 我:😄 面试官:不能用除法 我:😓
前端·算法·面试
L2ncE4 分钟前
双非计算机自救指南(找工作版)
后端·面试·程序员
cdg==吃蛋糕8 分钟前
solr自动建议接口简单使用
后端·python·flask
Joseit18 分钟前
基于 Spring Boot实现的图书管理系统
java·spring boot·后端
{⌐■_■}37 分钟前
【go】什么是Go语言的GPM模型?工作流程?为什么Go语言中的GMP模型需要有P?
java·开发语言·后端·golang
好易学数据结构1 小时前
可视化图解算法:按之字形顺序打印二叉树( Z字形、锯齿形遍历)
数据结构·算法·leetcode·面试·二叉树·力扣·笔试·遍历·二叉树遍历·牛客网·层序遍历·z·z字形遍历·锯齿形遍历
张力尹1 小时前
「架构篇 1」认识 MVC / MVP / MVVM / MVI
android·面试·架构
IT杨秀才1 小时前
LangChain框架入门系列(5):Memory
人工智能·后端·langchain
张力尹1 小时前
「架构篇 2」认识 MVC / MVP / MVVM / MVI
android·面试·架构
程序猿chen1 小时前
JVM考古现场(二十四):逆熵者·时间晶体的永恒之战
java·jvm·git·后端·程序人生·java-ee·改行学it