Java 基础体系 · 第 23/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

JPA 与 Hibernate:实体状态、关联、查询、事务和 N+1 诊断

JPA(Jakarta Persistence)是一套对象持久化规范,定义了实体、持久化上下文、查询语言、事务协作和关联映射等契约;Hibernate 是 JPA 的常见实现,同时提供了超出规范的扩展。二者的关系可以类比为 JDBC 与具体数据库驱动:

  • JPA 规定应用如何描述实体、执行查询和管理生命周期。
  • Hibernate 负责把这些操作翻译为 SQL,并实现脏检查、缓存、批处理等机制。
  • 数据库事务、锁、隔离级别和连接资源仍然由 JDBC 驱动、连接池和数据库共同参与。

因此,看到一段 JPA 代码时,不能只问“它会不会生成 SQL”,还要问:

  1. 当前对象处于什么实体状态?
  2. 它是否受某个持久化上下文管理?
  3. 关联的哪一方负责写外键?
  4. SQL 何时真正发送?
  5. 事务何时开始、何时提交?
  6. 查询返回的是实体、代理、初始化后的集合,还是脱离上下文后的对象?
  7. 数据量增长后,SQL 数量和结果集大小如何变化?

一、先建立运行模型:对象、持久化上下文与数据库

1.1 持久化上下文不是“对象缓存”这么简单

持久化上下文(persistence context)是实体实例与数据库身份之间的管理范围。对一个实体类型和主键值,持久化上下文通常维护唯一的 Java 对象实例:

同一个 EntityManager
    find(Order.class, 1L) ──┐
                           ├── 同一个 Order Java 对象
getReference(Order.class, 1L) ─┘

这个性质称为一级缓存或身份映射(identity map)。如果上下文中已经管理了 Order#1,再次按相同主键加载,JPA 通常不会创建第二个代表同一数据库行的托管实例。

但这不表示:

  • 数据库中的行永远不会被其他事务修改;
  • find() 每次都不会访问数据库;
  • 查询结果一定不包含重复引用;
  • 对象离开上下文后仍然自动同步。

一级缓存解决的是当前持久化上下文内部的对象身份一致性,不是全局缓存,也不是并发控制。

1.2 一次典型请求的数据流

以一个由 Spring 事务方法包围的操作为例,典型路径如下:

sequenceDiagram
    participant App as 应用代码
    participant EM as EntityManager
    participant PC as Persistence Context
    participant Pool as 连接池
    participant DB as 数据库

    App->>EM: begin transaction
    EM->>Pool: 获取 JDBC Connection
    App->>EM: find / JPQL 查询
    EM->>PC: 创建或返回 managed entity
    PC->>DB: 执行 SELECT
    App->>PC: 修改实体属性
    App->>EM: commit
    EM->>PC: flush,执行脏检查
    PC->>DB: INSERT/UPDATE/DELETE
    DB-->>PC: 成功或约束/锁/超时错误
    EM->>Pool: 释放 Connection

关键点是:修改托管实体字段通常不会立即执行 UPDATE。Hibernate 会在 flush 阶段检查实体当前状态与快照的差异,再生成 SQL。

不过,“通常延迟到 flush”不等于“提交前一定完全不执行 SQL”。以下行为可能触发 flush:

  • 显式调用 EntityManager.flush()
  • 提交事务;
  • 执行会影响查询结果一致性的 JPQL 查询;
  • Hibernate 的特定 flush 策略或查询配置。

具体 SQL 时机属于实现和配置共同决定的行为,不能把“调用 setter”误认为“数据库已经更新”。


二、实体状态:Transient、Managed、Detached、Removed

JPA 定义了实体生命周期状态。下面以 Order 为例说明状态转换。

2.1 四种核心状态

Transient:新建但未被管理

Order order = new Order("A-100");

此时 order 只是普通 Java 对象:

  • 没有被当前持久化上下文管理;
  • 对它的字段修改不会自动写入数据库;
  • 若没有显式 persist 或级联持久化,事务提交不会插入它。

Managed:托管

entityManager.persist(order);

order 进入当前持久化上下文后成为 managed entity。之后:

order.setStatus(OrderStatus.PAID);

只要仍在同一持久化上下文和事务中,Hibernate 通常会在 flush 时检测到变化并执行更新。

Detached:游离

entityManager.detach(order);
// 或 entityManager.clear()
// 或 EntityManager 关闭

对象仍然存在于 Java 堆中,但不再由当前持久化上下文管理。此后:

order.setStatus(OrderStatus.CANCELLED);

不会自动同步数据库。

Removed:已标记删除

entityManager.remove(order);

实体仍可能暂时存在于持久化上下文中,但已被标记为删除,通常在 flush 时执行 DELETE。删除前再次改变它的状态,不应被当作可靠的持久化更新路径。

2.2 状态转换图

stateDiagram-v2
    [*] --> Transient
    Transient --> Managed: persist()
    Managed --> Detached: detach()/clear()/close()
    Managed --> Removed: remove()
    Detached --> Managed: merge()
    Managed --> Managed: 修改字段/flush
    Removed --> [*]: flush/commit 后删除

merge() 的语义尤其容易误解:它不是“把这个 Java 对象重新挂回上下文”,而是把游离对象的状态复制到一个由持久化上下文管理的实例上,并返回那个 managed 实例。

Order detached = loadAndCloseEntityManager();

detached.setStatus(OrderStatus.PAID);

Order managed = entityManager.merge(detached);

System.out.println(detached == managed); // 通常为 false
managed.setComment("paid by card");

