Spring Data JPA 全解:Repository 抽象与 JPQL
先破一个误解:Spring Data JPA 不是「替你写 SQL 的机器」,而是一套对象状态管理系统。你真正要盯的从来不是语句长什么样,而是这个对象此刻有没有被「持久化上下文」看管。这件事想通了,JPA 九成的「玄学行为」会自动退化成常识:为什么没写 UPDATE 数据却变了、为什么 save 之后什么都没发生、为什么事务一关就报 no Session。
六个词先各给一句话(全文反复用):
- JPA:规范(Jakarta Persistence),只有注解与接口,不含实现,位置和 JDBC 规范一样
- Hibernate:Spring Boot 默认的 JPA 实现,真正生成 SQL、维护会话与缓存的那台引擎
- Spring Data JPA:再往上包一层的声明式抽象,你只写接口方法,实现由代理提供
- 持久化上下文:一段「看管期」里的对象登记表,生命周期通常与一个事务重合;
EntityManager是它的操作入口 - 一级缓存:不是额外加装的缓存,它本身就是持久化上下文。同一事务内同一个 id 只发一条 SQL、只存在一个实例
- 脏检查:实体加载时留下一份字段快照,flush 时逐字段比对,差异才变成 UPDATE
持久化上下文就是你手上的购物车,EntityManager 是导购。把商品放进车里(persist)不等于你买了它——车还在你手上,随时能拿回货架(remove)。推到收银台结账(flush)那一刻才真正落单:导购逐件核对车里多了什么、换了什么,逐项扫码录入,UPDATE 就是这么冒出来的。所以 repository.save(entity) 的字面意思不是「写进数据库」,而是「摆进购物车」,只有事务提交那一下才真的扣款。而已经被你推出商场的购物车(游离态实体)——导购不再记账,回家把车里的泡面换成牛排,商场账目纹丝不动,这正是「改了字段数据库却没变」的真相。至于懒加载:它像只看封面就决定借哪本书,等你真要读正文,管理员再跑一趟库房取(补一条 SELECT);借一本还行,一次借一百本、每本都跑一趟,就是 N+1。抓住「谁在看管、什么时候对账」这两个问题,本篇后面每个坑都有坐标系。

这张环形图就是本篇的地图:瞬时 → 托管 → 游离 → 删除,四个状态由两问判定(有没有被上下文接纳、有没有标识符),三条转换线是 persist / find、merge、remove,探针是 entityManager.contains(u),别靠猜。第四节逐格展开。
学完这一篇,你要能回答三个问题:
- 我只写了一行
u.setNickName("新昵称"),没调save、没写 UPDATE,为什么数据库真的变了? repository.save(entity)到底什么时候发 SQL?为什么 id 不为空时反而多做了一次 SELECT?- 一次列表请求打出 101 条 SQL,那个 N+1 该用哪种手段治?为什么说
open-in-view不算治?
先用沙盘摸一下本篇最容易翻车的那对组合——关联抓取策略 × 访问时机。切一下按钮,右侧的 Hibernate SQL 日志立刻变:
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
六个格子连看一遍,你会发现既省钱又不留隐患的只有右下两格(声明式抓取)。前两格一个用 101 条 SQL 换正确性,一个直接抛异常;EAGER 那两格是把代价摊到全项目每一次查询上。工程纪律就两条:实体一律显式写 LAZY,要一起取的关联按方法声明。
初学 JPA,最大的困惑往往是名词打架:一会儿 JPA,一会儿 Hibernate,一会儿 Spring Data JPA。它们不是三个竞品,而是「规范 / 实现 / 抽象」三层:

