JPA开发中有个现象经常让人摸不着头脑:明明是两次独立的查询,拿到的两个对象看起来毫无关系,可修改其中一个的属性,另一个对象的数据也变了。更诡异的是,有时候根本没有调用save方法,数据库里的数据却被更新了。这背后其实是JPA的持久化上下文在起作用,理解了这个机制,很多看似诡异的行为就都能解释了。

一级缓存:JPA实体同一性的根源
要理解这个问题,先要知道一级缓存的存在。每个EntityManager内部都维护着一块持久化上下文,它本质上是一个Map结构,key是实体的主键标识(由实体类型和主键值共同确定),value是实体对象本身以及该对象加载时的一个快照。
当执行find方法或者主键查询时,JPA会先查这个Map,如果主键对应的实体已经在缓存里,就直接返回缓存中的那个Java对象引用,根本不会向数据库发送SQL。当执行JPQL或Criteria查询时,即使SQL查出了数据,JPA在组装实体时依然会检查一级缓存:如果相同主键的实体已经存在,就丢弃新查出来的数据,返回缓存中的旧对象。
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
User user1 = em.find(User.class, 1L);
User user2 = em.createQuery("select u from User u where u.id = 1", User.class)
.getSingleResult();
System.out.println(user1 == user2); // 输出 true,是同一个对象
user1.setName("张三");
System.out.println(user2.getName()); // 输出 张三,跟着变了
上面的代码中,两次查询返回的是同一个引用,所以user1 == user2的结果是true。这就是为什么修改一个对象会影响另一个——它们本来就是同一个对象。这个设计并不是缺陷,而是JPA刻意为之:在同一个事务中保证数据一致性,避免同一行数据在内存中出现多个不同步的副本。
没有调用save为什么数据库也更新了
理解了同一性,再来解释另一个常见疑惑:只调用了setter,没有执行任何更新语句,数据库数据却变了。这是因为处于持久化状态(managed)的实体,会被脏检查机制跟踪。
事务提交时,持久化上下文会把每个受管实体和它加载时的快照做对比,发现属性有差异,就自动生成update语句。也就是说,处于managed状态的实体,你改了它,事务提交时就会自动同步到数据库,不需要显式调用merge或persist。这在带来便利的同时也埋了坑:如果不小心修改了一个受管实体的属性,即便业务逻辑并不想更新数据库,脏数据也会被悄悄写进去。
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
User user = em.find(User.class, 1L);
user.setName("改了就会被提交"); // 只改属性,不调用任何保存方法
em.getTransaction().commit(); // 提交时自动执行 update
注意,只有受管状态的实体才会被脏检查。通过new出来的新对象、被detach脱管的对象,修改属性不会触发任何数据库操作。这也是排查“数据库莫名其妙被更新”问题时首先要确认的点:这个对象是不是还挂在某个活跃的持久化上下文里。
跨EntityManager查询为什么会返回不同对象
一级缓存是EntityManager级别的,不同事务、不同的EntityManager实例各自持有独立的缓存。所以在两个不同的持久化上下文中查询同一个主键,得到的就是两个完全不同的Java对象,互相修改也不会影响。
EntityManager em1 = emf.createEntityManager();
EntityManager em2 = emf.createEntityManager();
User u1 = em1.find(User.class, 1L);
User u2 = em2.find(User.class, 1L);
System.out.println(u1 == u2); // false,不同上下文返回不同实例
u1.setName("A上下文修改");
System.out.println(u2.getName()); // 不受影响
在Spring Data JPA中,默认情况下每个Repository方法都运行在各自的事务里(没有外层事务时),方法返回后持久化上下文关闭,实体变为脱管状态,所以通常不会遇到同一性问题。但一旦在Service方法上加了@Transactional,方法内的多次查询就共享同一个持久化上下文,同一性问题就会显现出来。很多人在长事务中遇到“对象被莫名修改”,就是这个原因。
如果确实需要在同一个上下文中重新从数据库加载最新数据,可以使用refresh方法,它会丢弃缓存中的实体,重新执行查询并覆盖实体状态;如果想让实体脱离上下文管理,可以调用detach;如果想清空整个缓存,调用clear。
如何避免误改数据与同一性陷阱
第一,尽量保持事务短小。事务越长,持久化上下文持有的受管实体越多,被误改的风险越大。只读查询可以用@Transactional(readOnly = true)标记,部分实现会因此跳过脏检查快照维护,既省内存又避免误更新。
第二,分清实体的四种状态并谨慎操作。新建状态、受管状态、脱管状态、删除状态对修改的响应完全不同。只想在内存里改数据不想落库时,先确认对象已脱管,或者干脆复制一个新的DTO出来操作,这是业务开发中最稳妥的做法,也顺便避免了把实体直接暴露给前端被反序列化覆盖属性的风险。
第三,注意缓存清理的时机。批量处理大量数据时,定期调用flush加clear把变更刷入数据库并释放缓存,既控制内存又保证数据及时可见。但要注意clear之后所有受管实体都会脱管,后续再想更新必须重新merge。
归根结底,JPA的实体同一性设计服务于事务内数据一致性。遇到“改一个影响另一个”时,先看这两个引用是否来自同一个EntityManager,再看对象是否处于受管状态,问题基本都能定位清楚。