正确使用方式是继续使用 managed。修改 detached 不会再次自动同步。

2.3 persist()merge() 的差异

操作 参数要求 返回值 典型语义
persist(entity) 通常是 transient 实体 void 让该实例成为 managed,并计划插入
merge(entity) transient 或 detached 实体 managed 实例 将状态复制给 managed 实例

示例:

Order source = new Order("A-101");

Order merged = entityManager.merge(source);

source.setStatus(OrderStatus.PAID);   // 不应依赖它被持久化
merged.setStatus(OrderStatus.PAID);   // 由上下文跟踪

对于 detached 对象,merge() 还可能覆盖数据库中较新的值。假设:

  1. 事务 T1 加载订单,得到状态 NEW
  2. 事务 T2 将状态改为 PAID 并提交;
  3. T1 仍持有旧对象,将其 CANCELLED 后执行 merge()
  4. 如果没有 @Version,T1 可能覆盖 T2 的更新。

因此,merge() 不是并发控制。需要防止丢失更新时,应使用乐观锁版本列。

2.4 @Version 如何阻止丢失更新

@Entity
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Version
    private long version;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    // constructors/getters/setters
}

假设初始行是:

id = 1, status = NEW, version = 3

两个事务都读取版本 3:

-- T1
UPDATE orders
SET status = 'PAID', version = 4
WHERE id = 1 AND version = 3;

-- T2
UPDATE orders
SET status = 'CANCELLED', version = 4
WHERE id = 1 AND version = 3;

第一个成功后,数据库中的版本变为 4。第二个 UPDATE 的匹配行数为 0,Hibernate 会将其识别为乐观锁冲突,通常抛出 OptimisticLockException 或 Hibernate 对应异常。

这个机制的逻辑是:

更新成功    当前数据库 version=读取时 version\text{更新成功} \iff \text{当前数据库 version} = \text{读取时 version}

它检测的是“读取后是否有人修改过”,不是锁住数据。异常发生后,当前事务通常不可继续安全提交,应回滚并重新读取、合并业务决策,再决定是否重试。


三、flush、commit 和 rollback:对象变化何时成为数据库变化

3.1 flush 不等于 commit

  • flush:把持久化上下文中的变更同步到数据库连接;
  • commit:提交数据库事务,使变更对其他事务可见,并释放或结束事务资源;
  • rollback:撤销数据库事务中的 SQL 效果,但不会把 Java 对象自动恢复成修改前的值。

例如:

@Transactional
public void pay(long id) {
    Order order = entityManager.find(Order.class, id);
    order.setStatus(OrderStatus.PAID);

    entityManager.flush(); // 此时 UPDATE 可能已经执行
    throw new RuntimeException("business failure");
}

如果异常导致事务回滚,数据库中的 UPDATE 会撤销;但当前 Java 对象的 status 仍可能是 PAID。这就是为什么不应在回滚后继续把同一持久化上下文中的实体当作可靠快照使用。

3.2 flush 的顺序影响约束和 SQL 结果

Hibernate 会根据内部动作队列安排插入、更新、删除等操作,但具体排序是实现行为,不应依赖“代码中先调用哪个 setter”来推断 SQL 顺序。

例如,以下代码中的两个对象可能在 flush 时被统一处理:

Order order = new Order("A-200");
OrderItem item = new OrderItem(order, "BOOK", 2);

entityManager.persist(order);
entityManager.persist(item);

如果 item.order_id 依赖 order.id,Hibernate 需要先取得订单主键,再插入明细。使用数据库自增主键时,插入时机可能受到主键生成策略影响;使用序列或应用侧生成的 UUID 时,批处理和排序空间通常不同。

因此,遇到外键约束异常,应检查:

  1. 关联拥有方的外键值是否已经设置;
  2. 主键生成策略是否要求先执行插入;
  3. 级联配置是否覆盖了预期实体;
  4. flush 时是否存在删除后又引用、插入顺序不满足约束等问题。

3.3 查询前自动 flush 的反直觉表现

以下代码可能在 JPQL 查询前先执行 UPDATE

@Transactional
public long countPaidOrders(Order order) {
    order.setStatus(OrderStatus.PAID);

    return entityManager.createQuery("""
        select count(o)
        from Order o
        where o.status = :status
        """, Long.class)
        .setParameter("status", OrderStatus.PAID)
        .getSingleResult();
}

原因是查询结果应该与当前持久化上下文中的待刷新变化保持合理一致性。Hibernate 默认的自动 flush 策略可能判断该查询涉及 orders 表,于是先 flush。

这不意味着所有查询都一定触发 flush;是否触发取决于 flush 模式、查询涉及的表和实现判断。需要控制边界时,可以显式:

entityManager.flush();

或者对只读场景使用适当的事务和查询设置,但不能仅靠“没有看到 UPDATE”推断对象没有改变。


四、实体映射:数据库身份、列、枚举和版本

一个可运行的简化实体如下:

package com.example.order;

import jakarta.persistence.*;

@Entity
@Table(name = "orders")
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "order_number", nullable = false, unique = true, length = 40)
    private String orderNumber;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private OrderStatus status = OrderStatus.NEW;

    @Version
    private long version;

    protected Order() {
        // JPA 需要无参构造器;业务代码不应直接依赖它创建对象
    }

    public Order(String orderNumber) {
        this.orderNumber = orderNumber;
    }

    public Long getId() {
        return id;
    }

    public String getOrderNumber() {
        return orderNumber;
    }

    public OrderStatus getStatus() {
        return status;
    }

    public void setStatus(OrderStatus status) {
        this.status = status;
    }

    public long getVersion() {
        return version;
    }
}
public enum OrderStatus {
    NEW, PAID, CANCELLED
}