| 层 | 角色 | 负责什么 | 类比 |
|---|---|---|---|
| JPA(Jakarta Persistence) | 规范 / 标准 | 定义注解与接口(@Entity、EntityManager),不含实现 | JDBC 规范 |
| Hibernate | 实现 | 把 JPA 规范落地,真正生成 SQL、管理会话与缓存 | MySQL 驱动 |
| Spring Data JPA | 抽象 | 在 JPA 之上再包一层:声明接口方法即可查询 | 更懂你的助手 |
一句话:JPA 是「合同」,Hibernate 是「干活的工人」,Spring Data JPA 是「替你写方法的秘书」。一条真实调用链值得先记住,后面每一节都在拆它其中某一段:Controller 里的一行 userService.activate(42L) → Spring Data 的动态代理(PartTree 解析或 SimpleJpaRepository)→ EntityManager.find(Hibernate 的 Session 实现它)→ 一级缓存查表(命中就是同一个实例,不发 SQL)→ 未命中才向 HikariCP 借连接发 select(#28)。
- 你调用的是接口方法,不是你写的实现类——第五节讲代理怎么给出实现
- 返回的对象是否受管取决于这次调用有没有跑在事务里——第四节讲状态
- 发不发 SQL 由脏检查与 flush 时机决定,而不是由你写没写
save——第十节讲 flush
换实现(例如 EclipseLink)不影响业务代码,因为上层只依赖 JPA 规范,这正是分层的价值;而派生查询、Pageable、Specification 这些便利特性属于 Spring Data JPA,与具体实现无关。本篇以 Spring Boot 默认的 Hibernate 6 为例,注意包名已从 javax.persistence 迁到 jakarta.persistence。
三行依赖换来全套能力,无需再手写 DAO 实现:
<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>这个 starter 实际带来四样东西:spring-data-jpa(Repository 抽象)、hibernate-core(JPA 实现)、spring-orm(粘合层与事务管理器)、HikariCP(默认连接池,见 #28)。自动配置会替你启用 @EnableJpaRepositories,所以项目里通常看不到它——但知道它在,排查「Repository 没被扫到」时才有方向。
配置的重头戏是 ddl-auto——它决定启动时框架怎么对待你的表结构,也是生产事故的高发区:
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: trueddl-auto 取值 | 含义 | 用在哪 |
|---|---|---|
none | 什么都不做(默认) | 生产(配合 Flyway / Liquibase) |
validate | 只校验表结构与实体是否一致,不一致就启动失败 | 生产推荐 |
update | 自动给实体「加」新表和列,但从不删列、不改类型 | 只限本地开发 |
create / create-drop | 每次启动重建表 / 关闭时删表 | 测试 / 内存库(Boot 对内存库默认就是它) |
把最常见的场景摆出来:实体新加了一个 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 每次启动重建,结构对了但数据没了。
ddl-auto: update 在生产环境是破坏性的。它不会删列,却会擅自加列、加索引;实体重命名后旧列永久残留;一旦多人共用同一个库,谁先启动谁改表,表结构就此失控。生产一律 validate 或 none,改表交给 Flyway / Liquibase 这类迁移工具,并把迁移脚本纳入代码评审。看到 Schema-validation 报错时,正确修复是补一份 V8__add_user_remark.sql,而不是把 validate 改成 update 蒙混过关。
命名策略也常让人踩坑:CamelCaseToUnderscoresNamingStrategy(Boot 默认的物理命名策略)会把实体属性 userName 映射成列 user_name;改了策略、或碰上大小写敏感的数据库(PostgreSQL 未加引号的标识符会转小写),就会出现「表/列不存在」。纪律只有一条:列名一律 snake_case、属性名一律 camelCase,靠命名策略统一转换,别两边混着用;跨库或历史表命名不规整时,就在 @Column(name = "...") 里显式写死。
上面这份 yml 是结论,不是练习。真正的做法是自己勾一遍:只勾「数据源」能不能起来、勾上「JPA」多出的几行分别是谁、加上「日志」之后 Hibernate: 那几行从哪来、以及生产那份 profile 为什么要和开发分开:
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 }
勾完之后做一次反向自检——把生成的 jpa 那一段里 ddl-auto 与 open-in-view 两行逐条说出「删掉它会发生什么」。说不出来,就说明这两节还没读透。
实体是 JPA 的核心,字段上的注解决定表结构映射。下面这份是最贴近真实工程的一张实体写法(含主键策略、枚举、关联、审计与乐观锁字段):
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 省略}注解对照表(右边一列全是踩过才知道的):
| 注解 | 作用 | 最容易忘的一条 |
|---|---|---|
@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(第十三节) |
@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
随堂第一题,这题的后果不会报错,所以人人都栽过:
@GeneratedValue(strategy = IDENTITY) 有个副作用——插入时必须立刻拿回自增主键,导致 Hibernate 无法批量插入(每个 insert 都是一次独立往返)。批量导入场景要么改用 SEQUENCE,要么退回 JDBC 批量接口,这是第十节批处理的前提。
回到开头那张环形图。JPA 里一个对象只有四种身份,而身份决定行为:
| 状态 | 怎么进入 | em.contains(u) | 改字段会自动发 SQL 吗 | 典型出现位置 |
|---|---|---|---|---|
| 瞬时态 transient | new User() | false | 不会,跟数据库毫无关系 | DTO 转换后、Service 刚组装的对象 |
| 托管态 managed | persist / find / 事务内的 findById | true | 会,flush 时由脏检查发 UPDATE | @Transactional 方法体内 |
| 游离态 detached | 事务结束 / em.detach() / 从别层传进来 | false | 不会,除非 merge 回来 | Controller 返回值、异步线程、缓存里的对象 |
| 删除态 removed | em.remove(u)(u 仍受管时) | 关闭前仍可能 true | DELETE 在 flush 时发 | 删除流程 |
判定只有一个探针:entityManager.contains(u)。切换状态的是上下文,不是对象——对象一直是你手里那个对象,只是「有没有人记账」变了。
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()
表格里最容易在线上出事的是第三行——游离态。它的完整形状是「接口返回 200、日志里没有 UPDATE、库里原封不动」,全程没有任何异常。把这六帧连看一遍,你以后见到「更新偶发不生效」就知道先问哪一句(正解见第十节的 merge,反例见第十四节最后一行):

