前言
最近在某社区看到一句扎心的话:"2025年的程序员,不是在优化简历,就是在准备优化简历的路上。"
我们就像在写一段没有事务保护的代码------一个异常就可能让所有努力付诸东流。
当潮水退去,才知道谁在裸泳。
最近review代码时,发现不少同事对Spring事务的理解还停留在"加个@Transactional就完事"的阶段。
这让我想起自己刚入行时,也曾在事务的坑里摔得鼻青脸肿。
今天趁着周日(单双休的休),把这些年积累的事务心得整理成文。
耐心看完,你一定有所收获。

正文
好了,咱们开始聊正事儿,从事务的基本概念说起吧!
什么是事务?
事务,英文名叫 Transaction。我特意查了下,这词儿来自拉丁语"transactio",意思是"完成"或者"处理"。在计算机的世界里,它指的是一组操作,要么全干完,要么全不干,就像个打包好的整体。
为了更好理解,咱们拿银行转账举个例子。你想给爸妈转1000块钱,这事儿分两步走:
- 第一步:你的账户扣掉1000元;
- 第二步:爸妈的账户加上1000元。
这俩步骤必须一起成功,不然就乱套了。
为啥这么说呢?想象一下,要是你的钱扣了,爸妈那边却没收到,这1000块不就凭空没了?反过来,要是你没扣钱,爸妈账户却多了1000,银行岂不是白赚了?(开玩笑,便宜谁也不能便宜银行啊!)
所以,任何一个步骤出错,另一边都得跟着停下来。
这时候,事务就派上用场了。它能保证这两步要么全成,要么全不干。
在数据库里,这种"非黑即白"的特性有个专业名字,叫原子性(Atomicity)。
它是事务的四大特性之一,简称 ACID
:
原子性(Atomicity)
:操作要么全做,要么全不做,没中间状态。一致性(Consistency)
:事务得让数据库从一个正常状态,稳稳当当过渡到另一个正常状态。隔离性(Isolation)
:多个事务一块儿跑时,互相不能干扰。持久性(Durability)
:一旦事务提交,数据就永久保存,丢不了。
正式定义来了:事务就是一组操作的集合,要么全部成功,要么全部失败。简单吧?
这个概念在现实中超重要,像银行转账、电商下单,哪儿都少不了。
数据库里的事务还能在系统崩了的时候,保持数据不乱。要么一切顺利完成(叫提交事务),要么就像啥也没干(叫回滚事务)。
看看这个流程图就明白了:
SpringBoot中的事务管理
说完了概念,我们来讲讲应用。
SpringBoot提供了好几种方式来管理事务,最常用也最简单的就是通过 @Transactional
注解实现。
而 @Transactional
实际是利用了 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 自己拿根新的,或者干脆不拿?
常见的传播行为有这些:
-
REQUIRED(默认值) :
-
如果当前已经有事务,那么方法B就加入这个已有的事务。
-
如果当前没有事务,那么就给方法B新建一个事务。
-
简单说:有就加入,没有就新建,大家尽量在一个"罩子"里。
graph TD A[方法A] -->|无事务| B[新建事务] C[方法B] -->|已有事务| D[加入事务]
-
-
REQUIRES_NEW:
-
不管当前有没有事务,它总会为自己创建一个全新的事务。
-
如果当前已经有事务,那么原来的事务会先"暂停"一下,等方法B这个新事务执行完了,再"恢复"执行。
-
简单说:我就是要单干,不管外面有没有"罩子",我自己必须有一个新的。
-
这通常用在一些需要独立提交或回滚的日志记录、或者不希望影响外部事务的操作上。
graph TD A[当前事务] --> B[挂起] B --> C[新建事务]
-
-
NESTED:
- 如果当前有事务,就在嵌套事务内执行
- 如果没有,就新建一个事务
- 比较复杂,用得少
-
SUPPORTS:
- 如果当前有事务,那我就加入你。
- 如果当前没事务,那就算了,我也不创建新的,就在没有事务的环境下运行。
- 简单说:有"罩子"我就蹭,没"罩子"我就裸奔。
- 适合查询这种可有可无事务的场景。
-
NOT_SUPPORTED:
- 以非事务方式执行
- 坚决不带事务跑,有事务就先挂起
- 一句话:别烦我,我不玩事务。
-
NEVER:
- 以非事务方式执行
- 如果当前有事务,就抛出异常
- "有事务?滚 😂!"
-
MANDATORY:
- 必须在一个已有的事务中执行
- 否则抛出异常
- "没有大腿抱?那我不干了!😡"
事务的隔离级别
隔离级别是干啥的?
简单说,就是解决多个事务一块跑时互相干扰的问题,比如脏读、不可重复读、幻读这些麻烦。
当多个事务同时操作数据库时,为了避免数据错乱,就需要设定一些规则来隔离它们,这就是事务的隔离级别(Isolation Level) 。隔离级别越高,数据越安全,但并发性能可能越差(因为限制更多了)。
并发事务可能引发以下问题(按严重程度递增):
- 脏读 (Dirty Read) :一个事务读到了另一个事务尚未提交的修改。就像偷看了别人还在草稿阶段、可能随时会删掉的内容。
- 不可重复读 (Non-Repeatable Read) :在一个事务内,两次读取同一行数据,结果却不一样。因为期间有其他事务提交了对这行数据的修改。就像你反复确认一个信息,结果每次都不一样。
- 幻读 (Phantom Read) :在一个事务内,两次执行同样的范围查询,第二次查询看到了第一次没看到的新行 。因为期间有其他事务插入了符合条件的新数据。就像你数人数,数了两遍发现多出来几个人。
Spring支持的标准隔离级别:
级别 | 脏读 | 不可重复读 | 幻读 | 性能 |
---|---|---|---|---|
READ_UNCOMMITTED | ✓ | ✓ | ✓ | 最好 |
READ_COMMITTED | × | ✓ | ✓ | 好 |
REPEATABLE_READ | × | × | ✓ | 一般 |
SERIALIZABLE | × | × | × | 最差 |
-
READ_UNCOMMITTED(读未提交) :
- 隔离级别最低,几乎没有隔离。
- 可能发生脏读、不可重复读、幻读。
- 性能最好,但数据最不安全。
- 类比:可以随便看别人正在写的草稿。
-
READ_COMMITTED(读已提交) :
- 保证只能读到已经提交的数据,解决了脏读问题。
- 但还可能发生不可重复读和幻读。
- 这是大多数数据库(如 Oracle, SQL Server, PostgreSQL)的默认级别。
-
REPEATABLE_READ(可重复读) :
- 保证在一个事务内多次读取同一数据时,结果总是一致的,解决了不可重复读问题。
- 但仍可能发生幻读(理论上,但 MySQL InnoDB 通过 MVCC 和间隙锁解决了幻读)。
- 这是 MySQL 的默认隔离级别。
-
SERIALIZABLE(串行化) :
- 隔离级别最高,强制事务串行执行(一个接一个),避免了所有并发问题。
- 但性能最差,因为失去了并发性。
- 类比:大家排队,一个一个来。
选择哪个隔离级别,需要在数据一致性和系统性能之间做权衡。
通常 READ_COMMITTED
或 REPEATABLE_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;
}
}
}
这个过程就像开手动挡汽车,虽然麻烦点,但控制感更强。
它的执行流程大致如下:
注意事项
-
方法可见性:只对
public
方法生效:-
@Transactional
加在private
、protected
或package-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[✘ 无效];
-
-
异常处理:默认只认
RuntimeException
和Error
- 默认情况下,只有当方法抛出
RuntimeException
或Error
时,事务才会回滚。 - 如果你抛出的是受检异常 (Checked Exception,比如
IOException
,SQLException
),事务不会回滚! - 得用
@Transactional(rollbackFor=Exception.class)
才管用 - 或者
rollbackFor = {SpecificException.class, ...}
指定特定异常回滚 - 也可以用
noRollbackFor
来指定哪些异常不回滚
- 默认情况下,只有当方法抛出
-
自调用问题:同一个类内部调用会失效
- 在一个 Service 类里面,一个没有
@Transactional
注解的方法 A 调用同一个类里面另一个有@Transactional
注解的方法 B,方法 B 的事务不会生效。 - 因为 Spring AOP 代理是基于目标对象的代理实例。当你在类内部直接调用
this.methodB()
时,是直接调用原始对象的方法,绕过了代理对象,自然事务拦截器就没机会工作了。 - 解决方案 :
- 注入自己代理对象:通过
ApplicationContext
获取自身的代理 Bean,再用代理对象调用。 - 将事务方法移到另一个 Bean 中,通过 Bean 注入调用。
- 使用
AspectJ
(配置更复杂)。
- 注入自己代理对象:通过
- 在一个 Service 类里面,一个没有
-
事务超时:防止长时间锁定资源
- 可以通过
@Transactional(timeout = 10)
设置事务超时时间(单位秒)。如果事务执行时间超过设定值,会自动回滚并抛出异常。 - 有助于防止某个事务长时间占用数据库连接或锁,影响系统性能。
- 可以通过
-
只读事务:优化查询性能
- 对于只有查询操作的方法,可以设置
@Transactional(readOnly = true)
。 - 这会告诉数据库这是一个只读操作,数据库可以进行一些性能优化,比如不记录回滚日志。同时也能在某些隔离级别下防止误操作(如尝试更新)。
- 对于只有查询操作的方法,可以设置
-
多数据源事务:需要特殊处理
- 需要分布式事务
- 可以看这篇文章:juejin.cn/post/749418...
常见踩坑场景
- 异常被吃掉:
java
@Transactional
public void transfer() {
try {
// 业务代码
} catch (Exception e) {
// 异常被捕获,事务不会回滚
}
}
正确做法 :要么在 catch
块里手动回滚(如果用编程式事务),要么重新抛出异常(或者包装成 RuntimeException
抛出),让 @Transactional
能捕获到。
- 错误配置隔离级别:
java
// 场景:统计报表,但用了最低隔离级别
@Transactional(isolation = Isolation.READ_UNCOMMITTED)
public Report generateReport() {
// 这里读取的数据可能是其他事务未提交的"脏"数据
List<Data> data = fetchData();
// 基于可能不准确的数据生成报表...
Report report = processData(data);
return report;
}
反思:
- 除非你明确知道自己在做什么以及能接受脏读的后果,否则不要轻易使用
READ_UNCOMMITTED
。 - 务必根据业务场景选择合适的隔离级别。
- 大事务问题:
问题
- 事务持有数据库连接和锁的时间过长,严重影响并发性能。
- 外部调用(HTTP、文件 IO)不受数据库事务控制,一旦它们失败,事务回滚了,但外部操作可能已经生效,导致状态不一致。
建议:
- 保持事务短小精悍:尽量只包含必要的数据库操作。
- 将非事务性操作(如远程调用、发消息、写文件)移出事务边界。可以先完成数据库事务,成功后再执行这些操作(可能需要考虑最终一致性方案)。
- 如果业务逻辑确实复杂,考虑拆分成多个小事务,或者使用分布式事务管理。
写在最后
事务教会我们一个朴素的真理:人生没有"部分提交",每个选择都应当全力以赴,即使失败也要优雅回滚。
就像Spring的事务管理,重要的不是永不犯错,而是知道何时该坚持,何时该放手。
程序员的生活何尝不是一场精心设计的事务?
我们熬夜写代码是begin,成功上线是commit,遇到bug时的回滚不过是下一次尝试的开始。
在这里,愿每一位同行者:
- 思维清晰,逻辑永不宕机;
- Bug 少少,灵感多多,发量稳固如山;
- 在面对复杂系统和难解问题时,能保持耐心与智慧,最终找到那优雅的解决方案;
更重要的是,在敲代码之余,也能找到生活的平衡点,身体健康,心情愉悦,享受创造带来的成就,也拥抱生活赋予的温暖。
平安喜乐。