这里有几个不同层次的契约:

  • @Entity@Id@Version 属于 JPA 标准映射能力;
  • GenerationType.IDENTITY 的具体行为由数据库和 Hibernate 方言共同决定;
  • @Enumerated(EnumType.STRING) 将枚举名存为字符串,避免枚举顺序变化导致 ORDINAL 数值含义改变;
  • nullable = false 主要用于生成 DDL 或元数据表达,最终约束是否存在取决于实际数据库 schema 是否已正确迁移;
  • 字段访问还是属性访问取决于注解放置位置。上例把注解放在字段上,因此 JPA 使用字段访问。

实体的无参构造器通常要求至少为 protected。实体也不应设计成不可变值对象的简单替代品,因为 Hibernate 需要在生命周期中构造、识别和更新它们。


五、关联映射:谁拥有外键,谁负责写关系

5.1 数据库关系与对象关系不是同一结构

假设数据库如下:

create table orders (
    id bigint primary key,
    order_number varchar(40) not null unique,
    status varchar(20) not null,
    version bigint not null
);

create table order_items (
    id bigint primary key,
    order_id bigint not null,
    sku varchar(80) not null,
    quantity integer not null,
    constraint fk_item_order
        foreign key (order_id) references orders(id)
);

数据库只需要 order_items.order_id 表示关系。Java 对象可能同时拥有:

order.getItems();
item.getOrder();

但双向引用的两端不是同等的写入来源。通常把含有外键映射的一方定义为 owning side:

@Entity
@Table(name = "order_items")
public class OrderItem {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "order_id", nullable = false)
    private Order order;

    @Column(nullable = false, length = 80)
    private String sku;

    @Column(nullable = false)
    private int quantity;

    protected OrderItem() {}

    public OrderItem(Order order, String sku, int quantity) {
        this.order = order;
        this.sku = sku;
        this.quantity = quantity;
    }

    public void setOrder(Order order) {
        this.order = order;
    }
}
@Entity
@Table(name = "orders")
public class Order {
    // id、orderNumber 等省略

    @OneToMany(
        mappedBy = "order",
        cascade = CascadeType.ALL,
        orphanRemoval = true
    )
    private final List<OrderItem> items = new ArrayList<>();

    public void addItem(String sku, int quantity) {
        OrderItem item = new OrderItem(this, sku, quantity);
        items.add(item);
    }

    public void removeItem(OrderItem item) {
        if (items.remove(item)) {
            item.setOrder(null);
        }
    }

    public List<OrderItem> getItems() {
        return items;
    }
}

这里:

  • OrderItem.order 是 owning side,因为它映射 order_id
  • Order.items 使用 mappedBy = "order",表示它是反向导航属性,不单独维护外键;
  • 只执行 order.getItems().add(item) 而不设置 item.order,可能不会更新 order_id
  • addItem 同时维护两边,避免内存中的对象图与数据库关系不一致。

5.2 cascade 不是“自动保存所有相关对象”

cascade = CascadeType.ALL 表示某些实体操作可以沿关联传播,包括:

  • PERSIST
  • MERGE
  • REMOVE
  • REFRESH
  • DETACH

它不等于“任何时候都自动加载或自动保存”。例如:

Order order = new Order("A-300");
order.addItem("PEN", 3);

entityManager.persist(order);

因为 Order.items 配置了 cascade = ALL,新建的明细可以随订单持久化。若没有 PERSIST 级联,则需要显式:

entityManager.persist(order);
entityManager.persist(item);

cascade 还可能放大删除范围。对共享子对象、引用数据或生命周期独立的实体,不应随意配置 CascadeType.REMOVE

5.3 orphanRemovalCascadeType.REMOVE 不同

order.removeItem(item);

若集合关联设置了 orphanRemoval = true,并且该明细不再被父实体引用,Hibernate 通常会在 flush 时删除对应行。

两者的触发条件不同:

  • CascadeType.REMOVE:删除父实体时,将删除操作传播给子实体;
  • orphanRemoval = true:子实体从父集合中移除时,可能删除子实体。

orphanRemoval 适合“子实体完全依附于父实体”的聚合关系,不适合子对象可被多个父对象共享的模型。

5.4 LAZYEAGER 的实际含义

FetchType.LAZY 表示允许实现延迟加载;FetchType.EAGER 表示关联在实体可用时必须被获取。JPA 对不同关联的默认值并不相同:

  • @ManyToOne@OneToOne 默认 EAGER
  • @OneToMany@ManyToMany 默认 LAZY

工程上常显式写出 fetch = FetchType.LAZY,尤其是 @ManyToOne

@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "order_id", nullable = false)
private Order order;

LAZY 不是“绝对不加载”。Hibernate 可能通过代理、字节码增强或其他机制实现延迟加载;具体能否延迟、何时初始化,受实体结构、增强方式和实现版本影响。


六、懒加载、代理与 LazyInitializationException

6.1 为什么事务外访问集合会失败

Order order = orderService.findById(id);
// findById 方法已经结束,持久化上下文也可能已关闭

order.getItems().size(); // 可能抛 LazyInitializationException

order.items 可能只是一个尚未加载的 Hibernate 集合包装器。真正读取 size() 时需要访问数据库,但此时:

  • 实体已经 detached;
  • 关联没有初始化;
  • 没有可用的持久化上下文或 JDBC 连接。

于是 Hibernate 无法完成加载,抛出 LazyInitializationException