亲手把四态跑一遍,比读十遍表格有用:
| 参数 | 你会看到 | 对应正文 |
|---|---|---|
| 实体四种状态 | contains() 如何在 persist / 事务结束 / merge 之间翻转,以及「行李托运」这个类比的对应关系 | 本节的表 |
| 一级缓存命中 | 同一事务两次 find 只发一条 SQL、返回同一实例;换事务就重新打库 | 本节第三条 |
实验里最反直觉的一行是 again == fresh 为 true。它意味着你拿到的不是「一行数据的副本」,而是「这一行在内存里唯一的那个替身」。理解了这一点,「为什么我改了对象没调 save 却生效」和「为什么两个 Service 改同一个实体互相看得见」就都不需要背了。
开头那张环形 PNG 给的是全景,但四态真正要记的是「哪一下动作把它推到哪一格」。把下面这五格点着走一遍,每格都写清了此刻 contains() 返回什么、改字段有没有人管:
Spring Data JPA 的 Repository 就是快餐店的点单台。你在窗口喊一句「一份红烧肉」(声明 findByStatus),厨房照做,但你从没进过后厨、也不知道今天哪位师傅炒——你只负责报菜名,实现是后厨的事。菜单上印好的家常项(save / findById / deleteById)由中央厨房统一供货(SimpleJpaRepository),你要的新菜式则由「菜名解析器」(PartTree)现场翻译成做法。菜单一旦写歧义——比如报了个不存在的菜名 findByUname——不是做菜做到一半才发现,而是开店当天就开不了(启动失败),这是 JPA 相比手写 SQL 最友好的一点:错误前移。
Spring Data 提供了一串 Repository 接口,越往下能力越多:
| 接口 | 继承 | 提供的能力 |
|---|---|---|
Repository<T, ID> | 无 | 只是个标记接口,啥方法都没有 |
CrudRepository<T, ID> | Repository | save / findById / delete / count 等 |
PagingAndSortingRepository<T, ID> | CrudRepository | 追加 findAll(Pageable) / findAll(Sort) |
JpaRepository<T, ID> | 上者 + QueryByExampleExecutor | 追加 flush / saveAndFlush / 批量删除、getReferenceById |
JpaSpecificationExecutor<T> | 独立接口(不继承上面那条链) | 追加 findAll(Specification, Pageable) 等动态条件查询 |
为什么写个接口就够了,不用实现类? 因为启动时 Spring Data 会为每个 Repository 接口生成一个动态代理(JdkDynamicAopProxy + RepositoryFactory),实际干活的是内置的 SimpleJpaRepository,你自己新增的方法则由「方法名解析器」或 @Query 翻译成查询。
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,而不是「找不到实现」。
这是 Spring Data JPA 最迷人的地方:方法名本身就是查询语句,框架按约定解析。
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);}派生关键字对照表(记住这 12 个,日常够用):
| 关键字 | 生成条件 | 示例 |
|---|---|---|
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 |
派生方法的读法是「顺序解析」:框架先剥掉前缀(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!。这是派生查询最友好的一点——错误前移,你不可能把它带上生产。
顺带两条易错点:findByDeletedFalse 里的 Deleted 也必须是真实属性;findByStatusAndUserNameContaining 传 null 关键字不会「忽略该条件」,而是生成 like null,查不到任何东西——可选条件属于第八节的 Specification。
派生方法超过 3 个条件就已经很难读了,Or 一多还会出现优先级歧义(AAndBOrC 到底是 (A∧B)∨C 还是 A∧(B∨C),你得去查生成的 SQL 才敢确认)。一旦方法名开始「绕口令」,果断改用 @Query 或 Specification——可读性优先于省事。
方法名表达不了的查询,用 @Query 手写。JPQL 是「面向实体」的查询语言,写的是实体名和属性名,不是表名和列名:
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 已经把库改了」这类错乱
投影是被严重低估的一招:列表页只需要三列,却把整个实体(连带它的懒加载代理、快照、版本字段)全部装配出来,既慢又容易在出参时炸懒加载。
// 写法一:接口投影(闭口式,只要这三列)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
JPQL 里还能用 SpEL 做动态拼接。下面这个例子按传入的排序字段排序,同时用白名单规避注入:
@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,也是一种可被探测的信息泄露。
JPA 的分页是「参数注入式」的,把 Pageable 作为方法参数即可:
public interface UserRepository extends JpaRepository<User, Long> { Page<User> findByStatus(UserStatus status, Pageable pageable);}// 第 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>直接返回 Page<User> 时,JSON 形状是这样的(前端可以照着渲染分页控件):content 是当前页的数组,另有 totalElements / totalPages / number(当前页码,从 0 起)/ size / first / last。生产建议包一层自己的 DTO——直接返回实体会把 version、懒加载代理和 pageable 元数据一起暴露出去,还常常顺手触发 LazyInitializationException。
Page 与 Slice 的区别值得单独拎出来:
| 类型 | 是否查总数 | 适用场景 |
|---|---|---|
Page<T> | 会额外执行 COUNT(*) | 需要「共 N 页 / 共 M 条」的后台表格 |
Slice<T> | 不查总数,只判断有没有下一页 | 无限滚动 / 「加载更多」 |
页码从 0 开始是 Spring Data 的老规矩,和前端习惯的「第 1 页」差一,接口层记得换算(或统一在 Controller 用 page - 1),否则永远差一页。信息流这类不需要总数的场景用 Slice,能省掉一次在大表上昂贵的 COUNT;带 @Query 的分页如果 COUNT 语句复杂,还要自己写 countQuery。
条件一旦多起来(每个都可能为空),派生查询就撑不住了。JPA 的正规解法是 Specification(Criteria API 的封装,QueryDSL 是它的另一种写法):
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])); }; }}// 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(见第十七节的决策卡)。
关联映射的第一件事是记住默认值不对称:
| 关联 | 默认抓取 | 为什么这个默认值讨厌 |
|---|---|---|
@ManyToOne、@OneToOne | EAGER | 「查一个订单顺带查一个用户」听起来无害,但它对每一个查询生效,关联层数一多就是隐藏的全表 JOIN |
@OneToMany、@ManyToMany | LAZY | 默认懒是保护你,但一旦在循环里读就是 N+1,出了事务读就是异常 |
所以规范写法是:@ManyToOne 显式改成 LAZY,@OneToMany 保持 LAZY 并显式写出来,让读取范围由查询决定,而不是由实体决定。
@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); // ← 漏掉这一行,就是最常见的「存了却没关联上」 }}经典的 N+1 问题来了。你想查 100 个订单及其下单用户:
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 本都追加了一次「拆封」。

