《Spring Boot 高级》6.1 EntityManager 与持久化上下文

拆开 @PersistenceContext 注入的那个 EntityManager:它为什么是共享代理、如何绑定到当前事务,持久化上下文作为一级缓存怎样工作,实体的四种状态如何转换,以及 persist / merge / detach / refresh 各自的真实语义。

本节目标:把 @PersistenceContext 注入的那个 EntityManager 从「一个接口」拆成「共享代理 + 线程绑定 + 一级缓存」,并给出实体四种状态的可复现实验。
适用版本:Spring Boot 4.1.x(Java 21)

6.1 EntityManager 与持久化上下文

实战卷解决的是「@Transactional 该加在哪一层、save 还是 saveAndFlush」,本节解决的是「这一行注解背后,框架到底创建了什么对象、绑在哪个线程、什么时候销毁」。把这个链路讲清楚,你才能在遇到 LazyInitializationException、脏数据不落库、detached entity passed to persist 这类问题时,直接定位到是哪一环出的错。

我们仍然沿用图书借阅服务:Book、Member、Loan 三个实体。下面的代码都跑在 spring-boot-starter-data-jpa 提供的 HibernateJpaVendorAdapter 之上。

6.1.1 两个对象,两种生命周期

JPA 里最容易混淆的一对概念是 EntityManagerFactory 和 EntityManager。它们的职责边界非常清楚:

对象线程安全性生命周期对应关系
EntityManagerFactory线程安全与应用同寿命(单例)一个持久化单元一个
EntityManager非线程安全一次事务 / 一次请求从工厂创建、用完即弃

EntityManagerFactory 是重量级的:它持有连接池、元模型(Metamodel)、命名查询、二级缓存区域、方言等。jakarta.persistence.EntityManagerFactory 接口暴露了 createEntityManager()、getMetamodel()、getCriteriaBuilder() 等方法,但它本身不参与任何一次具体的读写。

EntityManager 则是轻量的、一次性的。它封装了一个持久化上下文(persistence context),负责把实体对象和数据库行对应起来。jakarta.persistence.EntityManager 上你常用的方法——persist、merge、remove、find、getReference、refresh、detach、flush、contains——全都作用于「当前这个持久化上下文」。

正因为 EntityManager 非线程安全,而 Spring 的 @Repository / @Service 默认是单例(被所有请求线程共享),框架不可能把一个真实的 EntityManager 注入进去。它注入的必须是一个能被多线程安全调用的东西——这就是共享代理。

6.1.2 注入进来的其实是一个共享代理

当你在 BookRepository 里写:

@Repository
public class BookRepository {

    @PersistenceContext
    private EntityManager em;

    public Book findById(Long id) {
        return em.find(Book.class, id);
    }
}

@PersistenceContext 由 org.springframework.orm.jpa.support.PersistenceAnnotationBeanPostProcessor 处理。它并不会去找一个 EntityManager 类型的 bean(那样的 bean 根本不存在),而是调用:

org.springframework.orm.jpa.SharedEntityManagerCreator
    #createSharedEntityManager(EntityManagerFactory, Map, boolean, Class...)

返回一个实现了 jakarta.persistence.EntityManager 的 JDK 动态代理。这个代理背后挂着一个 SharedEntityAgentInvocationHandler(SharedEntityManagerCreator 的内部类),你每一次调用 em.find(...),都先经过这个 handler。

handler 对一部分方法做了短路处理,完全不碰数据库连接:

  • isOpen() 恒返回 true——代理永远不会「关闭」;
  • close() 被静默忽略——共享代理不允许你关它;
  • getEntityManagerFactory() 直接返回工厂;
  • getCriteriaBuilder() / getMetamodel() 转发到工厂(避免为了拿元模型而创建上下文);
  • getTransaction() 直接抛 IllegalStateException,提示你「共享 EntityManager 上不能开事务,请用 Spring 事务」。

其余方法(find、persist、merge……)才会去解析「当前线程此刻应该用哪个真实的 EntityManager」。这个代理还实现了 org.springframework.orm.jpa.EntityManagerProxy,额外提供 getTargetEntityManager(),让你能拿到底层真正的那个 EM——排障时很有用。

6.1.3 代理如何绑定到当前事务

关键逻辑在 org.springframework.orm.jpa.EntityManagerFactoryUtils#doGetTransactionalEntityManager(EntityManagerFactory, Map, boolean)。它的流程可以概括为三步:

  1. 先问 org.springframework.transaction.support.TransactionSynchronizationManager#getResource(emf):当前线程上有没有已经绑定的 EntityManagerHolder?
  2. 有就直接复用——这就是「同一个事务里注入到不同 bean 的 EntityManager 是同一个」的原因;
  3. 没有且当前没有活跃的事务同步(isSynchronizationActive() 为 false),返回 null;代理会退化成「为这一次调用临时创建一个 EntityManager、调用完关掉」,因此事务外每次调用都会新开一个上下文,两次 find 之间不共享一级缓存。

