Skip to content

Hibernate / JPA 核心全过程原理 ​

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

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

学习目标 ​

学完本章要能说清楚:

  1. JPA 是规范,Hibernate 是实现,Spring Data JPA 是 Repository 封装。
  2. EntityManager、Persistence Context、Session、Transaction 各自负责什么。
  3. persist、find、merge、remove、flush、clear 分别做什么。
  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 ​

事务结束、clear、detach 后,对象还在 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 风险、事务边界问题,本质都可以从“实体当前是否被持久化上下文管理”这条主线解释。