
1. 事务隔离级别快速扫盲先把基础打牢聊到 Spring 事务隔离级别我见过太多同行一上来就背“读未提交、读已提交、可重复读、串行化”这四个词面试能说出来回到工位一写代码还是两眼一抹黑。说白了隔离级别这玩意儿是数据库层面的概念Spring 只是给你提供了一个入口去设置它。你要是连数据库为什么需要隔离级别、这四级到底防住了什么都没搞明白那后面所有配置都是空中楼阁。先说个场景你肯定遇到过。两个人同时操作同一行数据A 事务改了还没提交B 事务一查居然能查到 A 改完之后的新值。这就叫脏读。再比如B 事务先查了一次总数是 100过了一会儿又查一次变成 90 了因为中间有人提交了删除操作这叫不可重复读。还有一个更隐蔽的B 事务按条件查出来 10 条记录这个间隙里别人插入了一条符合条件的新数据B 再查一次变成 11 条了这叫幻读。这三个问题就是隔离级别存在的意义。数据库用不同的锁机制和多版本并发控制策略在这三个问题之间做取舍。隔离级别越高一致性越强但并发能力越差级别越低性能越好但你要忍受各种脏数据。1.1 四个级别分别是什么能防住什么我直接给你一张表把四个级别和三个问题的关系列清楚隔离级别脏读不可重复读幻读典型数据库默认值READ_UNCOMMITTED读未提交可能发生可能发生可能发生几乎没人用READ_COMMITTED读已提交不会可能发生可能发生Oracle、PostgreSQL、SQL ServerREPEATABLE_READ可重复读不会不会可能发生MySQL InnoDB实际基本防住了SERIALIZABLE串行化不会不会不会几乎没人当默认值这里有个特别容易踩坑的知识点也是面试高频题MySQL 在 REPEATABLE_READ 级别下通过 next-key lock间隙锁记录锁把幻读也基本防住了。但注意这只是 MySQL InnoDB 引擎的实现手段标准 SQL 规范里 REPEATABLE_READ 是防不住幻读的。你如果在 Oracle 上把隔离级别设为 REPEATABLE_READ照样会出现幻读。所以啊脱离数据库谈隔离级别全是耍流氓。1.2 为什么要反复强调“数据库默认值”不一样因为很多人在 Spring 里写Transactional(isolation Isolation.DEFAULT)以为这就是什么都不设完全交给 Spring。其实 DEFAULT 的意思是Spring 不主动干预使用底层数据库连接默认的隔离级别。那问题就来了MySQL 默认是 REPEATABLE_READOracle 默认是 READ_COMMITTED。同一个业务代码用同一套注解部署在不同的数据库上行为完全不一样。我当年就吃过这个亏本地开发用 MySQL 跑得好好的测试环境切到 PostgreSQL出现了一个非常诡异的“同一条 SQL 查出两次不同的结果”的问题排查了一下午最后发现就是隔离级别默认值变了。所以说你在 Spring 里配隔离级别本质上是在跟数据库的默认行为较劲。你得先知道数据库默认是什么再决定要不要覆盖它。这里我建议做 Java 后端的朋友把“数据库选型”和“隔离级别选型”放在一起考虑别只盯着 Spring 的注解配置。2. Spring 如何映射数据库隔离级别Spring 对事务隔离级别的支持核心体现在TransactionDefinition接口上。这个接口定义了一套事务的元信息包括隔离级别、传播行为、超时时间、是否只读等。具体到隔离级别这块Spring 定义了一个Isolation枚举里面有五个值DEFAULT、READ_UNCOMMITTED、READ_COMMITTED、REPEATABLE_READ、SERIALIZABLE。注意DEFAULT不是 SQL 标准里的级别它是 Spring 自己加的一个“哨兵值”语义是交给底层数据源决定。2.1 从注解到数据库一条完整的调用链你平时最常用的配置方式应该是这样的Service public class OrderService { Transactional(isolation Isolation.REPEATABLE_READ) public void createOrder(OrderDTO dto) { // 业务逻辑 } }这一行注解执行的时候到底发生了什么我拆给你看Spring 容器启动时扫描到Transactional注解通过BeanPostProcessor为这个 Bean 创建代理对象。方法被调用时事务拦截器TransactionInterceptor拦截到请求。拦截器从注解解析出隔离级别比如REPEATABLE_READ封装成TransactionAttribute。调用PlatformTransactionManager.getTransaction()时把这个隔离级别传给DataSourceTransactionManager。事务管理器拿到连接后执行connection.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ)把 JDBC 连接的隔离级别改掉。最后才是真正执行你的业务 SQL。这条链路你不需要背但得理解一个关键点隔离级别的设置是发生在“获取连接之后、执行第一条 SQL 之前”。这意味着连接池里的这个连接从被借出来到归还隔离级别都会被修改。如果你用的是 HikariCP 这种连接池还涉及连接归还后隔离级别要不要重置的问题。2.2 为什么我推荐显式配置而不是依赖默认我在项目里有一条铁律凡是用到 Transactional 的方法必须显式写明 isolation。原因很简单默认的DEFAULT太不可控了。你以为你写的是“让数据库自己决定”实际上你是在把事务的一致性完全托付给部署环境。万一哪天 DBA 把 MySQL 的全局tx_isolation从 REPEATABLE_READ 改成了 READ_COMMITTED你的业务代码一点感知都没有但行为已经悄悄变了。举一个真实案例。我们有个财务模块每天凌晨跑批量核销任务本来在 REPEATABLE_READ 下跑得很稳。有一次 DBA 为了优化线上高峰期性能把隔离级别调成了 READ_COMMITTED结果核销任务在并发场景下出现了重复处理同一笔订单的情况。排查了两天最后发现是隔离级别变了DBA 也很冤因为他不知道业务里有事务依赖这个级别。你如果在代码里写死Isolation.REPEATABLE_READ这个改动上线的时候 DBA 或者代码评审就会发现冲突问题就能提前暴露。2.3 千万别忽略事务管理器是否支持这里再提醒一个细节。Spring 默认的事务管理器是DataSourceTransactionManager它基于 JDBC 连接来设置隔离级别所以对 MySQL、PostgreSQL、Oracle 这种传统关系型数据库都有效。但如果你用的是 JPA Hibernate或者是 JTA 分布式事务隔离级别的设置链路会不一样。比如 Hibernate 自己有hibernate.connection.isolation这个配置项如果你在 Spring 里通过注解设置了隔离级别同时又在 Hibernate 配置文件里设置了那以 Spring 的为准因为 Spring 的HibernateTransactionManager会在运行时覆盖掉 Hibernate 的静态配置。这个坑比较隐蔽但真遇到了会让人抓狂。3. 底层机制为什么隔离级别有时候“不生效”标题说“从懵到精通”那“精通”的标志是什么我认为是能理解为什么配置了隔离级别但某些场景下它的表现不符合预期。这是排障能力的分水岭。3.1 自调用导致事务注解完全失效这是 Spring 事务里最常见的一个坑没有之一。Service public class OrderService { Transactional(isolation Isolation.REPEATABLE_READ) public void createOrder(OrderDTO dto) { // 事务生效 updateStock(dto.getProductId(), dto.getCount()); } Transactional public void updateStock(Long productId, Integer count) { // 你以为这也是一个独立事务 // 其实不是它被 this 调用了 // this 是原始对象不是代理对象 // 所以这里的 Transactional 完全是摆设 } }在一个类的内部一个方法调用另一个方法走的是this引用也就是原始对象不是 Spring 生成的代理对象。代理对象根本没机会拦截这次调用事务注解自然就失效了。这种问题经常出现在刚把业务从无事务改成有事务的项目里排查的时候特别难发现因为代码看起来毫无问题。解决方案有三个一是把被调用的方法拆到另一个 Service 里强制走代理二是往类里注入自身用代理对象调用三是用AopContext.currentProxy()获取当前代理。我个人推荐第一种职责更清晰也更容易测试。提示自调用问题不只会导致事务失效如果方法上有Async注解同样会失效。这类问题在代码评审时应该成为高频关注点。3.2 隔离级别与连接池的“隐性绑定”另一个常见的“配置了但没生效”的情况出在连接池对连接的复用上。MySQL 的连接是通过线程来绑定事务的但连接池里的连接是被多个线程复用的。假设线程 A 拿到连接后事务管理器把隔离级别设成了 REPEATABLE_READ事务结束后连接归还给连接池。连接池如果不重置隔离级别那下一个线程 B 借出这条连接时它的隔离级别还是 REPEATABLE_READ——即使线程 B 的事务配置的是 READ_COMMITTED。好消息是主流的连接池HikariCP、Druid、dbcp2都会在连接归还时做状态清理包括重置隔离级别。HikariCP 默认会执行resetConnection检查如果连接池初始的隔离级别和当前连接的实际隔离级别不一致就会把它重置回去。但这里有个性能隐患每次借出/归还连接都做事务隔离级别重置在高并发下会增加额外开销。所以高并发系统里不要频繁切换隔离级别最好全局统一。如果实在有某个方法需要特殊级别尽量让这个方法独占一个专用数据源不要把隔离级别设置和连接复用混在一起。3.3 Spring 事务传播行为会影响隔离级别吗传播行为和隔离级别是两个维度的东西但它们在执行顺序上有关联。Spring 的propagation定义了事务如何传播比如REQUIRED表示有事务就加入没有就新建REQUIRES_NEW表示无论如何都挂起当前事务新建一个。当你用REQUIRES_NEW新建一个事务时Spring 会从连接池里再拿一条连接在这条新连接上重新设置隔离级别跟外层事务的连接互不干扰。这就是为什么REQUIRES_NEW能实现“内部事务独立提交/回滚”的根本原因。但反过来如果你用的是REQUIRED加入已有事务那隔离级别以第一个创建事务的人为准。什么意思外层事务是 READ_COMMITTED内层方法写的是 REPEATABLE_READ内层方法加入外层事务后实际执行的隔离级别是 READ_COMMITTED而不是注解上写的 REPEATABLE_READ。因为连接已经设置过隔离级别了Spring 不会因为你内层方法设置不同就再改一次。这个知识特别重要很多资深开发都会在这里栽跟头。你在做代码评审时如果看到嵌套事务的隔离级别写得五花八门基本可以断定这个开发没有理解“以第一个事务为准”的规则。4. 实操从配置到验证完整跑一遍光说不练假把式。我下面带你从头到尾搭一个验证 Spring 事务隔离级别的小项目包括表结构、代码、以及怎么用两个并发线程实际验证脏读和不可重复读。这套方法对我排查生产问题很有帮助你也可以用作面试准备或新人培训。4.1 环境准备与表结构建议直接用 Spring Boot 3.x MyBatis-Plus HikariCP MySQL 8.0。MySQL 版本不要太老8.0 对事务的支持和隔离级别处理已经非常成熟。建一张最简单的账户表CREATE TABLE account ( id bigint NOT NULL AUTO_INCREMENT, balance decimal(10,2) NOT NULL DEFAULT 0.00, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; INSERT INTO account (id, balance) VALUES (1, 1000.00);4.2 核心代码验证脏读脏读场景的验证逻辑是事务 A 修改数据但不提交事务 B 去读如果能读到修改后的值说明是 READ_UNCOMMITTED如果读不到说明隔离级别至少是 READ_COMMITTED。Service public class IsolationTestService { Autowired private JdbcTemplate jdbcTemplate; Transactional(isolation Isolation.READ_UNCOMMITTED) public void updateBalanceWithoutCommit(Long id, BigDecimal newBalance) { jdbcTemplate.update(UPDATE account SET balance ? WHERE id ?, newBalance, id); // 这里不抛异常、不返回事务挂起不提交 try { Thread.sleep(5000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } public BigDecimal queryBalance(Long id) { // 这个方法不开启事务直接用新连接去读 return jdbcTemplate.queryForObject(SELECT balance FROM account WHERE id ?, BigDecimal.class, id); } }然后在测试类里先启动一个线程执行updateBalanceWithoutCommit隔 1 秒后主线程执行queryBalance。如果查出来是修改后的新值说明当前隔离级别下发生了脏读。注意在 Spring 里验证隔离级别最干净的方式是用JdbcTemplate直接操作 SQL避免 MyBatis 缓存干扰。同时两个操作要拿到两个不同的数据库连接所以查询方法不要加事务让它每次直接去连接池取连接。4.3 验证不可重复读和幻读不可重复读的验证思路是开启一个 REPEATABLE_READ 或 READ_COMMITTED 事务在事务内第一次查询后另一个事务去修改并提交数据然后当前事务再查一次对比两次结果。Transactional(isolation Isolation.READ_COMMITTED) public void checkRepeatableRead(Long id) throws InterruptedException { BigDecimal first jdbcTemplate.queryForObject( SELECT balance FROM account WHERE id ?, BigDecimal.class, id); // 故意阻塞让另一个事务去更新并提交 Thread.sleep(3000); BigDecimal second jdbcTemplate.queryForObject( SELECT balance FROM account WHERE id ?, BigDecimal.class, id); System.out.println(第一次查询: first , 第二次查询: second); }如果隔离级别是 READ_COMMITTED两次查询结果可能不一样。如果隔离级别是 REPEATABLE_READ因为 InnoDB 的多版本并发控制机制即使其他事务提交了修改当前事务的快照里看到的仍然是第一次查询时的值——这就是“可重复读”名字的由来。幻读的验证稍复杂需要在 REPEATABLE_READ 下做范围查询后另一个事务插入符合条件的记录并提交再查一次看看记录数有没有变化。MySQL InnoDB 在 REPEATABLE_READ 下用间隙锁锁住了范围导致插入操作会被阻塞所以基本看不到幻读。但如果你把隔离级别降到 READ_COMMITTED间隙锁会失效这时候就能复现幻读。这套验证方法做完你对“隔离级别到底在锁什么”会有非常直观的体感。比死记硬背概念强十倍。5. 生产环境怎么选不同业务的隔离级别选型理论学完、实验做完回到真实项目里到底每个业务该用哪个隔离级别我按业务类型给你梳理一套选型思路你可以直接抄作业。5.1 互联网高并发读写场景首选 READ_COMMITTED电商的商品详情、用户中心、社区内容这类高并发读多写少的系统我强烈建议使用 READ_COMMITTED。原因是 MySQL 默认的 REPEATABLE_READ 在多版本并发控制下会产生更多的 undo log 和间隙锁竞争虽然 InnoDB 已经优化得不错但相较于 READ_COMMITTED 还是多了一些锁和版本链的开销。而且这类业务的正确性容错相对高。比如用户查看文章阅读量看到的是不是最新的、中途变了会不会惊讶根本无所谓。商品库存这种强一致数据应该通过SELECT ... FOR UPDATE或者乐观锁来做而不是指望隔离级别兜底。注意MySQL 默认的 REPEATABLE_READ 下SELECT ... FOR UPDATE不仅锁住命中的行还会锁住匹配的间隙这就是经典的“间隙锁导致并发插入阻塞”问题。把隔离级别降到 READ_COMMITTED 能显著减少这种锁冲突。很多大厂的 MySQL 规范就是强制 READ_COMMITTED不是没有道理的。5.2 金融交易对账场景求稳就 REPEATABLE_READ转账、支付、订单状态流转这类业务同一笔数据在同一个事务里可能需要被多次读取校验。比如先查余额校验是否大于扣减金额然后扣减再插入流水。如果没有 REPEATABLE_READ 保证两次查询可能读到不同的余额导致严重的逻辑错误。这种场景下我更倾向于把隔离级别设为 REPEATABLE_READ并且在事务内部用SELECT ... FOR UPDATE主动加锁。虽然性能会打折但金融系统的第一优先级永远是正确性和可审计性性能可以靠分库分表和缓存去解决。5.3 报表统计与批量任务看情况使用 SERIALIZABLE报表统计这类读多写少的场景很多人会纠结要不要用 SERIALIZABLE。我的建议是能用别用。SERIALIZABLE 是所有事务串行执行在报表这种大查询场景下会造成长时间的锁持有其他业务直接卡死。更好的方案是让报表查询走只读副本或者用普通隔离级别先查一个近似快照如果对一致性有硬性要求就在业务代码里加校验逻辑而不是让数据库把所有并发全部串行化。记住隔离级别是最后一道防线不是第一选择。能用锁粒度、乐观锁、业务校验解决的问题就不要把所有并发压力都甩给数据库的隔离机制。6. 常见问题与排查速查表最后这部分我把自己这些年踩过、帮别人排过的 Spring 事务隔离级别问题汇总成一张速查表。建议你收藏起来下次遇到类似现象直接对照排查。现象可能原因排查思路解决方案注解上配了隔离级别但不生效自调用方法内部 this 调用检查调用链是否经过代理拆 Service 或注入自身代理嵌套事务内层级别不生效内层加入已有事务以第一个为准查看传播行为是否为 REQUIRED显式指定 REQUIRES_NEW 或统一级别同一连接池不同线程出现脏读连接归还时未重置隔离级别确认连接池版本和配置升级 HikariCP 或开启 resetMySQL 下幻读被防住了但其他数据库没有InnoDB 的 next-key lock 机制确认数据库引擎跨数据库迁移时重测隔离级别READ_COMMITTED 下并发插入出现死锁间隙锁消失后记录锁竞争加剧查看死锁日志优化 SQL 顺序或使用乐观锁事务方法执行慢拖垮整体性能隔离级别过高锁等待增加用SHOW ENGINE INNODB STATUS看锁评估降级到 READ_COMMITTED排查这类问题的通用套路我总结为三板斧第一板斧看日志。Spring 事务的开启和提交会在日志里体现像TransactionInterceptor可以开启 debug 级别看详细日志看它到底有没有拦截到方法。第二板斧看数据库状态。执行SELECT TRX_ISOLATION_LEVEL FROM information_schema.INNODB_TRX;直接看当前连接的事务隔离级别一秒就知道连接上的真实配置。第三板斧在业务代码里打印连接信息。在DataSourceTransactionManager上做个小切面打印connection.getTransactionIsolation()的返回值对比你期望的级别一目了然。我个人做事务相关排障最快的一次只花了五分钟就是用第三板斧在测试环境打印了实际连接隔离级别发现连接池配置把默认级别改成了 READ_COMMITTED而 Spring 的DEFAULT又完全不做覆盖两边一叠加事务行为完全偏离预期。从那以后我所有项目的application.yml里都会显式写清楚spring.datasource.hikari.transaction-isolation哪怕它就是默认值也要写出来。配置透明化是避免事务类诡异问题最便宜的手段。