Spring Data JPA 全解:Repository 抽象与 JPQL

bee2026-10-08122 分钟0 次阅读
持久化上下文与实体四态、脏检查与 flush 时机、方法名派生查询、@Query 与 JPQL、分页与动态条件、N+1 与懒加载、审计与乐观锁——把 JPA 的生产用法一次讲透。
1 / 191
小节
〇、30 秒看懂
2 / 191

先破一个误解:Spring Data JPA 不是「替你写 SQL 的机器」,而是一套对象状态管理系统。你真正要盯的从来不是语句长什么样,而是这个对象此刻有没有被「持久化上下文」看管。这件事想通了,JPA 九成的「玄学行为」会自动退化成常识:为什么没写 UPDATE 数据却变了、为什么 save 之后什么都没发生、为什么事务一关就报 no Session。

3 / 191

六个词先各给一句话(全文反复用):

4 / 191
  • JPA:规范(Jakarta Persistence),只有注解与接口,不含实现,位置和 JDBC 规范一样
  • Hibernate:Spring Boot 默认的 JPA 实现,真正生成 SQL、维护会话与缓存的那台引擎
  • Spring Data JPA:再往上包一层的声明式抽象,你只写接口方法,实现由代理提供
  • 持久化上下文:一段「看管期」里的对象登记表,生命周期通常与一个事务重合;EntityManager 是它的操作入口
  • 一级缓存:不是额外加装的缓存,它本身就是持久化上下文。同一事务内同一个 id 只发一条 SQL、只存在一个实例
  • 脏检查:实体加载时留下一份字段快照,flush 时逐字段比对,差异才变成 UPDATE
5 / 191
类比

持久化上下文就是你手上的购物车,EntityManager 是导购。把商品放进车里(persist)不等于你买了它——车还在你手上,随时能拿回货架(remove)。推到收银台结账(flush)那一刻才真正落单:导购逐件核对车里多了什么、换了什么,逐项扫码录入,UPDATE 就是这么冒出来的。所以 repository.save(entity) 的字面意思不是「写进数据库」,而是「摆进购物车」,只有事务提交那一下才真的扣款。而已经被你推出商场的购物车(游离态实体)——导购不再记账,回家把车里的泡面换成牛排,商场账目纹丝不动,这正是「改了字段数据库却没变」的真相。至于懒加载:它像只看封面就决定借哪本书,等你真要读正文,管理员再跑一趟库房取(补一条 SELECT);借一本还行,一次借一百本、每本都跑一趟,就是 N+1。抓住「谁在看管、什么时候对账」这两个问题,本篇后面每个坑都有坐标系。

6 / 191
架构图
图 · 实体的四种状态:四张脸一个上下文
图 · 实体的四种状态:四张脸一个上下文
7 / 191

这张环形图就是本篇的地图:瞬时 → 托管 → 游离 → 删除,四个状态由两问判定(有没有被上下文接纳、有没有标识符),三条转换线是 persist / find、merge、remove,探针是 entityManager.contains(u),别靠猜。第四节逐格展开。

8 / 191

学完这一篇,你要能回答三个问题:

9 / 191
  • 我只写了一行 u.setNickName("新昵称"),没调 save、没写 UPDATE,为什么数据库真的变了?
  • repository.save(entity) 到底什么时候发 SQL?为什么 id 不为空时反而多做了一次 SELECT?
  • 一次列表请求打出 101 条 SQL,那个 N+1 该用哪种手段治?为什么说 open-in-view 不算治?
10 / 191

先用沙盘摸一下本篇最容易翻车的那对组合——关联抓取策略 × 访问时机。切一下按钮,右侧的 Hibernate SQL 日志立刻变:

11 / 191
沙盘
沙盘订单列表:关联抓取策略 × 访问时机
运行结果
Hibernate: select o.id, o.order_no, o.user_id from t_order o where o.status = ?
Hibernate: select u.id, u.user_name from t_user u where u.id = ? -- 还有 99 条
-- 合计:1 + 100 = 101 条 SQL
不报错,结果也完全正确,只是慢。循环里每读一个未初始化的代理就补一条 SELECT,这就是 N+1 的现场,见第九节。
12 / 191
提示

六个格子连看一遍,你会发现既省钱又不留隐患的只有右下两格(声明式抓取)。前两格一个用 101 条 SQL 换正确性,一个直接抛异常;EAGER 那两格是把代价摊到全项目每一次查询上。工程纪律就两条:实体一律显式写 LAZY,要一起取的关联按方法声明。

13 / 191
小节
一、JPA / Hibernate / Spring Data JPA:三层关系先理清
14 / 191

初学 JPA,最大的困惑往往是名词打架:一会儿 JPA,一会儿 Hibernate,一会儿 Spring Data JPA。它们不是三个竞品,而是「规范 / 实现 / 抽象」三层:

15 / 191
架构图
图 1 · JPA 分层与职责
图 1 · JPA 分层与职责
16 / 191
对照表
层角色负责什么类比
JPA(Jakarta Persistence)规范 / 标准定义注解与接口(@Entity、EntityManager),不含实现JDBC 规范
Hibernate实现把 JPA 规范落地,真正生成 SQL、管理会话与缓存MySQL 驱动
Spring Data JPA抽象在 JPA 之上再包一层:声明接口方法即可查询更懂你的助手
17 / 191

