CodeRoad Map 程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。
由 程序员柏风 独立维护 · 关于作者
学习 更多
Spring Data JPA 入门
这一课你将能做到
定义 JPA 实体并映射到表,用注解控制主键策略、列名和枚举存储
用方法名派生查询零 SQL 实现常用检索,分页一行代码搞定
对比 JPA 与 MyBatis 的适用场景,能说清 save 的更新原理和 N+1 问题
为什么要学
上一课 MyBatis 的思路是「SQL 由你写,框架帮你搬」;这一课的 JPA 反过来——你描述「实体长什么样、要查什么」,框架自己生成 SQL 。标准 CRUD 一个 SQL 都不用写,改个字段连 SQL 都不用动。
JPA(Java Persistence API,现为 Jakarta Persistence)是 ORM 规范,Hibernate 是它的参考实现,Spring Data JPA 再封装一层仓库接口。国内业务系统 MyBatis 系占多数,但 JPA 在海外项目、快速原型、领域驱动设计项目中非常常见,而且「实体映射、主键策略、关联关系」这些概念两边通用。两个都会,选型时才有话语权。
先建立图像
三层关系一图流:
你的代码
TaskRepository extends JpaRepository ← Spring Data 提供 CRUD 实现
↓ 操作的是
Task 实体(@Entity,映射 task 表)
↓ 实际执行
Hibernate(JPA 实现)负责生成 SQL、管理实体状态、脏检查
↓
数据库
JPA 最特别的概念是实体有「状态」 :从库里查出来的对象被 Hibernate「托管」,你改了它的字段,事务提交时 Hibernate 自动比对并发 UPDATE——不用显式调用 update。这个「脏检查」机制是 save 行为、N+1 问题、缓存问题的共同源头。
名词 人话 Entity 被 @Entity 标注、与表一一对应的类 Repository 数据访问接口,方法名即查询语句 持久化上下文 Hibernate 的「托管区」,管着本事务内所有实体的状态 脏检查 事务提交前对比实体快照,变了就自动 UPDATE
跟着做
步骤 1:引入依赖与配置
<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:
jpa:
hibernate:
ddl-auto: update # 开发期自动按实体更新表结构;生产禁用,用迁移工具
show-sql: true # 控制台打印 SQL
properties:
hibernate:
format_sql: true
注意与上一课互斥 :JDBC/MyBatis 与 JPA 都引时数据源共用没问题,但别让两套框架建同一张表。本课建议把上一课的 schema.sql 暂时移走,让 JPA 自己建表。
步骤 2:定义实体
package com.example.taskflow.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "task")
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // 数据库自增
private Long id;
@Column(nullable = false, length = 50)
private String title;
@Column(nullable = false)
private Integer priority;
@Enumerated(EnumType.STRING) // 枚举按名字存 varchar,别用 ORDINAL
@Column(nullable = false, length = 20)
private TaskStatus status = TaskStatus.TODO;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist // 插入前回调:自动填创建时间
public void prePersist() {
if (createdAt == null) createdAt = LocalDateTime.now();
}
// JPA 实体规范要求:必须有无参构造(protected 即可)
protected Task() { }
public Task(String title, Integer priority) {
this.title = title;
this.priority = priority;
}
// getter/setter 全部生成(IDE: Alt+Insert),此处省略
public Long getId() { return id; }
public String getTitle() { return title; }
public Integer getPriority() { return priority; }
public TaskStatus getStatus() { return status; }
public void setStatus(TaskStatus status) { this.status = status; }
public LocalDateTime getCreatedAt() { return createdAt; }
public enum TaskStatus { TODO, DOING, DONE }
}
关键注解读法:@Id + @GeneratedValue 定主键;@Column 控制列的 null/长度/列名;@Enumerated(STRING) 避免枚举按序号存储后「插队一个枚举值全表错位」的惨案;@PrePersist 是实体的初始化钩子(对应上一课 Bean 的 @PostConstruct)。
步骤 3:Repository——方法名即查询 package com.example.taskflow.repository;
import com.example.taskflow.entity.Task;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface TaskRepository extends JpaRepository<Task, Long> {
// 以下全部不用写实现,方法名解析成 WHERE 条件
List<Task> findByStatus(Task.TaskStatus status);
List<Task> findByTitleContainingOrderByPriorityAsc(String keyword);
List<Task> findByPriorityLessThanEqual(Integer maxPriority);
long countByStatus(Task.TaskStatus status);
// 复杂查询退回 JPQL(面向实体的 SQL)
@Query("select t from Task t where t.status = :status and t.priority <= :max")
List<Task> findUrgent(@Param("status") Task.TaskStatus status, @Param("max") int max);
// 分页:参数传 Pageable,返回带总数与分页信息的 Page
Page<Task> findByStatus(Task.TaskStatus status, Pageable pageable);
}
方法名关键字速查:And、Or、Between、LessThan(Equal)、GreaterThan(Equal)、Containing(LIKE %x%)、StartingWith、In、IsNull、OrderBy 字段 Asc/Desc。IDEA 会实时提示可组合的字段,写错直接标红。
步骤 4:Service 与 CRUD 验证 package com.example.taskflow.service;
import com.example.taskflow.entity.Task;
import com.example.taskflow.repository.TaskRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class TaskJpaService {
private final TaskRepository taskRepository;
public TaskJpaService(TaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
@Transactional
public Task create(String title, int priority) {
return taskRepository.save(new Task(title, priority)); // 新对象 → INSERT
}
@Transactional
public Task complete(long id) {
Task task = taskRepository.findById(id)
.orElseThrow(() -> new BusinessException(ErrorCode.TASK_NOT_FOUND));
task.setStatus(Task.TaskStatus.DONE);
return task; // 不调用 save!托管实体被修改,事务提交时自动 UPDATE
}
@Transactional(readOnly = true)
public Page<Task> page(int page, int size) {
return taskRepository.findAll(
PageRequest.of(page - 1, size, Sort.by("priority").ascending()));
}
@Transactional(readOnly = true)
public List<Task> urgent() {
return taskRepository.findUrgent(Task.TaskStatus.TODO, 3);
}
}
**「complete 不调用 save 也生效」**是 JPA 的招牌行为:findById 返回的实体在事务内被持久化上下文托管,事务提交时 Hibernate 脏检查发现 status 变了,自动发 UPDATE。忘记这一点,就会写出事务外改实体「改了个寂寞」的 bug。
Controller 直接复用第 7 课的套路(构造器注入 Service + 统一响应),不再重复。
步骤 5:分页结果长什么样 访问分页接口(page=1, size=10),返回体:
{
"content": [ {"id":1,"title":"学习 Spring Boot 配置",...} ],
"totalElements": 25,
"totalPages": 3,
"number": 0,
"size": 10
}
content 是当前页数据,totalElements 驱动前端总数展示,number 从 0 起算。对比 MyBatis 需要单独写 COUNT 语句,这里一条都没写。注意 Page 直接作为 JSON 返回会输出一堆执行时才算的字段,生产建议转成自己的分页 DTO (total、pages、records 三件套)。
步骤 6:看一眼 Hibernate 生成的 SQL Hibernate: insert into task (title,priority,status,created_at) values (?,?,?,?)
Hibernate: select t1_0.id,t1_0.created_at,t1_0.priority,t1_0.status,t1_0.title from task t1_0 where t1_0.id=?
Hibernate: update task set status=? where id=?
三步对应 create → findById → complete 的自动 UPDATE。「JPA 不写 SQL」的真相是「SQL 换个地方生成」 ——性能出问题时依然要回到 SQL 层面分析,这也是你先学 MyBatis 再学 JPA 的价值。
讲清楚要点
save 的两条路径要分清 :对象是新的(id 为 null)→ persist → INSERT;对象有 id 且被托管 → 脏检查 → UPDATE。游离对象(事务外查出来再改)save 会先 SELECT 比对再决定合并——理解这个机制才能解释「为什么改了没存上」或「为什么多了一条 SELECT」。
readOnly = true 是免费的优化 :只读事务 Hibernate 关闭脏检查快照,还能让驱动做只读优化;查询方法养成标注习惯。
ddl-auto 的生产纪律 :update/create-drop 只属于开发期;生产表结构变更用 Flyway/Liquibase 迁移脚本管理,谁能跑 SQL 变更必须有审批。
关联关系先学「用」,再学「映射」 :真实项目大量使用关联查询,但 @OneToMany/@ManyToOne 的级联、懒加载语义复杂,初学者先掌握单表 + 显式 JOIN 查询(@Query),不着急上完整对象图。
N+1 问题(进阶必知) :查 1 次 list 再对每条记录查 1 次关联 = 1 + N 条 SQL。解法:@EntityGraph、JOIN FETCH 的 JPQL。先用 SQL 日志训练自己「看见 N+1」。
你可能会懵的地方
JPA、Hibernate、Spring Data JPA 三个名字什么关系?
JPA 是规范(接口和注解标准);Hibernate 是实现(真正生成 SQL 的引擎);Spring Data JPA 是 Spring 的封装(Repository 抽象、方法名解析、事务集成)。你写的代码对着 Spring Data JPA,底层干活的是 Hibernate。
为什么实体必须有 protected 无参构造?
Hibernate 通过反射创建实例再逐字段填充,没有无参构造它无从下手。这是规范的硬性要求,别把构造器全私有化。
@Transactional 加在哪一层?
Service 层。Repository 的内置方法自带事务(SimpleJpaRepository 已标注),但跨多个 Repository 操作时必须在 Service 方法上开事务,保证「多个写操作同生共死」——下一课专门展开。
id 用 Long 还是 long?
Long。新增场景 id 还没生成,基本类型 long 只能是 0,无法表达「未持久化」;包装类型 null 语义清晰,save 后 Hibernate 回填。
JPA 能写原生 SQL 吗?
能:@Query(value = "...", nativeQuery = true),返回原生类型或投影接口。复杂报表、DBA 优化过的 SQL 都走这条路——「纯 JPA 不写 SQL」从来不是教条。
错法对照 错法 问题 正解 事务外修改实体(没 @Transactional) 脏检查不触发,改了不生效 修改操作放进事务方法 枚举用 @Enumerated(ORDINAL) 插入新枚举值导致历史数据语义错乱 @Enumerated(EnumType.STRING) 生产环境 ddl-auto: update 表结构失控,无变更记录 生产用 Flyway/Liquibase 实体当接口出入参直接返回 懒加载字段序列化报错、字段耦合 返回独立 DTO findById 放循环里逐条查 N+1,DB 压力爆炸 批量 findByIdAll / JOIN FETCH
卡点急救
启动报 No identifier specified for entity :实体没标 @Id,或注解 import 错成了 Spring 的(必须是 jakarta.persistence.Id)。
修改实体不生效 :99% 是不在事务内。给 Service 方法加 @Transactional;确认调用经过代理(同类 this 调用会失效,参考第 6 课)。
PersistentObjectException: detached entity passed to persist :给「已有 id 的对象」save 却被当成新增。更新场景先 findById 再改字段,或用 merge 语义(save 本身兼容,但要保证主键策略正确)。
建表没生成 :ddl-auto 配置确认是 update/create;检查实体是否在扫描包内;H2 内存库每次重启清空属正常,用 DB_CLOSE_DELAY=-1 保持会话内数据。
面试常问
save 和 saveAndFlush 的区别?save 的更新原理?
save 按主键判断新增或更新:新对象 persist,已有对象 merge;INSERT/UPDATE 通常延迟到事务提交或 flush 时执行。saveAndFlush 立即刷库(同事务内要用生成的主键或捕获约束异常时用)。更新的本质是持久化上下文的脏检查,不是显式 UPDATE 语句。
什么是 N+1 问题?怎么解决?
查询主表 1 次后,对每条记录再发 1 次关联查询,共 1+N 条 SQL。解决:JPQL 的 JOIN FETCH、@EntityGraph、批量 IN 查询;发现手段是开 SQL 日志观察条数。
JPA 与 MyBatis 怎么选型?
JPA:标准 CRUD、领域模型驱动、跨库移植、快速开发占优;MyBatis:SQL 可控、复杂关联与调优、DBA 协作友好、国内生态更广。混合使用也是常见实践:CRUD 走 JPA、报表走原生 SQL。
JPA 实体的生命周期状态?
瞬态(new 出来未托管)、托管(在持久化上下文中,脏检查生效)、游离(session 关闭后仍带 id)、删除(待删除)。托管态的修改会在 flush 时自动同步,这是理解一切 JPA 怪现象的钥匙。
自测三问
「不调用 save 直接改实体字段也能落库」的机制叫什么?它的前提条件是什么?
方法名派生查询 findByStatusAndPriorityLessThan 会生成什么样的 WHERE?
ddl-auto 四个值 update / create / create-drop / validate 各适合什么环境?
小结一句话 JPA 把「实体 ↔ 表」双向映射交给框架,标准 CRUD 零 SQL、分页一行搞定——但托管与脏检查的心智模型不建立,坑就藏在「为什么没存上」里。
延伸