Spring Data JPA:三层塔——规范、引擎与仓库抽象
以「JDBC 四段式体力活与全自动 ORM 的黑盒恐惧」引入:JPA、Hibernate、Spring Data JPA 三层分工,实体映射与派生查询方法,@Query 与分页,N+1 复现与 JOIN FETCH、@EntityGraph 两种对策,附 H2 起步一键切 MySQL 实验。
前置知识
- Java 数据库连接:见过 JDBC 的 Connection、PreparedStatement——没读过也能跟,第 1 节会把四段式体力活带一遍;
- SQL 数据操作与查询:会写 SELECT 与 JOIN——没读过也能跟,把 SQL 当成「查表的句子」即可;
- IoC 与依赖注入:知道接口注入实现靠容器——没读过也能跟。
学习目标
读完本文你将能够:
- 说清 JPA、Hibernate、Spring Data JPA 三层各管什么,遇到报错知道该查谁;
- 写出满足 MySQL 自增主键的实体映射,并解释 @Enumerated 为什么必须用 STRING;
- 用派生查询方法名表达简单条件,判断它什么时候表达不了、该退到 @Query;
- 用 Pageable 一次拿回分页数据与总条数;
- 复现 N+1 查询,说清成因,并用 JOIN FETCH 与 @EntityGraph 两种对策各自解决;
- 把存储层从 H2 切换到 MySQL 而不改一行 Java 代码。
预计 50 分钟,需要一个能跑 Spring Boot 的环境。
1. 你现在要解决什么问题
JDBC 时代每个查询都是四段式体力活:拿连接、拼 SQL、遍历 ResultSet 逐列取值、关资源。写十个查询就是十遍这套动作,改一个字段名要全局搜索字符串拼 SQL。于是有人喊「上全自动 ORM」,上了之后又出现新的恐惧:列表页莫名变慢,控制台刷出几十条一模一样的 SQL,却没人知道是谁发的——ORM 成了黑盒。出路不是二选一站队,而是先看清楚这套体系其实是三层:谁定标准、谁生成 SQL、谁省模板代码。分清三层,你既能享受自动化的红利,也能在变慢时知道拧哪个螺丝。
2. 心智模型:三层塔
第三层 Spring Data JPA 仓库抽象:JpaRepository 接口即实现,省掉模板代码
↑ 建立在其上
第二层 JPA(规范) jakarta.persistence 包的注解与接口标准,只定契约
↑ 由谁实现
第一层 Hibernate 真正把实体操作翻译成 SQL 的引擎(Boot 3.5.x 搭 Hibernate 6.x)- JPA 是规范:@Entity、@Id 这些注解和 EntityManager 这套接口属于它,是 Jakarta EE 的一部分。它不生成一行 SQL;
- Hibernate 是实现:规范说「实体该映射到表」,Hibernate 负责真的生成 INSERT、SELECT。你看到的每一条 SQL 都是它写的;
- Spring Data JPA 是仓库抽象:在规范之上再封装一层——你只声明接口,它生成实现,连「按主键查」这种模板方法都替你写好。
报错分流口诀:SQL 语法与映射生成的 SQL 不对,查 Hibernate;注解语义拿不准,查 JPA 规范;「接口没有实现类」「方法名解析不出来」,查 Spring Data JPA。
3. 准备现场:先用 H2 跑通
两个依赖,外加一个内存数据库 H2:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>spring:
datasource:
url: jdbc:h2:mem:demo
username: sa
password: ""
jpa:
hibernate:
ddl-auto: update # 开发期让 Hibernate 建表;生产应为 none,表结构交给迁移工具
show-sql: true # 控制台打印 SQL
logging:
level:
org.hibernate.orm.jdbc.bind: trace # Hibernate 6 里 SQL 的绑定参数日志在这里看选 H2 不是将就:内存库零安装、秒起,先把三层跑通;第 8 节只改配置就能切到 MySQL——顺便体会 自动配置 的价值。
4. 实体映射:把类对到表
@Entity
@Table(name = "t_task")
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // 对应 MySQL 的 AUTO_INCREMENT
private Long id;
@Column(nullable = false, length = 50)
private String title;
@Enumerated(EnumType.STRING) // 存 "TODO"/"DONE",不写这句是埋雷
private TaskStatus status;
private LocalDate createdAt;
protected Task() {
} // JPA 要求无参构造器(protected 即可),Hibernate 反射实例化用
// getter/setter 略,可用 Lombok 的 @Getter @Setter
}
public enum TaskStatus { TODO, DOING, DONE }三个要点。@GeneratedValue 用 IDENTITY 才与 MySQL 的自增列对上号,主键由数据库发号。@Enumerated 必须显式写 STRING:不写它默认 ORDINAL 存下标,哪天有人在枚举中间插一个新值,历史数据全体错位——下标是位置,字符串是身份,数据永远存身份。顺带一句 Lombok 的坑:@Data 生成的 hashCode/equals 会把所有字段算进去,与 JPA 的托管状态、延迟加载语义打架,实体上建议只用 @Getter/@Setter。
5. 仓库接口:继承即得 CRUD
public interface TaskRepository extends JpaRepository<Task, Long> {
}空接口,启动时 Spring Data JPA 生成实现并注册成 Bean,save、findById(返回 Optional)、findAll、deleteById、count 全部自带。注入即用:
Task saved = repository.save(task);
Optional<Task> found = repository.findById(saved.getId());6. 派生查询方法、@Query 与分页
Spring Data JPA 会解析方法名生成查询:findBy 加属性名加连接词与关键词。
public interface TaskRepository extends JpaRepository<Task, Long> {
List<Task> findByStatus(TaskStatus status);
List<Task> findByStatusAndCreatedAtAfter(TaskStatus status, LocalDate date);
List<Task> findByTitleContaining(String keyword);
}方法名能表达的是「属性的与或非与比较」(And、Or、After、Before、Containing、OrderBy 等关键词)。它的能力边界要心里有数:一旦需要 join 关联、聚合、子查询,方法名会拗成不可读的长句甚至表达不了——别硬拗,退到 @Query:
@Query("select t from Task t where t.status = :status order by t.createdAt desc")
List<Task> findRecent(@Param("status") TaskStatus status);
@Query(value = "select * from t_task where status = :status", nativeQuery = true)
List<Task> findRaw(TaskStatus status);第一条是 JPQL:面向实体与属性(Task、t.status),换数据库不用改;第二条 nativeQuery 面向表与列,能用数据库方言特有语法,代价是绑定具体数据库。默认首选 JPQL。
分页一把抓。方法加 Pageable 参数、返回 Page,总条数与当前页数据一次拿回:
Page<Task> page = repository.findByStatus(TaskStatus.TODO, Pageable.ofSize(10).withPage(0));
long total = page.getTotalElements();
List<Task> records = page.getContent();前端列表页从此不用再发第二条 count 请求。
7. 关系映射与 N+1:现象、复现、对策
给任务挂上主人:
@Entity
@Table(name = "t_user")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
protected User() {
}
// getter/setter 略
}@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "owner_id")
private User owner;先把事实钉准确:JPA 规范里 @ManyToOne 默认 EAGER(查任务顺带把 owner 查出来),@OneToMany 默认 LAZY。实践纪律是不管默认值、所有关联显式声明 LAZY——需要什么在查询里明说,而不是让数据库白查一通。
N+1 现场复现。show-sql 开着,跑这段:
List<Task> tasks = taskRepository.findAll(); // 第 1 条 SQL
for (Task t : tasks) {
System.out.println(t.getOwner().getName()); // 每个任务再各发 1 条,共 N 条
}控制台里数出 1 + N 条 select。成因一句话:owner 是 LAZY,首次访问才发查询,而循环恰好逐个摸它。LAZY 本身不是罪——它防的是「不需要时不白查」;罪在「循环里逐个触发延迟加载」。
两种对策,各有适用:
@Query("select t from Task t join fetch t.owner")
List<Task> findAllWithOwner(); // JOIN FETCH:一条 SQL 连表取回
@EntityGraph(attributePaths = "owner")
List<Task> findAll(); // 给既有方法补抓取路径,写法声明式JOIN FETCH 写在 @Query 里,控制力强,适合查询本身复杂、要精确控制连接方式的场景;@EntityGraph 贴在仓库方法上,不写一句 JPQL 就能给 findAll 或派生方法追加关联抓取,适合「就差这一两个属性」的简单场景。另有一个帮凶要点名:Boot 默认开启 open-in-view(spring.jpa.open-in-view),数据库会话一直活到视图渲染结束,所以你在控制器里摸 LAZY 字段不报错——N+1 因此更隐蔽。启动日志里那条著名的 open-in-view 警告就是在提醒这件事,生产建议显式关闭。
8. 换 MySQL:只改配置
把 H2 依赖换成 MySQL 驱动:
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>spring:
datasource:
url: jdbc:mysql://localhost:3306/demo
username: root
password: yourpass实体一行不改、仓库一行不改。方言由 Hibernate 连上数据库后自动探测,连建表语句都会跟着换成 MySQL 语法——第 2 节那座三层塔此刻兑现红利:换的是第一层之下的东西,上两层毫无感知。
9. 分工说明:MyBatis 与事务归谁讲
两句话划清边界。第一,本篇是 JPA 主线;国内工程大量使用的 MyBatis / MyBatis-Plus 是另一条路线(SQL 全部手写、映射自己做,与「自动生成 SQL」互为镜像),请进入 050-mybatis 模块系统学习,先精通一条再对照另一条。第二,本篇多次出现「托管状态、事务提交时 flush」的说法,@Transactional 的事务语义、传播行为、失效场景,是下一篇 事务管理 的正题。
10. 实验:CRUD 五连加派生三例
@Bean
CommandLineRunner demo(TaskRepository repo) {
return args -> {
Task t = new Task();
t.setTitle("写周报");
t.setStatus(TaskStatus.TODO);
t.setCreatedAt(LocalDate.now());
repo.save(t); // INSERT
Task loaded = repo.findById(t.getId()).orElseThrow(); // SELECT
loaded.setTitle("写周报并提交");
repo.save(loaded); // 先 SELECT 比对,再 UPDATE
repo.findByStatus(TaskStatus.TODO); // 派生查询一
repo.findByStatusAndCreatedAtAfter(TaskStatus.TODO, LocalDate.now().minusDays(7));
repo.findByTitleContaining("周报"); // 派生查询三
Page<Task> page = repo.findAll(PageRequest.of(0, 10)); // 分页:count 加 limit 两条
System.out.println(page.getTotalElements() + " / " + page.getContent().size());
repo.deleteById(t.getId()); // DELETE
};
}控制台预期(列顺序以你的实体定义为准):
Hibernate: insert into t_task (created_at,status,title,id) values (?,?,?,?)
Hibernate: select t1_0.id,t1_0.created_at,t1_0.status,t1_0.title from t_task t1_0 where t1_0.id=?
Hibernate: select t1_0.id,t1_0.created_at,t1_0.status,t1_0.title from t_task t1_0 where t1_0.id=?
Hibernate: update t_task set created_at=?,status=?,title=? where id=?
Hibernate: select t1_0.id,t1_0.created_at,t1_0.status,t1_0.title from t_task t1_0 where t1_0.status=?
Hibernate: select count(t1_0.id) from t_task t1_0 where t1_0.status=?
Hibernate: select ... from t_task t1_0 where t1_0.status=? limit ?两个值得盯住的细节。第一次:save 一个已带 id 的对象,控制台会先出 SELECT 再出 UPDATE——save 对带 id 的对象走 merge 语义,先查库比对再更新。第二次:findById 查不到返回 Optional.empty;它的兄弟 getReferenceById 不发 SQL,只还你一个延迟代理,等你真去访问字段才查库、查不到抛 EntityNotFoundException——适合「只为了拿去关联」的场景。跑完本节,回头做第 7 节的 N+1 复现,把 1 + N 亲手数出来。
自检
- JPA、Hibernate、Spring Data JPA 各管什么?生成的 SQL 语法不对,该查哪一层?
- 派生方法名表达不了的查询怎么办?JPQL 与 nativeQuery 怎么选,代价各是什么?
- @Enumerated 不写会怎样?为什么 STRING 比 ORDINAL 安全?
- N+1 的成因用一句话说清;JOIN FETCH 与 @EntityGraph 各自适合什么场景?
- @ManyToOne 的 fetch 默认值是什么?@OneToMany 呢?实践纪律是什么?
- findById 查不到返回什么?getReferenceById 查不到时什么时候才炸?
- save 一个已带 id 的对象,控制台为什么先出现 SELECT 再出现 UPDATE?
- 生产环境 ddl-auto 应该设成什么?为什么?