你遇到过这样的情况吗代码里用 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 再查一次 ListMapString, 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而是要搞清楚一个关键问题你去看数据库的时候数据到底是提交了还是没有提交。这里有个非常容易误导人的时间线我画个流程你感受一下事务方法createOrder开始执行。orderRepository.save(order)被调用注意这一步通常只是把实体纳入持久化上下文管理并没有立刻执行INSERT。jdbcTemplate.queryForList(...)执行直接落到数据库上此时数据库里根本没有这条订单数据所以查不到。方法正常 returnSpring 事务管理器提交事务INSERT这时才真正执行数据落库。你去数据库客户端查看到了这条数据。也就是说你查数据库客户端的时候事务已经提交了数据库里当然有数据而代码里查询的时候数据还没来得及 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 自动 flushJPQL 查询与原生 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 内部调用同类的方法 BB 上也标注了Transactional(propagation Propagation.REQUIRES_NEW)B 负责创建新的记录并提交。方法 A 继续查询同一个列表期望能看到 B 创建的新记录结果看不到。但是去数据库客户端查数据已经存在。排查思路找到嵌套调用的那段代码看看方法 B 是不是通过 this 调用的。如果是这里的 Transactional 注解实际上没有生效因为 Spring AOP 代理只对外部调用生效this 调用不会经过代理。Service public class OrderService { public void process() { ListOrder initOrders orderRepository.findByStatus(OrderStatus.INIT); // 这里是通过 this 调用Transactional 不生效 this.createOrderWithNewTx(); // 期望能看到新订单但实际看不到 ListOrder 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 后每条查询都会读取最新已提交数据不再受第一次查询快照的束缚。但是注意三个问题READ COMMITTED 下同一个事务内两次查询结果可能不一致如果业务代码里有依赖两次查询结果一致的逻辑可能引发新问题。在 MySQL 可重复读和读已提交下普通 SELECT 都是非锁读不会因为调整隔离级别带来锁竞争但如果查询是SELECT ... FOR UPDATE情况就复杂了需要额外分析。如果这个方法是接口默认方法或者被其他方法代理调用隔离级别注解可能不生效需要确认代理边界。除了调隔离级别在动手之前先问自己一个问题这个查询真的必须和前面的操作在同一个事务里吗很多时候把查询拆到事务外或者换一个独立连接问题自然消失。比如一些报表类接口根本不需要事务用REQUIRES_NOT_SUPPORTED挂个非事务连接就行。4.3 缓存陈旧问题refresh 与 clear 的取舍场景三的解法与其他两个不太一样它要处理的是已经加载到一级缓存的实体无法感知数据库变更。entityManager.refresh(entity)的作用是用数据库里的最新数据重新填充实体属性同时更新一级缓存里的快照。它适合单个实体需要刷新的场景。代价是必须保证实体仍然处于 managed 状态如果一个实体已经 detachedrefresh 会抛出异常。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-sqltrue spring.jpa.properties.hibernate.format_sqltrue这样 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()注意需要 exposeProxytrue。调试这类问题的时候建议先确认执行线程的 connection 是不是同一个、事务是不是同一个再往深处查。从我个人的经验看JPA 事务内查询不到数据的问题基本都可以归结为三类flush 时机、快照隔离、一级缓存。每类都有对应的排查思路和解决手段按方法去做一般很快能定位。如果真的遇到上述三种都排除还是不行的那就把 show-sql 日志打开把时序图或者日志时间线贴出来再分析大概率是事务传播级别和连接获取时机上的边缘 case但那种情况已经是另一个级别的问题了。
