Skip to content

Hibernate / JPA 核心全过程原理

Hibernate 是 JPA 规范最常见的实现之一。JPA 规定“应该怎么用实体管理持久化数据”,Hibernate 负责把这些规范真正落地:维护实体状态、管理持久化上下文、做脏检查、生成 SQL、处理懒加载、执行 flush、和数据库交互。

MyBatis 的学习主线是“SQL 怎么被找到、参数怎么绑定、结果怎么映射”。Hibernate / JPA 的学习主线完全不同:它的核心是“对象状态怎么被管理,事务提交时为什么会自动生成 SQL”。

学习目标

学完本章要能说清楚:

  1. JPA 是规范,Hibernate 是实现,Spring Data JPA 是 Repository 封装。
  2. EntityManagerPersistence ContextSessionTransaction 各自负责什么。
  3. persistfindmergeremoveflushclear 分别做什么。
  4. 临时态、托管态、游离态、删除态如何流转。
  5. 为什么事务内修改实体字段,不写 update 也会更新数据库。
  6. 脏检查的快照比较过程是什么。
  7. flush 和 commit 的区别是什么。
  8. 懒加载为什么会有 LazyInitializationException
  9. N+1 查询为什么发生,怎么用 DTO、fetch join、批量抓取解决。
  10. 为什么 Hibernate / JPA 项目必须看最终 SQL。

三层关系:JPA、Hibernate、Spring Data JPA

mermaid
flowchart TD
    A["业务代码调用 Repository 或 EntityManager"] --> B["Spring Data JPA Repository 代理"]
    B --> C["JPA EntityManager 规范接口"]
    C --> D["Hibernate Session 实现"]
    D --> E["JDBC"]
    E --> F["数据库"]
层次作用可以怎么理解
JPAJava 持久化规范定义接口、注解和语义
HibernateJPA 的具体实现真正管理实体状态并生成 SQL
Spring Data JPASpring 的 Repository 封装减少 DAO/Repository 模板代码

所以面试里说“Spring Data JPA 会自动生成 SQL”是不精确的。更准确的说法是:Spring Data JPA 解析 Repository 方法或调用 JPA API,底层通常由 Hibernate 根据实体映射和状态生成 SQL。

为什么需要持久化上下文

没有 ORM 时,Java 对象和数据库记录没有持续关系。你查出一行数据,改了对象字段,数据库不会自动知道。

Hibernate 的核心设计是引入持久化上下文,也就是 Persistence Context。它可以理解成一次事务内的“实体管理区”。

它负责:

  1. 保存托管实体。
  2. 保证同一个主键在同一个上下文中只有一个对象实例。
  3. 保存实体加载时的快照。
  4. 在 flush 时做脏检查。
  5. 管理新增、删除、更新动作的 SQL 队列。
  6. 支持懒加载代理和关联加载。
mermaid
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

java
@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)让某个实体脱离托管

实体四种状态

mermaid
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

java
Asset asset = new Asset();
asset.setAssetCode("A-1001");

只是普通 Java 对象。没有进入持久化上下文,事务提交时不会自动插入数据库。

托管态 Managed

java
entityManager.persist(asset);

或:

java
Asset asset = entityManager.find(Asset.class, 1L);

对象被持久化上下文管理。这个状态最重要:只有托管态实体的变化才会被脏检查自动同步。

游离态 Detached

事务结束、cleardetach 后,对象还在 Java 内存中,但 Hibernate 不再管理它。

java
Asset asset = entityManager.find(Asset.class, 1L);
entityManager.clear();
asset.setAssetName("新名称");

这次修改不会自动更新数据库,因为对象已经不在持久化上下文里。

删除态 Removed

java
Asset asset = entityManager.find(Asset.class, 1L);
entityManager.remove(asset);

实体被标记为删除,真正 SQL 通常在 flush 时执行。

一次 find 查询的完整过程

java
Asset asset = entityManager.find(Asset.class, 1L);

执行链路:

mermaid
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 同一个主键,通常返回同一个对象实例。

java
Asset a1 = entityManager.find(Asset.class, 1L);
Asset a2 = entityManager.find(Asset.class, 1L);
System.out.println(a1 == a2); // 通常是 true

这不是二级缓存,而是持久化上下文的一级缓存。

一次 persist 新增的完整过程

java
Asset asset = new Asset();
asset.setAssetCode("A-1001");
entityManager.persist(asset);

过程:

mermaid
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 最核心的原理。

java
@Transactional
public void rename(Long id, String name) {
    Asset asset = entityManager.find(Asset.class, id);
    asset.setAssetName(name);
}

没有调用 update,但事务提交时可能生成:

sql
update asset set asset_name = ? where id = ?

过程:

mermaid
flowchart TD
    A["find 查询实体"] --> B["保存快照 assetName=旧名称"]
    B --> C["业务代码 setAssetName=新名称"]
    C --> D["事务提交前触发 flush"]
    D --> E["比较当前实体和快照"]
    E --> F{"字段是否变化?"}
    F -->|"是"| G["生成 update SQL"]
    F -->|"否"| H["不生成 SQL"]