这不是数据库找不到数据,而是对象仍想执行数据库访问,但管理它的上下文已经结束

6.2 三种合理解决路径

在事务内完成需要的数据读取

@Transactional(readOnly = true)
public OrderView getOrder(long id) {
    Order order = orderRepository.findDetailedById(id)
        .orElseThrow();

    return new OrderView(
        order.getId(),
        order.getOrderNumber(),
        order.getItems().stream()
            .map(item -> new ItemView(item.getId(), item.getSku(), item.getQuantity()))
            .toList()
    );
}

事务内访问集合时,Hibernate 可以执行必要的查询;返回 DTO 后,Web 层不再依赖实体懒加载。

查询时明确获取关联

@Query("""
    select distinct o
    from Order o
    left join fetch o.items
    where o.id = :id
    """)
Optional<Order> findDetailedById(@Param("id") long id);

这里的 distinct 不是简单为了数据库去重,而是避免一对多 join 结果中的重复父实体影响对象结果。具体 SQL 结果仍可能包含多行,每行对应一个明细。

使用 DTO 投影

@Query("""
    select new com.example.order.OrderSummary(
        o.id, o.orderNumber, o.status
    )
    from Order o
    where o.id = :id
    """)
Optional<OrderSummary> findSummary(long id);

DTO 投影直接返回查询所需的数据,避免把完整实体图暴露到表示层。代价是 DTO 查询与字段结构耦合,需要在需求变化时维护查询。

6.3 不应把 Open Session in View 当成唯一修复

Open Session in View(OSIV)让请求处理期间保持持久化上下文打开,从而允许 Web 层触发懒加载。它可以减少 LazyInitializationException,但也改变了数据访问边界:

  • 序列化对象时可能隐式执行 SQL;
  • 模板循环中访问关联会产生不可见查询;
  • 事务已经提交后,懒加载查询可能运行在另一个自动提交连接上;
  • 响应耗时和 SQL 数量不再集中由服务层控制。

是否启用 OSIV 是架构取舍,不是 JPA 规范要求。即使启用,也不能用它掩盖查询设计和 N+1 问题。


七、查询:JPQL 以实体模型为对象,SQL 以表为对象

7.1 JPQL 查询实体属性而不是表列

List<Order> orders = entityManager.createQuery("""
    select o
    from Order o
    where o.status = :status
    order by o.id desc
    """, Order.class)
    .setParameter("status", OrderStatus.PAID)
    .getResultList();

JPQL 中:

  • Order 是实体名;
  • o.status 是实体属性;
  • :status 是命名参数;
  • 返回值是 Order 实体列表。

数据库列名可能是 order_status,但 JPQL 仍使用 Java 属性名或实体名。JPQL 不是把 SQL 字符串直接换了一套关键字,而是面向持久化实体模型的查询语言。

7.2 参数绑定避免拼接和类型错误

错误方式:

String jpql = "select o from Order o where o.orderNumber = '" + number + "'";

问题包括 SQL/JPQL 注入风险、引号处理、类型转换和查询计划复用问题。正确方式:

TypedQuery<Order> query = entityManager.createQuery("""
    select o from Order o
    where o.orderNumber = :number
    """, Order.class);

query.setParameter("number", number);
Order order = query.getSingleResult();

getSingleResult() 在没有结果时抛 NoResultException,多于一条时抛 NonUniqueResultException。如果业务语义允许“没有结果”,getResultList() 再判断通常更直观:

List<Order> result = query.setMaxResults(1).getResultList();
Optional<Order> order = result.stream().findFirst();

7.3 joinjoin fetch 和普通关联访问不是一回事

普通连接:

select o
from Order o
join o.items i
where i.sku = :sku

它用于限制订单,但返回的主要对象仍是 Order。它不保证 o.items 已被初始化。

获取连接:

select distinct o
from Order o
join fetch o.items i
where i.sku = :sku

join fetch 同时表达:

  1. 用关联参与查询;
  2. 在本次查询中加载关联。

但它不是任意场景的万能预加载。一个订单有 1000 个明细时,fetch join 会扩大结果集;同时 fetch 多个一对多集合可能产生笛卡尔积:

订单 1 × 明细 100 × 标签 20 = 2000 行中间结果

最终 ORM 可能去重为一个订单,但数据库、网络和 Hibernate 仍需处理这些中间行。

7.4 一对多 fetch join 与分页的冲突

以下查询看似合理:

select distinct o
from Order o
left join fetch o.items
order by o.id

如果再加分页:

.setFirstResult(0)
.setMaxResults(20)

数据库分页作用于 join 后的行,而不是“20 个订单”。实现可能:

  • 内存中分页;
  • 发出警告;
  • 返回数量少于预期;
  • 生成巨大的结果集。

Hibernate 的具体行为与版本和配置有关,不能假定所有版本都相同。

更稳妥的两步查询是:

List<Long> ids = entityManager.createQuery("""
    select o.id
    from Order o
    where o.status = :status
    order by o.id
    """, Long.class)
    .setParameter("status", OrderStatus.PAID)
    .setFirstResult(offset)
    .setMaxResults(limit)
    .getResultList();

if (ids.isEmpty()) {
    return List.of();
}

List<Order> orders = entityManager.createQuery("""
    select distinct o
    from Order o
    left join fetch o.items
    where o.id in :ids
    order by o.id
    """, Order.class)
    .setParameter("ids", ids)
    .getResultList();

第一步分页父实体主键,第二步加载关联。第二步的 IN 结果顺序不一定与第一步一致,因此需要在应用层按 ids 重排,或采用数据库特定的排序表达式。