三种解法各有分工,按「改动范围」从小到大排列:
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);}| 解法 | SQL 条数 | 适用 | 代价 |
|---|---|---|---|
@EntityGraph | 1 | Spring Data 仓库方法上,声明式 | 只能声明「一起取」,不能只取部分列;一对多路径要小心行放大 |
JOIN FETCH | 1 | 需要写复杂 JPQL 时;配合分页要留意语义 | 与 Pageable 同用会有 query returns entities that have not been fully fetched 之类的坑 |
| DTO 投影 | 1 | 列表页 / 报表 / 出参 | 不能直接改回实体,写操作仍要走实体或 @Modifying |
open-in-view=true | 仍是 101 | 不算解法 | 把补查挪到视图渲染阶段,串行执行且占着连接(第十二节) |
- 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只解决「谁跟着谁存/删」,不解决抓取;两件事分开想
这个实验就是上面那段代码的运行时版本,注意 SQL 条数从 101 变回 1 的那一下:
上面那句「定位方法只有一个:数 SQL」值得单独做一个实验。切到「怎么发现」,看 show-sql 与 generate_statistics 各自把什么摆到你面前;再切「join fetch」与「批处理」,看 101 条分别被压回几条——顺带演示那个最容易踩的坑:两个一对多同时 fetch 会撞上 MultipleBagFetchException:
想彻底看清「101」是怎么一条条长出来的,就把它摊成单步执行。左边六行就是你项目里那段再普通不过的代码,右边同步刷新变量——盯着 sqlCount 从 1 涨到 101 的那几拍,以及第 4 步里那个「看起来还是 null 但其实是个代理」的对象:
List<Order> list = repo.findByStatus(PAID); // 1 条主查询,返回 100 个订单for (Order o : list) { // 每个 o 都是托管态实体 String name = o.getUser().getUserName(); // 这里读的是代理 log.info("{} 下单给 {}", o.getOrderNo(), name); // 日志里躺着 100 条 SELECT} // 循环结束:SQL 计数 = 101// 改成 JOIN FETCH o.user 之后再走一遍 // 计数回到 1| sqlCount | 1 |
| list.size() | 100 |
| 每个元素的 user | 只带回 user_id |
OrderRepository.findByStatusHibernate query随堂第二题,这题在生产日志里每天都在发生:
现在把第四节的状态机和「结账」这件事接上。save 的真相是:它对新增对象做 persist,对已有 id 的对象做 merge,两者都只是把对象交给上下文,都不等于发 SQL。

