前言 作为一名深耕Java后端多年的开发者,从Java 8一路用到Java 21,虚拟线程(Virtual Thread)无疑是JDK近年来最革命性的特性之一——它彻底解决了传统平台线程高内存占用、高上下文切换开销的痛点,让数万甚至数十万并发线程的实现变得轻而易举。
但在实际业务落地中,尤其是我们日常开发最核心的数据库事务管理 场景,虚拟线程和Spring事务体系、MyBatis-Plus(以下简称MP)的整合,藏着非常多的坑。
一、两个底层原理 所有的坑,本质上都是对底层原理的误解。在整合之前,必须先搞懂两个核心体系的底层逻辑,尤其是和线程绑定相关的部分。
1. 虚拟线程 虚拟线程是JVM实现的轻量级用户态线程 ,和传统的平台线程(内核态线程)核心差异如下,重点关注和事务相关的特性:
特性
平台线程
虚拟线程
事务相关影响
实现方式
内核态线程,和OS线程1:1映射
JVM用户态实现,和OS线程N:M映射
无直接影响,核心看ThreadLocal行为
内存占用
栈空间固定1MB左右,创建成本高
栈空间动态伸缩,最小仅几百字节,创建成本极低
支持大量并发事务任务,无OOM风险
阻塞处理
阻塞时会占用整个内核线程,造成资源浪费
阻塞时(如JDBC查询、网络IO)会自动挂载,让出载体线程给其他虚拟线程,阻塞结束后重新挂载
解决了传统JDBC阻塞占用线程的问题,大幅提升数据库操作的并发能力
ThreadLocal绑定
绑定到平台线程,生命周期和线程池一致
完全绑定到虚拟线程本身 ,和载体线程无关,载体线程切换时不会丢失
这是和Spring事务兼容的核心基础!
生命周期
通常和线程池绑定,长生命周期
Per-Task模式,一个任务对应一个虚拟线程,任务结束立即销毁
事务生命周期和虚拟线程完全绑定,无线程复用带来的事务上下文污染问题
关键纠正:很多人误以为虚拟线程的ThreadLocal会在载体线程切换时丢失,这是完全错误的 。Java 21的虚拟线程,ThreadLocal是完全绑定在虚拟线程实例上的,无论载体线程怎么切换,只要是同一个虚拟线程,ThreadLocal的数据就不会丢失,这是Spring事务能在虚拟线程中正常工作的核心前提。
2. Spring事务 + MyBatis-Plus 我们日常用的@Transactional注解,以及MP的CRUD操作,底层完全依赖两个核心组件,而它们的核心都是ThreadLocal :
TransactionSynchronizationManager(事务同步管理器)
Spring事务的核心,用ThreadLocal存储当前线程的事务上下文,包括:数据库连接Connection、事务隔离级别、是否只读、事务同步器等。
同一个事务内的所有数据库操作,必须拿到同一个Connection,才能保证事务的原子性,而这个Connection就是从这个ThreadLocal里获取的。
MyBatis-Plus的 SqlSessionUtils
MP的CRUD操作,会通过SqlSessionUtils获取SqlSession,而SqlSession的获取逻辑,优先从TransactionSynchronizationManager的ThreadLocal中获取当前事务绑定的Connection。
也就是说:只要是同一个线程内的MP操作,在 @Transactional注解下,一定会拿到同一个数据库连接,保证在同一个事务中 。
二、环境版本 虚拟线程是Java 21正式发布的稳定特性,而Spring、MP对虚拟线程的兼容,有严格的版本要求,版本不对,大概率会出现各种奇奇怪怪的问题:
组件
最低版本要求
推荐版本
说明
JDK
21
21 LTS
必须使用正式版,不支持预览版
Spring Boot
3.2.0
3.2.x/3.3.x
3.2.0开始正式支持虚拟线程的自动配置,无需手动写Bean
Spring Framework
6.1.0
6.1.x+
6.1.0开始对虚拟线程做了全面适配,优化了ThreadLocal的传递
MyBatis-Plus
3.5.5
3.5.6+
3.5.5开始兼容Spring 6+,修复了ThreadLocal相关的兼容性问题
数据库连接池
HikariCP 5.0.0
HikariCP 5.1.0
Spring Boot 3.x默认自带,对虚拟线程的阻塞场景做了优化
注意:绝对不要用Spring Boot 3.0/3.1版本强行整合虚拟线程,这两个版本没有官方的自动配置支持,手动配置很容易出现事务上下文丢失的问题。
三、整合步骤 Spring Boot 3.2+对虚拟线程的支持已经做到了极致,整合步骤非常简单,核心就是开启虚拟线程自动配置 + 规范事务写法。
1. 第一步:pom.xml 依赖 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 <parent > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-parent</artifactId > <version > 3.2.5</version > <relativePath /> </parent > <dependencies > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-web</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-jdbc</artifactId > </dependency > <dependency > <groupId > com.baomidou</groupId > <artifactId > mybatis-plus-spring-boot3-starter</artifactId > <version > 3.5.6</version > </dependency > <dependency > <groupId > com.mysql</groupId > <artifactId > mysql-connector-j</artifactId > <scope > runtime</scope > </dependency > <dependency > <groupId > org.projectlombok</groupId > <artifactId > lombok</artifactId > <optional > true</optional > </dependency > </dependencies > <build > <plugins > <plugin > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-maven-plugin</artifactId > <configuration > <source > 21</source > <target > 21</target > </configuration > </plugin > </plugins > </build >
2. 第二步:application.yml 配置 核心就一行:开启虚拟线程自动配置,其他都是常规的MP和连接池配置。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 spring: threads: virtual: enabled: true datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/test_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 3000 idle-timeout: 600000 max-lifetime: 1800000 mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0
划重点: spring.threads.virtual.enabled=true 这一行,是Spring Boot官方提供的最稳定的虚拟线程开启方式 ,它会自动替换Spring内部的TaskExecutor为虚拟线程执行器,无需手动写任何Bean配置,避免了手动配置带来的事务兼容问题。
3. 第三步:事务写法 开启虚拟线程后,同步事务的写法和之前完全一致,没有任何变化;异步事务需要注意注解的位置,核心原则:@Transactional必须加在虚拟线程执行的方法 上。
场景1:同步事务(MVC请求) 开启虚拟线程后,Spring MVC的每个请求都会自动使用虚拟线程处理,事务写法完全不变,开箱即用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 @RestController @RequestMapping("/test") @RequiredArgsConstructor public class testController { private final testService testService; @PostMapping("/weight/distribute") public ResponseEntity<Void> distributeWeight (@RequestBody WeightDistributeDTO dto) { testService.distributeWeight(dto.getInbdWoId(), dto.getGoodsWeight(), dto.getUserId()); return ResponseEntity.ok().build(); } }@Service @RequiredArgsConstructor public class testService { private final InbdRecItemMapper recItemMapper; private final InvPalletMapper palletMapper; @Transactional(rollbackFor = Exception.class) public void distributeWeight (Long inbdWoId, BigDecimal goodsWeight, Long userId) { List<InbdRecItem> recItems = recItemMapper.selectList(Wrappers.lambdaQuery(InbdRecItem.class) .eq(InbdRecItem::getInbdWoId, inbdWoId)); doDistribute(recItems, goodsWeight); recItemMapper.updateBatchById(recItems); if (recItems.isEmpty()) { throw new BusinessException ("明细不能为空" ); } } }
场景2:异步事务(@Async) 这是最高频的使用场景,也是最容易出现事务失效的场景,核心规则:@Async和@Transactional必须加在同一个方法上 ,让异步方法的执行(虚拟线程)和事务绑定 。
正确写法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @Service @RequiredArgsConstructor public class AsynctestService { private final InbdRecItemMapper recItemMapper; @Async @Transactional(rollbackFor = Exception.class) public void asyncBatchHandle (List<InbdRecItem> items) { items.forEach(this ::handleItem); recItemMapper.updateBatchById(items); if (items.stream().anyMatch(item -> item.getActualWeight().compareTo(BigDecimal.ZERO) < 0 )) { throw new BusinessException ("重量不能为负数" ); } } }
错误写法(事务完全失效):
1 2 3 4 5 6 7 8 9 10 11 12 @Service @RequiredArgsConstructor public class ErrorAsyncService { private final AsynctestService asynctestService; @Transactional(rollbackFor = Exception.class) public void errorAsyncHandle (List<InbdRecItem> items) { asynctestService.asyncBatchHandle(items); } }
四、注意事项(★) 坑1:异步事务失效 问题现象 :@Async方法里的数据库操作异常,事务没有回滚。
根本原因 :
Spring的@Transactional是基于AOP实现的,事务上下文绑定到当前线程的ThreadLocal。
主线程开启的事务,和@Async虚拟线程是两个完全独立的线程,ThreadLocal不共享,虚拟线程里的操作根本不在主线程的事务中。
更严重的是:如果@Async方法上没有加@Transactional,MP会为每个CRUD操作单独获取连接、单独提交事务,完全没有原子性。
避坑方案 :
🔴必须在@Async注解的方法上,同时加上@Transactional注解,让事务绑定到虚拟线程。
异步事务必须用REQUIRED传播级别(默认),不要用REQUIRES_NEW以外的其他传播级别,避免上下文错乱。
必须抛出异步方法的异常,否则异常不会被事务切面捕获,无法触发回滚。
坑2:JDBC连接池阻塞 问题现象 :开启虚拟线程后,并发量上去了,但是系统吞吐量没有提升,大量虚拟线程处于阻塞状态。
根本原因 :
虚拟线程的核心优势是解决IO阻塞的线程占用问题,但JDBC连接池的连接数是有限的。
传统平台线程场景,我们通常把HikariCP的最大连接数设为CPU核心数*2,但虚拟线程场景下,大量并发请求会同时争抢连接,连接池满了之后,所有虚拟线程都会阻塞在获取连接的步骤,性能直接打折扣。
更严重的是:如果连接数设置过大,数据库的性能会急剧下降,反而拖垮整个系统。
避坑方案 :
虚拟线程场景下,HikariCP的maximum-pool-size不要超过20-30,数据库的最大连接数也要对应调整。
核心逻辑:数据库的处理能力是有限的,过多的连接只会造成数据库的上下文切换开销,反而降低性能。
业界最佳实践:MySQL的最佳连接数通常在20-50之间,和虚拟线程的数量无关。
必须设置合理的connection-timeout(建议3000ms),避免虚拟线程长时间阻塞等待连接。
🔴绝对不要用无界的连接池,否则会直接把数据库打挂。
坑3:长事务占用连接,导致连接池耗尽 问题现象 :系统运行一段时间后,连接池满了,所有请求都超时,数据库连接全部处于占用状态。
根本原因 :
虚拟线程适合执行短平快的任务,而长事务会长期占用数据库连接,导致连接池被快速耗尽。
比如:在一个事务里,先做数据库查询,然后调用第三方接口(耗时5s),再做数据库更新,整个事务持续5s,连接被占用5s,高并发下连接池瞬间就满了。
虚拟线程的并发量是平台线程的几十倍,长事务的危害被无限放大,之前平台线程场景下不明显的问题,在虚拟线程场景下会直接导致系统雪崩。
避坑方案 :
🔴绝对禁止在事务中执行非数据库操作 :比如第三方接口调用、文件IO、本地耗时计算,必须把这些操作提到事务之外。
大事务拆分成小事务:批量处理场景,拆分成多个小批次,每个批次用独立的事务,避免单个事务执行时间过长。
严格控制事务的执行时间,单个事务的执行时间建议不超过100ms。
坑4:ThreadLocal传递错乱,事务上下文污染 问题现象 :偶尔出现事务提交/回滚异常,或者拿到了不属于当前事务的数据库连接,极难复现。
根本原因 :
很多开发者习惯用InheritableThreadLocal传递上下文,比如用户信息、租户ID。
虚拟线程场景下,InheritableThreadLocal会从父线程拷贝数据到子虚拟线程,但如果父线程是平台线程池中的线程,会出现线程复用导致的上下文错乱。
更严重的是:如果不小心把事务上下文的ThreadLocal传递到了其他虚拟线程,会导致多个线程共用同一个数据库连接,直接造成事务错乱。
避坑方案 :
虚拟线程场景下,优先使用ThreadLocal,而不是InheritableThreadLocal。
如果必须传递上下文,使用Spring 6.1+提供的ContextSnapshot来传递,而不是手动操作ThreadLocal,它会安全地把父线程的上下文传递到子虚拟线程,不会出现错乱。
🔴绝对不要手动操作TransactionSynchronizationManager的ThreadLocal,避免事务上下文污染。
坑5:synchronized锁导致虚拟线程无法挂载,性能急剧下降 问题现象 :开启虚拟线程后,并发量上去了,CPU使用率飙升,但是吞吐量没有提升,大量虚拟线程处于RUNNABLE状态。
根本原因 :
Java 21之前,虚拟线程遇到synchronized重量级锁时,会被固定在载体线程上,无法挂载,导致载体线程被占用,其他虚拟线程无法使用,完全失去了虚拟线程的优势。
Java 21虽然已经优化了这个问题,但是在高并发场景下,synchronized锁依然会成为性能瓶颈,导致大量虚拟线程阻塞在锁上。
避坑方案 :
虚拟线程场景下,优先使用java.util.concurrent.locks.ReentrantLock替代synchronized,它完全兼容虚拟线程的挂载机制,阻塞时会让出载体线程。
必须缩小锁的范围,只锁最核心的资源竞争代码,不要锁整个方法。
🔴绝对不要在事务中加锁,否则会导致锁的持有时间过长,同时占用连接和锁,放大并发问题。
坑6:事务同步器在虚拟线程中的异常 问题现象 :用TransactionSynchronizationManager.registerSynchronization注册的事务同步器,比如afterCommit回调,偶尔不执行,或者执行时报错。
根本原因 :
事务同步器是绑定到当前事务的ThreadLocal中的,而如果在虚拟线程中注册同步器,虚拟线程在事务结束前就销毁了,会导致同步器丢失。
更常见的是:在主线程中开启事务,然后在子虚拟线程中注册同步器,子虚拟线程的ThreadLocal和主线程不共享,同步器根本没有注册到当前事务中。
避坑方案 :
事务同步器必须在事务所在的同一个虚拟线程 中注册,绝对不能跨线程注册。
🔴异步场景下,优先使用@TransactionalEventListener,而不是手动注册事务同步器,它会安全地处理事务事件的监听,兼容虚拟线程。
坑7:MP分页插件、乐观锁插件的兼容性 问题现象 :分页查询偶尔出现分页参数错乱,或者乐观锁版本号更新失败。
根本原因 :
MP的分页插件、乐观锁插件,底层是用ThreadLocal来传递分页参数、版本号的。
如果在虚拟线程中,手动把分页参数设置到ThreadLocal中,然后异步执行,会出现参数传递错乱的问题。
避坑方案 :
升级MP到3.5.6+版本,官方已经修复了虚拟线程场景下的ThreadLocal兼容性问题。
🔴分页参数必须在同一个虚拟线程 中设置和使用,不要跨线程传递分页参数。
🔴绝对不要在多个虚拟线程中共用同一个Page对象,避免线程安全问题。
五、最佳实践 1. 异步事务 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 @Service @RequiredArgsConstructor public class StandardAsyncService { private final InbdRecItemMapper recItemMapper; private final ApplicationEventPublisher eventPublisher; @Async @Transactional(rollbackFor = Exception.class) public CompletableFuture<Void> standardAsyncHandle (List<Long> ids) { try { List<InbdRecItem> items = recItemMapper.selectBatchIds(ids); if (CollUtil.isEmpty(items)) { return CompletableFuture.completedFuture(null ); } items.forEach(item -> { item.setActualWeight(calculateWeight(item)); item.setUpdateTime(LocalDateTime.now()); }); recItemMapper.updateBatchById(items); eventPublisher.publishEvent(new WeightDistributeEvent (ids)); return CompletableFuture.completedFuture(null ); } catch (Exception e) { log.error("异步处理失败" , e); throw new BusinessException ("异步处理失败" , e); } } @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handleWeightDistributeEvent (WeightDistributeEvent event) { log.info("重量分配完成,ids: {}" , event.getIds()); } }
2. 批量数据处理 虚拟线程非常适合批量数据处理,但必须避免长事务,正确的做法是拆分小批次 + 虚拟线程并行处理 + 独立事务:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 @Service @RequiredArgsConstructor public class BatchHandleService { private final InbdRecItemMapper recItemMapper; private final AsyncHandleService asyncHandleService; public void batchHandle () { List<InbdRecItem> allItems = recItemMapper.selectList(Wrappers.lambdaQuery(InbdRecItem.class) .eq(InbdRecItem::getStatus, 0 )); if (CollUtil.isEmpty(allItems)) { return ; } List<List<InbdRecItem>> batchList = ListUtil.split(allItems, 100 ); List<CompletableFuture<Void>> futures = batchList.stream() .map(batch -> asyncHandleService.handleBatch(batch)) .toList(); CompletableFuture.allOf(futures.toArray(new CompletableFuture [0 ])).join(); log.info("批量处理完成,总条数: {}" , allItems.size()); } }@Service @RequiredArgsConstructor public class AsyncHandleService { private final InbdRecItemMapper recItemMapper; @Async @Transactional(rollbackFor = Exception.class) public CompletableFuture<Void> handleBatch (List<InbdRecItem> batch) { try { batch.forEach(item -> item.setStatus(1 )); recItemMapper.updateBatchById(batch); return CompletableFuture.completedFuture(null ); } catch (Exception e) { log.error("批次处理失败" , e); throw new BusinessException ("批次处理失败" , e); } } }
3. 连接池最佳配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 10 connection-timeout: 3000 idle-timeout: 600000 max-lifetime: 1800000 connection-test-query: SELECT 1
4. 监控与排查
虚拟线程监控 :Spring Boot 3.2+已经集成了虚拟线程的监控,可通过Actuator暴露/actuator/threads端点,查看虚拟线程的数量、状态、阻塞情况。
事务监控 :开启Spring事务的debug日志,查看事务的创建、提交、回滚情况,定位事务失效问题:
1 2 3 4 logging: level: org.springframework.transaction: debug com.baomidou.mybatisplus: debug
连接池监控 :通过HikariCP的Metrics,监控连接池的活跃连接数、等待线程数,及时发现连接池瓶颈。
六、测试验证 1. 同步事务回滚测试 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 @SpringBootTest public class VirtualThreadTransactionTest { @Autowired private testService testService; @Autowired private InbdRecItemMapper recItemMapper; @Test public void testSyncTransactionRollback () { Long inbdWoId = 1L ; BigDecimal goodsWeight = new BigDecimal ("100" ); Long userId = 1L ; try { testService.distributeWeight(inbdWoId, goodsWeight, userId); } catch (BusinessException e) { System.out.println("异常捕获成功" ); } List<InbdRecItem> items = recItemMapper.selectList(Wrappers.lambdaQuery(InbdRecItem.class) .eq(InbdRecItem::getInbdWoId, inbdWoId)); items.forEach(item -> { assert item.getActualWeight() == null ; }); System.out.println("同步事务回滚测试通过" ); } }
2. 异步事务回滚测试 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 @SpringBootTest public class AsyncTransactionTest { @Autowired private AsynctestService asynctestService; @Autowired private InbdRecItemMapper recItemMapper; @Test public void testAsyncTransactionRollback () throws Exception { List<InbdRecItem> items = recItemMapper.selectList(Wrappers.lambdaQuery(InbdRecItem.class) .eq(InbdRecItem::getInbdWoId, 1L )); items.forEach(item -> item.setActualWeight(new BigDecimal ("-1" ))); try { asynctestService.asyncBatchHandle(items).get(); } catch (ExecutionException e) { System.out.println("异步异常捕获成功" ); } List<InbdRecItem> dbItems = recItemMapper.selectBatchIds(items.stream().map(InbdRecItem::getId).toList()); dbItems.forEach(item -> { assert item.getActualWeight().compareTo(new BigDecimal ("-1" )) != 0 ; }); System.out.println("异步事务回滚测试通过" ); } }