☰
JPA事务内查询不到刚保存的数据?flush、一级缓存与隔离级别深度解析
2026/10/25 19:08:49 网站建设 项目流程

你遇到过这样的情况吗?代码里用 JPA 在事务内save了一条记录,紧接着再查,怎么都查不到,但是打开数据库客户端一select,数据明明就在那里。很多人第一反应是"数据库出问题了"或"JPA 有 bug",然后开始怀疑人生。

先说结论:数据库大概率没背锅,真正的问题出在 JPA 的持久化上下文(Persistence Context)和事务机制上。这个场景我排查过不止一次,身边同事也踩过相似的坑,每次都能看到新人对着屏幕发呆。

这篇文章我会把"事务内查询不到记录"这类问题的完整链路拆开:先从复现现象讲起,再深入到 flush、一级缓存、事务隔离级别这几个底层机制,最后给出定位方法和可落地的解决姿势。无论你是刚接触 JPA 的新人,还是已经用它写过一段时间项目的老手,都值得认真看一下——尤其是那些"偶尔出现、重启就好"的诡异问题,大概率都是这几个原因在作祟。

1. 一个让数据库"背锅"的诡异现象:复现与初判

先还原一个我实际处理过的现场。订单创建服务里有这么一段代码,当时负责的同事怎么都想不通问题出在哪。

1.1 最小复现样例:事务里保存后立刻查询

@Service public class OrderService { private static final Logger log = LoggerFactory.getLogger(OrderService.class); private final OrderRepository orderRepository; private final JdbcTemplate jdbcTemplate; public OrderService(OrderRepository orderRepository, JdbcTemplate jdbcTemplate) { this.orderRepository = orderRepository; this.jdbcTemplate = jdbcTemplate; } @Transactional public Long createOrder(OrderCreateDTO dto) { Order order = new Order(); order.setOrderNo(dto.getOrderNo()); order.setStatus(OrderStatus.CREATED); orderRepository.save(order); // 刚保存完,用 JdbcTemplate 再查一次 List<Map<String, Object>> rows = jdbcTemplate.queryForList( "SELECT * FROM t_order WHERE order_no = ?", dto.getOrderNo()); if (rows.isEmpty()) { log.warn("订单保存成功却查询不到,orderNo={}", dto.getOrderNo()); } return order.getId(); } }

这段代码在执行的时候,createOrder方法确实成功返回了,日志里也确实打印了那条 warning:"订单保存成功却查询不到"。更让人崩溃的是,同事当时立刻打开 Navicat 执行了同样的 SQL,查到了那条订单数据。于是他得出一个结论:JPA 的save方法有问题,数据库里明明有数据,代码却查不到。

这个结论对吗?不对。问题不在save,也不在数据库,而是出在"查询的时机"和"查询的路径"上。

1.2 第一轮排查:数据到底提交了没有

面对"代码查不到、数据库能查到"这种矛盾,第一步不是去猜框架 bug,而是要搞清楚一个关键问题:你去看数据库的时候,数据到底是提交了还是没有提交。

这里有个非常容易误导人的时间线,我画个流程你感受一下:

  1. 事务方法createOrder开始执行。
  2. orderRepository.save(order)被调用,注意这一步通常只是把实体纳入持久化上下文管理,并没有立刻执行INSERT。
  3. jdbcTemplate.queryForList(...)执行,直接落到数据库上,此时数据库里根本没有这条订单数据,所以查不到。
  4. 方法正常 return,Spring 事务管理器提交事务,INSERT这时才真正执行,数据落库。
  5. 你去数据库客户端查,看到了这条数据。

也就是说:你查数据库客户端的时候,事务已经提交了,数据库里当然有数据;而代码里查询的时候,数据还没来得及 flush 到数据库,自然查不到。两个动作发生在不同的时间点,却拿着同一个 SQL 的结果做对比,于是"矛盾"就产生了。

如果你的代码里也有类似的"先在事务内保存,紧接着查询验证"的逻辑,先别急着怀疑数据库,把日志打开看看执行顺序,多半就是这个问题。

2. JPA 持久化上下文的三个暗坑:缓存、flush 与快照

要真正搞懂这个问题,光知道"时机不对"是不够的,还要理解背后的三个底层机制:一级缓存、自动 flush、事务隔离级别的快照读取。下面逐个拆开讲,这些都是 JPA 面试题里经常出现、实战里又特别容易踩的点。

2.1 一级缓存:EntityManager 的"私人背包"

每个 EntityManager 都有一个持久化上下文,你可以把它理解成一个"私人背包"。当你通过 JPA 加载实体、保存实体时,实体实际上是先放进这个背包里的,而不是直接跟数据库打交道。

举个例子:

@Transactional public void demo() { Order order = orderRepository.findById(1L).get(); order.setStatus(OrderStatus.PAID); // 此时数据库里的 status 还是原来的值 // 但持久化上下文里已经记下"status 应该改成 PAID" }

这时候如果你在另一个线程、或者另一个事务里去数据库查,看到的还是旧值。只有等这个事务提交,Hibernate 才会生成UPDATE t_order SET status = ? WHERE id = ?执行到数据库。

这个概念看起来简单,但它是理解后面所有问题的基础。一级缓存的存在,意味着你操作实体并不等于操作数据库。中间还隔着一层"待提交"的状态。

2.2 自动 flush:JPQL 查询与原生 SQL 的分岔路

那么问题来了:既然实体变更都堆在持久化上下文里,JPA 在查询的时候总不能拿着旧数据吧?JPA 规范早就考虑到这一点,引入了 flush 机制。

默认的 FlushModeType 是 AUTO,在这种模式下,执行 JPQL 查询之前,JPA 会先自动 flush 持久化上下文里的变更,保证查询能命中当前事务内的最新修改。所以如果你用findByXxx这类由 Spring Data JPA 生成的 JPQL 查询方法,同一个事务内先save再查,通常是可以查到的——因为 flush 帮你把数据推到了数据库。

但这里有一个分岔路:原生 SQL 和 JdbcTemplate 是不受 JPA 自动 flush 管理的。用 JdbcTemplate 查询时,SQL 直接发到数据库,JPA 根本不知道你要干什么,自然也想不到要先把持久化上下文里的数据 flush 一下。于是就会出现开头那个诡异现象:JPA 这边明明保存了,JdbcTemplate 那边却查不到。

为了让你看得更清楚,我把几种查询方式在"事务内先 save 后立查"时的表现整理成一张表:

查询方式是否能立即查到刚 save 的记录原因
Spring Data JPA 的 JPQL 方法(如 findByOrderNo)通常能查到FlushMode.AUTO 会在 JPQL 查询前自动 flush
原生 SQL @Query(nativeQuery = true)不一定,依赖 Hibernate 实现部分场景 Hibernate 会尝试推断是否 flush,但不可靠
JdbcTemplate 直接查询查不到JdbcTemplate 绕过 EntityManager,完全不触发 JPA flush
同一实体再次 findById查到的是缓存实体,不是数据库最新值一级缓存直接命中,不查询数据库

这张表建议收藏。很多"事务内查不到数据"的排查,本质上就是拿表格里的某一列去做对照。

2.3 事务隔离级别:MySQL 可重复读的"时间胶囊"

如果说 flush 解决的是"数据还没提交时查不到",那么还有个更隐蔽的坑:数据明明已经提交了(其他事务提交的),你的当前事务却依然看不到。这就跟事务隔离级别有关了。

MySQL InnoDB 引擎默认的隔离级别是 REPEATABLE READ(可重复读)。在这个隔离级别下,普通 SELECT 走的是 MVCC 一致性快照读取,也就是说:当前事务第一次执行 SELECT 的时候,会对当时的已提交数据建立一个快照,之后事务内所有普通 SELECT 都读这个快照,不管其他事务中间有没有提交新数据。

这个机制本身是为了保证事务内的读一致性,但也带来了一个副作用:如果事务 A 先查了一次列表,然后事务 B 插入了一条新数据并提交,事务 A 再去查同一个列表,是看不到事务 B 新插入的那条数据的。从数据库客户端的视角看,数据就在那里;从事务 A 的视角看,数据就是不存在。这就是所谓的"时间胶囊"效应——你被固定在了第一次查询的那个时间点。

JPA 本身并没有自己的快照机制,它依赖底层数据库的连接事务隔离级别。所以这种问题在 MySQL 上尤其常见,如果你切到 PostgreSQL 默认的 READ COMMITTED,同样的事务顺序就可能不会出现这个问题,因为每种查询都会读最新已提交数据,不用等第一次建立快照。

3. 三个高频踩坑场景的完整定位链路

原理讲完,我们来看看实际工作中最常踩的三个场景。每个场景我都会按"现象 → 排查思路 → 根因 → 解法"的顺序展开,你可以拿着自己的问题对号入座。

3.1 场景一:JdbcTemplate 混入 JPA 事务,flush 时机没到

现象:同一个事务方法里,先调用orderRepository.save(order),紧接着用 JdbcTemplate 执行SELECT * FROM t_order WHERE id = ?,返回空。

排查思路:先确认save之后 Hibernate 到底有没有执行INSERT。把 spring.jpa.show-sql 开关打开,看日志输出。如果是场景一,你会发现日志里根本没有 INSERT,说明数据还在持久化上下文里,没有推到数据库。

根因:JPA 的自动 flush 只对 JPQL 查询生效。JdbcTemplate 是另一条数据访问路径,它没有和 EntityManager 打招呼,直接查询数据库,此时数据库里确实还没有这条数据。

解法(先列举,后面章节细讲):

  • 如果能确认查询逻辑必须紧跟在保存之后,可以在保存后调用orderRepository.flush()或saveAndFlush()强制先落库。
  • 如果这里的查询只是校验性质的,考虑换成 JPA 的查询方法,利用自动 flush 特性。
  • 从设计上反思:为什么同一个事务里要先写再查?很多时候这个"再查"本身就没有必要。

这个场景是三个里面最好定位的,因为现象极其稳定:每次都复现,不是偶发。只要看到"JPA save 后接 JdbcTemplate 查询"这个八字组合,基本可以直接锁定。

3.2 场景二:自调用让 @Transactional 失效,新事务没开成

现象:方法 A 标注了@Transactional,查询了一次列表。方法 A 内部调用同类的方法 B(B 上也标注了@Transactional(propagation = Propagation.REQUIRES_NEW),B 负责创建新的记录并提交)。方法 A 继续查询同一个列表,期望能看到 B 创建的新记录,结果看不到。但是去数据库客户端查,数据已经存在。

排查思路:找到嵌套调用的那段代码,看看方法 B 是不是通过 this 调用的。如果是,这里的 @Transactional 注解实际上没有生效,因为 Spring AOP 代理只对外部调用生效,this 调用不会经过代理。

@Service public class OrderService { public void process() { List<Order> initOrders = orderRepository.findByStatus(OrderStatus.INIT); // 这里是通过 this 调用,@Transactional 不生效 this.createOrderWithNewTx(); // 期望能看到新订单,但实际看不到 List<Order> again = orderRepository.findByStatus(OrderStatus.INIT); } @Transactional(propagation = Propagation.REQUIRES_NEW) public void createOrderWithNewTx() { // do something } }

根因:这里其实是两个问题叠加。第一,this.createOrderWithNewTx()使 REQUIRES_NEW 失效,方法 B 实际是在方法 A 的事务里执行,并没有开启新事务。第二,由于方法 A 的事务早先已经执行过查询,MySQL 可重复读快照已经建立,就算方法 B 的数据最终提交了,方法 A 后续的查询依然读的是旧快照。

如果你在代码审查时还看到"方法 B 里保存的数据在方法 A 里要立刻可见"这类需求,更要警惕,因为这种需求在默认隔离级别下本来就很难成立。

解法:自调用问题可以通过注入自身代理来规避,或者把方法 B 移到另一个 Service Bean 中。但更核心的是要理解:在可重复读隔离级别下,一个事务里做了第一次查询之后,就不能再期待看到其他事务后续提交的数据了。如果确实需要看到最新数据,要么让 B 在 A 的查询之前执行,要么把 A 的隔离级别降到 READ COMMITTED,要么把查询放到新事务里。

3.3 场景三:一级缓存返回了"陈年旧账"

现象:事务内先通过orderRepository.findById(1L)加载了一个实体,此时数据库里这个实体的 status 是 INIT。另一个事务随后把数据库里这条记录的 status 改成了 PAID 并提交。紧接着当前事务再次调用orderRepository.findById(1L),拿到返回结果,发现 status 竟然还是 INIT。数据库客户端查询,明明已经是 PAID。

排查思路:在第二次findById前后打日志,观察是否真的执行了 SQL。你会发现第二次根本没有 SQL 输出。

根因:findById在同一个持久化上下文里,会根据主键优先从一级缓存返回实体,不会主动查询数据库。一级缓存里的实体对象是第一次加载时的状态,后续数据库的变更对它不可见。本质上这不是"查不到记录",而是"查到了一条记录,但它是旧版本"。

@Transactional public Order demo(Long orderId) { // 第一次查询,实体进入一级缓存 Order order1 = orderRepository.findById(orderId).get(); // 假设其他事务在这里把数据库的记录改掉了 // 第二次查询,直接命中一级缓存,不会执行 SQL Order order2 = orderRepository.findById(orderId).get(); // order1 == order2,而且数据是第一次加载时的旧状态 return order2; }

解法:如果确定需要拿到数据库最新数据,可以:

  • 用entityManager.refresh(entity)强制刷新实体状态,让 JPA 重新从数据库加载。
  • 在查询前调用entityManager.clear()清空一级缓存,再findById就会重新查数据库。注意 clear 会让持久化上下文里所有未提交变更丢失,慎用。

4. 对症下药:三个坑各自的解决姿势

搞清楚根因之后,解决手段就清晰了。但这里有个原则要提前说:不要为了解决一个诡异现象,在一堆地方无脑加 flush 或 clear。每一种操作都有副作用,下面我说清楚各自的适用范围和代价。

4.1 flush 时机问题:saveAndFlush 的正确打开方式

saveAndFlush是 Spring Data JPA 提供的方法,它在save之后紧接着调用flush(),把当前持久化上下文里的变更立刻同步到数据库。注意这里同步到数据库不代表事务提交,只是执行了 SQL,如果后面事务回滚,这条 INSERT 会一起回滚。

什么时候应该用saveAndFlush?我一般看两个条件:

  • 事务内确实需要立刻看到这条数据,且是通过原生 SQL / JdbcTemplate 去查。
  • 后续逻辑依赖数据库回读的自增主键或数据库默认值。 特别是第二种情况很常见:默认值由数据库填充时,save 之后内存里的实体未必有这些值,只有 flush 之后回读才知道。

什么时候不该用?如果只是普通的"保存然后返回给前端",完全不需要 flush。flush 会提前执行 SQL、占用数据库连接更久、增加锁冲突概率,对性能没有好处。很多新人听到"saveAndFlush 可以解决查不到"就到处用,结果把性能搞下去一大截,这是典型的以偏概全。

4.2 快照问题:需要实时数据时该怎么办

如果你遇到的是场景二那种"其他事务已提交但当前事务看不见"的快照问题,最直接的解决手段是调整隔离级别,但这需要非常谨慎。

在 Spring 中可以用@Transactional(isolation = Isolation.READ_COMMITTED)单独为某个方法设置隔离级别。改成 READ COMMITTED 后,每条查询都会读取最新已提交数据,不再受第一次查询快照的束缚。

但是注意三个问题:

  1. READ COMMITTED 下,同一个事务内两次查询结果可能不一致,如果业务代码里有依赖"两次查询结果一致"的逻辑,可能引发新问题。
  2. 在 MySQL 可重复读和读已提交下,普通 SELECT 都是非锁读,不会因为调整隔离级别带来锁竞争;但如果查询是SELECT ... FOR UPDATE,情况就复杂了,需要额外分析。
  3. 如果这个方法是接口默认方法或者被其他方法代理调用,隔离级别注解可能不生效,需要确认代理边界。

除了调隔离级别,在动手之前先问自己一个问题:"这个查询真的必须和前面的操作在同一个事务里吗?"很多时候,把查询拆到事务外或者换一个独立连接,问题自然消失。比如一些报表类接口,根本不需要事务,用REQUIRES_NOT_SUPPORTED挂个非事务连接就行。

4.3 缓存陈旧问题:refresh 与 clear 的取舍

场景三的解法与其他两个不太一样,它要处理的是"已经加载到一级缓存的实体,无法感知数据库变更"。

entityManager.refresh(entity)的作用是:用数据库里的最新数据重新填充实体属性,同时更新一级缓存里的快照。它适合"单个实体需要刷新"的场景。代价是必须保证实体仍然处于 managed 状态,如果一个实体已经 detached,refresh 会抛出异常。

entityManager.clear()的作用是:清空持久化上下文里所有实体。清空之后,之前的实体全部变成 detached 状态,任何修改都不会自动同步到数据库。这个操作影响面非常大,一般只在"批量处理大量实体导致内存占用过高"时使用,不建议为了读取最新数据而随意调用。

实践中我的建议是:优先考虑 refresh,且最好在业务上单独封装一个方法,比如loadOrderWithFreshData(orderId),因为你是在为"获取最新数据"这个特殊需求写代码,代码要有名字,让人看得懂。

5. 沉淀下来的一些经验与排查清单

最后分享一些实战中的沉淀,这些都是排查完几个项目后觉得真正有用的东西。

5.1 三问排查法:先问提交了没、快照旧没旧、缓存脏没脏

遇到"JPA 查不到数据,数据库能查到"这类现象,我建议按照下面三个问题依次追问,可以帮你快速定位问题在哪一层。

第一问:当前事务提交了吗?

怎么判断?看执行顺序。如果数据库客户端看到数据的时间点在代码查询之后,大概率是"还没提交"或"还没 flush"。此时去日志里找有没有对应的insert/update语句,如果没有,就是 flush 时机问题。

第二问:当前事务是不是被快照固定住了?

如果其他事务已经提交且数据库客户端能看到,但当前事务查询却看不到,重点检查是不是 MySQL 可重复读 + 事务内首次查询早于数据提交时间。可以试着把第二次查询放在一个 REQUIRES_NEW 方法里,看能不能查到,能查到基本就坐实了快照问题。

第三问:查到的实体是不是缓存里的旧版本?

不仅findById会命中一级缓存,findAll在某些情况下也会走缓存。如果发现同一次请求里相同主键的实体返回的是同一个对象引用,且值不是最新,那就是一级缓存命中。别忘了 JPA 的缓存机制只对持久化上下文内的实体查询生效,如果你的项目还接入了 Redis 二级缓存,那又是另一层需要排查的因素。

5.2 show-sql 日志:判断数据是否真实落库的关键手段

排查这类问题,日志比 debug 断点好用得多,因为断点只能看到内存里的状态,看不到数据库到底执行了什么。建议在开发环境把下面两个配置打开:

spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true

这样 Hibernate 会打印所有执行的 SQL。重点看两个地方:

  • 有没有insert into t_order ...这条语句,以及它是在业务代码里的哪个时间点出现的。
  • 有没有对应的commit。

如果你发现方法 return 之前根本没有 insert 语句,那就可以非常明确地说:数据库里那条数据是"后面才有的",代码查不到完全不奇怪。日志是事后复盘的最好证据,平时排查问题时养成看 SQL 日志的习惯,比盯着报错信息瞎猜高效得多。

5.3 设计层面:同一事务里别混用多种数据访问方式

经历过这些之后,我在新项目的代码评审里基本都会关注一点:一个事务方法内,是否混用了多种数据访问技术(JPA + JdbcTemplate / MyBatis)。

混用的坏处不是不能用,而是很容易踩到 flush、快照、缓存三条线交叉的灰色地带。JPA 默认帮你管理持久化上下文,但 JdbcTemplate 完全感知不到这层管理,两者放在一个事务里,就要时刻问自己:"我查的是数据库,还是 EntityManager 的私人背包?"

我的建议是:能不用 JdbcTemplate 就不用在事务内用,尽量保持数据访问层的统一。如果确实需要混合使用,比如 JPA 没有的能力才用 JdbcTemplate 实现,那至少要约定一套规矩:使用 JdbcTemplate 之前,如果当前事务有过写操作,必须先 flush。这个规矩可以做成代码审查清单里的一项,也可以在最容易踩坑的位置加注释提醒后来人。

5.4 最后再说一个容易被忽略的小细节

还有一点想顺带提一下:如果你在同一个类里看到@Transactional方法调用同类方法,不要想当然认为被调用方法的传播级别会生效,尤其是REQUIRES_NEW。这是一个非常隐蔽的坑,我见过有人为了"让新事务生效",在方法里 new 了一个新对象自己调用自己,这种绕法虽然能绕过代理问题,但代码可读性极差,而且新对象的事务管理器不一定注入正确,容易引入新问题。更优雅的做法是把被调方法放到另一个 Service Bean 里,或者注入AopContext.currentProxy()(注意需要 exposeProxy=true)。调试这类问题的时候,建议先确认执行线程的 connection 是不是同一个、事务是不是同一个,再往深处查。

从我个人的经验看,JPA 事务内查询不到数据的问题基本都可以归结为三类:flush 时机、快照隔离、一级缓存。每类都有对应的排查思路和解决手段,按方法去做,一般很快能定位。如果真的遇到上述三种都排除还是不行的,那就把 show-sql 日志打开,把时序图或者日志时间线贴出来再分析,大概率是事务传播级别和连接获取时机上的边缘 case,但那种情况已经是另一个级别的问题了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询