7.5 EntityGraph:把获取计划与查询条件分开

JPA EntityGraph 可以表达本次查询需要加载的属性节点:

EntityGraph<Order> graph =
    entityManager.createEntityGraph(Order.class);
graph.addAttributeNodes("items");

Order order = entityManager.find(
    Order.class,
    id,
    Map.of("jakarta.persistence.fetchgraph", graph)
);

EntityGraph 适合在不重写查询条件时改变加载计划,但具体实现对 fetch graph、load graph 和默认 fetch 类型的细节解释可能不同。它仍然不能自动解决大集合、分页或多集合 join 带来的数据量问题。

7.6 Criteria 和原生 SQL 的边界

Criteria API 适合动态组合条件:

CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Order> cq = cb.createQuery(Order.class);
Root<Order> root = cq.from(Order.class);

cq.select(root)
  .where(
      cb.equal(root.get("status"), OrderStatus.PAID)
  )
  .orderBy(cb.desc(root.get("id")));

List<Order> result = entityManager.createQuery(cq).getResultList();

它是类型相对安全的动态查询方式,但字符串属性名仍可能在运行时出错;使用静态 metamodel 可以进一步提高检查能力。

原生 SQL 适合窗口函数、数据库特有语法、复杂报表或必须精确控制执行计划的场景:

List<?> rows = entityManager.createNativeQuery("""
    select id, order_number, status
    from orders
    where status = ?
    """)
    .setParameter(1, "PAID")
    .getResultList();

原生查询返回实体时,结果列必须能满足实体映射要求;返回标量或 DTO 时,应明确处理列类型和映射。原生 SQL 不会自动获得 JPQL 的数据库可移植性。


八、事务:JPA 操作必须落在正确的事务边界内

8.1 事务包含什么

一个数据库事务通常具有以下性质:

  • 一组操作作为一个原子单元提交或回滚;
  • 其他事务根据隔离级别观察这些操作;
  • 提交后数据持久化到数据库的承诺由数据库保证;
  • 回滚只影响该数据库事务中的数据库效果。

JPA 的 EntityManager 负责持久化上下文,但不能脱离事务随意写数据库。对于事务型读写,应用通常使用:

  • Jakarta Transactions 的 @Transactional
  • Spring 的 @Transactional
  • 容器管理的事务;
  • 资源本地事务(resource-local)中的显式 begin/commit

8.2 Spring @Transactional 的边界和代理陷阱

典型服务方法:

@Service
public class OrderService {
    private final OrderRepository repository;

    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }

    @Transactional
    public void pay(long id) {
        Order order = repository.findById(id).orElseThrow();
        order.setStatus(OrderStatus.PAID);
    }
}

方法结束时,事务拦截器提交事务,Hibernate 在提交前 flush,脏检查产生 UPDATE

但 Spring 的声明式事务通常依赖代理。以下自调用可能绕过代理:

public void outer() {
    inner(); // 直接调用本类方法,可能不经过事务代理
}

@Transactional
public void inner() {
    // 事务边界是否生效取决于调用路径
}

这不是 JPA 状态规则,而是事务框架代理模型。应从代理对象调用事务方法,或重新划分服务边界。

8.3 事务异常后的恢复

数据库约束错误、乐观锁异常、死锁或连接异常发生后,当前事务可能已经被标记为 rollback-only,不能继续执行正常写入。错误处理应遵循:

  1. 记录原始异常和业务上下文;
  2. 回滚当前事务;
  3. 释放 EntityManager 和连接;
  4. 对可重试异常,在新的事务中重新读取并重试;
  5. 对约束冲突转换为明确的业务错误。

不要捕获数据库异常后继续在同一事务里“修复并提交”,因为 JDBC 连接或 JPA 事务状态可能已经不适合继续使用。


九、隔离、锁与实体状态的交互

JPA 不定义数据库所有隔离语义;隔离级别主要由数据库和 JDBC 连接配置决定。常见现象包括:

  • 脏读:读到其他事务尚未提交的数据;
  • 不可重复读:同一事务两次读取同一行得到不同值;
  • 幻读:同一条件查询两次,行集合发生变化;
  • 丢失更新:后提交的旧值覆盖先提交的新值。

@Version 主要解决实体级别的乐观并发更新。需要悲观锁时,可以:

Order order = entityManager.find(
    Order.class,
    id,
    LockModeType.PESSIMISTIC_WRITE
);

典型数据库效果可能类似:

select ...
from orders
where id = ?
for update

但具体锁语法由数据库方言决定。执行路径中还要考虑:

  • 行是否存在;
  • 锁等待时间;
  • 锁顺序是否导致死锁;
  • 事务是否足够短;
  • 查询是否使用索引;
  • 事务回滚后锁是否释放。

悲观锁保护的是锁覆盖范围内的数据库操作;把实体加载为 managed 并不自动意味着数据库行被锁定。


十、N+1 查询:从结果集基数推导 SQL 数量

10.1 N+1 的形式化定义

设一个查询先加载了 NN 个父实体:

List<Order> orders = repository.findByStatus(OrderStatus.PAID);

随后对每个订单访问懒加载集合:

for (Order order : orders) {
    order.getItems().size();
}

如果关联没有提前加载,SQL 数量通常是:

Q(N)=1+NQ(N) = 1 + N

其中:

  • 1 是查询订单列表;
  • N 是每个订单初始化一次 items 的查询。

N = 500 时,查询数量可能从 1 增长到 501。即使每条 SQL 很快,网络往返、连接占用、数据库解析和锁竞争也会叠加。

