Hibernate / JPA 核心全过程原理
Hibernate 是 JPA 规范最常见的实现之一。JPA 规定“应该怎么用实体管理持久化数据”,Hibernate 负责把这些规范真正落地:维护实体状态、管理持久化上下文、做脏检查、生成 SQL、处理懒加载、执行 flush、和数据库交互。
MyBatis 的学习主线是“SQL 怎么被找到、参数怎么绑定、结果怎么映射”。Hibernate / JPA 的学习主线完全不同:它的核心是“对象状态怎么被管理,事务提交时为什么会自动生成 SQL”。
学习目标
学完本章要能说清楚:
- JPA 是规范,Hibernate 是实现,Spring Data JPA 是 Repository 封装。
EntityManager、Persistence Context、Session、Transaction各自负责什么。persist、find、merge、remove、flush、clear分别做什么。- 临时态、托管态、游离态、删除态如何流转。
- 为什么事务内修改实体字段,不写 update 也会更新数据库。
- 脏检查的快照比较过程是什么。
- flush 和 commit 的区别是什么。
- 懒加载为什么会有
LazyInitializationException。 - N+1 查询为什么发生,怎么用 DTO、fetch join、批量抓取解决。
- 为什么 Hibernate / JPA 项目必须看最终 SQL。
三层关系:JPA、Hibernate、Spring Data JPA
flowchart TD
A["业务代码调用 Repository 或 EntityManager"] --> B["Spring Data JPA Repository 代理"]
B --> C["JPA EntityManager 规范接口"]
C --> D["Hibernate Session 实现"]
D --> E["JDBC"]
E --> F["数据库"]| 层次 | 作用 | 可以怎么理解 |
|---|---|---|
| JPA | Java 持久化规范 | 定义接口、注解和语义 |
| Hibernate | JPA 的具体实现 | 真正管理实体状态并生成 SQL |
| Spring Data JPA | Spring 的 Repository 封装 | 减少 DAO/Repository 模板代码 |
所以面试里说“Spring Data JPA 会自动生成 SQL”是不精确的。更准确的说法是:Spring Data JPA 解析 Repository 方法或调用 JPA API,底层通常由 Hibernate 根据实体映射和状态生成 SQL。
为什么需要持久化上下文
没有 ORM 时,Java 对象和数据库记录没有持续关系。你查出一行数据,改了对象字段,数据库不会自动知道。
Hibernate 的核心设计是引入持久化上下文,也就是 Persistence Context。它可以理解成一次事务内的“实体管理区”。
它负责:
- 保存托管实体。
- 保证同一个主键在同一个上下文中只有一个对象实例。
- 保存实体加载时的快照。
- 在 flush 时做脏检查。
- 管理新增、删除、更新动作的 SQL 队列。
- 支持懒加载代理和关联加载。
flowchart TD
A["数据库记录"] --> B["EntityManager.find"]
B --> C["实体进入 Persistence Context"]
C --> D["保存原始快照"]
C --> E["业务代码修改实体"]
E --> F["flush 时比较当前值和快照"]
F --> G{"字段是否变化?"}
G -->|"是"| H["生成 update SQL"]
G -->|"否"| I["不更新数据库"]如果没有持久化上下文,Hibernate 就无法知道对象之前是什么值,也无法自动判断哪些字段发生了变化。
EntityManager 和 Session
JPA 中常用 EntityManager。
@PersistenceContext
private EntityManager entityManager;Hibernate 底层有 Session。在使用 JPA 时,你通常不用直接操作 Session,但要知道它是 Hibernate 真正实现持久化上下文的核心对象。
| API | 说明 |
|---|---|
entityManager.persist(entity) | 新实体进入托管态,准备 insert |
entityManager.find(Entity.class, id) | 根据主键查询,返回托管态实体 |
entityManager.merge(entity) | 把游离态实体的数据合并到新的托管实体 |
entityManager.remove(entity) | 标记实体为删除态 |
entityManager.flush() | 把持久化上下文中的变更同步到数据库 |
entityManager.clear() | 清空持久化上下文,实体变游离态 |
entityManager.detach(entity) | 让某个实体脱离托管 |
实体四种状态
flowchart TD
A["Transient 临时态:new 出来,还没有主键或未被管理"] --> B["persist"]
B --> C["Managed 托管态:被持久化上下文管理"]
C --> D["detach / clear / 事务结束"]
D --> E["Detached 游离态:有数据库身份,但不再被当前上下文管理"]
E --> F["merge"]
F --> C
C --> G["remove"]
G --> H["Removed 删除态:等待 flush 删除"]临时态 Transient
Asset asset = new Asset();
asset.setAssetCode("A-1001");只是普通 Java 对象。没有进入持久化上下文,事务提交时不会自动插入数据库。
托管态 Managed
entityManager.persist(asset);或:
Asset asset = entityManager.find(Asset.class, 1L);对象被持久化上下文管理。这个状态最重要:只有托管态实体的变化才会被脏检查自动同步。
游离态 Detached
事务结束、clear、detach 后,对象还在 Java 内存中,但 Hibernate 不再管理它。
Asset asset = entityManager.find(Asset.class, 1L);
entityManager.clear();
asset.setAssetName("新名称");这次修改不会自动更新数据库,因为对象已经不在持久化上下文里。
删除态 Removed
Asset asset = entityManager.find(Asset.class, 1L);
entityManager.remove(asset);实体被标记为删除,真正 SQL 通常在 flush 时执行。
一次 find 查询的完整过程
Asset asset = entityManager.find(Asset.class, 1L);执行链路:
flowchart TD
A["调用 EntityManager.find"] --> B{"Persistence Context 是否已有该主键实体?"}
B -->|"有"| C["直接返回同一个托管对象"]
B -->|"没有"| D["生成 select SQL"]
D --> E["JDBC 查询数据库"]
E --> F["ResultSet 转 Entity"]
F --> G["实体放入 Persistence Context"]
G --> H["保存实体快照"]
H --> I["返回托管态实体"]这解释了一个重要现象:同一个事务里两次 find 同一个主键,通常返回同一个对象实例。
Asset a1 = entityManager.find(Asset.class, 1L);
Asset a2 = entityManager.find(Asset.class, 1L);
System.out.println(a1 == a2); // 通常是 true这不是二级缓存,而是持久化上下文的一级缓存。
一次 persist 新增的完整过程
Asset asset = new Asset();
asset.setAssetCode("A-1001");
entityManager.persist(asset);过程:
flowchart TD
A["new Entity"] --> B["persist"]
B --> C["实体进入托管态"]
C --> D["根据主键策略决定是否立即访问数据库"]
D --> E["insert 动作加入 ActionQueue"]
E --> F["flush"]
F --> G["执行 insert SQL"]主键策略会影响 SQL 执行时机:
| 主键策略 | 特点 |
|---|---|
IDENTITY | 数据库自增,需要插入后才能拿到 id,可能较早执行 insert |
SEQUENCE | 先从序列拿 id,再延迟 insert |
TABLE | 用表模拟序列,性能通常较差 |
| 手动赋值 | 业务自己设置主键 |
不要以为 persist 一定马上执行 insert。很多情况下 Hibernate 会先记录动作,等 flush 时统一同步。
脏检查:为什么不写 update 也会更新
这是 Hibernate/JPA 最核心的原理。
@Transactional
public void rename(Long id, String name) {
Asset asset = entityManager.find(Asset.class, id);
asset.setAssetName(name);
}没有调用 update,但事务提交时可能生成:
update asset set asset_name = ? where id = ?过程:
flowchart TD
A["find 查询实体"] --> B["保存快照 assetName=旧名称"]
B --> C["业务代码 setAssetName=新名称"]
C --> D["事务提交前触发 flush"]
D --> E["比较当前实体和快照"]
E --> F{"字段是否变化?"}
F -->|"是"| G["生成 update SQL"]
F -->|"否"| H["不生成 SQL"]脏检查依赖两个前提:
- 实体必须是托管态。
- flush 时持久化上下文还存在。
如果对象是游离态,修改字段不会自动保存。
Asset asset = assetRepository.findById(id).orElseThrow();
// 事务结束后返回到外层
asset.setAssetName("新名称"); // 不会自动更新数据库flush 和 commit 的区别
很多人把 flush 和 commit 混在一起。它们不是一回事。
| 动作 | 作用 |
|---|---|
| flush | 把持久化上下文中的变化同步成 SQL 发给数据库 |
| commit | 提交数据库事务,让变更真正对其他事务可见并持久化 |
flush 后 SQL 已经执行,但事务还没提交。如果后面回滚,flush 产生的变更也会回滚。
flowchart TD
A["业务修改托管实体"] --> B["flush"]
B --> C["执行 insert/update/delete SQL"]
C --> D{"后续业务是否异常?"}
D -->|"是"| E["rollback,数据库变更撤销"]
D -->|"否"| F["commit,事务提交"]Hibernate 可能在这些时机 flush:
- 事务提交前。
- 执行 JPQL/Criteria 查询前,为了保证查询能看到当前事务内变更。
- 手动调用
entityManager.flush()。 - flush mode 要求同步时。
所以线上看到“我只是查了一下,为什么先发了 update”,可能是查询前自动 flush 触发了脏检查。
merge:为什么不是把游离对象重新托管那么简单
merge 经常被误用。
Asset detached = new Asset();
detached.setId(1L);
detached.setAssetName("新名称");
Asset managed = entityManager.merge(detached);重点:merge 返回的是新的托管对象,传入的 detached 本身通常仍然是游离对象。
flowchart TD
A["传入 detached 实体"] --> B["EntityManager.merge"]
B --> C["查找或创建 managed 实体"]
C --> D["把 detached 字段复制到 managed"]
D --> E["返回 managed 实体"]
A --> F["原 detached 不一定被托管"]风险:如果前端传来的对象字段不完整,merge 可能把空值也复制进去,导致误更新。商业项目里更推荐:
- 先根据 id 查询托管实体。
- 校验权限和业务状态。
- 只修改允许变更的字段。
- 让脏检查生成 SQL。
@Transactional
public void updateAssetName(Long id, String name) {
Asset asset = entityManager.find(Asset.class, id);
asset.changeName(name);
}懒加载为什么会异常
懒加载是指关联对象不在主查询时立即加载,而是在访问属性时再查。
@ManyToOne(fetch = FetchType.LAZY)
private Department department;查询资产时,department 可能只是代理对象。
flowchart TD
A["查询 Asset"] --> B["department 字段放入代理对象"]
B --> C{"是否访问 department.name?"}
C -->|"否"| D["不查询 department 表"]
C -->|"是"| E{"Session 是否还开着?"}
E -->|"是"| F["发 SQL 加载 Department"]
E -->|"否"| G["LazyInitializationException"]典型错误:
public Asset getAsset(Long id) {
return assetRepository.findById(id).orElseThrow();
}
// Controller 返回 JSON 时序列化 asset.department.name
// 此时事务已经结束,懒加载失败正确做法:
- Service 事务内完成 DTO 转换。
- 列表页直接查询 DTO 投影。
- 必要时使用 fetch join。
- 不要把 Entity 直接返回给前端。
N+1 查询为什么发生
N+1 的本质是:先查 N 条主表记录,再逐条访问懒加载关联,每条记录再发一条 SQL。
List<Asset> assets = assetRepository.findByStatus(1);
for (Asset asset : assets) {
String deptName = asset.getDepartment().getDeptName();
}SQL 可能是:
select * from asset where status = 1;
select * from department where id = 10;
select * from department where id = 11;
select * from department where id = 12;flowchart TD
A["查询资产列表 1 次"] --> B["得到 N 条 Asset"]
B --> C["循环访问 department"]
C --> D["每条 Asset 触发一次 department 查询"]
D --> E["总 SQL = 1 + N"]解决方式:
DTO 投影
public record AssetListDTO(Long id, String assetCode, String assetName, String deptName) {
}@Query("""
select new com.demo.AssetListDTO(a.id, a.assetCode, a.assetName, d.deptName)
from Asset a
join a.department d
where a.status = :status
""")
List<AssetListDTO> listAssets(@Param("status") Integer status);列表页通常最推荐 DTO 投影,因为它明确需要哪些字段,不会把整个对象图带出来。
fetch join
@Query("""
select a
from Asset a
join fetch a.department
where a.status = :status
""")
List<Asset> listWithDepartment(@Param("status") Integer status);fetch join 会在一次 SQL 中把关联对象加载出来。但分页 + 一对多 fetch join 要谨慎,可能导致数据膨胀和内存分页。
批量抓取
Hibernate 支持 batch size,让多个懒加载查询合并成批量查询。但这属于优化手段,不应该掩盖错误的接口模型。
关联关系设计:不要为了对象好看乱建关系
JPA 支持:
@ManyToOne@OneToMany@OneToOne@ManyToMany
但商业项目里要克制。
常见建议:
| 关系 | 建议 |
|---|---|
| 多对一 | 可以使用,但默认考虑懒加载 |
| 一对多 | 谨慎使用,容易加载集合过大 |
| 多对多 | 生产中通常拆成中间实体 |
| 双向关联 | 只在确实需要时使用,注意序列化循环 |
不要把数据库里所有外键都映射成对象关联。对象图越复杂,隐式 SQL 越难控制。
事务边界:为什么 JPA 更依赖 Service 层事务
JPA 的实体状态、懒加载、脏检查都依赖持久化上下文,而持久化上下文通常和事务边界绑定。
flowchart TD
A["进入 @Transactional Service 方法"] --> B["创建或加入事务"]
B --> C["绑定 EntityManager 到当前线程"]
C --> D["Repository 查询实体"]
D --> E["实体进入持久化上下文"]
E --> F["业务修改实体或转 DTO"]
F --> G["方法结束 flush"]
G --> H["commit 或 rollback"]
H --> I["关闭持久化上下文"]如果事务边界太小:
- 懒加载字段在 Controller 序列化时失败。
- 修改实体时已经游离,不会自动保存。
- 多个 Repository 操作不能形成一个业务原子动作。
如果事务边界太大:
- 持有数据库连接时间过长。
- 锁等待时间变长。
- 持久化上下文缓存对象过多。
- 远程调用失败导致事务时间不可控。
正确做法:事务放在 Service 层,覆盖一个完整本地数据库业务动作,不要包住慢远程调用。
商业 Demo:资产状态变更和列表查询
实体:
@Entity
@Table(name = "asset")
public class Asset {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "asset_code", nullable = false, length = 64)
private String assetCode;
@Column(name = "asset_name", nullable = false, length = 128)
private String assetName;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "dept_id", nullable = false)
private Department department;
@Column(nullable = false)
private Integer status;
public void disable() {
if (this.status == 0) {
return;
}
this.status = 0;
}
}Repository:
public interface AssetRepository extends JpaRepository<Asset, Long> {
@Query("""
select new com.demo.AssetListDTO(a.id, a.assetCode, a.assetName, d.deptName)
from Asset a
join a.department d
where (:status is null or a.status = :status)
and (:keyword is null or a.assetName like concat('%', :keyword, '%'))
order by a.id desc
""")
Page<AssetListDTO> pageAssets(@Param("status") Integer status,
@Param("keyword") String keyword,
Pageable pageable);
}Service:
@Service
public class AssetService {
private final AssetRepository assetRepository;
public AssetService(AssetRepository assetRepository) {
this.assetRepository = assetRepository;
}
@Transactional
public void disableAsset(Long id) {
Asset asset = assetRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("资产不存在"));
asset.disable();
// 不需要 save。asset 是托管态,事务提交时脏检查会生成 update。
}
@Transactional(readOnly = true)
public Page<AssetListDTO> pageAssets(AssetQuery query) {
Pageable pageable = PageRequest.of(query.pageNo() - 1, query.pageSize());
return assetRepository.pageAssets(query.status(), query.keyword(), pageable);
}
}这个 Demo 的关键点:
- 修改接口先查询托管实体,再调用领域方法。
- 列表页返回 DTO,不返回 Entity。
- 查询明确 join 需要的部门名称,避免 N+1。
- 事务在 Service 层。
- 分页查询要关注 count SQL 和最终 SQL。
线上排查流程
接口慢
flowchart TD
A["发现 JPA 接口慢"] --> B["打开 SQL 日志和参数日志"]
B --> C["统计实际执行了多少条 SQL"]
C --> D{"是否 N+1?"}
D -->|"是"| E["改 DTO 投影、fetch join 或批量抓取"]
D -->|"否"| F["拿最终 SQL 去数据库 EXPLAIN"]
F --> G{"是否索引/分页/count 问题?"}
G -->|"是"| H["优化 SQL、索引、分页或 count"]
G -->|"否"| I["检查锁等待、连接池、事务过大、返回对象过多"]自动更新不生效
排查:
- 方法是否有事务。
- 实体是否是托管态。
- 是否在事务外修改了游离对象。
- 是否修改的是不可持久化字段。
- 是否事务回滚了。
- 是否 flush mode 或只读事务影响了同步。
懒加载异常
排查:
- 是否直接把 Entity 返回给 Controller。
- 是否在事务结束后才访问懒加载字段。
- 是否 JSON 序列化触发了关联属性访问。
- 是否应该改 DTO 查询。
- 是否需要 fetch join。
保存时产生意外 update
排查:
- 是否查询出了托管实体又修改了字段。
- 是否查询前自动 flush。
- 是否
merge把前端空字段复制到了托管实体。 - 是否级联配置过大。
- 是否实体 setter 中有副作用。
常见坑
| 问题 | 原因 | 正确做法 |
|---|---|---|
| 直接返回 Entity | 懒加载异常、循环引用、字段暴露 | Service 内转 DTO |
| N+1 查询 | 列表后逐条访问关联 | DTO 投影、fetch join、批量抓取 |
滥用 merge | 空字段覆盖、语义不清 | 先查托管实体,再改允许字段 |
| 双向关联过多 | 对象图复杂、序列化循环 | 只建必要关系 |
| 事务外修改实体 | 实体已游离,不会脏检查 | 修改放在事务内 |
| 批量导入不 clear | 持久化上下文对象过多 | 分批 flush + clear |
| 以为不用看 SQL | 生成 SQL 可能低效 | 开 SQL 日志并 EXPLAIN |
面试标准回答
JPA 和 Hibernate 什么关系
JPA 是 Java 持久化规范,定义 Entity、EntityManager、持久化上下文、JPQL 等标准;Hibernate 是 JPA 的常见实现,真正负责维护实体状态、脏检查、生成 SQL、懒加载和缓存;Spring Data JPA 又在 JPA 之上封装 Repository,减少数据访问模板代码。为什么 JPA 修改实体不用写 update
事务内查询出来的实体处于托管态,持久化上下文会保存实体快照。业务代码修改字段后,事务提交前 flush 会触发脏检查,Hibernate 比较当前实体和快照,如果发现字段变化,就生成 update SQL。前提是实体仍然处于托管态并且事务正常提交。flush 和 commit 的区别
flush 是把持久化上下文中的 insert、update、delete 同步成 SQL 发给数据库;commit 是提交数据库事务。flush 后 SQL 可能已经执行,但事务还没提交,如果后续 rollback,flush 的变更也会撤销。什么是 N+1,怎么解决
N+1 是先查询一批主实体,再循环访问懒加载关联,导致每条主记录额外发一条 SQL。解决方式包括 DTO 投影、fetch join、批量抓取、合理设计接口返回字段。列表页通常推荐 DTO 投影,避免返回完整 Entity 对象图。为什么不建议 Controller 直接返回 Entity
Entity 是持久化模型,不是接口模型。直接返回 Entity 容易暴露字段、触发懒加载异常、造成循环引用、产生隐式 SQL,也会让接口结构和数据库表结构强耦合。商业项目一般在 Service 层查询并转换成 DTO 返回。关联知识点
- Hibernate 核心概念
- Hibernate 学习路线
- JPA 基础
- Spring Data JPA
- Spring Data JPA 查询
- Spring Data JPA 事务与分页
- ORM 面试题
- MyBatis 核心全过程原理
本章小结
Hibernate / JPA 的核心不是注解,而是持久化上下文。实体进入托管态后,Hibernate 保存快照;事务提交或查询前 flush 时,脏检查比较当前值和快照,生成 SQL 同步数据库。懒加载、N+1、自动更新、merge 风险、事务边界问题,本质都可以从“实体当前是否被持久化上下文管理”这条主线解释。