那这个 EntityManagerHolder 是谁绑上去的?是事务管理器。看 org.springframework.orm.jpa.JpaTransactionManager#doBegin:

JpaTransactionManager.doBegin(txObject, definition)
  ├─ createEntityManagerForTransaction()      // emf.createNativeEntityManager(properties)
  ├─ txObject.setEntityManagerHolder(new EntityManagerHolder(newEm), true)
  ├─ getJpaDialect().beginTransaction(em, definition)   // Hibernate 侧 tx.begin()
  ├─ getJpaDialect().getJdbcConnection(em, readOnly)    // 顺手把 JDBC 连接也取出来
  └─ TransactionSynchronizationManager.bindResource(getDataSource(), conHolder)

也就是说,一个 @Transactional 方法被调用时:事务管理器先创建真实的 EntityManager,把它包进 EntityManagerHolder 绑定到 EntityManagerFactory 这个 key 上;紧接着 beginTransaction 在 Hibernate 层开启事务;再把底层 JDBC 连接也绑到 DataSource 上(6.3 会详细讲这一步为什么决定「连接什么时候被占」)。

方法结束时,doCleanupAfterCompletion 依次解绑并清理:

TransactionSynchronizationManager.unbindResourceIfPossible(emf)   // 解绑 EntityManager
getJpaDialect().releaseJdbcConnection(...)                        // 归还 JDBC 连接
getJpaDialect().cleanupTransaction(...)                           // 恢复 FlushMode 等状态
txObject.getEntityManagerHolder().closeAll()                      // 关闭 EntityManager

这条「doBegin 绑定 → doCleanupAfterCompletion 解绑」的对称结构,是理解持久化上下文生命周期的骨架。

6.1.4 持久化上下文就是一级缓存

持久化上下文(persistence context)是 JPA 规范对「一批被管理的实体」的抽象。在 Hibernate 里,它由 Session 实现,常被叫作「一级缓存」。

它的行为有三条,全部可以观察:

  1. 同一事务内,同一主键只对应一个 Java 实例。 两次 em.find(Book.class, 1L) 返回的是同一个对象引用,第二次不会再发 SQL。
  2. 它是写缓冲。 persist / merge / remove 不立刻发 SQL,只把变化记进上下文,等到 flush 才批量写(6.2 展开)。
  3. 它是脏检查的依据。 Hibernate 在 flush 时把每个受管实体的当前快照与加载时的快照对比,发现差异就生成 UPDATE——所以修改一个受管实体的字段后你不需要调用任何 save。

第 3 条最反直觉,也最值得自己验证一次:

@Transactional
public void renameBook(Long id, String newTitle) {
    Book book = em.find(Book.class, id);   // 发 SELECT,book 进入 managed
    book.setTitle(newTitle);               // 只改了内存,没有调用 save/merge
}                                          // 提交时 flush:自动 UPDATE

这条链路的代价是:如果你在一个大事务里加载了上千个实体,flush 时会逐个做快照对比,内存与 CPU 都被拖高。这正是「事务要短」在框架层面的原因。

6.1.5 实体的四种状态

JPA 把实体分成四种状态,EntityManager#contains(entity) 是区分它们的最直接手段。

状态含义contains()典型进入方式
new(瞬时)刚 new 出来,与上下文无关falsenew Book(...)
managed(受管)在上下文中,有快照、参与脏检查truepersist、find、getReference
detached(游离)曾经受管,现在不在上下文中false事务提交、detach、clear
removed(已删除)受管但已标记删除,等待 flush 出 DELETEtrueremove

状态之间的转换是本节最该记住的一张图:

new ──persist──▶ managed ──remove──▶ removed ──flush/commit──▶ (行被删,对象变 detached)
                  ▲   │
      merge ◀─────┘   └──detach/clear/事务结束──▶ detached
       │                                              │
       └──────────────── merge ◀─────────────────────┘

两个容易踩的点:

  • removed 仍然是 managed。 contains() 返回 true,你还能继续改它的字段,但 flush 时出的是 DELETE 而不是 UPDATE。
  • 事务结束不销毁对象,只让它变 detached。 那个 Book 实例还在,但脱离了上下文,不再有脏检查,也不再有延迟加载能力——后者就是 LazyInitializationException 的根源。

6.1.6 persist / merge / detach / refresh 的真实语义

这四个方法经常被当成「增删改查」的同义词,其实它们的契约差别很大。

方法作用对象返回值关键行为
persistnewvoid把 new 变 managed;若对象已 detached,抛 EntityExistsException(或忽略,取决于提供者)
mergenew / detached / managed受管副本复制状态进上下文,返回的那个才是受管的
detachmanagedvoid移出上下文,变 detached;内存对象不变
refreshmanagedvoid用数据库当前值覆盖内存改动,并重新加载延迟属性

merge 的返回值是最高频的坑。看这段:

@Transactional
public Book update(Book detached) {
    Book merged = em.merge(detached);
    // 关键:merged 才是受管的,detached 依然是游离的
    return merged;
}

如果 update 之后你继续改 detached(而不是 merged),那些改动永远不会落库,因为脏检查只盯着 merged。Hibernate 在 merge 时做的是:按主键查上下文 → 查到就复制字段进去并返回该受管实例,查不到就当作 new 走 persist 逻辑。

refresh 则用于「我确信库里的值才是对的」:

Book book = em.find(Book.class, 1L);
book.setTitle("错的标题");
em.refresh(book);          // 重新 SELECT,把 title 覆盖回库里的值

它同时会重新加载尚未初始化的延迟关联——处理「别人刚改了关联数据,我要看最新值」时有用。

6.1.7 PersistenceContextType.TRANSACTION 下的生命周期

@PersistenceContext 有一个 type 属性,取值是 jakarta.persistence.PersistenceContextType 的两个枚举:TRANSACTION(默认)与 EXTENDED。Spring 注入的共享代理固定走 TRANSACTION 语义。

TRANSACTION 的含义是:上下文的边界与事务边界对齐。具体到 Spring:

时点发生了什么
@Transactional 进入JpaTransactionManager#doBegin 创建 EntityManager,绑定到线程
方法体执行所有注入的 EntityManager 代理都解析到同一个 EM,共享同一个一级缓存
方法返回前doCommit 触发 flush,脏检查生成 SQL,事务提交
doCleanupAfterCompletion解绑、归还连接、closeAll() 关闭 EM,所有实体变 detached

这就解释了一个常见现象:同一个 @Transactional 方法内部两次 find 同一主键只发一次 SQL,而方法外再 find 会重新发 SQL——因为方法外是另一次事务、另一个上下文。

EXTENDED 则让上下文跨越多次事务存活(主要用于有状态的会话 bean,如 JPA 的 EXTENDED 用例)。Spring 的共享代理不支持它;如果你确实需要跨请求的长上下文,应该显式管理 EntityManager,而不是依赖注入的代理。

6.1.8 亲手验证这套机制

下面三段代码/命令都能在本机跑通(Spring Boot 4.1.1 + Hibernate 7.4.5.Final),用来把上面的结论变成可观察现象。

实验一:确认注入的是代理。

@PersistenceContext
private EntityManager em;

@PostConstruct
void inspect() {
    System.out.println(em.getClass().getName());        // jdk.proxy...$Proxy
    System.out.println(em instanceof EntityManagerProxy); // true
}

实验二:观察一级缓存命中。

@Transactional
public void probeCache(Long id) {
    Book a = em.find(Book.class, id);
    Book b = em.find(Book.class, id);
    System.out.println(a == b);          // true,同一实例,只发一次 SELECT
    System.out.println(em.contains(a));  // true
}

实验三:看 Hibernate 生成的 SQL。

spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true
logging:
  level:
    org.hibernate.SQL: debug

把 org.hibernate.SQL 调到 debug 后,每次 flush 的 insert / update / delete 都会打印出来,配合事务边界看,就能直观看到「SQL 是在方法返回前那一瞬间批量发出的」。

6.1.9 排障清单

现象大概率原因定位手段
改了字段没落库改的是 detached 对象,或 merge 的返回值没用打 em.contains(entity)
detached entity passed to persist对已有主键的游离对象调了 persist改用 merge
同一实体出现两个不同实例事务外多次 find,或跨了两个事务打对象 == 与 em.contains
内存随事务大小线性上涨一级缓存把加载过的实体全留着大事务分批 em.clear()
事务里看不到别处刚提交的改动一级缓存命中,没回库em.refresh(entity) 强制回库

最后一行要特别注意:一级缓存是无过期的。一个长事务里 find 过的实体,即使库里的行已经被别人改过,你读到的仍是加载时的快照——这不是 bug,是 TRANSACTION 上下文的语义。

小结

  • EntityManagerFactory 线程安全、与应用同寿命;EntityManager 非线程安全、一次事务一个。
  • @PersistenceContext 注入的是 SharedEntityManagerCreator#createSharedEntityManager 生成的 JDK 动态代理,isOpen/close/getTransaction 等被短路,其余方法才去解析线程绑定的真实 EM。
  • 真实 EM 由 JpaTransactionManager#doBegin 创建并绑定到线程,doCleanupAfterCompletion 解绑、归还连接并关闭。
  • 持久化上下文是一级缓存:同主键同实例、写缓冲、脏检查依据;TRANSACTION 上下文的边界就是事务边界。
  • 实体四种状态 new / managed / detached / removed 用 contains() 区分;merge 返回受管副本、refresh 覆盖内存改动、detach 只影响上下文不影响对象。

下一节顺着「写缓冲」这条线往下走:这些挂起的改动到底在什么时候真正变成 SQL。

阅读导航:上一节:5.3 阻塞代码的隔离 · 下一节:6.2 事务与 Flush 时机 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计