SimpleJpaRepository 里那几行著名代码,语义上就是这一个分支:
// 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 的必然代价
时机对照表——这张表背下来,写操作的行为就不用猜了:
| 你的动作 | 此刻发生了什么 | 有 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 出去的语句一起撤销 | 什么都没留下 |
上面这张表值得压成一张对照图:左栏那些动作只在你的内存里发生,右栏才是数据库真的收到了语句。新手九成数的困惑(「为什么没生效」「为什么多了一条 SELECT」)都能在这张图上找到落点:

亲手看一遍这两个参数,「save 不等于落库」和「游离实体的更新为何失效」就会变成你亲眼见过的两件事:
这道题是 JPA 面试与线上事故的同一道题:
批处理是 flush 的另一面:Hibernate 的批量插入不是自动的。标准写法是每 50 条一次 em.flush(); em.clear();——flush 让这一批真的发出去(配合 hibernate.jdbc.batch_size: 50),clear 把上下文腾空,否则一百万个实体全压在堆里,最后累的是内存而不是数据库。
不在循环里 flush 的代价是异常会跑到离 bug 很远的地方。往两张唯一键相同的行里连续 insert,日志里一句错误都没有,方法返回时才炸 DataIntegrityViolationException: could not execute statement; constraint [uk_user_uname]——堆栈指向提交阶段,你已经看不出是哪一条数据了。写批量导入时请养成两个习惯:分批 flush/clear,以及在测试里用 saveAndFlush 让异常回到离它最近的那一行。
上面那句「配合 hibernate.jdbc.batch_size: 50」是本篇唯一一个可以直接拖的数字。它反直觉的地方在于:默认值是 0,也就是不批处理,你以为框架替你攒着发,其实是一行一条地往返。拖一遍看它什么时候真的省钱:
- 50 条是 Hibernate 官方文档与第十节写法对齐的那个数字
- 每攒够一批发一次,同时 flush + clear 把上下文腾空
- 要点:主键策略不能是 IDENTITY——它会强制每条立刻 insert,批处理直接失效
- 批量导入、初始化数据、消息落库都落在这一档
IDENTITY 主键策略与批处理天生冲突——为了拿到自增 id,Hibernate 必须每条 insert 后立即执行,于是 batch_size 形同虚设。批量写入量大时把主键策略换成 SEQUENCE(或 allocationSize 匹配的生成器),这一课在第三节和第十五节的实验里都能亲眼验证。
JPA 只负责「什么时候发 SQL」,而「这些改动算不算数」是事务决定的;至于「这些数据能不能跨请求复用」,则由缓存层回答。
save 成功、SQL 也发出去了,只要外层事务最终回滚,数据库里依然什么都不会留下。反过来,把日志写进「新事务」里,就会出现「业务失败了但日志留下来了」这种刻意设计。
| 参数 | 你要盯住 | 与 JPA 的关系 |
|---|---|---|
| REQUIRED | 内外层加入同一个事务,内层被标记 rollbackOnly 时外层提交会收到 UnexpectedRollbackException | Service 里两个都带 @Transactional 的方法互调,就是这个画面 |
| REQUIRES_NEW | 内层挂起外层、另开一个连接;外层回滚不影响它 | 审计日志、操作流水常用它,代价是两个连接同时被占用 |
| NESTED | 用保存点,内层回滚只回到保存点 | 批量导入「单条失败不影响整批」的实现方式 |
| 回滚规则 | 默认只回滚 RuntimeException / Error,受检异常不回滚 | 你的 save 已经 flush 出去了,方法却抛 IOException 正常返回——数据留下了,这可能不是你要的 |
@Transactional 是通过代理生效的,同类内部自调用不会经过代理,于是那层事务注解形同虚设(#14 有完整机制)。JPA 场景下这条特别阴:内层「没有事务」时拿到的实体不受管,改字段静默失效——就是第十节 detach 参数演示的那幅画面。
第四节的 identity 保证只活在一个事务里。想跨请求复用同一份数据,那是 @Cacheable 与 Hibernate 二级缓存的领域,两者常被混为一谈——第十二节给对照表。
JPA 自己的一级缓存和这里的 @Cacheable 完全不是一回事——前者跟事务同生共死、只是 identity 保证;后者跨请求,是真正的负载削减手段。把两者混起来的典型后果是「加了缓存还是慢」和「缓存里放了游离实体」。连接池视角(pool 实验)在第十二节末尾,因为它的病因就是那里的 open-in-view。
spring.jpa.open-in-view 是 Boot 里少数默认值会咬人的配置之一,而且它打开时会打一行 WARN 提醒你自己去关。它做的事很单纯:把持久化上下文的存活期从「事务」延长到「整个 HTTP 请求」——请求进来就打开 EntityManager,视图/序列化结束才关。
spring: jpa: open-in-view: false # 生产建议显式关掉;Boot 默认是 true| 配置 | 上下文存活期 | 事务外读懒字段 | 副作用 |
|---|---|---|---|
open-in-view: true(默认) | 整个请求 | 「能读」,Hibernate 在渲染阶段补 SQL | 请求线程长时间绑定连接;N+1 被推迟到视图层串行发生;慢页面直接拖干连接池 |
open-in-view: false | 事务方法内 | 立刻 LazyInitializationException | 问题在开发期暴露,逼你把读取范围收敛进事务 |
关掉它之后正确的做法只有三种,且都在前面出现过:事务内把要用的字段读全、按方法声明 @EntityGraph / JOIN FETCH、用投影返回 DTO。第三派还有个额外好处——出参对象是纯数据,天然可以进缓存。
上下文绑事务、事务绑连接,所以 open-in-view、长事务、慢查询最终都会以连接池指标的形式把病显出来。切到「达到上限排队」与「等待超时」,把 SQLTransientConnectionException 里的 active / idle / awaiting 三个数字和上面的表对上:
缓存这件事必须分清层次(下表四行,其中 Query 缓存只是二级缓存的附庸),否则「明明加了 @Cacheable 为什么还是慢」永远查不动:
| 层 | 作用域 | 默认 | 存的是 | 什么时候用 |
|---|---|---|---|---|
| 一级缓存(持久化上下文) | 一个事务 / 一个 EntityManager | 开启,关不掉 | 实体实例 + 字段快照 | 它不是优化手段,是 identity 保证;跨请求省不了任何一次查询 |
| Hibernate 二级缓存 | SessionFactory 级 | 关闭 | 实体的字段状态(序列化后) | 读多写少的字典表;要显式选 RegionFactory,集群下各存一份仍会脏 |
| Query 缓存 | 同上 | 关闭 | 「命中主键的 id 列表」 | 必须与二级缓存配合,否则只省掉一次 id 查询 |
@Cacheable(Spring) | 你自己定义的 cache name | 需 @EnableCaching | 方法返回值 | 跨请求复用的正解;生产放 Redis,见 #32 |
@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)。
实验按到这里,可以换成命令行自己敲。这台控制台连着浏览器里的同一个内核,回显全部由内核算出来:query 走一遍 Repository → JdbcTemplate,beans 与 cond 看这个 Bean 到底是怎么被装进来的,然后把本篇四个主题依次敲出来——四态、flush、N+1、连接被拖干:
lab jpa flush 与 lab jpa detach 要连着敲才有对比——前者只调 setter,UPDATE 真的发出去了;后者同样只调 setter,什么都不发生。同一行代码、两种命运,差别只在「此刻有没有人替你记账」,也就是第四节那句 contains()。
审计字段(创建时间 / 修改人)不需要手动赋值,开启审计后由框架在生命周期事件里自动填充:
@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;}@Configuration@EnableJpaAuditing(auditorAwareRef = "auditorProvider") // ← 少了这个注解,字段永远 nullpublic class JpaConfig { @Bean public AuditorAware<String> auditorProvider() { // 真实项目:从 SecurityContextHolder 取当前登录人;未登录返回 Optional.empty() return () -> Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication()) .map(Authentication::getName); }}乐观锁用来防止并发覆盖。给实体加 @Version 字段,Hibernate 在每次更新时都会带上版本条件:
@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 重新抄一份最新版、把改动重做一遍。整个过程门卫没有锁住柜子(不加行锁),只在最后核对一次编号——所以它适合「很少两人同时改同一行」的场景;如果每人都抢着改,就该换成「归档柜一次只让一个人进」(悲观锁)。
并发更新失败的现场是这样的:
-- 用户 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冲突频繁的场合才用悲观锁——它真的加行锁,SELECT ... FOR UPDATE:
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);}| 维度 | 乐观锁 @Version | 悲观锁 PESSIMISTIC_WRITE |
|---|---|---|
| 数据库代价 | 不加锁,只在 UPDATE 条件里带版本号 | 行锁,其他事务在锁窗口内等待 |
| 冲突时表现 | 抛异常,由上层重试 | 阻塞,事务变慢 |
| 适合 | 读多写少、冲突概率低(资料、配置、内容) | 余额、库存、券码这类必争资源 |
| 陷阱 | 重试必须有次数上限与退避 | 锁窗口越长,连接池越紧张(#28) |
随堂第三题,考的是冲突之后你该怎么办:
@Version 有三个「不按预期工作」的经典场景:① 业务代码手动 setVersion(...)(版本号被覆盖,等于没加锁);② 用 @Modifying 的 JPQL 批量 UPDATE(绕过实体生命周期,版本号不会自增,要自己写 SET a.version = a.version + 1);③ 游离实体带着旧版本号 merge(报出来的异常文案里会出现 unsaved-value mapping was incorrect,看着像配置错,其实是版本冲突)。
| 报错原文(片段) | 现象 | 真实根因 | 一句修复 | 深挖 |
|---|---|---|---|---|
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 42 | getReferenceById 拿到对象,一读字段就炸 | 该方法只造代理不查库,行已被删或不存在 | 改用 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 只加在只读方法上 | 四、十节 |
这张表里最值得抄进笔记的是最后一行——JPA 最贵的故障都不是异常,而是「静默无效」。排查动作永远同一套:打开 show-sql 或 org.hibernate.SQL=DEBUG,数一数这次请求发了几条 SQL、参数是什么、有没有 UPDATE。SQL 条数不会骗人。
表里第一行那句 no Session,是 JPA 新手最常被吓到的一次报错——它读起来像「数据库连接没了」,其实和连接一点关系都没有。下面这段是真实堆栈,先别看解析,点出你认为的凶手行:
Controller 里把 Service 返回的 User 直接交给 Jackson 序列化,接口偶发 500。本地单测(没有事务边界)一切正常,一到联调就炸。
目标:跑通 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。再加一份测试:
@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"); }}预期控制台 SQL 日志(show-sql: true 时形状必须一致):
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列,因为它不装配实体,也就不参与脏检查
open-in-view显式设为false,在 Controller 里读user.getDept().getName()→ 立刻看到LazyInitializationException(第十四节第一行原文)。本篇最重要的实验:报错不是灾难,是把雷从请求末尾挪回 Service。@ManyToOne(fetch = LAZY)改成 EAGER → SQL 从 101 条变 1 条,但每个只要订单号的统计也开始 JOIN 用户表。体会「局部优化变成全局代价」。- 手动
setVersion(3)再保存 → 观察版本号行为如何违背预期(第十三节的坑)。 PageRequest.of(0, 10)改成of(1, 10)→ 少了第一页;前端页码 1 对应 Spring Data 的 0。- 在
@Transactional(readOnly = true)的方法里setXxx→ 提交正常、日志无 UPDATE、库里不变。不报错的 bug 最难查。 ddl-auto改成update,给实体加字段后重启 → 日志出现alter table ... add column;改回validate并补一份迁移脚本。
需求:GET /admin/orders?keyword=&status=&deptId=&from=&to=&page=1&size=20&sortField=createdAt&order=desc。验收清单:
- [ ]
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 行结论
做完这三档,你对 JPA 的掌握就从「会写注解」升级到「知道每一条 SQL 是谁在什么时候决定发的、出问题时知道往哪一层查」。
repository.save(u) 之后数据库里有这行数据吗?答:不一定。save 只是 persist / merge,把对象交给上下文;SQL 在 flush 时才发,而 flush 默认发生在下一次查询前和事务提交时。
没调 save、没写 UPDATE,只改了托管实体的字段,数据会变吗?答:会变。「加载时的快照 + 提交前的脏检查」就是全部机制。
同一个事务里 findById(1L) 调两次,发几条 SQL?答:一条,而且两次拿到的是同一个实例(== 为 true);换了事务才会重新打库。
LazyInitializationException 的正确修法有哪三种?答:事务内把字段读全、按方法声明 @EntityGraph / JOIN FETCH、改用投影 DTO。open-in-view 是掩盖,不是修复。
N+1 怎么产生、怎么定位?答:懒加载 + 循环访问;定位靠数 SQL(show-sql 或 logging.level.org.hibernate.SQL=debug,或开 generate_statistics)。
生产环境的 ddl-auto 该填什么?表结构改动走哪条路?答:validate(或 none);改表交给 Flyway / Liquibase 的迁移脚本,并把脚本纳入代码评审。
save 只是摆进购物车,结账(flush)才真正落库;readOnly 直接跳过结账,游离的车改了没人记。懒加载是封面不是全书,循环一读就变 N+1;@EntityGraph 治一条,join fetch 治一条,投影最干净。上下文管身份、二级管字段、@Cacheable 管返回值;生产 validate、迁移交脚本,版本号冲突就重读再改。
这一篇的骨架只有三句话。第一句:JPA 管的是对象状态,不是 SQL——瞬时 / 托管 / 游离 / 删除四态由持久化上下文决定,contains() 是唯一判据,事务边界就是状态切换点。第二句:SQL 由 flush 决定,而不是由你写了几行决定——脏检查负责「没写 UPDATE 也更新」,延迟刷写负责「异常出现在离 bug 很远的地方」,readOnly 负责「什么都不发生且不报错」。第三句:懒加载是把双刃剑——它让「只看封面」成为可能,也让循环变成 101 条 SQL;解法永远是「把这次要用的东西一次取全」:@EntityGraph、JOIN FETCH、投影。再加上两条工程纪律:生产禁用 ddl-auto: update、显式关掉 open-in-view,JPA 就从「玄学」变成了可预测、可诊断、可优化的工具。