脏检查依赖两个前提:

  1. 实体必须是托管态。
  2. flush 时持久化上下文还存在。

如果对象是游离态,修改字段不会自动保存。

java
Asset asset = assetRepository.findById(id).orElseThrow();
// 事务结束后返回到外层
asset.setAssetName("新名称"); // 不会自动更新数据库

flush 和 commit 的区别

很多人把 flush 和 commit 混在一起。它们不是一回事。

动作作用
flush把持久化上下文中的变化同步成 SQL 发给数据库
commit提交数据库事务,让变更真正对其他事务可见并持久化

flush 后 SQL 已经执行,但事务还没提交。如果后面回滚,flush 产生的变更也会回滚。

mermaid
flowchart TD
    A["业务修改托管实体"] --> B["flush"]
    B --> C["执行 insert/update/delete SQL"]
    C --> D{"后续业务是否异常?"}
    D -->|"是"| E["rollback,数据库变更撤销"]
    D -->|"否"| F["commit,事务提交"]

Hibernate 可能在这些时机 flush:

  1. 事务提交前。
  2. 执行 JPQL/Criteria 查询前,为了保证查询能看到当前事务内变更。
  3. 手动调用 entityManager.flush()
  4. flush mode 要求同步时。

所以线上看到“我只是查了一下,为什么先发了 update”,可能是查询前自动 flush 触发了脏检查。

merge:为什么不是把游离对象重新托管那么简单

merge 经常被误用。

java
Asset detached = new Asset();
detached.setId(1L);
detached.setAssetName("新名称");
Asset managed = entityManager.merge(detached);

重点:merge 返回的是新的托管对象,传入的 detached 本身通常仍然是游离对象。

mermaid
flowchart TD
    A["传入 detached 实体"] --> B["EntityManager.merge"]
    B --> C["查找或创建 managed 实体"]
    C --> D["把 detached 字段复制到 managed"]
    D --> E["返回 managed 实体"]
    A --> F["原 detached 不一定被托管"]

风险:如果前端传来的对象字段不完整,merge 可能把空值也复制进去,导致误更新。商业项目里更推荐:

  1. 先根据 id 查询托管实体。
  2. 校验权限和业务状态。
  3. 只修改允许变更的字段。
  4. 让脏检查生成 SQL。
java
@Transactional
public void updateAssetName(Long id, String name) {
    Asset asset = entityManager.find(Asset.class, id);
    asset.changeName(name);
}

懒加载为什么会异常

懒加载是指关联对象不在主查询时立即加载,而是在访问属性时再查。

java
@ManyToOne(fetch = FetchType.LAZY)
private Department department;

查询资产时,department 可能只是代理对象。

mermaid
flowchart TD
    A["查询 Asset"] --> B["department 字段放入代理对象"]
    B --> C{"是否访问 department.name?"}
    C -->|"否"| D["不查询 department 表"]
    C -->|"是"| E{"Session 是否还开着?"}
    E -->|"是"| F["发 SQL 加载 Department"]
    E -->|"否"| G["LazyInitializationException"]

典型错误:

java
public Asset getAsset(Long id) {
    return assetRepository.findById(id).orElseThrow();
}

// Controller 返回 JSON 时序列化 asset.department.name
// 此时事务已经结束,懒加载失败

正确做法:

  1. Service 事务内完成 DTO 转换。
  2. 列表页直接查询 DTO 投影。
  3. 必要时使用 fetch join。
  4. 不要把 Entity 直接返回给前端。

N+1 查询为什么发生

N+1 的本质是:先查 N 条主表记录,再逐条访问懒加载关联,每条记录再发一条 SQL。

java
List<Asset> assets = assetRepository.findByStatus(1);
for (Asset asset : assets) {
    String deptName = asset.getDepartment().getDeptName();
}

SQL 可能是:

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;
mermaid
flowchart TD
    A["查询资产列表 1 次"] --> B["得到 N 条 Asset"]
    B --> C["循环访问 department"]
    C --> D["每条 Asset 触发一次 department 查询"]
    D --> E["总 SQL = 1 + N"]

解决方式:

DTO 投影

java
public record AssetListDTO(Long id, String assetCode, String assetName, String deptName) {
}
java
@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

java
@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 支持:

  1. @ManyToOne
  2. @OneToMany
  3. @OneToOne
  4. @ManyToMany

但商业项目里要克制。

常见建议:

关系建议
多对一可以使用,但默认考虑懒加载
一对多谨慎使用,容易加载集合过大
多对多生产中通常拆成中间实体
双向关联只在确实需要时使用,注意序列化循环

不要把数据库里所有外键都映射成对象关联。对象图越复杂,隐式 SQL 越难控制。

事务边界:为什么 JPA 更依赖 Service 层事务

