想象一个再常见不过的场景:用户在App上下了一单,后台先扣库存、再生成订单、最后给用户加积分。如果扣完库存正要写订单时,数据库连接突然断开,等排查完发现库存已经扣了,订单却没有,怎么办?这就是事务存在的意义。Spring Boot作为现在Java后端最主流的框架,提供了非常好用的事务抽象,很多人都会用@Transactional,但真被问到“隔离级别怎么选”“传播特性到底干什么用”时,往往就开始含糊了。
这篇博客,我不打算讲教科书式的定义。我会从实际业务场景出发,把Spring Boot的事务机制、四种隔离级别、七种传播特性一一拆开来讲,顺便把我在项目里踩过的事务失效的坑也一并交代清楚。标题里藏着的两个核心问题——“Spring中如何使用事务”和“不同隔离级别的区别”——我会在后文逐一展开。不管你是刚接触Spring Boot的新手,还是写了两三年业务代码但一直对事务一知半解的同学,这篇文章应该都能给你一些实在的东西。
1. 从一次线上数据错乱说起:事务到底在解决什么问题
开始之前,先把事务回归到最朴素的定义。事务是一组操作的集合,要么全部成功,要么全部失败,不存在中间状态。数据库之所以要提供事务,是为了保证数据在面对并发、异常时依然可靠,也就是ACID:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。
1.1 没有事务时,一次转账操作可能发生什么
我们用一个银行转账的例子来理解。A账户要向B账户转1000元,在代码里就是两步操作:第一步,A账户扣减1000;第二步,B账户增加1000。如果没有事务,第一步成功了,第二步因为网络超时、数据库报错等原因失败,A的钱少了,B的钱没多,这笔账怎么都对不上。
这里的关键点在于,数据库的每一条SQL执行,本身是"自动提交"的。也就是说,如果你不在代码里显式开启事务,扣款SQL执行完就永久生效了。Spring中的@Transactional做的事情,本质上就是把一组SQL纳入同一个事务边界,让它们要么一起提交,要么一起回滚。这一点理解清楚了,后面所有内容才有根基。
1.2 Spring Boot里的事务到底由谁在执行
很多初学Spring Boot的人会有一个误解,以为@Transactional是Spring容器在"魔法般"地管理事务。实际上Spring的事务管理建立在两个基础能力之上:
平台事务管理器(PlatformTransactionManager):Spring Boot的自动配置会根据你引入的依赖自动装配。比如你引入了
spring-boot-starter-jdbc或mybatis-spring-boot-starter,默认注入的就是DataSourceTransactionManager;如果你用的是Spring Data JPA,则会装配JpaTransactionManager。动态代理(AOP):当你在某个方法上标注
@Transactional,Spring会在容器启动时对这个类的Bean生成代理对象,所有外部调用都会先经过代理。代理负责开启事务、提交事务、回滚事务,再执行业务代码。
我们来看一个最简单的使用方式:
@Service public class OrderService { @Autowired private ProductStockMapper stockMapper; @Autowired private OrderMapper orderMapper; @Transactional(rollbackFor = Exception.class) public void createOrder(OrderDTO dto) { // 1. 扣减库存 stockMapper.deduct(dto.getProductId(), dto.getCount()); // 2. 创建订单 orderMapper.insert(dto); // 3. 积分服务调用等 } }这里需要注意rollbackFor = Exception.class。Spring默认只对运行时异常(RuntimeException)和Error进行回滚,如果你直接写@Transactional而不指定rollbackFor,当业务代码抛出IOException、SQLException这类受检异常时,事务是不会回滚的。这一点我在第四节还会专门展开。
1.3 事务边界、事务管理器、回滚规则:三个必须先理清的概念
理解Spring事务,有三个概念绕不开:
事务边界:指的是一个事务从哪行代码开始,到哪行代码结束。@Transactional标注在方法上时,事务边界就是"进入方法前开启,方法正常返回时提交,方法抛出异常时回滚"。需要注意的是,事务边界默认只在方法级别,一个事务不会跨越两个不同方法的调用链,除非你把@Transactional同时标在多个方法上并且传播行为允许复用事务。
事务管理器:Spring提供的事务抽象层核心接口。DataSourceTransactionManager内部做的事情,可以简化理解为:从数据源getConnection()拿到数据库连接,然后调用连接的setAutoCommit(false),最后执行connection.commit()或connection.rollback()。这里的重点是,一个事务绑定的是一个数据库连接,所有SQL都要在同一个连接上执行。
回滚规则:Spring的事务回滚由异常触发。默认情况下,只有方法抛出的异常往外传播,经过代理对象时,代理才会决定回滚。如果你的代码内部把异常吞掉了,Spring根本感知不到异常发生,自然不会回滚。这是事务失效最常见的原因之一。
这三件事理解了,你再看网上各种关于事务的教程,基本不会再有"看不懂"的情况。
2. 四种隔离级别:脏读、不可重复读、幻读是怎么被解决的
隔离级别解决的,其实是一类问题:多个事务同时操作同一条数据时,彼此之间能看到什么、不能看到什么。
2.1 隔离级别不是在Spring层实现的,理解这点很重要
首先要说明的是,@Transactional注解上写的isolation = Isolation.READ_COMMITTED,并不会由Spring自己去实现什么逻辑。Spring只是通过JDBC连接,向数据库发送一条类似SET TRANSACTION ISOLATION LEVEL READ COMMITTED的指令,真正的隔离行为由数据库的锁机制和MVCC(多版本并发控制)来保证。
所以,同样的隔离级别,在不同数据库上的实际表现可能不一样。MySQL的默认隔离级别是REPEATABLE_READ,而Oracle、PostgreSQL默认是READ_COMMITTED。在项目中讨论隔离级别时,一定要先确认底层数据库是谁。
2.2 每种隔离级别解决什么,牺牲什么
数据库标准定义了四种隔离级别,从宽松到严格依次是:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现方式与代价 |
|---|---|---|---|---|
| READ_UNCOMMITTED(读未提交) | 可能发生 | 可能发生 | 可能发生 | 基本不加锁,性能最好,但数据最不可靠 |
| READ_COMMITTED(读已提交) | 不会发生 | 可能发生 | 可能发生 | 读时读取已提交的最新版本,写时加行锁 |
| REPEATABLE_READ(可重复读) | 不会发生 | 不会发生 | 可能发生(MySQL通过间隙锁解决) | 读使用MVCC快照,写加行锁+间隙锁 |
| SERIALIZABLE(串行化) | 不会发生 | 不会发生 | 不会发生 | 读加共享锁,写加排他锁,并发能力严重下降 |
脏读:一个事务读到了另一个事务尚未提交的数据。假设事务A把商品价格从100改成80,还没有提交。事务B在这个时候读价格,读到的是80。如果事务A最终回滚了,那么B就基于一个不存在的数据做了决策。这显然不可接受。READ_UNCOMMITTED级别下,B会读到A未提交的修改,脏读因此产生。
不可重复读:一个事务内两次读取同一条记录,结果却不一样。还是那个价格例子,事务B第一次读价格是100;在B还没结束时,事务A把价格改成了80并提交;B第二次再读价格,变成了80。也就是说,同一事务内读到的数据前后不一致。READ_COMMITTED解决了脏读,但无法避免不可重复读,因为它每次读到的都是"最新已提交版本"。
幻读:一个事务内两次执行同一条查询语句,得到的结果集条数不一致。比如一个统计订单数量的查询,第一次查出来是10条,在事务未结束时,另一个事务插入了一条新订单并提交,第二次查询变成11条。多出来的那条就像幻觉一样。"不可重复读"关注的是同一条数据内容变化,"幻读"关注的是一批数据的数量变化。MySQL默认的REPEATABLE_READ级别下,快照读已经基本避免了幻读,但如果使用当前读(如SELECT ... FOR UPDATE),依然可能发生,MySQL主要依靠间隙锁来解决。
2.3 我在实际项目中怎么选隔离级别
真实项目里,绝大多数场景用数据库默认级别就够了。换句话说,MySQL项目你就用REPEATABLE_READ,PostgreSQL/Oracle项目你就用READ_COMMITTED。我对团队的建议从来是:除非有明确的数据一致性需求,否则不要轻易改全局隔离级别。
什么情况下才需要手动指定隔离级别?举一个真实例子。在某个对账系统中,我们需要先查询一批账单数据,再根据账单结果做后续处理。这个场景要求整个处理过程中,查询出来的数据不能被其他事务修改,否则前后计算对不上。这时候我们把事务隔离级别设置为SERIALIZABLE,用并发换准确,因为对账本身就是低频任务,性能损失可以接受。
@Transactional(isolation = Isolation.SERIALIZABLE) public void reconcile(List<Long> billIds) { // 对账逻辑:查询、比对、状态更新 }反过来,如果是一个高并发的库存扣减场景,我不会依赖隔离级别解决问题,而是考虑用乐观锁(例如UPDATE ... SET stock = stock - #{count} WHERE id = #{id} AND stock >= #{count})、分布式锁或者Redis原子操作。把隔离级别调到SERIALIZABLE在这种场景下往往是下策。
2.4 一个容易忽略的问题:并发锁和隔离级别是一对搭档
隔离级别和数据库锁不是各自独立的东西。REPEATABLE_READ之所以能保证同一个事务多次读到的数据一致,依赖的是MVCC的快照读机制;但当你执行SELECT ... FOR UPDATE或UPDATE语句时,走的是当前读,会加行锁甚至间隙锁。当两个事务试图更新同一条记录时,后执行的一方会被阻塞,直到前一个事务提交或回滚。
这里有个很隐蔽的坑:在READ_COMMITTED级别下,一个事务内的两条UPDATE语句之间,如果该记录被其他事务修改并提交了,当前事务的第二次更新会直接基于最新值执行,而不会重新校验之前的条件。换句话说,"读已提交"级别下,你无法在一个事务内用两条UPDATE实现可重复读的效果。如果业务上需要"先查后改,且不允许中间被改",最好的办法不是调隔离级别,而是用SELECT ... FOR UPDATE主动加锁,或者把隔离级别调到REPEATABLE_READ。
3. 传播特性:七个选项,真正常用的其实就几个
如果说隔离级别解决的是"事务与事务之间相互隔离的程度",传播特性解决的就是"当两个带事务的方法相互调用时,事务该如何合并、挂起、或各自独立"。这里要强调:传播特性的核心发生场景是同一个线程内、同一个Spring容器内、两个@Transactional方法之间的调用关系。
3.1 从一个"子方法也要事务"的调用场景说起
假设有一个订单服务,创建订单后需要给用户发送一条站内消息。订单创建和消息发送各自有独立的Service方法。问题是:如果消息发送失败,订单要不要一起回滚?这就引出了Spring支持的七种传播行为。我先用一张表把全部角色列出来,再挑重点逐一说:
| 传播行为 | 含义 | 典型场景 |
|---|---|---|
| REQUIRED | 有事务则加入,没有则新建 | 默认值,绝大多数业务方法适合 |
| SUPPORTS | 有事务则加入,没有就以非事务方式执行 | 查询方法,有没有事务都行 |
| MANDATORY | 必须已有事务,否则抛异常 | 不能被独立调用的内部方法 |
| REQUIRES_NEW | 总是新建一个独立事务,挂起当前事务 | 写日志、消息推送等独立提交场景 |
| NOT_SUPPORTED | 以非事务方式执行,挂起当前事务 | 某些不建议在事务里执行的长任务 |
| NEVER | 必须以非事务方式执行,否则抛异常 | 明确禁止事务的清理、批处理操作 |
| NESTED | 嵌套事务,内部事务基于Savepoint回滚 | 批量处理中的单条失败不影响整体 |
3.2 REQUIRED:默认值用对了没?
REQUIRED是@Transactional的默认值。它的规则是:如果当前线程已经存在一个事务,就直接加入这个事务;如果当前没有事务,就新建一个。这也是我身边绝大多数人日常写的唯一一种传播级别。
值得展开的是"加入现有事务"到底意味着什么。加入事务最直观的后果是:内层方法的回滚会影响外层事务的整体状态。举例来说:
@Service public class OrderService { @Autowired private MessageService messageService; @Transactional(rollbackFor = Exception.class) public void createOrder() { // 订单创建逻辑 try { messageService.sendMessage(); } catch (Exception e) { // 什么都没做 } } } @Service public class MessageService { @Transactional(rollbackFor = Exception.class) public void sendMessage() { // 如果这里抛异常 } }在上面代码中,sendMessage()加了REQUIRED,它加入的是createOrder()开启的事务。当sendMessage()抛出异常时,这个异常默认会传递到外层方法,标记整个事务为rollback-only。即使外层方法用try-catch把异常吞掉了,事务最终提交时也会抛出UnexpectedRollbackException,数据照样回滚。这个现象很多人在第一次遇到时都非常困惑——明明没让事务回滚,为什么数据还是没写进去?
3.3 REQUIRES_NEW:需要独立提交时的首选
REQUIRES_NEW的语义很干净:不管当前有没有事务,都新建一个独立事务,当前事务先挂起,等新事务执行完再恢复。
这个传播级别最常见的应用场景是操作日志。比如一个核心业务方法开启了一个长事务,期间要记录每一步的操作日志。如果你把日志写入和业务逻辑放在同一个事务里,一旦业务逻辑后续回滚,日志也会被回滚掉,这就失去了审计的意义。这时候日志方法就应该用REQUIRES_NEW:
@Transactional(propagation = Propagation.REQUIRES_NEW) public void writeLog(LogEntity log) { logMapper.insert(log); }用REQUIRES_NEW之后,即使外层事务回滚,日志数据也已经独立提交了。我最早从这个特性里尝到甜头,是在处理定时任务时要记录每个批次任务的执行结果,外层失败不影响任务日志的留存。
不过要提醒一句:REQUIRES_NEW不是免费的。它意味着一次额外的数据库连接获取和释放,在高并发场景下可能放大数据库连接池的压力。一个方法调用链条上如果嵌套了三层REQUIRES_NEW,那就同时有三条连接被占用。所以能用REQUIRED就不要图省事全用REQUIRES_NEW。
3.4 NESTED:部分成功也能接受的批量任务救星
NESTED是用Savepoint机制实现的嵌套事务。它和REQUIRED的关键区别在于:NESTED不是简单加入外层事务,而是设置一个回滚保存点。当内层事务抛异常时,外层事务可以只回滚到保存点,不一定要整体回滚。
这个特性在处理批量数据时非常好用。比如一个批量导入员工的场景,需求是"一条数据失败不影响其他数据导入成功":
@Transactional(rollbackFor = Exception.class) public void batchImport(List<Employee> employees) { for (Employee employee : employees) { try { importSingle(employee); } catch (Exception e) { // 记录失败原因,继续处理下一条 } } } @Transactional(propagation = Propagation.NESTED) public void importSingle(Employee employee) { // 单条导入逻辑,失败时只回滚自己这一条 }这里有个很关键的实现细节:NESTED依赖数据库的Savepoint能力。在MySQL中,使用的是SAVEPOINT语法;在部分不支持Savepoint的数据库或某些连接池场景下,NESTED可能退化为和REQUIRED一样的行为。使用前最好确认你的数据库和驱动支持情况。
很多人会拿NESTED和REQUIRES_NEW做对比,我简单做个区分:REQUIRES_NEW是物理上的独立事务,它的提交和回滚完全不受外层影响;NESTED是逻辑上的子事务,内层回滚只回滚到保存点,外层事务最终如何结束还是由外层决定。如果你需要"内层先独立提交",选REQUIRES_NEW;如果你需要"内层失败不影响外层整体",选NESTED。
3.5 SUPPORTS、MANDATORY、NOT_SUPPORTED、NEVER:各自为谁而生
这几个传播级别用得相对少,但工作中偶尔会遇到,我分头说一下:
SUPPORTS:有事务就加入,没有就以非事务方式跑。最适合查询类方法。如果一个方法被事务方法调用,查询就在事务中执行;被非事务方法调用,也不会强制开启事务,减少无意义的连接占用。
MANDATORY:必须有事务才执行,否则直接抛异常。这适合那些"只能作为内部子方法被调用"的方法。比如你在一个Service方法中需要强制在事务上下文里执行一个公共方法,可以用它来防止有人直接绕过外层事务调用。
NOT_SUPPORTED:挂起当前事务,以非事务方式执行。典型场景是事务内执行一段非常耗时的外部接口调用,不想让数据库连接被长时间占用。不过说实话,把耗时操作挪到事务外部或者用异步处理,通常比在事务里挂起更合理。
NEVER:如果当前存在事务就抛异常。适合一些清理类、批量修正类的执行入口,确保绝对不会有长事务包裹。这个级别现实中我几乎没用过,更多是作为约束性设计存在。
4. 事务失效的坑,我一次性帮你排完
这是一篇讲Spring事务的文章绕不开的部分。我在code review时最常见到的场景就是:开发同学说"我加了@Transactional,数据还是写进去了",结果查下来全是下面这些原因之一。
4.1 自调用:同一个类里方法调用,代理根本不经过
这是Spring事务失效的头号原因。前面说了,@Transactional靠AOP代理实现。外部调用通过代理对象进入目标方法时,代理才会开启事务;但同一个类中的methodA()直接调用methodB(),是this.methodB()这种形式,走的是对象本身,不是代理对象,事务注解自然就不生效了。
@Service public class ProductService { public void updateProduct(Product product) { // 这里直接调用下面的事务方法,事务不生效 this.updateStock(product); } @Transactional(rollbackFor = Exception.class) public void updateStock(Product product) { // 更新库存 } }解决方案有三种:要么把updateStock拆分到另一个Service类中,由外部Bean调用;要么在类中注入自身代理@Autowired private ProductService self,然后通过self.updateStock()调用;要么使用AopContext.currentProxy()强制走代理(需要在启动类加@EnableAspectJAutoProxy(exposeProxy = true))。我个人最推荐的是拆分到另一个Service,因为代码结构更清晰,也不容易在后续维护中埋坑。
4.2 异常被try-catch吞掉:Spring根本不知道发生了异常
一个事务方法里,内部捕获了异常而没有重新抛出,方法正常返回,代理层看到的是"方法成功结束",于是执行提交。这是异常处理不当导致的事务失效,非常隐蔽。
@Transactional(rollbackFor = Exception.class) public void order() { try { orderMapper.insert(order); stockMapper.deduct(stock); } catch (Exception e) { log.error("下单失败", e); // 没有重新抛出异常 } }正确的做法要么是让异常继续往外抛,要么在捕获后手动执行TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()强制标记回滚。不过从设计角度,事务方法内尽量不要捕获可能影响整体逻辑的异常,把异常交给事务边界去处理是更干净的方式。
4.3 非public方法:代理模式天然的限制
@Transactional标注在private、protected或包可见方法上,事务也不生效。Spring的AOP默认基于代理机制,private方法根本不会被代理拦截。类内部自调用省略了代理,非public方法则连代理织入的入口都没有。
这里要补充说明:@Transactional标注在public方法上是官方推荐的用法,基于JDK动态代理或CGLIB都能正确处理。如果你确实需要在非public方法上使用事务,可以先想想能否把这段逻辑提取到单独的公共方法中。
4.4 rollbackFor设置不对:受检异常默认不回滚
前面已经反复提到。Spring默认只回滚RuntimeException和Error,受检异常(checked exception)不会触发回滚。原因也好理解:Spring的设计哲学是,事务回滚应该由"不可预期的错误"触发,受检异常往往代表一种可以处理的业务预期,默认不该中断事务。
如果你希望受检异常也回滚,必须显式声明:
@Transactional(rollbackFor = Exception.class) public void importData() throws IOException { // 如果抛IOException,也会回滚 }甚至在某些业务里,你可以指定只对特定异常回滚,比如@Transactional(noRollbackFor = BusinessException.class),允许某个已知的业务异常不影响数据提交。不过这种用法要克制,否则排查问题时容易晕。
4.5 多线程调用:事务绑定的是线程,不是方法
事务和数据库连接的绑定关系基本是与线程绑定的。如果你在事务方法内自己new Thread(...)或者使用@Async异步调用另一个数据库操作方法,那么子线程里的操作不在当前事务的连接上执行,自然也不会参与同一个事务的提交和回滚。
@Transactional(rollbackFor = Exception.class) public void createOrder() { orderMapper.insert(order); asyncService.sendNotify(); // 如果sendNotify里的逻辑失败,不影响createOrder的事务 }这种场景如果要求"通知发送失败也必须回滚订单",异步调用就不合适。需要的是同步执行,或者在异步方法里独立开启新事务、接受两边数据可能存在不一致的结果。做架构设计时要提前想清楚,异步操作和事务是一对天然的矛盾体。
4.6 其他几个容易被忽略的开关
还有几个低概率但真实存在的情况:一是数据源没有配置事务管理器,Spring Boot虽然会默认配置,但如果你自定义了多个数据源或者用了奇怪的连接池组合,可能覆盖了默认配置,导致事务管理器没有正确绑定到@Transactional上;二是数据库表引擎问题,比如MySQL的MyISAM引擎本身不支持事务,换成InnoDB才行;三是类没有被Spring容器管理,也就是你直接new了一个Service对象来调用方法,代理自然不存在。
4.7 一个排查失效问题的标准链路
遇到事务不生效,我通常按下述顺序排查,效率很高:
- 先确认
@Transactional标注的方法是public,并且是被外部Bean调用的。 - 在方法里打断点或者加日志,确认是否经过了代理对象(可以观察到当前sqlSession中的connection是否设置了
autoCommit=false)。 - 检查方法内部是否有catch住了异常没有继续抛。
- 检查数据库表是否为事务引擎(MySQL下是InnoDB)。
- 确认事务管理器是否被正确注入,多个数据源时确认
@Transactional使用的transactionManager是哪一个。 - 确认异常类型和
rollbackFor的配置是否匹配。
这套链路走完,基本能定位95%以上的事务失效问题。
5. 想验证隔离级别和传播特性?自己动手搭一个最小实验
理论讲再多,不如自己跑一遍实验。我建议你在本地搭一个最简单的Spring Boot工程,用实际数据来验证上面提到的隔离级别和传播特性。这个实验成本很低,收获却非常大。
5.1 准备一个简单的Spring Boot工程
工程只需要三个依赖:spring-boot-starter-web、spring-boot-starter-jdbc、mysql-connector-j(或者你用H2内存数据库也可以,但为了更贴近生产,建议直接用本地MySQL)。建一张简单的账户表:
CREATE TABLE `account` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `balance` decimal(10,2) DEFAULT '0.00', PRIMARY KEY (`id`) ) ENGINE=InnoDB;插入一条测试数据,id=1,balance=1000。
5.2 复现脏读/不可重复读的实验步骤
写两个Service方法。方法A负责开启一个事务,先更新balance为800,然后Thread.sleep(5000),5秒后才提交;方法B负责在另一个线程或另一个浏览器请求里,在A未提交时去查询balance。
@Service public class TxTestService { @Autowired private JdbcTemplate jdbcTemplate; @Transactional(rollbackFor = Exception.class) public void updateBalanceWithSleep() throws InterruptedException { jdbcTemplate.update("UPDATE account SET balance = 800 WHERE id = 1"); System.out.println("事务A已更新,未提交,开始休眠..."); Thread.sleep(5000); } @Transactional public BigDecimal queryBalance() { BigDecimal balance = jdbcTemplate.queryForObject( "SELECT balance FROM account WHERE id = 1", BigDecimal.class); System.out.println("事务B查询到的余额:" + balance); return balance; } }先设置事务隔离级别为READ_UNCOMMITTED,同时发起更新和查询请求,你会发现事务B能读到事务A未提交的800。再把隔离级别改成READ_COMMITTED,同样的操作,事务B在A提交前读到的仍然是1000。这个实验非常直观,比我写一万字解释都有说服力。
5.3 复现传播特性的实验步骤
传播特性的实验也不用复杂。写三个方法:
outerMethod():@Transactional,先插入一条记录,再调用innerMethod(),最后抛出异常。innerMethodRequired():@Transactional(propagation = Propagation.REQUIRED),插入一条记录。innerMethodRequiresNew():@Transactional(propagation = Propagation.REQUIRES_NEW),插入一条记录。
分别组合调用,观察最终数据库里留下了哪些记录:
| 外层方法操作 | 内层传播级别 | 最终结果 |
|---|---|---|
| 调用内层后抛异常回滚 | REQUIRED | 内层记录也回滚,库里没有 |
| 调用内层后抛异常回滚 | REQUIRES_NEW | 内层记录已提交,库里保留 |
这个实验一做,你对"加入事务"和"新建独立事务"的理解就不是停留在概念层面了。
5.4 观察控制台与数据库状态
做实验时别光看结果,注意控制台日志。你可以在application.yml中可以配置:
logging: level: org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG开启事务管理器的日志后,每次开启事务、提交、回滚都会输出日志。你可以清晰地看到传播行为是怎么改变事务边界的。这个细节对理解Spring事务内部机制特别有帮助。
如果以后接手了分布式事务相关的工作,你会发现:分布式事务虽然技术上更复杂,但核心要解决的还是事务的原子性和隔离性问题。你在单体事务上积累的清晰认知,会给那个阶段打下很扎实的基础。有时间的话,真的建议把上面的实验都跑一遍——纸上得来终觉浅,事务这块尤其如此。