N+1 不只发生在集合上。以下代码也可能造成 N+1:

for (Order order : orders) {
    System.out.println(order.getCustomer().getName());
}

如果 customer 是懒加载的 @ManyToOne,每个不同客户可能触发额外查询。由于一级缓存,若多个订单引用同一客户,实际查询数可能小于 N,但复杂度仍可能随父结果增长。

10.2 N+1 的完整触发路径

1. select ... from orders where status = 'PAID'
2. 返回 Order#1 ... Order#N
3. 第一次 order.getItems()
4. Hibernate 发现集合未初始化
5. select ... from order_items where order_id = 1
6. 第二次 order.getItems()
7. select ... from order_items where order_id = 2
...

注意,调用 getItems() 本身未必触发查询;读取集合、迭代、调用 size() 或序列化它通常才会要求初始化。Hibernate 对集合 size() 的优化方式可能因映射和实现而不同,不能仅凭 Java 方法名推断 SQL。

10.3 为什么 EAGER 不是通用修复

把关联改成 EAGER 可能让某些场景提前加载,但不保证生成一个高效 join 查询。实现可能选择:

  • 一个 join;
  • 一条父查询加多条子查询;
  • 其他内部加载策略。

因此:

@ManyToOne(fetch = FetchType.EAGER)
private Customer customer;

不等价于:

select ...
from orders o
join customers c on ...

而且 EAGER 会使每次加载订单都承担客户加载成本,即使本次业务根本不需要客户。加载计划应由用例查询决定,而不是由实体默认映射承担所有场景。


十一、N+1 的诊断:先证明 SQL 数量,再选择修复方式

11.1 开启 SQL 和绑定参数日志

开发环境可以配置 Hibernate SQL 日志。以 Spring Boot 常见配置为例:

spring.jpa.show-sql=false
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.orm.jdbc.bind=TRACE

不同 Hibernate 大版本的绑定参数日志类别可能不同,升级时应以当前版本文档和实际日志为准。生产环境谨慎开启参数日志,因为参数可能包含个人信息、令牌或业务机密。

日志中应观察:

select ... from orders ...
select ... from order_items where order_id=?
select ... from order_items where order_id=?
select ... from order_items where order_id=?

? 只说明使用了绑定参数,不表示每条 SQL 相同;需要结合参数绑定日志、数据库慢查询日志或 tracing 判断具体键值。

11.2 Hibernate Statistics 用于测试验证

Hibernate 提供统计能力,但它属于实现能力,不是 JPA 标准:

spring.jpa.properties.hibernate.generate_statistics=true
logging.level.org.hibernate.stat=DEBUG

测试中可以在执行前后读取统计信息,检查查询计数。例如,一个预期“父查询加一次批量关联查询”的用例,若查询数随订单数量线性增长,就说明仍有 N+1。

统计计数需要注意:

  • 一级缓存可能使重复 find 不再发 SQL;
  • 测试数据量过小可能掩盖问题;
  • 二级缓存可能减少 SQL,但未必减少对象图处理;
  • 统计信息是 SessionFactory 级别或实现级别数据,测试之间要清零。

11.3 数据库端验证

应用日志只能告诉你“发出了多少条 SQL”。还应在数据库端检查:

explain
select *
from order_items
where order_id = 123;

诊断关联查询时要确认:

  • order_items.order_id 是否有索引;
  • 是否每条查询都使用了索引;
  • 单条 SQL 是否返回过多列;
  • join 后中间结果是否爆炸;
  • 数据库连接数和等待时间是否随请求增长。

N+1 的修复不是单纯把 SQL 数量降到 1。若单条 join 查询返回百万行,它可能比几十条有索引的批量查询更糟。


十二、N+1 的修复策略及其边界

12.1 Fetch join:适合明确的单次读取图

@Query("""
    select distinct o
    from Order o
    left join fetch o.customer
    left join fetch o.items
    where o.id = :id
    """)
Optional<Order> findDetails(@Param("id") long id);

适合按单个订单加载完整详情。对于列表页,需要评估:

  • 父实体数量;
  • 每个父实体的子集合大小;
  • 是否有分页;
  • 是否同时 join 多个集合;
  • 是否需要所有字段。

12.2 @BatchSize 或批量获取:折中方案

Hibernate 提供实现扩展,例如:

@BatchSize(size = 32)
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items = new ArrayList<>();

当多个订单的集合需要初始化时,Hibernate 可能把多个主键合并为:

select ...
from order_items
where order_id in (?, ?, ..., ?)

查询数量大致从 1+N1 + N 降低为:

1+NB1 + \left\lceil \frac{N}{B} \right\rceil

其中 BB 是批量大小。若 N=100B=32,子查询理论上约为 4 次,而不是 100 次。但实际数量会受数据库 IN 参数上限、已初始化集合、批量策略和查询路径影响。

Hibernate 还可能通过全局配置实现批量获取。它不是 JPA 标准,因此需要在 Hibernate 升级和跨实现迁移时单独验证。

12.3 SUBSELECT:适合同一上下文中的批量集合加载

Hibernate 的 @Fetch(FetchMode.SUBSELECT) 可以让多个父实体的关联通过子查询批量加载:

@Fetch(FetchMode.SUBSELECT)
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items = new ArrayList<>();

它可能生成类似:

select *
from order_items
where order_id in (
    select o.id
    from orders o
    where o.status = ?
)

这也是 Hibernate 扩展。若父查询结果很大,子查询可能扫描和返回大量数据;如果上下文中同时存在多个不相关父查询,其加载范围也可能不符合直觉。

