从 MyBatis 到 JPA:一个 CRUD 程序员的认知重建
环境:Spring Boot 3.2.7 | Spring Data JPA 3.2.7 | Hibernate 6.4 | JDK 21 | H2 内存数据库
引言
如果你进新公司第一天,clone 代码,打开项目。
@Entity、@OneToMany、@ManyToOne、EntityManager、JPQL......满屏都是你没见过的注解。翻遍整个项目,找不到一行 SQL,也找不到一个 XML Mapper。
因为你之前是写 MyBatis 的。
在 MyBatis 的世界里,SQL 就是安全感。接口慢?打开 XML 看 SQL。数据不对?打开 XML 看 SQL。排查问题?打开 XML 看 SQL。一切尽在掌控。
但现在,调一个 departmentRepository.findById(1L),日志里蹦出来七八条 SQL------有些我根本不知道是谁发的、为什么发、发了几条。
对此你不必焦虑。这篇文章不是 JPA 教程,也不是"JPA 比 MyBatis 好"的布道文。它记录的是一个习惯了 SQL-first 思维的开发者,在被迫使用 ORM-first 框架后,经历的四次认知重建。如果你也面临同样的切换,这些内容可以帮你少走一周的迷茫。
环境信息
| 组件 | 版本 |
|---|---|
| Spring Boot | 3.2.7 |
| Spring Data JPA | 3.2.7 |
| Hibernate | 6.4.10.Final |
| JDK | 21 |
| 数据库 | H2(测试)/ MySQL 8.4(生产) |
项目结构
bash
jpa-mindset-shift/
├── pom.xml
└── src/
├── main/java/com/coderplus/jpademo/
│ ├── Application.java
│ ├── entity/
│ │ ├── Employee.java # 员工实体
│ │ ├── Department.java # 部门实体(1:N)
│ │ └── Project.java # 项目实体(N:1,关联到 Employee)
│ ├── repository/
│ │ ├── EmployeeRepository.java
│ │ └── DepartmentRepository.java
│ └── service/
│ └── EmployeeService.java # 四个要点的演示方法
└── test/java/com/coderplus/jpademo/
├── Point1_DirtyCheckingTest.java # 要点1:我没调 save,数据怎么变了?
├── Point2_NPlusOneTest.java # 要点2:查 1 条数据,SQL 刷了 51 行?
├── Point3_DetachedEntityTest.java # 要点3:数据一会儿存进去了,一会儿又没了?
└── Point4_CascadeTest.java # 要点4:删一个员工,部门怎么也没了?
认知重建之前:先理解 MyBatis 和 JPA 在"争"什么
在进入具体要点之前,先花一分钟把这两种框架的底层思路搞清楚。很多困惑的根本原因,不是 JPA 难用,而是你用 MyBatis 的思维模型去套 JPA------就像用开手动挡的习惯去开自动挡,每个自动换挡的动作都让你觉得"车失控了"。
sql
MyBatis 的思维模型:
你 → 写 SQL → 数据库执行 → 结果映射到对象
你是导演。每一帧画面都是你安排的。
JPA 的思维模型:
你 → 操作对象 → 框架生成 SQL → 数据库执行 → 对象状态自动同步
你是编剧。你写剧本(映射规则),导演(Hibernate)负责拍。
MyBatis 是 SQL-centric(SQL 优先),JPA 是 Object-centric(对象优先)。 这不是一个比另一个"高级",而是两种范式。理解了这一点,下面的四个要点你就能用"哦,原来 JPA 是这样想的"的心态去看,而不是"这什么破设计"。
要点 1:我没调 save,数据怎么自己变了?
场景
进公司第二天,我们在 Service 里写了一段逻辑:
java
@Transactional
public void updateEmployeeSalary(Long empId, BigDecimal newSalary) {
Employee emp = employeeRepository.findById(empId).orElseThrow();
emp.setSalary(newSalary);
emp.setUpdateTime(LocalDateTime.now());
// 我故意没调 employeeRepository.save(emp)
// 想看看不调 save 会不会报错
}
调用这个方法后,去数据库里查,会发现------salary 和 update_time 竟然真的更新了。
你可能会问:"等一下,没写 UPDATE,数据库怎么变了?"
SQL 日志揭示了真相
vbnet
Hibernate: select e1_0.id, e1_0.department_id, e1_0.name, e1_0.salary, e1_0.update_time
from t_employee e1_0 where e1_0.id=?
Hibernate: update t_employee set department_id=?, name=?, salary=?, update_time=?
where id=?
事务提交的时候,Hibernate 自动生成了 UPDATE。即使我根本没调 save()。
发生了什么:持久化上下文和脏检查
JPA 有一个核心概念叫持久化上下文(Persistence Context)------你可以把它理解为一层"对象快照缓存":
ini
┌─ Persistence Context ──────────────────────────────┐
│ │
│ Employee{id=1, name="张三", salary=10000} │ ← 快照(刚查出来时的状态)
│ Employee{id=1, name="张三", salary=15000} │ ← 当前对象(你改了 salary)
│ │
│ 事务提交时,Hibernate 逐字段比较快照和当前值 │
│ 发现 salary 变了 → 生成 UPDATE │
│ │
└─────────────────────────────────────────────────────┘
这个机制叫 Dirty Checking(脏检查) 。findById() 查出来的实体是"托管态(Managed)"的------Hibernate 拿着它的快照。当持久化上下文**刷新(flush)**时,Hibernate 遍历所有托管态实体,逐个字段和快照对比。有变化?自动生成 UPDATE。
flush 会在以下三个时机触发:
- 事务提交前(最常遇到)
- 显式调用
entityManager.flush()或repository.flush() - 执行 JPQL/HQL 查询前(auto-flush,防止查询结果与内存中的脏数据不一致)
所以准确地说:不是"提交时才同步",而是"flush 时同步,提交时会触发 flush"。
JPA 的四种实体状态
理解这个行为的关键,是搞清楚 JPA 实体的四种生命周期状态:
| 状态 | 含义 | 触发方式 |
|---|---|---|
| Transient(瞬时态) | 刚 new 出来的对象,和数据库没任何关系 | new Employee() |
| Managed(托管态) | 在持久化上下文中,有快照,会自动同步 | findById() / persist() / merge() |
| Detached(游离态) | 数据库有这条记录,但当前持久化上下文不管它 | 事务提交后 / detach() / clear() |
| Removed(删除态) | 标记为待删除,事务提交时生成 DELETE | remove() |
Dirty Checking 只对 Managed 状态的实体生效。 这就是为什么同一个对象在事务里改了会更新,在事务外改了就没反应(要点 3 会展开讲)。
MyBatis 思维 → JPA 思维的转变
| MyBatis 的思路 | JPA 的思路 |
|---|---|
| "我要更新数据,所以我得写 UPDATE" | "我改了对象,框架应该自己同步" |
| 数据库操作是显式的 | 数据库操作是隐式的(由状态变化触发) |
| 控制感来自每一行 SQL | 控制感来自理解状态机规则 |
认知重建 #1:JPA 不是你忘了调 save,而是它的设计里,"调 save"本就不是必须的。托管态实体的事务提交 = 自动同步。你需要关注的不是"该不该调 save",而是"这个对象现在是什么状态"。
要点 2:查 1 条员工信息,SQL 日志刷了 51 行
场景
我们在 Controller 里写了一个简单的查询接口:
java
@GetMapping("/employees/{id}")
public Employee getEmployee(@PathVariable Long id) {
return employeeRepository.findById(id).orElseThrow();
}
调用这个接口,控制台的 SQL 日志直接刷屏了:
vbnet
Hibernate: select ... from t_employee where id=?
Hibernate: select ... from t_department where id=?
Hibernate: select ... from t_project where owner_id=?
Hibernate: select ... from t_project where owner_id=?
Hibernate: select ... from t_project where owner_id=?
...(每个关联一层,每条关联一次查询)
查一个员工,发了 N 条 SQL。生产环境这么搞,数据库直接被打穿。
实体关系图
问题出在实体设计上:
java
@Entity
public class Employee {
@Id @GeneratedValue
private Long id;
private String name;
private BigDecimal salary;
@ManyToOne(fetch = FetchType.LAZY) // 注意:@ManyToOne 默认是 EAGER,必须显式设为 LAZY
private Department department;
@OneToMany(mappedBy = "owner") // @OneToMany 默认就是 LAZY
private List<Project> projects;
}
findById() 触发了第一条 SQL 查出 Employee。但问题在后面------Spring MVC 序列化这个 Employee 为 JSON 时,Jackson 会调用 getDepartment()、getProjects()、getTasks()......每次访问一个 LAZY 关联属性,Hibernate 就去数据库查一次。
这就是经典的 N+1 问题:1 条主查询 + N 条关联查询。
MyBatis 的典型用法中很少遇到这种情况------因为多数开发者习惯每个查询单独写 SQL,要什么 JOIN 就写什么 JOIN。但严格来说,MyBatis 的 <collection> 映射配合 fetchType="lazy" 同样可能产生 N+1,只是触发路径不像 JPA 这么"隐形"。JPA 里关联是声明在实体上的,你不主动碰它,它也可能被框架(JSON 序列化、toString()、调试器的变量查看)意外触发------这种"明明不是我写的 SQL,为什么在跑"的感觉才是最容易让人困惑的地方。
三种解法
方案 A:JOIN FETCH ------ 手写 JPQL,一次查完
java
@Query("SELECT e FROM Employee e " +
"JOIN FETCH e.department " +
"JOIN FETCH e.projects " +
"WHERE e.id = :id")
Optional<Employee> findByIdWithDetails(@Param("id") Long id);
生成的 SQL 只有一条,三个表一次 JOIN 拿回来。
方案 B:@EntityGraph ------ 声明式指定加载策略
java
// 在 Repository 方法上直接声明要加载的关联属性
@EntityGraph(attributePaths = {"department", "projects"})
Optional<Employee> findWithGraphById(Long id);
不写 JPQL,用注解声明"这个查询要带上哪些关联"。Spring Data JPA 会自动生成对应的 JOIN FETCH SQL。适合不想写 SQL 的场景。
方案 C:@BatchSize ------ 把 N+1 优化成 N/10+1
java
@BatchSize(size = 10)
@OneToMany(mappedBy = "owner")
private List<Project> projects;
Hibernate 会把 100 条独立懒加载查询合并成 10 条 WHERE owner_id IN (?,?,?,?,?,?,?,?,?,?)。不是根治,但在不方便改 JPQL 时是一个轻量优化。Demo 中未演示此配置,你可以自行在实体上加 @BatchSize 注解对比前后 SQL 条数。
一个隐藏的雷:MultipleBagFetchException
当你尝试同时 JOIN FETCH 两个 @OneToMany(List 类型)时:
java
@Query("SELECT e FROM Employee e " +
"JOIN FETCH e.projects " +
"JOIN FETCH e.tasks " + // ← 第二个 List 关联
"WHERE e.id = :id")
Hibernate 直接抛异常:MultipleBagFetchException ------不能同时急加载多个 "Bag"(Hibernate 把没加 @OrderColumn 的 List 视为 Bag)。
解法:将其中一个 List 改成 Set,或者只 JOIN FETCH 一个 @OneToMany,另一个靠 @BatchSize 兜底。这个异常是很多 JPA 新手的第一个"框架报错为什么我看不懂"时刻,提前知道就不慌了。
什么时候用哪个?
less
查询场景简单、关联少 → @EntityGraph(声明式,够用)
查询场景复杂、多条件排序 → JOIN FETCH JPQL(灵活控制)
关联特别多、不经常一起查 → @BatchSize(轻量优化)
JSON 序列化场景 → 别直接返回 Entity!用 DTO
多个 @OneToMany 要一起 FETCH → 其中一个改成 Set,或只用 @BatchSize
Spring Boot 3.x 的一个关键默认值
Spring Boot 3.x 默认 spring.jpa.open-in-view=false(2.x 时代默认是 true)。这意味着 Controller 里返回 Entity 时,如果 JSON 序列化触发懒加载,会直接抛 LazyInitializationException,而不是默默地发 N 条 SQL。这是好事------问题直接暴露,比静悄悄拖垮数据库强 。但如果你升级到 3.x 后突然发现 Controller 接口报 LazyInitializationException,这就是原因------你应该用 DTO 或 JOIN FETCH 来根治,而不是把 open-in-view 改回 true。
认知重建 #2:MyBatis 里 SQL 是你写的,每行都有心理预期。JPA 里 SQL 是框架生成的,你必须开着 SQL 日志,否则性能和问题都是"黑盒"。默认懒加载不代表安全------序列化、调试、日志都可能意外触发。查了什么、查了多少条,心里要有数。
要点 3:数据一会儿存进去了,一会儿又没了
场景
写了一个 Controller,接收前端传来的员工 ID 和新工资,更新后返回:
java
@PutMapping("/employees/{id}/salary")
public Employee updateSalary(@PathVariable Long id,
@RequestParam BigDecimal salary) {
Employee emp = employeeRepository.findById(id).orElseThrow();
emp.setSalary(salary);
employeeRepository.save(emp); // 我特意调了 save
return emp;
}
试了几次,有的请求正常更新了,有的没反应。同一个代码,行为不一致------这是最让人抓狂的情况。
根因:Detached Entity 和 save() 的实际行为
问题出在方法上没有 @Transactional。
java
// 没有 @Transactional 的情况
public Employee updateSalary(Long id, BigDecimal salary) {
// 1. findById 在一个隐式事务里执行
// 查完后事务提交 → emp 变成 Detached 状态
Employee emp = employeeRepository.findById(id).orElseThrow();
// 2. 此时 emp 已经是 Detached 了
emp.setSalary(salary);
// 3. save() 对 Detached 实体的行为是 merge():
// 去数据库查一条 → 把你的值复制过去 → 返回一个新的 Managed 实体
employeeRepository.save(emp);
// 4. 但!如果 salary 和原值一样,或者某些条件下
// merge 可能不触发 UPDATE(取决于 Hibernate 的脏检查判断)
return emp; // 返回的还是 Detached 实体
}
而加上 @Transactional 后:
java
@Transactional // ← 整个方法在一个事务里
public Employee updateSalary(Long id, BigDecimal salary) {
// findById 出来的 emp 全程是 Managed 状态
Employee emp = employeeRepository.findById(id).orElseThrow();
emp.setSalary(salary);
// 不需要调 save()。事务提交时 Dirty Checking 自动生成 UPDATE
return emp;
}
正确姿势
css
┌─────────────────────────────────────────────────────┐
│ │
│ Controller 层 │
│ ├── 接收请求,调 Service │
│ ├── 不要在这里操作 Entity │
│ └── 不要直接返回 Entity(用 DTO) │
│ │
│ Service 层 │
│ ├── @Transactional 标注 │
│ ├── 在这里改 Entity 属性 │
│ └── 不要跨方法传递 Managed Entity │
│ │
│ 事务边界 = Service 方法 │
│ 事务内 = Managed 实体,改了自动同步 │
│ 事务外 = Detached 实体,改了没人管 │
│ │
└─────────────────────────────────────────────────────┘
认知重建 #3:MyBatis 的世界是"无状态"的------每次数据库操作独立,调了 mapper 就生效。JPA 的世界是"有状态"的------实体的状态和事务边界强绑定。同一个对象,在事务里是 Managed(自动同步),在事务外是 Detached(改了白改)。这就是为什么 JPA 的最佳实践是"业务逻辑写在 Service、事务边界在 Service"。
要点 4:删了一个员工,部门数据怎么也丢了?
场景
我们的 Employee 实体有一个 @OneToMany 关联到 Project:
java
@Entity
public class Employee {
// ...
@OneToMany(mappedBy = "owner", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Project> projects = new ArrayList<>();
}
如果给给 projects 设了 CascadeType.ALL------以为"这样就不用单独保存 Project 了,操作 Employee 的时候顺便把 Project 也管了吧。"
然后有一天,有个方法从员工名下移除一个项目:
java
@Transactional
public void removeProjectFromEmployee(Long empId, Long projectId) {
Employee emp = employeeRepository.findById(empId).orElseThrow();
emp.getProjects().removeIf(p -> p.getId().equals(projectId));
employeeRepository.save(emp);
}
你会发现------项目不仅从员工的列表里移除了,整条 Project 记录从数据库里被物理删除了。 其他同事关联到这个项目的记录全丢了。
根因:orphanRemoval + CascadeType.ALL
orphanRemoval = true 的含义是:如果一个子实体从父实体的集合中被移除,且不再被任何其他父实体引用,就 DELETE 它。
CascadeType.ALL 代表所有级联操作都会传播到子实体,包括 REMOVE。
这两者组合在一起:
sql
emp.getProjects().remove(project)
→ project 从集合中移除
→ orphanRemoval 判定:这个 project 成了"孤儿"
→ CascadeType.ALL 允许级联删除
→ 事务提交:DELETE FROM t_project WHERE id = ?
和要点 1 一样,你没有显式调 delete(),但删库行为已经发生了。
正确处理方式
java
// ❌ 错误:CascadeType.ALL + orphanRemoval,一锅端
@OneToMany(mappedBy = "owner", cascade = CascadeType.ALL, orphanRemoval = true)
// ✅ 正确:只级联需要的操作,删除用手写 JPQL
@OneToMany(mappedBy = "owner", cascade = {CascadeType.PERSIST, CascadeType.MERGE})
private List<Project> projects;
// 需要删除时,显式调用 Repository
@Modifying
@Query("DELETE FROM Project p WHERE p.id = :id AND p.owner.id = :ownerId")
void deleteByIdAndOwner(@Param("id") Long id, @Param("ownerId") Long ownerId);
级联操作速查表
| Cascade 类型 | 行为 | 什么时候用 |
|---|---|---|
PERSIST |
保存父实体时自动保存子实体 | 父子关系紧密、子实体生命周期完全由父管理 |
MERGE |
合并父实体时自动合并子实体 | 同上 |
REMOVE |
删除父实体时自动删除子实体 | 谨慎使用,确认子实体不被其他父引用 |
REFRESH |
刷新父实体时自动刷新子实体 | 很少用 |
DETACH |
父实体脱管时子实体也脱管 | 很少用 |
ALL |
以上全部 | 基本上不要用 |
认知重建 #4:MyBatis 里"删了什么"是显式写在 SQL 里的。JPA 里级联操作是声明在实体关系上的,删了什么取决于你的注解怎么写的。CascadeType.ALL 看起来省事,实际上是埋雷。永远显式声明级联范围,删除操作建议用手写 JPQL 而不是依赖级联。
不只是适应,有些东西确实更优雅
前面四个要点聚焦在"认知切换的阵痛"。但坦诚地说,用 JPA 两周后,你会开始感受到一些 MyBatis 做不到的爽点:
1. 方法命名查询------简单查询不用写 SQL
java
// MyBatis:需要写 XML 或注解 SQL
@Select("SELECT * FROM t_employee WHERE department_id = #{deptId} AND salary > #{minSalary}")
List<Employee> findByDeptAndMinSalary(@Param("deptId") Long deptId, @Param("minSalary") BigDecimal min);
// JPA:方法名即查询
List<Employee> findByDepartmentIdAndSalaryGreaterThan(Long departmentId, BigDecimal salary);
JPA 会根据方法名自动生成 SQL。简单查询不需要写任何 JPQL 或 SQL。
适用场景:2-3 个条件的简单查询。如果条件超过 3 个或者需要 JOIN,还是建议写 JPQL------方法名会变得不可读(findByDepartmentNameAndSalaryBetweenAndProjectsTitleContainingOrderByUpdateTimeDesc 谁看得懂?)。
2. 自动 DDL------开发阶段不用手动建表
yaml
spring.jpa.hibernate.ddl-auto: update # 开发环境
改实体字段 → 重启 → 表结构自动同步。虽然生产环境不要用(用 Flyway/Liquibase 管理 DDL),但开发阶段省了大量 ALTER TABLE 的手动操作。
3. @Version 乐观锁------一行注解解决并发更新
java
@Entity
public class Employee {
@Version
private Long version;
}
MyBatis-Plus 需要配置乐观锁插件,还得在 SQL 里手动加 version = version + 1。JPA 一个注解搞定------Hibernate 自动在 UPDATE 的 WHERE 里加 version = ?,并在更新后对 version 自增。
核心领悟:这是两种范式,不是两种工具
它不是"谁更好",而是"谁适合什么样的场景":
| 维度 | MyBatis | JPA |
|---|---|---|
| 核心思维 | SQL 优先:我想查什么,我就写什么 SQL | 对象优先:我操作对象,框架负责持久化 |
| 控制层级 | 控制每一行 SQL | 控制映射规则和状态机 |
| 入门门槛 | 低:会 SQL 就能干活 | 高:理解 ORM 状态机、级联、懒加载才能不踩雷 |
| 简单 CRUD | 手写 SQL 或代码生成器生成 | 方法命名查询或 JpaRepository 内置方法 |
| 复杂查询 | 天然优势:SQL 直接写,优化手段多 | 需要 JPQL / Criteria API / 原生 SQL 兜底 |
| 关联管理 | 自己写 JOIN,查多查少心里有数 | 声明式关联,不控制就 N+1 |
| 数据库变迁 | 改 SQL(每个 SQL 都要改) | 改实体映射(框架适配方言) |
| 最适合场景 | 复杂查询多、多表关联、遗留数据库、报表系统 | CRUD 密集、对象关系复杂、DDD、需要快速迭代 |
| 不适合场景 | 大量简单 CRUD 写到手酸 | 复杂报表、多表联查、需要精细控制 SQL |
一句话总结:JPA 不是 MyBatis 的升级版------它是另一条路。 理解了这一点,那些让你困惑的设计就不再是 bug,而是 trade-off。你不需要在两者之间选一个"正确的"------你需要的是理解各自的取舍,然后在合适的场景里用合适的工具。
总结:四个认知重建
-
从"显式 UPDATE"到"托管态 Dirty Checking" :JPA 里调不调
save()不重要,重要的是对象在不在托管态。事务内改了属性 = 自动同步。你要关注的不再是 SQL 本身,而是实体的生命周期状态。 -
从"SQL 写多少查多少"到"声明式关联" :MyBatis 里你不写 JOIN 就没有额外查询。JPA 里关联声明在实体上,JSON 序列化、
toString()、调试器都可能意外触发 N+1。SQL 日志必须开着,JOIN FETCH 和 @EntityGraph 是必备技能。 -
从"无状态操作"到"事务边界决定一切" :MyBatis 每次 mapper 调用独立生效。JPA 里实体状态和事务生命周期绑定------事务内 Managed(自动同步),事务外 Detached(改了白改)。Service 层加
@Transactional不是可选项,是基础要求。 -
从"删什么自己写"到"声明式级联" :JPA 的 Cascade 是 ORM 层面的,和数据库外键 CASCADE 是两回事。别用
CascadeType.ALL,显式声明级联范围。删除操作建议用@Modifying+ JPQL 显式执行。
完整源码
Demo 代码已开源,包含本文四个要点的可运行测试用例:
🔗 Gitee:
https://gitee.com/gcchech/articles-demo(jpa-mindset-shift模块)
本地运行方式:
bash
git clone https://gitee.com/gcchech/articles-demo.git
cd articles-demo/jpa-mindset-shift
mvn test
每个测试方法对应一个要点,SQL 日志已配置为输出到控制台,你可以亲眼看到每一条生成的 SQL。
如果你是 JPA 老手,欢迎在评论区补充你当年从 MyBatis 转 JPA 的认知转变经历。如果你正在经历这个切换,留言说说你最大的困惑是什么------大概率不止你一个人遇到的。