一句话:JPA 是「合同」,Hibernate 是「干活的工人」,Spring Data JPA 是「替你写方法的秘书」。一条真实调用链值得先记住,后面每一节都在拆它其中某一段:Controller 里的一行 userService.activate(42L) → Spring Data 的动态代理(PartTree 解析或 SimpleJpaRepository)→ EntityManager.find(Hibernate 的 Session 实现它)→ 一级缓存查表(命中就是同一个实例,不发 SQL)→ 未命中才向 HikariCP 借连接发 select(#28)。

18 / 191
  • 你调用的是接口方法,不是你写的实现类——第五节讲代理怎么给出实现
  • 返回的对象是否受管取决于这次调用有没有跑在事务里——第四节讲状态
  • 发不发 SQL 由脏检查与 flush 时机决定,而不是由你写没写 save——第十节讲 flush
19 / 191
说明

换实现(例如 EclipseLink)不影响业务代码,因为上层只依赖 JPA 规范,这正是分层的价值;而派生查询、Pageable、Specification 这些便利特性属于 Spring Data JPA,与具体实现无关。本篇以 Spring Boot 默认的 Hibernate 6 为例,注意包名已从 javax.persistence 迁到 jakarta.persistence。

20 / 191
小节
二、起步:依赖、配置与 `ddl-auto`
21 / 191

三行依赖换来全套能力,无需再手写 DAO 实现:

22 / 191
xml
<dependency>    <groupId>org.springframework.boot</groupId>    <artifactId>spring-boot-starter-data-jpa</artifactId></dependency><dependency>    <groupId>com.mysql</groupId>    <artifactId>mysql-connector-j</artifactId>    <scope>runtime</scope></dependency>
23 / 191

这个 starter 实际带来四样东西:spring-data-jpa(Repository 抽象)、hibernate-core(JPA 实现)、spring-orm(粘合层与事务管理器)、HikariCP(默认连接池,见 #28)。自动配置会替你启用 @EnableJpaRepositories,所以项目里通常看不到它——但知道它在,排查「Repository 没被扫到」时才有方向。

24 / 191

配置的重头戏是 ddl-auto——它决定启动时框架怎么对待你的表结构,也是生产事故的高发区:

25 / 191
yaml
spring:  datasource:    url: jdbc:mysql://localhost:3306/demo?useSSL=false&serverTimezone=Asia/Shanghai    username: root    password: secret  jpa:    hibernate:      ddl-auto: validate        # 生产环境用 validate 或 none,绝不用 update    open-in-view: false         # 强烈建议显式关掉,理由见第十二节    show-sql: true              # 控制台打印生成的 SQL(生产请关)    properties:      hibernate:        format_sql: true        jdbc.batch_size: 50     # 批处理:配合第十节的 flush/clear 才真正省下往返        order_inserts: true
26 / 191
对照表
ddl-auto 取值含义用在哪
none什么都不做(默认)生产(配合 Flyway / Liquibase)
validate只校验表结构与实体是否一致,不一致就启动失败生产推荐
update自动给实体「加」新表和列,但从不删列、不改类型只限本地开发
create / create-drop每次启动重建表 / 关闭时删表测试 / 内存库(Boot 对内存库默认就是它)
27 / 191

把最常见的场景摆出来:实体新加了一个 remark 字段,表没动。update 会在启动日志里补一句 alter table t_user add column remark varchar(255)——只加不减,改类型、删列、重命名它一概不管,多人共用一个库时谁先启动谁改表;validate 则直接启动失败(Schema-validation: missing column [remark] in table [t_user]),把结构漂移拦在流量进来之前;none 什么都不做,错误延后到运行期第一条用到该列的 SQL,变成「启动正常、线上随机 500」;create-drop 每次启动重建,结构对了但数据没了。

28 / 191
坑

ddl-auto: update 在生产环境是破坏性的。它不会删列,却会擅自加列、加索引;实体重命名后旧列永久残留;一旦多人共用同一个库,谁先启动谁改表,表结构就此失控。生产一律 validate 或 none,改表交给 Flyway / Liquibase 这类迁移工具,并把迁移脚本纳入代码评审。看到 Schema-validation 报错时,正确修复是补一份 V8__add_user_remark.sql,而不是把 validate 改成 update 蒙混过关。

29 / 191

命名策略也常让人踩坑:CamelCaseToUnderscoresNamingStrategy(Boot 默认的物理命名策略)会把实体属性 userName 映射成列 user_name;改了策略、或碰上大小写敏感的数据库(PostgreSQL 未加引号的标识符会转小写),就会出现「表/列不存在」。纪律只有一条:列名一律 snake_case、属性名一律 camelCase,靠命名策略统一转换,别两边混着用;跨库或历史表命名不规整时,就在 @Column(name = "...") 里显式写死。

30 / 191

上面这份 yml 是结论,不是练习。真正的做法是自己勾一遍:只勾「数据源」能不能起来、勾上「JPA」多出的几行分别是谁、加上「日志」之后 Hibernate: 那几行从哪来、以及生产那份 profile 为什么要和开发分开:

31 / 191
生成器
生成器把 JPA 该配的一次配全application.yml3 / 5
先只勾 datasource 看最小可跑片段,再勾 jpa —— 注意生成出来的 ddl-auto 与 open-in-view 两行,正是本节与第十二节的主角;logging 让 Hibernate 的 SQL 与参数出现在控制台;actuator 才谈得上看连接池指标
产物
server:
  port: 8080

spring:
  application:
    name: demo-service
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/bee_order?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
    username: ${DB_USER:root}          # ${} 占位符:环境变量优先,冒号后是默认值
    password: ${DB_PASS:}
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 30000
      max-lifetime: 1740000            # 必须小于 MySQL 的 wait_timeout
      pool-name: beeHikari
  jpa:
    open-in-view: never
    hibernate:
      ddl-auto: validate                 # 生产用 validate,别用 update/create
    show-sql: false
    properties:
      hibernate.format_sql: true
      hibernate.jdbc.batch_size: 50

logging:
  level:
    root: INFO
    com.example.demoservice: DEBUG
    org.springframework.jdbc.core.JdbcTemplate: DEBUG   # 打 SQL 与参数
  file:
    name: logs/app.log
  logback:
    rollingpolicy: { max-file-size: 50MB, max-history: 14 }
勾了这些,代价与理由在这里
datasource池参数写在这里才生效;写在代码里 new HikariDataSource() 就白配了。
jpaopen-in-view: never 关掉「请求期间懒加载」这条隐式事务延长线,避免 Controller 里查库把连接占满。
logging级别可按包精细控制;logging.level.root=DEBUG 会把三方库全打爆,别在生产这么干。
32 / 191
要点

勾完之后做一次反向自检——把生成的 jpa 那一段里 ddl-auto 与 open-in-view 两行逐条说出「删掉它会发生什么」。说不出来,就说明这两节还没读透。

33 / 191
小节
三、实体映射:注解速查
34 / 191

实体是 JPA 的核心,字段上的注解决定表结构映射。下面这份是最贴近真实工程的一张实体写法(含主键策略、枚举、关联、审计与乐观锁字段):

35 / 191
java
package com.example.demo.entity;import jakarta.persistence.*;@Entity@Table(name = "t_user",       indexes = @Index(name = "idx_user_status_created", columnList = "status, created_at"))public class User {    @Id    @GeneratedValue(strategy = GenerationType.IDENTITY)   // 自增主键:注意是包装类型 Long    private Long id;    @Column(name = "user_name", nullable = false, length = 32, unique = true)    private String userName;    @Column(name = "nick_name", length = 32)    private String nickName;    @Enumerated(EnumType.STRING)              // 存枚举名字符串,别用 ORDINAL    @Column(nullable = false, length = 16)    private UserStatus status;    @ManyToOne(fetch = FetchType.LAZY)        // 多对一默认 EAGER,实践中显式改 LAZY    @JoinColumn(name = "dept_id")    private Dept dept;    @Version    private Integer version;                  // 乐观锁版本号,见第十三节    public User() {                           // Hibernate 反射实例化需要无参构造    }    @Override    public boolean equals(Object o) {        if (this == o) return true;        if (!(o instanceof User other)) return false;        return id != null && id.equals(other.id);   // 只有 id 参与判等    }    @Override    public int hashCode() {        return getClass().hashCode();               // 稳定,不随字段变化    }    // getter / setter 省略}
36 / 191

注解对照表(右边一列全是踩过才知道的):

37 / 191
对照表
注解作用最容易忘的一条
@Entity声明持久化实体需要无参构造器;字段全 final 会直接把 Hibernate 难住
@Table表名 / 索引 / 唯一约束不写就靠命名策略推导,跨库时最容易「表不存在」
@Id主键缺它启动就报 No identifier specified for entity
@GeneratedValue主键生成策略IDENTITY 会让批量插入退化(见下方要点)
@Column列名 / 长度 / 非空 / 唯一它的 unique=true 只在建表时生成约束;validate 环境下不替你校验任何东西
@Enumerated枚举映射默认是 ORDINAL,务必显式写 STRING
@Lob大文本 / 二进制MySQL 上最好再补 columnDefinition = "TEXT",否则 @Lob byte[] 可能变成 MEDIUMBLOB
@Transient不参与映射和 @JsonIgnore 是两件事:一个管数据库,一个管 JSON
@Version乐观锁类型用 Integer / Long,业务代码绝不手动改它
@EntityListeners挂审计监听器少了配置类上的 @EnableJpaAuditing,审计字段永远是 null(第十三节)
38 / 191
  • @GeneratedValue 策略:IDENTITY 用数据库自增列(MySQL 常用)、SEQUENCE 用序列(Oracle / PostgreSQL,allocationSize 要和序列步长一致)、AUTO 交给框架选、TABLE 用一张表模拟(几乎不用)
  • equals / hashCode 只按 id 判等:托管实体的字段会随 flush 变化,用业务字段做 hashCode 的实体放进 HashSet 后就再也找不出来;id == null(还没入库)时一律不相等,是最省事的正确写法
  • 双向关联(User.orders ↔ Order.user)必须有一侧标 @JsonBackReference,或者干脆用 DTO 出参,否则 Jackson 会 Infinite recursion、toString() 会 StackOverflowError
39 / 191

随堂第一题,这题的后果不会报错,所以人人都栽过:

40 / 191
随堂自测
随堂自测实体里只写了 `private UserStatus status;`(没加 @Enumerated,等于默认的 ORDINAL),库里已经有几万行数据。半年后产品要求把「待激活 PENDING」插到枚举第一位,重新发布后会发生什么?
先自己选一个,选中立刻告诉你对不对
41 / 191
要点

@GeneratedValue(strategy = IDENTITY) 有个副作用——插入时必须立刻拿回自增主键,导致 Hibernate 无法批量插入(每个 insert 都是一次独立往返)。批量导入场景要么改用 SEQUENCE,要么退回 JDBC 批量接口,这是第十节批处理的前提。

42 / 191
小节
四、实体四态与持久化上下文:本篇的心脏
43 / 191

回到开头那张环形图。JPA 里一个对象只有四种身份,而身份决定行为:

44 / 191
对照表
状态怎么进入em.contains(u)改字段会自动发 SQL 吗典型出现位置
瞬时态 transientnew User()false不会,跟数据库毫无关系DTO 转换后、Service 刚组装的对象
托管态 managedpersist / find / 事务内的 findByIdtrue会,flush 时由脏检查发 UPDATE@Transactional 方法体内
游离态 detached事务结束 / em.detach() / 从别层传进来false不会,除非 merge 回来Controller 返回值、异步线程、缓存里的对象
删除态 removedem.remove(u)(u 仍受管时)关闭前仍可能 trueDELETE 在 flush 时发删除流程
45 / 191

判定只有一个探针:entityManager.contains(u)。切换状态的是上下文,不是对象——对象一直是你手里那个对象,只是「有没有人记账」变了。

46 / 191
代码对照
代码java
package com.example.demo.repository;import com.example.demo.entity.User;import com.example.demo.entity.UserStatus;import jakarta.persistence.EntityManager;import org.springframework.stereotype.Repository;import org.springframework.transaction.annotation.Transactional;@Repositorypublic class UserStateProbe {    private final EntityManager em;    public UserStateProbe(EntityManager em) { this.em = em; }    @Transactional                       // 事务边界 = 持久化上下文边界    public void probe() {        User fresh = new User();        fresh.setUserName("alice");        System.out.println(em.contains(fresh));        // ① false:瞬时态        em.persist(fresh);        System.out.println(em.contains(fresh));        // ② true:托管态        // 注意:此刻通常还没有 INSERT(IDENTITY 策略除外,它必须先拿主键)        User again = em.find(User.class, fresh.getId());        System.out.println(again == fresh);            // ③ true:一级缓存,同一个实例,不发第二条 SQL        fresh.setStatus(UserStatus.ACTIVE);            // ④ 只是改了内存里的字段        em.flush();                                    // ⑤ 脏检查比对快照,UPDATE 在这一刻发出        // ⑥ 方法返回,上下文关闭:手里的 User 立刻变游离态,之后再改字段不会有任何 SQL    }}
解读
  • 同一事务内同一个 id 只有一个实例:这是 JPA 的 identity 保证,也是「改一处、处处可见」的原因
  • 一级缓存不是性能优化手段:它的作用域就是一个事务,跨请求什么都省不了。想跨请求复用,那是 @Cacheable 与二级缓存的事(第十二节)
  • getReferenceById(id)(旧版叫 getOne)不查库,直接给你一个只有 id 的代理;一旦事务已关而你访问了别的字段,就是 LazyInitializationException。需要真值就用 findById
  • 长事务里批量塞实体,上下文越积越大,最后累的是内存不是数据库——要定期 flush() + clear()
47 / 191

表格里最容易在线上出事的是第三行——游离态。它的完整形状是「接口返回 200、日志里没有 UPDATE、库里原封不动」,全程没有任何异常。把这六帧连看一遍,你以后见到「更新偶发不生效」就知道先问哪一句(正解见第十节的 merge,反例见第十四节最后一行):

48 / 191
原理动画
动图 · 改了字段,库里为什么没变
动图 · 改了字段,库里为什么没变
49 / 191

亲手把四态跑一遍,比读十遍表格有用:

50 / 191
内核实验
TeaVM持久化上下文与实体四态:真内核演示未启动
先跑「实体四种状态」盯住 contains() 的翻转,再切「一级缓存命中」看第二次 findById 为什么没有 SQL
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
51 / 191
对照表
参数你会看到对应正文
实体四种状态contains() 如何在 persist / 事务结束 / merge 之间翻转,以及「行李托运」这个类比的对应关系本节的表
一级缓存命中同一事务两次 find 只发一条 SQL、返回同一实例;换事务就重新打库本节第三条
52 / 191
提示

实验里最反直觉的一行是 again == fresh 为 true。它意味着你拿到的不是「一行数据的副本」,而是「这一行在内存里唯一的那个替身」。理解了这一点,「为什么我改了对象没调 save 却生效」和「为什么两个 Service 改同一个实体互相看得见」就都不需要背了。

53 / 191

开头那张环形 PNG 给的是全景,但四态真正要记的是「哪一下动作把它推到哪一格」。把下面这五格点着走一遍,每格都写清了此刻 contains() 返回什么、改字段有没有人管:

54 / 191
交互图解
回路实体四态怎么被推来推去(点着看)1 / 5
从 ① 点到 ⑤,注意第 ④ 格——它就是「改了字段却没生效」那一整类故障的现场
→
→
→
→
↻
持久化上下文
① 瞬时态:new 出来的对象
`new User()` 之后它跟数据库毫无关系,`contains()` 是 false,改多少字段都不可能有 SQL。DTO 转换出来、Service 刚组装好的对象都在这一格。
全部看懂了四态之间只有两条线真的改变命运:进上下文(persist / find / merge)和出上下文(事务结束)。判据永远只有一个:contains()。
55 / 191
小节
五、Repository 家族:接口为什么不用实现类
56 / 191
类比

Spring Data JPA 的 Repository 就是快餐店的点单台。你在窗口喊一句「一份红烧肉」(声明 findByStatus),厨房照做,但你从没进过后厨、也不知道今天哪位师傅炒——你只负责报菜名,实现是后厨的事。菜单上印好的家常项(save / findById / deleteById)由中央厨房统一供货(SimpleJpaRepository),你要的新菜式则由「菜名解析器」(PartTree)现场翻译成做法。菜单一旦写歧义——比如报了个不存在的菜名 findByUname——不是做菜做到一半才发现,而是开店当天就开不了(启动失败),这是 JPA 相比手写 SQL 最友好的一点:错误前移。

57 / 191

Spring Data 提供了一串 Repository 接口,越往下能力越多:

58 / 191
对照表
接口继承提供的能力
Repository<T, ID>无只是个标记接口,啥方法都没有
CrudRepository<T, ID>Repositorysave / findById / delete / count 等
PagingAndSortingRepository<T, ID>CrudRepository追加 findAll(Pageable) / findAll(Sort)
JpaRepository<T, ID>上者 + QueryByExampleExecutor追加 flush / saveAndFlush / 批量删除、getReferenceById
JpaSpecificationExecutor<T>独立接口(不继承上面那条链)追加 findAll(Specification, Pageable) 等动态条件查询
59 / 191

为什么写个接口就够了,不用实现类? 因为启动时 Spring Data 会为每个 Repository 接口生成一个动态代理(JdkDynamicAopProxy + RepositoryFactory),实际干活的是内置的 SimpleJpaRepository,你自己新增的方法则由「方法名解析器」或 @Query 翻译成查询。

60 / 191
代码对照
代码java
package com.example.demo.repository;import com.example.demo.entity.User;import com.example.demo.entity.UserStatus;import org.springframework.data.jpa.repository.JpaRepository;import org.springframework.data.jpa.repository.JpaSpecificationExecutor;public interface UserRepository        extends JpaRepository<User, Long>,            // 全套 CRUD + 分页 + flush                JpaSpecificationExecutor<User> {      // 动态条件(第八节要用)    // 什么都不写,就已经拥有 save / findById / findAll / delete 等全套能力    List<User> findByStatus(UserStatus status);}
解读
  • 继承 JpaRepository 即可获得 JPA 特有方法(如 flush、saveAndFlush);需要动态条件时额外继承 JpaSpecificationExecutor,它不在继承链上,容易被漏掉
  • 需要自定义查询时,只需在接口里继续声明方法,实现由代理在运行时织入
  • SimpleJpaRepository.save 就是一个 persist 或 merge 的分支——这正是第十节动画要拆解的内容
  • 扫描规则:默认只扫 @SpringBootApplication 所在包及其子包;接口所在包在外部时,要显式 @EnableJpaRepositories(basePackages = "com.other.repo")

说明:真要写自己的实现(比如拼一段 Redis 计数),约定是 UserRepository + UserRepositoryImpl(同名 + Impl 后缀,放同一个包),代理会自动把找不到派生规则的方法转到这个片段类去(Fragment 机制)。类名和包名都必须严格对上,否则报 No property found 或 Could not create query,而不是「找不到实现」。

61 / 191
小节
六、方法名派生查询:声明即查询
62 / 191

这是 Spring Data JPA 最迷人的地方:方法名本身就是查询语句,框架按约定解析。

63 / 191
java
public interface UserRepository extends JpaRepository<User, Long> {    // 等价于 WHERE user_name = ? AND status = ?    User findByUserNameAndStatus(String userName, UserStatus status);    // 按创建时间倒序,取前 3 条    List<User> findTop3ByOrderByCreatedAtDesc();    // 模糊匹配用户名    List<User> findByUserNameContaining(String keyword);    // 状态在给定集合内,且用户名非空    List<User> findByStatusInAndUserNameIsNotNull(Collection<UserStatus> statuses);    // 关联属性也能走:where d.name = ?(路径导航)    List<User> findByDept_Name(String deptName);    // 只要存在性判断,别真的把行捞回来    boolean existsByUserName(String userName);    // 派生删除:仍然要在事务里调用    long deleteByStatus(UserStatus status);}
64 / 191

派生关键字对照表(记住这 12 个,日常够用):

65 / 191
对照表
关键字生成条件示例
And / Or与 / 或findByAAndB
After / Before时间比较(>= / <=)findByCreatedAtAfter
Is / Equals等于findByUserName
Between区间findByAgeBetween
LessThan / GreaterThan / NotNull大小与判空findByAgeLessThanEqual
Like / Containing / StartingWith模糊findByUserNameContaining
In / NotIn集合内 / 不在findByStatusIn
IsNull / IsNotNull判空findByBioIsNull
True / False布尔findByDeletedTrue
OrderBy排序(可 Asc / Desc)findByStatusOrderByIdDesc
Top / First限制条数findTop3By...
Exists / Count存在性 / 计数existsByUserName、countByStatus
66 / 191

派生方法的读法是「顺序解析」:框架先剥掉前缀(find / read / query / get / count / exists / delete),再剥掉 By,剩下的按属性名从左到右解析,And / Or 作为分隔符——负责这件事的类是 PartTree。属性名必须与实体属性(不是列名)完全一致,findByUname 找不到 userName 就在启动阶段失败:Failed to create query for method ... No property uname found for type User!。这是派生查询最友好的一点——错误前移,你不可能把它带上生产。

67 / 191

顺带两条易错点:findByDeletedFalse 里的 Deleted 也必须是真实属性;findByStatusAndUserNameContaining 传 null 关键字不会「忽略该条件」,而是生成 like null,查不到任何东西——可选条件属于第八节的 Specification。

68 / 191
提示

派生方法超过 3 个条件就已经很难读了,Or 一多还会出现优先级歧义(AAndBOrC 到底是 (A∧B)∨C 还是 A∧(B∨C),你得去查生成的 SQL 才敢确认)。一旦方法名开始「绕口令」,果断改用 @Query 或 Specification——可读性优先于省事。

69 / 191
小节
七、@Query、JPQL、原生 SQL 与投影
70 / 191

方法名表达不了的查询,用 @Query 手写。JPQL 是「面向实体」的查询语言,写的是实体名和属性名,不是表名和列名:

71 / 191
代码对照
代码java
public interface UserRepository extends JpaRepository<User, Long> {    // JPQL:User 是实体名,u.userName 是属性名;具名参数用 :status    @Query("SELECT u FROM User u WHERE u.status = :status AND u.createdAt > :from")    List<User> findActiveSince(@Param("status") UserStatus status,                               @Param("from") LocalDateTime from);    // 位置参数(老写法,不推荐,参数一多就乱)    @Query("SELECT u FROM User u WHERE u.userName = ?1")    Optional<User> findOneByName(String userName);    // 原生 SQL:nativeQuery = true,这里才写真实表名列名    @Query(value = "SELECT * FROM t_user WHERE score > :min ORDER BY score DESC LIMIT 20",           nativeQuery = true)    List<User> findHighScore(@Param("min") int min);    // 修改操作:@Modifying + @Transactional 缺一不可    @Modifying(clearAutomatically = true, flushAutomatically = true)    @Transactional    @Query("UPDATE User u SET u.status = :status WHERE u.id IN :ids")    int updateStatusBatch(@Param("ids") List<Long> ids, @Param("status") UserStatus status);}
解读
  • 具名参数 :name 优于位置参数 ?1:改动参数顺序时不会错位
  • nativeQuery = true 时写的是数据库真实表结构,可移植性下降,但能用上数据库特有函数(JSON_EXTRACT、窗口函数);原生 SQL 的分页仍然可以传 Pageable,但 COUNT 语句要靠 countQuery 显式给出
  • @Modifying 告诉框架这是更新语句;它必须在事务里执行——少了 @Transactional 就是那句 TransactionRequiredException: Executing an update/delete query
  • @Modifying(clearAutomatically = true) 在更新后清上下文,flushAutomatically = true 在更新前先 flush,避免「上下文里还有没落盘的改动,UPDATE 已经把库改了」这类错乱
72 / 191

投影是被严重低估的一招:列表页只需要三列,却把整个实体(连带它的懒加载代理、快照、版本字段)全部装配出来,既慢又容易在出参时炸懒加载。

73 / 191
代码对照
代码java
// 写法一:接口投影(闭口式,只要这三列)public interface UserSummary {    Long getId();    String getUserName();    String getDeptName();          // 对应 JPQL 里的别名 d.name AS deptName}@Query("SELECT u.id AS id, u.userName AS userName, d.name AS deptName " +       "FROM User u JOIN u.dept d WHERE u.status = :status")List<UserSummary> findSummaries(@Param("status") UserStatus status);// 写法二:DTO 构造器表达式(真正 new 一个对象,不装实体)@Query("SELECT new com.example.demo.dto.UserRow(u.id, u.userName, d.name) " +       "FROM User u JOIN u.dept d WHERE u.status = :status")List<UserRow> findRows(@Param("status") UserStatus status);
解读
  • 接口投影是动态代理,属性名当别名;DTO 构造器表达式要求 SELECT new 全限定类名(...),参数类型与构造器完全匹配,包名写错时报 unable to locate Constructor
  • 投影的最大价值不是少取列,而是根本不产生实体:没有快照就没有脏检查,没有代理就没有 LazyInitializationException,序列化也不用再挂 @JsonIgnore
74 / 191

JPQL 里还能用 SpEL 做动态拼接。下面这个例子按传入的排序字段排序,同时用白名单规避注入:

75 / 191
代码对照
代码java
@Query("SELECT u FROM User u WHERE u.status = :status ORDER BY u.#{#sortField} DESC")List<User> findByStatusSorted(@Param("status") UserStatus status,                              @Param("sortField") String sortField);// 调用前:属性名白名单,注意这里必须是「实体属性名」而不是列名private static final Set<String> ALLOWED_SORT = Set.of("id", "userName", "createdAt");
解读

警告:SpEL 拼接排序字段和 MyBatis 的 ${} 是同一类风险。sortField 必须服务端白名单校验,绝不能把请求参数直接塞进 JPQL;另外 Pageable 里的 Sort 字段同样要校验,否则报的是 Query was given a Sort on property named xxx which does not exist——它把非法输入变成 500,也是一种可被探测的信息泄露。

76 / 191
小节
八、分页、排序与动态条件
77 / 191

JPA 的分页是「参数注入式」的,把 Pageable 作为方法参数即可:

78 / 191
java
public interface UserRepository extends JpaRepository<User, Long> {    Page<User> findByStatus(UserStatus status, Pageable pageable);}
79 / 191
java
// 第 0 页(Spring Data 页码从 0 开始!),每页 10 条,按 createdAt 倒序Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt"));Page<User> page = userRepository.findByStatus(UserStatus.ACTIVE, pageable);System.out.println(page.getTotalElements());  // 总记录数System.out.println(page.getTotalPages());     // 总页数System.out.println(page.getContent());        // 当前页数据 List<User>
80 / 191

直接返回 Page<User> 时,JSON 形状是这样的(前端可以照着渲染分页控件):content 是当前页的数组,另有 totalElements / totalPages / number(当前页码,从 0 起)/ size / first / last。生产建议包一层自己的 DTO——直接返回实体会把 version、懒加载代理和 pageable 元数据一起暴露出去,还常常顺手触发 LazyInitializationException。

81 / 191

Page 与 Slice 的区别值得单独拎出来:

82 / 191
对照表
类型是否查总数适用场景
Page<T>会额外执行 COUNT(*)需要「共 N 页 / 共 M 条」的后台表格
Slice<T>不查总数,只判断有没有下一页无限滚动 / 「加载更多」
83 / 191
要点

页码从 0 开始是 Spring Data 的老规矩,和前端习惯的「第 1 页」差一,接口层记得换算(或统一在 Controller 用 page - 1),否则永远差一页。信息流这类不需要总数的场景用 Slice,能省掉一次在大表上昂贵的 COUNT;带 @Query 的分页如果 COUNT 语句复杂,还要自己写 countQuery。

84 / 191

条件一旦多起来(每个都可能为空),派生查询就撑不住了。JPA 的正规解法是 Specification(Criteria API 的封装,QueryDSL 是它的另一种写法):

85 / 191
java
package com.example.demo.repository.spec;import com.example.demo.entity.User;import jakarta.persistence.criteria.Predicate;import org.springframework.data.jpa.domain.Specification;import java.util.ArrayList;import java.util.List;public final class UserSpecs {    private UserSpecs() {}    /** 任何字段都允许为 null:出现才拼,不出现就不进 SQL */    public static Specification<User> query(String keyword, UserStatus status, LocalDateTime since) {        return (root, cq, cb) -> {            List<Predicate> ps = new ArrayList<>();            if (keyword != null && !keyword.isBlank()) {                ps.add(cb.like(root.get("userName"), "%" + keyword + "%"));            }            if (status != null) {                ps.add(cb.equal(root.get("status"), status));            }            if (since != null) {                ps.add(cb.greaterThanOrEqualTo(root.get("createdAt"), since));            }            return cb.and(ps.toArray(new Predicate[0]));        };    }}
86 / 191
代码对照
代码java
// Repository 要多继承 JpaSpecificationExecutor<User>Page<User> page = userRepository.findAll(        UserSpecs.query(keyword, status, since),        PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt")));
解读
  • Specification 可以任意组合(spec1.and(spec2)),把「可见范围」「状态筛选」拆成可复用的小块是常见工程做法;它还是类型安全的,属性名写错编译期就能发现
  • 跨关联的条件用 root.join("dept"),注意它默认是 inner join,会把没有部门的用户过滤掉;要保留就写 root.join("dept", JoinType.LEFT)——这类细节正是「动态条件」比派生查询更难藏 bug 的地方

决策:后台「用户列表」要支持 6 个可选筛选项 + 关键字 + 时间区间 + 排序 + 分页,任意组合都可能为空。用 Spring Data JPA 实现,最合理的是哪一种?

- 写一个 findByKeywordAndStatusAndSince... 派生方法,用「传 null 表示不过滤」来兜住空值

- 手写一条 @Query,SQL 里用 (:kw is null or u.userName like :kw) 这类 trick 让条件自动失效

- 用 Specification(或 QueryDSL)按参数是否出现逐个 and 追加,交给 findAll(spec, pageable),排序字段过白名单

- 前端传什么 SQL 就拼什么,用 String 拼 JPQL 最灵活

结论:C。派生查询无法表达「可选」——传 null 会真的生成 = null 条件,结果直接清零,这是新手最容易踩的空值坑;B 的 is null or 写法在 MySQL 上会让索引选择变糟,且列一多就写崩,可读性也差;D 是注入事故。Specification 就是为动态条件设计的:每段按需追加、可单测、可复用,分页与计数仍由框架生成。真到了复杂聚合统计,那才退到原生 SQL(见第十七节的决策卡)。

87 / 191
小节
九、关联映射:懒加载、N+1 与三种解法
88 / 191

关联映射的第一件事是记住默认值不对称:

89 / 191
对照表
关联默认抓取为什么这个默认值讨厌
@ManyToOne、@OneToOneEAGER「查一个订单顺带查一个用户」听起来无害,但它对每一个查询生效,关联层数一多就是隐藏的全表 JOIN
@OneToMany、@ManyToManyLAZY默认懒是保护你,但一旦在循环里读就是 N+1,出了事务读就是异常
90 / 191

所以规范写法是:@ManyToOne 显式改成 LAZY,@OneToMany 保持 LAZY 并显式写出来,让读取范围由查询决定,而不是由实体决定。

91 / 191
java
@Entity@Table(name = "t_order")public class Order {    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)    private Long id;    @Column(name = "order_no", nullable = false, length = 32, unique = true)    private String orderNo;    @Enumerated(EnumType.STRING)    private OrderStatus status;    // 多个订单属于一个用户:默认 EAGER,实践中显式改 LAZY    @ManyToOne(fetch = FetchType.LAZY)    @JoinColumn(name = "user_id")    private User user;    // 一个订单有多条明细:默认就是 LAZY;mappedBy 指向对方那个字段    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)    private List<OrderItem> items = new ArrayList<>();    /** 双向关联必须自己维护两边,否则 insert 出来的 user_id 是 null */    public void addItem(OrderItem item) {        items.add(item);        item.setOrder(this);        // ← 漏掉这一行,就是最常见的「存了却没关联上」    }}
92 / 191

经典的 N+1 问题来了。你想查 100 个订单及其下单用户:

93 / 191
代码对照
代码java
List<Order> orders = orderRepository.findByStatus(OrderStatus.PAID); // 1 条 SQLfor (Order o : orders) {    System.out.println(o.getUser().getUserName());  // 每条订单都补 1 条 SQL 查用户}// 总计:1 + 100 = 101 条 SQL。数据库本身很快,慢的是 101 次网络往返
解读

类比:N+1 就像你想知道全班 100 个学生各自的班主任是谁。离谱但真实的做法是走到每个 student 面前问一句「你班主任是谁」(100 次跑腿,每次只带回一个名字);正确的做法是去教务处要一张名单(一次查询,把需要的列一起拿回来)。JOIN FETCH / @EntityGraph 就是那张名单,DTO 投影则是「只要这三列的名单」。而懒加载本身没有错——它像先看封面再决定借不借书,只看封面时它替你省了拆封的力气;错的是你在循环里对 100 本都追加了一次「拆封」。

94 / 191
原理动画
动图 · N+1 是怎么长出来的,又是怎么压回 1 条的
动图 · N+1 是怎么长出来的,又是怎么压回 1 条的
95 / 191

三种解法各有分工,按「改动范围」从小到大排列:

96 / 191
java
public interface OrderRepository extends JpaRepository<Order, Long> {    // 解法一:@EntityGraph —— 只把这一个方法的懒加载提升为立即抓取,实体不动    @EntityGraph(attributePaths = {"user"})    List<Order> findByStatus(OrderStatus status);    // 解法二:JOIN FETCH —— 在 JPQL 里写死这次要一起取    @Query("SELECT o FROM Order o JOIN FETCH o.user WHERE o.status = :status")    List<Order> findPaidWithUser(@Param("status") OrderStatus status);    // 解法三:投影 —— 根本不装实体,只要需要的列(最省,也最不容易出懒加载事故)    @Query("SELECT o.id AS orderId, o.orderNo AS orderNo, u.userName AS userName " +           "FROM Order o JOIN o.user u WHERE o.status = :status")    List<OrderRow> findRows(@Param("status") OrderStatus status);    // 明细列表要单独取:一对多别和多对一放在同一个 graph 里    @EntityGraph(attributePaths = {"user"})    @Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.status = :status")    List<Order> findWithItems(@Param("status") OrderStatus status);}
97 / 191
对照表
解法SQL 条数适用代价
@EntityGraph1Spring Data 仓库方法上,声明式只能声明「一起取」,不能只取部分列;一对多路径要小心行放大
JOIN FETCH1需要写复杂 JPQL 时;配合分页要留意语义与 Pageable 同用会有 query returns entities that have not been fully fetched 之类的坑
DTO 投影1列表页 / 报表 / 出参不能直接改回实体,写操作仍要走实体或 @Modifying
open-in-view=true仍是 101不算解法把补查挪到视图渲染阶段,串行执行且占着连接(第十二节)
98 / 191
  • N+1 的根因是「懒加载 + 循环访问」:每访问一个未初始化的关联就走一次数据库;定位方法只有一个:数 SQL。开 show-sql 或 logging.level.org.hibernate.SQL=debug,再打开 hibernate.generate_statistics: true 看 transactions / query statistics
  • 别对一对多滥用 FETCH JOIN:items 与 user 同时 fetch 会把行数做笛卡尔积放大(100 单 × 5 明细 = 500 行对象),比 N+1 更慢。常见折中是一次 JOIN FETCH o.user + BATCH 抓取 items(@BatchSize(size = 50))
  • @OneToMany 的级联与 orphanRemoval 只解决「谁跟着谁存/删」,不解决抓取;两件事分开想
99 / 191

这个实验就是上面那段代码的运行时版本,注意 SQL 条数从 101 变回 1 的那一下:

100 / 191
内核实验
TeaVM懒加载与 N+1 的完整现场未启动
跑「懒加载与 N+1」:看清代理什么时候补射 SELECT、@EntityGraph 与投影各自把它压成几条;末尾还会演示事务外访问的 no Session
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
101 / 191

上面那句「定位方法只有一个:数 SQL」值得单独做一个实验。切到「怎么发现」,看 show-sql 与 generate_statistics 各自把什么摆到你面前;再切「join fetch」与「批处理」,看 101 条分别被压回几条——顺带演示那个最容易踩的坑:两个一对多同时 fetch 会撞上 MultipleBagFetchException:

102 / 191
内核实验
TeaVM1 条怎么变成 101 条,又怎么被压回去未启动
按 naive → detect → join → batch 的顺序走:naive 数清那 101 条是谁发的;detect 对比 show-sql 与 generate_statistics 两种看法;join 看 JOIN FETCH 把它压成 1 条;batch 看 @BatchSize 与实体图的取舍,以及两个 List 一起 fetch 的报错
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
103 / 191

想彻底看清「101」是怎么一条条长出来的,就把它摊成单步执行。左边六行就是你项目里那段再普通不过的代码,右边同步刷新变量——盯着 sqlCount 从 1 涨到 101 的那几拍,以及第 4 步里那个「看起来还是 null 但其实是个代理」的对象:

104 / 191
单步调试台
单步台单步跟一遍:一条 SQL 是怎么长成 101 条的1 / 6
按「下一步」走六拍。第 2 步手里还是干净的 1 条,第 3 步开始每循环一轮就多一条
被调试的代码
1List<Order> list = repo.findByStatus(PAID); // 1 条主查询,返回 100 个订单
2for (Order o : list) { // 每个 o 都是托管态实体
3 String name = o.getUser().getUserName(); // 这里读的是代理
4 log.info("{} 下单给 {}", o.getOrderNo(), name); // 日志里躺着 100 条 SELECT
5} // 循环结束:SQL 计数 = 101
6// 改成 JOIN FETCH o.user 之后再走一遍 // 计数回到 1
此刻的变量
sqlCount1
list.size()100
每个元素的 user只带回 user_id
调用栈
1OrderRepository.findByStatus
2Hibernate query
1主查询只发一条,这没问题。关键在结果装配:因为 user 是 LAZY,Hibernate 不会去 JOIN 用户表,而是给每个订单塞一个**只装着外键的代理**。此刻数据库侧一条多余语句都没有。
105 / 191

随堂第二题,这题在生产日志里每天都在发生:

106 / 191
随堂自测
随堂自测列表接口一次查询打出 101 条 SQL(1 条主查询 + 100 条按 id 查用户)。同事给出四个方案,哪个是对的?
先自己选一个,选中立刻告诉你对不对
107 / 191
小节
十、事务边界与 flush:`save` 到底何时落库
108 / 191

现在把第四节的状态机和「结账」这件事接上。save 的真相是:它对新增对象做 persist,对已有 id 的对象做 merge,两者都只是把对象交给上下文,都不等于发 SQL。

109 / 191
原理动画
动图 · 一次 save() 到底发生什么
动图 · 一次 save() 到底发生什么
110 / 191

SimpleJpaRepository 里那几行著名代码,语义上就是这一个分支:

111 / 191
代码对照
代码java
// org.springframework.data.jpa.repository.support.SimpleJpaRepository(简化)@Transactionalpublic <S extends T> S save(S entity) {    if (entityInformation.isNew(entity)) {     // 主键为 null / 0 → 认为是新增        em.persist(entity);        return entity;    }    return em.merge(entity);                   // 有主键 → 先 SELECT 再比对再 UPDATE}
解读
  • isNew 默认看主键是否为空。如果你的实体主键是基本类型 long(永远不为 null),它会永远走 persist,第二次保存同一行就报 Identifier of entity ... already in persistence context 或直接撞主键;这就是「实体主键必须用包装类型 Long」的由来
  • 要按业务字段判断新旧,就实现 Persistable<T> 并覆写 isNew(),别把 id 塞成假值
  • merge 的第一步是先按 id 查一遍(要确认现在库里是什么、并处理级联),所以「明明只想 UPDATE,日志里却多了一条 SELECT」不是 bug,是游离实体走 merge 的必然代价
112 / 191

时机对照表——这张表背下来,写操作的行为就不用猜了:

113 / 191
对照表
你的动作此刻发生了什么有 SQL 吗
new User()瞬时态,与数据库无关无
repository.save(u)(u 无 id)persist:进入上下文,登记为「待插入」IDENTITY 策略会立刻 INSERT,其余没有
repository.save(u)(u 有 id,游离)merge:先 SELECT,把字段拷进新的受管实例一条 SELECT
u.setStatus(ACTIVE)(u 受管)只改内存字段 + 与快照的差异无
下一次查询之前(FlushModeType.AUTO)自动 flush,保证「读得到自己的写」差异变成 INSERT/UPDATE/DELETE
em.flush() / saveAndFlush()立刻把待发语句打到数据库(仍在事务里)有,可回滚
@Transactional 方法正常返回拦截器先 flush 再 commit落地
方法抛异常触发回滚前面 flush 出去的语句一起撤销什么都没留下
114 / 191

上面这张表值得压成一张对照图:左栏那些动作只在你的内存里发生,右栏才是数据库真的收到了语句。新手九成数的困惑(「为什么没生效」「为什么多了一条 SELECT」)都能在这张图上找到落点:

115 / 191
架构图
图 · 哪一刻真的发出 SQL
图 · 哪一刻真的发出 SQL
116 / 191

亲手看一遍这两个参数,「save 不等于落库」和「游离实体的更新为何失效」就会变成你亲眼见过的两件事:

117 / 191
内核实验
TeaVM脏检查、flush 时机与游离实体未启动
先跑「脏检查与 flush」:只调 setter 却真的更新了库;再切「游离态的更新为何失效」,数一数那次 merge 发了几条 SQL
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
118 / 191

这道题是 JPA 面试与线上事故的同一道题:

119 / 191
随堂自测
随堂自测一个标了 `@Transactional` 的方法里只有两行:`User u = userRepository.findById(1L).orElseThrow(); u.setNickName("新昵称");`——没有调 save、没有任何 UPDATE。方法正常返回后,`nick_name` 变了吗?
先自己选一个,选中立刻告诉你对不对
120 / 191

批处理是 flush 的另一面:Hibernate 的批量插入不是自动的。标准写法是每 50 条一次 em.flush(); em.clear();——flush 让这一批真的发出去(配合 hibernate.jdbc.batch_size: 50),clear 把上下文腾空,否则一百万个实体全压在堆里,最后累的是内存而不是数据库。

121 / 191
坑

不在循环里 flush 的代价是异常会跑到离 bug 很远的地方。往两张唯一键相同的行里连续 insert,日志里一句错误都没有,方法返回时才炸 DataIntegrityViolationException: could not execute statement; constraint [uk_user_uname]——堆栈指向提交阶段,你已经看不出是哪一条数据了。写批量导入时请养成两个习惯:分批 flush/clear,以及在测试里用 saveAndFlush 让异常回到离它最近的那一行。

122 / 191

上面那句「配合 hibernate.jdbc.batch_size: 50」是本篇唯一一个可以直接拖的数字。它反直觉的地方在于:默认值是 0,也就是不批处理,你以为框架替你攒着发,其实是一行一条地往返。拖一遍看它什么时候真的省钱:

123 / 191
参数调节台
调节台批量写入:一次攒多少条
spring.jpa.properties.hibernate.jdbc.batch_size
50条当前 0 – 500
常见正解:与 flush/clear 对齐
  • 50 条是 Hibernate 官方文档与第十节写法对齐的那个数字
  • 每攒够一批发一次,同时 flush + clear 把上下文腾空
  • 要点:主键策略不能是 IDENTITY——它会强制每条立刻 insert,批处理直接失效
  • 批量导入、初始化数据、消息落库都落在这一档
网络往返12%
内存占用40%
这一格真正的开关是「你有没有在循环里 flush + clear」;batch_size 只是把省下来的往返数放大,不解决上下文膨胀。
124 / 191
坑

IDENTITY 主键策略与批处理天生冲突——为了拿到自增 id,Hibernate 必须每条 insert 后立即执行,于是 batch_size 形同虚设。批量写入量大时把主键策略换成 SEQUENCE(或 allocationSize 匹配的生成器),这一课在第三节和第十五节的实验里都能亲眼验证。

125 / 191
小节
十一、把写操作放回事务里:两个补充实验
126 / 191

JPA 只负责「什么时候发 SQL」,而「这些改动算不算数」是事务决定的;至于「这些数据能不能跨请求复用」,则由缓存层回答。

127 / 191
小节
11.1 提交与回滚由谁裁决(`txprop`)
128 / 191

save 成功、SQL 也发出去了,只要外层事务最终回滚,数据库里依然什么都不会留下。反过来,把日志写进「新事务」里,就会出现「业务失败了但日志留下来了」这种刻意设计。

129 / 191
内核实验
TeaVM七种传播行为与回滚规则未启动
先跑 REQUIRED 看清「内外共用一条连接」,再切 REQUIRES_NEW / NESTED 比较挂起与保存点,最后用「回滚规则」确认抛受检异常时默认不回滚
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
130 / 191
对照表
参数你要盯住与 JPA 的关系
REQUIRED内外层加入同一个事务,内层被标记 rollbackOnly 时外层提交会收到 UnexpectedRollbackExceptionService 里两个都带 @Transactional 的方法互调,就是这个画面
REQUIRES_NEW内层挂起外层、另开一个连接;外层回滚不影响它审计日志、操作流水常用它,代价是两个连接同时被占用
NESTED用保存点,内层回滚只回到保存点批量导入「单条失败不影响整批」的实现方式
回滚规则默认只回滚 RuntimeException / Error,受检异常不回滚你的 save 已经 flush 出去了,方法却抛 IOException 正常返回——数据留下了,这可能不是你要的
131 / 191
说明

@Transactional 是通过代理生效的,同类内部自调用不会经过代理,于是那层事务注解形同虚设(#14 有完整机制)。JPA 场景下这条特别阴:内层「没有事务」时拿到的实体不受管,改字段静默失效——就是第十节 detach 参数演示的那幅画面。

132 / 191
小节
11.2 一级缓存之外还有两层缓存(`cache`)
133 / 191

第四节的 identity 保证只活在一个事务里。想跨请求复用同一份数据,那是 @Cacheable 与 Hibernate 二级缓存的领域,两者常被混为一谈——第十二节给对照表。

134 / 191
内核实验
TeaVM@Cacheable 的命中路径与三个坑未启动
按 miss → hit → key 的顺序跑一遍,再切 penetration / bust 看缓存被穿透与被击穿的差别
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
135 / 191
说明

JPA 自己的一级缓存和这里的 @Cacheable 完全不是一回事——前者跟事务同生共死、只是 identity 保证;后者跨请求,是真正的负载削减手段。把两者混起来的典型后果是「加了缓存还是慢」和「缓存里放了游离实体」。连接池视角(pool 实验)在第十二节末尾,因为它的病因就是那里的 open-in-view。

136 / 191
小节
十二、open-in-view 与缓存的层次分工
137 / 191

spring.jpa.open-in-view 是 Boot 里少数默认值会咬人的配置之一,而且它打开时会打一行 WARN 提醒你自己去关。它做的事很单纯:把持久化上下文的存活期从「事务」延长到「整个 HTTP 请求」——请求进来就打开 EntityManager,视图/序列化结束才关。

138 / 191
yaml
spring:  jpa:    open-in-view: false     # 生产建议显式关掉;Boot 默认是 true
139 / 191
对照表
配置上下文存活期事务外读懒字段副作用
open-in-view: true(默认)整个请求「能读」,Hibernate 在渲染阶段补 SQL请求线程长时间绑定连接;N+1 被推迟到视图层串行发生;慢页面直接拖干连接池
open-in-view: false事务方法内立刻 LazyInitializationException问题在开发期暴露,逼你把读取范围收敛进事务
140 / 191

关掉它之后正确的做法只有三种,且都在前面出现过:事务内把要用的字段读全、按方法声明 @EntityGraph / JOIN FETCH、用投影返回 DTO。第三派还有个额外好处——出参对象是纯数据,天然可以进缓存。

141 / 191

上下文绑事务、事务绑连接,所以 open-in-view、长事务、慢查询最终都会以连接池指标的形式把病显出来。切到「达到上限排队」与「等待超时」,把 SQLTransientConnectionException 里的 active / idle / awaiting 三个数字和上面的表对上:

142 / 191
内核实验
TeaVM连接池借还:open-in-view 与长事务的连锁反应未启动
先跑「命中空闲连接」看正常路径,再切排队 / 超时 / 泄漏检测三个参数,体会连接是怎么被慢慢抽干的
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
143 / 191

缓存这件事必须分清层次(下表四行,其中 Query 缓存只是二级缓存的附庸),否则「明明加了 @Cacheable 为什么还是慢」永远查不动:

144 / 191
对照表
层作用域默认存的是什么时候用
一级缓存(持久化上下文)一个事务 / 一个 EntityManager开启,关不掉实体实例 + 字段快照它不是优化手段,是 identity 保证;跨请求省不了任何一次查询
Hibernate 二级缓存SessionFactory 级关闭实体的字段状态(序列化后)读多写少的字典表;要显式选 RegionFactory,集群下各存一份仍会脏
Query 缓存同上关闭「命中主键的 id 列表」必须与二级缓存配合,否则只省掉一次 id 查询
@Cacheable(Spring)你自己定义的 cache name需 @EnableCaching方法返回值跨请求复用的正解;生产放 Redis,见 #32
145 / 191
代码对照
代码java
@Service@RequiredArgsConstructorpublic class ProductService {    private final ProductRepository repository;    // 缓存 DTO,而不是实体:实体出了上下文就是游离态,懒字段一碰就炸    @Transactional(readOnly = true)    @Cacheable(value = "product", key = "#id", unless = "#result == null")   // 需要 @EnableCaching    public ProductDetail detail(Long id) {        Product p = repository.findById(id).orElseThrow();        return new ProductDetail(p.getId(), p.getName(), p.getPrice());    }    @Transactional    @CacheEvict(value = "product", key = "#id")    public void changePrice(Long id, BigDecimal price) {        repository.findById(id).orElseThrow().setPrice(price);   // 托管态,flush 自动生效    }}
解读
  • 注解顺序有讲究:@Cacheable 命中时方法体根本不执行,于是事务也不会开启。事务注解要么放外层 Service,要么确认被缓存的方法确实只读
  • 缓存里放实体是事故温床:序列化再取出就是游离态,任何未初始化的关联访问都报 LazyInitializationException
  • 写操作要 @CacheEvict,缓存要有 ttl;穿透 / 击穿 / 雪崩三坑与解法在 #32 展开

提示:这三层的记忆口诀是「上下文管身份,二级管字段,@Cacheable 管返回值」。判断该动哪一层只看一个问题:这份数据要跨什么边界复用——同一个事务内(一级)、同一进程的多次事务(二级)、还是整个集群的多次请求(@Cacheable + Redis)。

146 / 191

实验按到这里,可以换成命令行自己敲。这台控制台连着浏览器里的同一个内核,回显全部由内核算出来:query 走一遍 Repository → JdbcTemplate,beans 与 cond 看这个 Bean 到底是怎么被装进来的,然后把本篇四个主题依次敲出来——四态、flush、N+1、连接被拖干:

147 / 191
内核控制台
148 / 191
说明

lab jpa flush 与 lab jpa detach 要连着敲才有对比——前者只调 setter,UPDATE 真的发出去了;后者同样只调 setter,什么都不发生。同一行代码、两种命运,差别只在「此刻有没有人替你记账」,也就是第四节那句 contains()。

149 / 191
小节
十三、审计与乐观锁
150 / 191

审计字段(创建时间 / 修改人)不需要手动赋值,开启审计后由框架在生命周期事件里自动填充:

151 / 191
java
@EntityListeners(AuditingEntityListener.class)      // 挂在实体上@Entitypublic class User {    @CreatedDate  @Column(name = "created_at", updatable = false) private LocalDateTime createdAt;    @LastModifiedDate @Column(name = "updated_at")               private LocalDateTime updatedAt;    @CreatedBy   @Column(name = "created_by", updatable = false) private String createdBy;}
152 / 191
java
@Configuration@EnableJpaAuditing(auditorAwareRef = "auditorProvider")     // ← 少了这个注解,字段永远 nullpublic class JpaConfig {    @Bean    public AuditorAware<String> auditorProvider() {        // 真实项目:从 SecurityContextHolder 取当前登录人;未登录返回 Optional.empty()        return () -> Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())                .map(Authentication::getName);    }}
153 / 191

乐观锁用来防止并发覆盖。给实体加 @Version 字段,Hibernate 在每次更新时都会带上版本条件:

154 / 191
代码对照
代码java
@Entity@Table(name = "t_account")public class Account {    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)    private Long id;    private BigDecimal balance;    @Version    private Integer version;         // 由 Hibernate 维护,业务代码绝不手动改}
解读

类比:乐观锁就是两个人各抄了一份合同。抄的时候都记下方号「第 3 版」(读到 version=3)。A 先签完拿去归档,门卫把编号推到第 4 版;B 随后拿着自己手里那份「第 3 版」去归档,门卫一看编号已经变了,当场拒收(UPDATE 影响 0 行 → 抛异常),并要求 B 重新抄一份最新版、把改动重做一遍。整个过程门卫没有锁住柜子(不加行锁),只在最后核对一次编号——所以它适合「很少两人同时改同一行」的场景;如果每人都抢着改,就该换成「归档柜一次只让一个人进」(悲观锁)。

155 / 191

并发更新失败的现场是这样的:

156 / 191
text
-- 用户 A 和 B 同时读取到 id=1, version=3-- A 先提交:UPDATE t_account SET balance = 100, version = 4 WHERE id = 1 AND version = 3;  -- 影响 1 行,成功-- B 随后提交:UPDATE t_account SET balance = 200, version = 4 WHERE id = 1 AND version = 3;  -- 影响 0 行!-- Hibernate: org.hibernate.StaleObjectStateException: Row was updated or deleted by another transaction-- Spring 转成: org.springframework.orm.ObjectOptimisticLockingFailureException-- JPA 层规范异常: jakarta.persistence.OptimisticLockException
157 / 191

冲突频繁的场合才用悲观锁——它真的加行锁,SELECT ... FOR UPDATE:

158 / 191
java
public interface AccountRepository extends JpaRepository<Account, Long> {    @Lock(LockModeType.PESSIMISTIC_WRITE)    @Query("SELECT a FROM Account a WHERE a.id = :id")    Optional<Account> findByIdForUpdate(@Param("id") Long id);}
159 / 191
对照表
维度乐观锁 @Version悲观锁 PESSIMISTIC_WRITE
数据库代价不加锁,只在 UPDATE 条件里带版本号行锁,其他事务在锁窗口内等待
冲突时表现抛异常,由上层重试阻塞,事务变慢
适合读多写少、冲突概率低(资料、配置、内容)余额、库存、券码这类必争资源
陷阱重试必须有次数上限与退避锁窗口越长,连接池越紧张(#28)
160 / 191

随堂第三题,考的是冲突之后你该怎么办:

161 / 191
随堂自测
随堂自测`Account` 带 `@Version`。两个线程同时读到 id=1、version=3,各自扣款后同时提交。最可能看到什么?
先自己选一个,选中立刻告诉你对不对
162 / 191
坑

@Version 有三个「不按预期工作」的经典场景:① 业务代码手动 setVersion(...)(版本号被覆盖,等于没加锁);② 用 @Modifying 的 JPQL 批量 UPDATE(绕过实体生命周期,版本号不会自增,要自己写 SET a.version = a.version + 1);③ 游离实体带着旧版本号 merge(报出来的异常文案里会出现 unsaved-value mapping was incorrect,看着像配置错,其实是版本冲突)。

163 / 191
小节
十四、常见报错速查
164 / 191
对照表
报错原文(片段)现象真实根因一句修复深挖
org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: demo.User.orders, could not initialize proxy - no Session事务外(Controller 返回、Jackson 序列化、异步线程)读关联就炸上下文随事务关闭,代理没人替你补查了事务内读全,或 @EntityGraph / JOIN FETCH / 投影;别用 open-in-view 掩盖九、十二节
jakarta.persistence.EntityNotFoundException: Unable to find demo.User with id 42getReferenceById 拿到对象,一读字段就炸该方法只造代理不查库,行已被删或不存在改用 findById(...).orElseThrow(...),把「不存在」变成你自己的业务异常第四节
org.hibernate.StaleObjectStateException / ObjectOptimisticLockingFailureException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect): [demo.User#42]并发更新偶发失败,重试就好带着旧 version 提交;或游离实体 merge 时版本已变在事务外重试(重读→重算→再提交),冲突密集就换悲观锁第十三节
jakarta.persistence.NonUniqueResultException: query did not return a unique result: 2返回 Optional<User> 的方法炸条件命中多行:user_name 忘了唯一索引,或 join 后行数放大给该列加唯一约束,或把返回改成 List / findTop1By...第六节
jakarta.persistence.TransactionRequiredException: Executing an update/delete query一进 @Modifying 方法就抛缺 @Transactional,或同类自调用绕过了代理补 @Transactional,并确认调用来自外部 Bean七、十一节
InvalidDataAccessApiUsageException: No EntityManager with actual transaction available for current thread - cannot reliably call 'flush'flush / saveAndFlush 报内部错整条调用链上没有事务,因而没有真正的上下文在 Service 层加 @Transactional,而不是在 Controller 里调 flush第十节
Failed to create query for method ... No property uname found for type User!启动阶段就失败派生查询的属性名与实体属性对不上(大小写、写成了列名)对照实体属性名;复杂就退回 @Query第六节
Schema-validation: missing column [remark] in table [t_user](外面常包一层 Unable to build Hibernate SessionFactory)上线启动失败ddl-auto: validate 发现实体与表结构漂移补 Flyway / Liquibase 迁移脚本;不要改成 update 蒙混过关第二节
DataIntegrityViolationException: could not execute statement ... Duplicate entry 'alice' for key 'uk_user_uname',堆栈指向方法返回处循环里毫无异常,提交时才炸语句被推迟到 flush 才发,异常点远离出错点分批 flush() + clear(),测试里用 saveAndFlush 让异常回到离 bug 最近的一行第十节
java.lang.StackOverflowError / Jackson Infinite recursion (class demo.Order)接口一返回就炸或日志刷屏双向关联互相引用,toString 与序列化都会递归出参用投影;必须直出实体就 @JsonManagedReference + @JsonBackReference第三节
不报错,但更新「没生效」接口返回 200,库里字段原封不动① readOnly=true 跳过脏检查;② 改的是游离实体;③ @Modifying 后上下文里还是旧对象确认在事务内改受管实体;@Modifying(clearAutomatically = true);readOnly 只加在只读方法上四、十节
165 / 191
提示

这张表里最值得抄进笔记的是最后一行——JPA 最贵的故障都不是异常,而是「静默无效」。排查动作永远同一套:打开 show-sql 或 org.hibernate.SQL=DEBUG,数一数这次请求发了几条 SQL、参数是什么、有没有 UPDATE。SQL 条数不会骗人。

166 / 191

表里第一行那句 no Session,是 JPA 新手最常被吓到的一次报错——它读起来像「数据库连接没了」,其实和连接一点关系都没有。下面这段是真实堆栈,先别看解析,点出你认为的凶手行:

167 / 191
报错急救
报错急救LazyInitializationException: could not initialize proxy - no Session
事务外读懒字段:报错说的是「没人替你查了」,不是「连不上库」

Controller 里把 Service 返回的 User 直接交给 Jackson 序列化,接口偶发 500。本地单测(没有事务边界)一切正常,一到联调就炸。

org.hibernate.LazyInitializationException: failed to lazily initialize a property of demo.User: dept (could not initialize proxy - no Session)
at org.hibernate.bytecode.internal.BytecodeProviderInitiator.getBytecodeProvider(BytecodeProviderInitiator.java:58)
at org.hibernate.proxy.AbstractLazyInitializer.initialize(AbstractLazyInitializer.java:175)
at org.hibernate.proxy.AbstractLazyInitializer.getImplementation(AbstractLazyInitializer.java:328)
at org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor.intercept(ByteBuddyInterceptor.java:44)
at demo.web.UserController.toVo(UserController.java:41)
at com.fasterxml.jackson.databind.ser.BeanSerializer.serialize(BeanSerializer.java:178)
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
168 / 191
小节
十五、动手练习
169 / 191
小节
第一档 · 照做:一个「四态 + 懒加载 + 分页」的最小工程
170 / 191

目标:跑通 User/Dept 模型,亲眼看到 SQL 条数随抓取策略变化。实体侧 Dept 只有一个 name 字段,User.dept 用 @ManyToOne(fetch = LAZY);UserRepository 同时继承 JpaRepository<User, Long> 与 JpaSpecificationExecutor<User>,并声明四种查询:派生的 findByStatusAndUserNameContaining、分页的 Page<User> findByStatus(UserStatus, Pageable)、声明式抓取的 findWithDept(@EntityGraph(attributePaths = "dept"))、以及第七节那条只取三列的投影 findSummaries。再加一份测试:

171 / 191
java
@SpringBootTestclass UserJpaTest {    @Autowired UserRepository repository;    @Autowired TestEntityManager entityManager;    @Test    void managedEntityNeedsNoSave() {        User u = repository.save(newUser("alice"));    // persist:此刻未必有 UPDATE        u.setNickName("Ali");                           // 只改内存里的字段        repository.flush();                             // 脏检查在这里发 UPDATE        entityManager.clear();                          // 模拟上下文关闭        assertThat(repository.findById(u.getId()).orElseThrow().getNickName()).isEqualTo("Ali");    }}
172 / 191

预期控制台 SQL 日志(show-sql: true 时形状必须一致):

173 / 191
代码对照
代码text
Hibernate: insert into t_user (nick_name, status, user_name, dept_id, version) values (?, ?, ?, ?, default)Hibernate: select u.id,u.user_name,u.nick_name,u.status,u.dept_id,u.version from t_user u where u.id=?Hibernate: update t_user set nick_name=?, status=?, dept_id=?, user_name=?, version=? where id=? and version=?Hibernate: select u.id,... from t_user u where u.status=? and u.user_name like ?     -- 派生查询Hibernate: select d.id,d.name from t_dept d where d.id=?                              -- 循环里逐条补查,出现 N 次-- 改用 findWithDept 之后只剩一条:Hibernate: select u.id,..., d.id, d.name from t_user u inner join t_dept d on d.id=u.dept_id where u.status=?
解读
  • insert 之后没有立刻出现 update,直到 flush() 那行才有——这就是「save ≠ 落库」
  • update 末尾带着 and version=?,且 version 被 +1,这就是乐观锁的全部实现
  • N+1 的日志形状是「一条主查询 + 一堆按 id 的重复查询」,声明式抓取之后只剩一条 JOIN
  • 投影那条查询里根本不出现 version 列,因为它不装配实体,也就不参与脏检查
174 / 191
小节
第二档 · 变体:每次只改一处,记录现象
175 / 191
  1. open-in-view 显式设为 false,在 Controller 里读 user.getDept().getName() → 立刻看到 LazyInitializationException(第十四节第一行原文)。本篇最重要的实验:报错不是灾难,是把雷从请求末尾挪回 Service。
  2. @ManyToOne(fetch = LAZY) 改成 EAGER → SQL 从 101 条变 1 条,但每个只要订单号的统计也开始 JOIN 用户表。体会「局部优化变成全局代价」。
  3. 手动 setVersion(3) 再保存 → 观察版本号行为如何违背预期(第十三节的坑)。
  4. PageRequest.of(0, 10) 改成 of(1, 10) → 少了第一页;前端页码 1 对应 Spring Data 的 0。
  5. 在 @Transactional(readOnly = true) 的方法里 setXxx → 提交正常、日志无 UPDATE、库里不变。不报错的 bug 最难查。
  6. ddl-auto 改成 update,给实体加字段后重启 → 日志出现 alter table ... add column;改回 validate 并补一份迁移脚本。
176 / 191
小节
第三档 · 造一个:后台「订单列表」接口
177 / 191

需求:GET /admin/orders?keyword=&status=&deptId=&from=&to=&page=1&size=20&sortField=createdAt&order=desc。验收清单:

178 / 191
  • [ ] Order.user 显式 LAZY;equals/hashCode 只按 id;@Version 存在且业务不改它
  • [ ] 动态条件用 Specification,参数为空时该条件不出现在 SQL 里(不许写 :x is null or ... 这类 trick)
  • [ ] 排序字段过服务端白名单(值必须是实体属性名),非法值回退 createdAt
  • [ ] 分别交付返回 Slice(不查总数)与 Page(查总数)两版,README 贴出两版 SQL 差异并说明信息流为何选 Slice
  • [ ] 列表出参用投影 DTO,序列化时既不出现按 id 的补查,也不出现 LazyInitializationException
  • [ ] PATCH /admin/orders/status 批量改状态:@Modifying(clearAutomatically = true) + @Transactional,并解释这条 UPDATE 为什么不会自增 version、你如何处理
  • [ ] 压测 100 并发,同时抓 hikaricp.connections.pending 与 SQL 日志,判断瓶颈在 SQL 条数、映射开销还是连接池,写 5 行结论
179 / 191

做完这三档,你对 JPA 的掌握就从「会写注解」升级到「知道每一条 SQL 是谁在什么时候决定发的、出问题时知道往哪一层查」。

180 / 191
小节
十六、要点自查
181 / 191
自检

repository.save(u) 之后数据库里有这行数据吗?答:不一定。save 只是 persist / merge,把对象交给上下文;SQL 在 flush 时才发,而 flush 默认发生在下一次查询前和事务提交时。

182 / 191
自检

没调 save、没写 UPDATE,只改了托管实体的字段,数据会变吗?答:会变。「加载时的快照 + 提交前的脏检查」就是全部机制。

183 / 191
自检

同一个事务里 findById(1L) 调两次,发几条 SQL?答:一条,而且两次拿到的是同一个实例(== 为 true);换了事务才会重新打库。

184 / 191
自检

LazyInitializationException 的正确修法有哪三种?答:事务内把字段读全、按方法声明 @EntityGraph / JOIN FETCH、改用投影 DTO。open-in-view 是掩盖,不是修复。

185 / 191
自检

N+1 怎么产生、怎么定位?答:懒加载 + 循环访问;定位靠数 SQL(show-sql 或 logging.level.org.hibernate.SQL=debug,或开 generate_statistics)。

186 / 191
自检

生产环境的 ddl-auto 该填什么?表结构改动走哪条路?答:validate(或 none);改表交给 Flyway / Liquibase 的迁移脚本,并把脚本纳入代码评审。

187 / 191
口诀

save 只是摆进购物车,结账(flush)才真正落库;readOnly 直接跳过结账,游离的车改了没人记。懒加载是封面不是全书,循环一读就变 N+1;@EntityGraph 治一条,join fetch 治一条,投影最干净。上下文管身份、二级管字段、@Cacheable 管返回值;生产 validate、迁移交脚本,版本号冲突就重读再改。

188 / 191
小节
十七、决策卡与总结
189 / 191
决策
决策一个后台管理系统要做「多条件组合筛选 + 多表联查 + 复杂统计」的报表页,团队用 Spring Data JPA。最合理的做法是?
190 / 191
决策
决策新项目是电商后台:70% 是领域清晰的 CRUD(商品、分类、会员、订单主表),30% 是多表联查的报表与营销活动 SQL;团队里两人写过 JPA,所有人都熟 SQL。数据访问层怎么选?
191 / 191
总结

这一篇的骨架只有三句话。第一句:JPA 管的是对象状态,不是 SQL——瞬时 / 托管 / 游离 / 删除四态由持久化上下文决定,contains() 是唯一判据,事务边界就是状态切换点。第二句:SQL 由 flush 决定,而不是由你写了几行决定——脏检查负责「没写 UPDATE 也更新」,延迟刷写负责「异常出现在离 bug 很远的地方」,readOnly 负责「什么都不发生且不报错」。第三句:懒加载是把双刃剑——它让「只看封面」成为可能,也让循环变成 101 条 SQL;解法永远是「把这次要用的东西一次取全」:@EntityGraph、JOIN FETCH、投影。再加上两条工程纪律:生产禁用 ddl-auto: update、显式关掉 open-in-view,JPA 就从「玄学」变成了可预测、可诊断、可优化的工具。