12.4 DTO 批量查询:列表和报表常更可控

例如直接查询订单与明细摘要:

public record OrderItemRow(
    Long orderId,
    String orderNumber,
    String sku,
    int quantity
) {}

JPQL 构造器投影:

List<OrderItemRow> rows = entityManager.createQuery("""
    select new com.example.order.OrderItemRow(
        o.id, o.orderNumber, i.sku, i.quantity
    )
    from Order o
    join o.items i
    where o.status = :status
    order by o.id, i.id
    """, OrderItemRow.class)
    .setParameter("status", OrderStatus.PAID)
    .getResultList();

它只读取需要的列,不创建完整实体图,也没有后续懒加载。但返回的是扁平行,需要应用层自行按订单分组;同时 DTO 不参与实体脏检查,不能直接当作更新对象使用。


十三、实体相等性、集合和代理问题

实体经常放入 Set,但 equals()hashCode() 设计不当会导致关联集合行为异常。

13.1 不能简单使用数据库自增 ID

新实体在插入前可能没有 ID:

OrderItem a = new OrderItem(order, "PEN", 1);
OrderItem b = new OrderItem(order, "PEN", 1);

如果 equals() 只比较尚未生成的 ID,多个 transient 实体可能被错误视为相等;如果 ID 生成后改变了 hashCode(),对象放入 HashSet 后可能再也找不到。

13.2 代理类型和业务相等性

Hibernate 可能用代理子类表示懒加载实体。使用:

getClass() == other.getClass()

可能导致代理对象与实际实体不相等;使用 instanceof 又可能把不同实体类型错误地视作相同。没有一种脱离实体身份策略的通用实现,必须结合:

  • 主键是否应用侧生成;
  • 实体是否会跨 Session 比较;
  • 是否使用 Set;
  • Hibernate 代理和字节码增强方式;
  • 业务是否真正需要值相等。

经验上,优先使用 List 表示有顺序或允许重复的子集合;只有明确需要集合去重语义时才使用 Set,并为实体相等性写测试。


十四、事务中的批处理与持久化上下文增长

批量导入不能无限制地把实体放入同一个上下文:

for (int i = 0; i < 100_000; i++) {
    Order order = new Order("B-" + i);
    entityManager.persist(order);

    if (i % 1000 == 0) {
        entityManager.flush();
        entityManager.clear();
    }
}

flush() 执行当前批次 SQL,clear() 清空持久化上下文,避免一级缓存和脏检查快照持续增长。

clear() 后:

  • 之前的实体全部 detached;
  • 继续修改旧引用不会自动持久化;
  • 关联对象引用可能不再属于当前上下文;
  • 需要重新加载或重新 merge

批处理还受主键策略影响。数据库自增 ID 可能要求插入时立即取得主键,限制部分 JDBC 批量优化;Hibernate 是否能有效批量化,还与 JDBC 驱动、数据库、排序插入配置和实体间依赖有关。不要仅看到配置了 batch size 就断言一定生成了批量网络请求,应通过 SQL 日志、数据库监控和实际执行计划验证。


十五、完整的服务层示例

下面的代码展示一个清晰的事务边界:事务内加载实体和关联,事务内完成业务修改,向外返回 DTO。

public record OrderView(
    Long id,
    String orderNumber,
    String status,
    List<ItemView> items
) {}

public record ItemView(
    Long id,
    String sku,
    int quantity
) {}
@Service
public class OrderApplicationService {
    private final EntityManager entityManager;

    public OrderApplicationService(EntityManager entityManager) {
        this.entityManager = entityManager;
    }

    @Transactional
    public OrderView pay(long orderId) {
        Order order = entityManager.createQuery("""
            select distinct o
            from Order o
            left join fetch o.items
            where o.id = :id
            """, Order.class)
            .setParameter("id", orderId)
            .getResultStream()
            .findFirst()
            .orElseThrow(() -> new IllegalArgumentException("order not found"));

        if (order.getStatus() == OrderStatus.CANCELLED) {
            throw new IllegalStateException("cancelled order cannot be paid");
        }

        order.setStatus(OrderStatus.PAID);

        // 不需要显式 update(order),因为 order 是 managed。
        return new OrderView(
            order.getId(),
            order.getOrderNumber(),
            order.getStatus().name(),
            order.getItems().stream()
                .map(item -> new ItemView(
                    item.getId(),
                    item.getSku(),
                    item.getQuantity()
                ))
                .toList()
        );
    }
}

执行过程是:

  1. 方法进入事务,获得与持久化上下文关联的连接;
  2. join fetch 查询订单和明细;
  3. 返回的 order 是 managed;
  4. 修改 status,Hibernate 记录或检测该变化;
  5. 构造 DTO,避免事务外触发懒加载;
  6. 方法正常返回;
  7. 提交前 flush,执行带版本条件的 UPDATE
  8. 若版本不匹配、约束失败或提交失败,事务回滚,调用方得到异常。

这里没有调用 entityManager.update(order),因为 JPA 没有这个标准 API,且 managed 实体不需要显式 update。Hibernate 的 Session.update() 是另一套实现 API,不能与 JPA 生命周期概念混用。


十六、常见错误与对应失败表现

错误一:只维护反向集合

order.getItems().add(item);

失败表现可能是:

  • Java 对象图看起来有关联;
  • order_items.order_id 仍为 NULL 或旧值;
  • flush 时出现外键约束错误,或关系没有被更新。

修复是维护 owning side:

item.setOrder(order);
order.getItems().add(item);

