本节目标:把
@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)。它的流程可以概括为三步:
- 先问
org.springframework.transaction.support.TransactionSynchronizationManager#getResource(emf):当前线程上有没有已经绑定的EntityManagerHolder? - 有就直接复用——这就是「同一个事务里注入到不同 bean 的
EntityManager是同一个」的原因; - 没有且当前没有活跃的事务同步(
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 实现,常被叫作「一级缓存」。
它的行为有三条,全部可以观察:
- 同一事务内,同一主键只对应一个 Java 实例。 两次
em.find(Book.class, 1L)返回的是同一个对象引用,第二次不会再发 SQL。 - 它是写缓冲。
persist/merge/remove不立刻发 SQL,只把变化记进上下文,等到 flush 才批量写(6.2 展开)。 - 它是脏检查的依据。 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 出来,与上下文无关 | false | new Book(...) |
| managed(受管) | 在上下文中,有快照、参与脏检查 | true | persist、find、getReference |
| detached(游离) | 曾经受管,现在不在上下文中 | false | 事务提交、detach、clear |
| removed(已删除) | 受管但已标记删除,等待 flush 出 DELETE | true | remove |
状态之间的转换是本节最该记住的一张图:
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 的真实语义
这四个方法经常被当成「增删改查」的同义词,其实它们的契约差别很大。
| 方法 | 作用对象 | 返回值 | 关键行为 |
|---|---|---|---|
persist | new | void | 把 new 变 managed;若对象已 detached,抛 EntityExistsException(或忽略,取决于提供者) |
merge | new / detached / managed | 受管副本 | 复制状态进上下文,返回的那个才是受管的 |
detach | managed | void | 移出上下文,变 detached;内存对象不变 |
refresh | managed | void | 用数据库当前值覆盖内存改动,并重新加载延迟属性 |
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 时机 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。