JPA 的实体状态、懒加载、脏检查都依赖持久化上下文,而持久化上下文通常和事务边界绑定。

mermaid
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["关闭持久化上下文"]

如果事务边界太小:

  1. 懒加载字段在 Controller 序列化时失败。
  2. 修改实体时已经游离,不会自动保存。
  3. 多个 Repository 操作不能形成一个业务原子动作。

如果事务边界太大:

  1. 持有数据库连接时间过长。
  2. 锁等待时间变长。
  3. 持久化上下文缓存对象过多。
  4. 远程调用失败导致事务时间不可控。

正确做法:事务放在 Service 层,覆盖一个完整本地数据库业务动作,不要包住慢远程调用。

商业 Demo:资产状态变更和列表查询

实体:

java
@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:

java
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:

java
@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 的关键点:

  1. 修改接口先查询托管实体,再调用领域方法。
  2. 列表页返回 DTO,不返回 Entity。
  3. 查询明确 join 需要的部门名称,避免 N+1。
  4. 事务在 Service 层。
  5. 分页查询要关注 count SQL 和最终 SQL。

线上排查流程

接口慢

mermaid
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["检查锁等待、连接池、事务过大、返回对象过多"]

自动更新不生效

排查:

  1. 方法是否有事务。
  2. 实体是否是托管态。
  3. 是否在事务外修改了游离对象。
  4. 是否修改的是不可持久化字段。
  5. 是否事务回滚了。
  6. 是否 flush mode 或只读事务影响了同步。

懒加载异常

排查:

  1. 是否直接把 Entity 返回给 Controller。
  2. 是否在事务结束后才访问懒加载字段。
  3. 是否 JSON 序列化触发了关联属性访问。
  4. 是否应该改 DTO 查询。
  5. 是否需要 fetch join。

保存时产生意外 update

排查:

  1. 是否查询出了托管实体又修改了字段。
  2. 是否查询前自动 flush。
  3. 是否 merge 把前端空字段复制到了托管实体。
  4. 是否级联配置过大。
  5. 是否实体 setter 中有副作用。

常见坑

问题原因正确做法
直接返回 Entity懒加载异常、循环引用、字段暴露Service 内转 DTO
N+1 查询列表后逐条访问关联DTO 投影、fetch join、批量抓取
滥用 merge空字段覆盖、语义不清先查托管实体,再改允许字段
双向关联过多对象图复杂、序列化循环只建必要关系
事务外修改实体实体已游离,不会脏检查修改放在事务内
批量导入不 clear持久化上下文对象过多分批 flush + clear
以为不用看 SQL生成 SQL 可能低效开 SQL 日志并 EXPLAIN

面试标准回答

JPA 和 Hibernate 什么关系

text
JPA 是 Java 持久化规范,定义 Entity、EntityManager、持久化上下文、JPQL 等标准;Hibernate 是 JPA 的常见实现,真正负责维护实体状态、脏检查、生成 SQL、懒加载和缓存;Spring Data JPA 又在 JPA 之上封装 Repository,减少数据访问模板代码。

为什么 JPA 修改实体不用写 update

text
事务内查询出来的实体处于托管态,持久化上下文会保存实体快照。业务代码修改字段后,事务提交前 flush 会触发脏检查,Hibernate 比较当前实体和快照,如果发现字段变化,就生成 update SQL。前提是实体仍然处于托管态并且事务正常提交。

flush 和 commit 的区别

text
flush 是把持久化上下文中的 insert、update、delete 同步成 SQL 发给数据库;commit 是提交数据库事务。flush 后 SQL 可能已经执行,但事务还没提交,如果后续 rollback,flush 的变更也会撤销。

什么是 N+1,怎么解决

text
N+1 是先查询一批主实体,再循环访问懒加载关联,导致每条主记录额外发一条 SQL。解决方式包括 DTO 投影、fetch join、批量抓取、合理设计接口返回字段。列表页通常推荐 DTO 投影,避免返回完整 Entity 对象图。

为什么不建议 Controller 直接返回 Entity

text
Entity 是持久化模型,不是接口模型。直接返回 Entity 容易暴露字段、触发懒加载异常、造成循环引用、产生隐式 SQL,也会让接口结构和数据库表结构强耦合。商业项目一般在 Service 层查询并转换成 DTO 返回。

关联知识点

  1. Hibernate 核心概念
  2. Hibernate 学习路线
  3. JPA 基础
  4. Spring Data JPA
  5. Spring Data JPA 查询
  6. Spring Data JPA 事务与分页
  7. ORM 面试题
  8. MyBatis 核心全过程原理

本章小结

Hibernate / JPA 的核心不是注解,而是持久化上下文。实体进入托管态后,Hibernate 保存快照;事务提交或查询前 flush 时,脏检查比较当前值和快照,生成 SQL 同步数据库。懒加载、N+1、自动更新、merge 风险、事务边界问题,本质都可以从“实体当前是否被持久化上下文管理”这条主线解释。