最好封装成聚合方法,避免调用方遗漏。

错误二:把 detached 对象当作 managed

Order order = repository.findById(id).orElseThrow();
// 事务已经结束
order.setStatus(OrderStatus.PAID);

失败表现是没有 SQL、更新丢失,或后续访问懒加载关联时抛异常。修复是让读取和修改处于同一事务,或者在新事务中重新加载实体,而不是盲目把 Web 请求传回的实体 merge()

错误三:用 merge() 保存整个旧对象图

entityManager.merge(requestMappedOrder);

失败风险包括:

  • 未提交的新值覆盖数据库较新的字段;
  • 空字段覆盖已有字段;
  • 级联 merge 扩大更新范围;
  • 旧关联集合导致错误删除或关系重建;
  • 业务可修改字段没有白名单控制。

更可控的方式是:根据 ID 在事务内重新加载 managed 实体,只修改允许变化的字段,并用 @Version 检测并发冲突。

错误四:看到 LAZY 就认为没有额外 SQL

懒加载的含义是延迟到需要时获取,不是永远不获取。序列化、日志拼接、模板访问、toString()equals() 都可能间接触发加载。若 SQL 只在某个接口的 JSON 序列化阶段出现,仍然是数据访问设计问题。

错误五:用一个 fetch join 解决所有 N+1

单集合详情查询通常适合 fetch join;分页列表、多集合关联、大规模导出则需要重新设计。诊断时同时记录:

  • SQL 条数;
  • 单条 SQL 返回行数;
  • 结果集总字节数;
  • 数据库耗时;
  • Hibernate 实体和集合初始化数量;
  • 事务持续时间。

只优化其中一项可能把 N+1 变成笛卡尔积或内存分页。


十七、规范、Hibernate 能力和工程建议的边界

应明确区分三类知识:

JPA 规范能力

包括:

  • 实体生命周期状态;
  • EntityManagerpersistmergeremovefind
  • JPQL;
  • @Entity@Id@OneToMany@ManyToOne
  • @Version 的乐观锁语义;
  • JPA 事务接口和锁模式抽象。

Hibernate 常见实现能力

包括:

  • Hibernate Session 和 JPA EntityManager 的关联;
  • SQL 日志类别;
  • Hibernate Statistics;
  • @BatchSize
  • @Fetch(FetchMode.SUBSELECT)
  • 批量插入、更新排序和特定 flush 行为;
  • 代理、字节码增强和部分懒加载细节。

这些能力不一定能迁移到其他 JPA 实现。Hibernate 升级时,日志类别、默认行为和性能策略也可能变化。

工程建议

包括:

  • 服务层定义事务边界;
  • 读接口优先返回 DTO;
  • 对列表查询显式设计加载计划;
  • 使用数据库迁移工具维护真实约束;
  • 用 SQL 计数和数据库执行计划验证 N+1;
  • 用版本列处理跨请求并发修改。

这些不是 JPA 规范的强制要求,但它们直接影响可诊断性、数据一致性和系统性能。


十八、建立可重复的 N+1 回归测试

可以用一个固定数据集验证查询数量:

@Test
@Transactional
void loadsOrdersWithoutNPlusOne() {
    entityManager.clear();
    statistics.clear();

    List<Order> orders = repository.findDetailedOrders(OrderStatus.PAID);

    List<OrderView> views = orders.stream()
        .map(order -> new OrderView(
            order.getId(),
            order.getOrderNumber(),
            order.getStatus().name(),
            order.getItems().stream()
                .map(item -> new ItemView(
                    item.getId(), item.getSku(), item.getQuantity()))
                .toList()
        ))
        .toList();

    assertEquals(3, views.size());
    assertTrue(statistics.getPrepareStatementCount() <= expectedCount);
}

测试重点不应是死记一个固定数字,而是验证增长关系:

  • 创建 10 个父实体,记录 SQL 数量;
  • 创建 100 个父实体,再记录 SQL 数量;
  • 若数量从近似常数变为近似线性增长,说明存在 N+1;
  • 若使用批量获取,SQL 数量应按批次增长,而不是按父实体增长。

测试还应覆盖:

  • 没有子集合的父实体;
  • 大集合;
  • 分页;
  • 多个集合关联;
  • 二级缓存开启和关闭;
  • 事务外访问 DTO 与实体的差异。

结语:从“对象操作”回到“状态、SQL 和事务”

JPA 与 Hibernate 的核心不是把表自动变成 Java 类,而是维护三种状态之间的映射:

Java 对象状态
    ↕ 持久化上下文、脏检查、关联加载
数据库行状态

实体只有在 managed 状态下才会由持久化上下文跟踪;flush 才把对象变化转成 SQL;commit 才完成事务提交。双向关联只有 owning side 真正负责外键写入;cascadeorphanRemoval 决定生命周期传播;LAZY 把读取成本推迟到访问时,因此可能暴露 N+1。查询优化也不能只看 SQL 条数,还必须结合结果集规模、分页语义、索引、事务时间和并发冲突。

排查一个 JPA 问题时,可以沿着这条因果链反向检查:

实体处于什么状态?
→ 当前是否有持久化上下文?
→ 关联的 owning side 是否正确?
→ 查询计划是否加载了所需数据?
→ flush 何时发生?
→ 事务是否仍然有效?
→ 实际发出了哪些 SQL?
→ 数据库执行计划和锁等待如何?

当这些问题都能用可观察的状态和 SQL 回答时,JPA 才不再是“偶尔自动生成 SQL 的黑盒”,而是一个可以推导、验证和控制的数据访问层。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。