SpringBoot4系列03 - 集成Spring Data JPA与Hibernate 7
上一篇结尾我留了个约定:OrderRepository 还要再换一次实现,如果接口还撑得住,分层就算做对了。这篇换 Spring Data JPA,我来兑现这句话。
结论先放这儿:接口契约一个字没改,Controller、DTO、状态机、错误响应全部原样。但说实话,这是系列里坑最多的一篇,比前两篇加起来还多,而且大多不在「报错」,在「悄悄按另一套规则跑」,等你发现时数据已经不对了。
最大的坑跟 JPA 没关系。我按老习惯去数 SQL 日志里的 Hibernate: 行数,数出来是 0。一条 SQL 都没发?应用明明在正常工作。对着日志看了半天才反应过来:Hibernate 7 的 SQL 日志走 org.hibernate.SQL 这个 logger,行首没有 Hibernate: 前缀,grep "Hibernate:" 数出来恒等于 0。拿着错的前提,之后所有推断都会被带偏,这事后面单独说。
代码全部可跑,工程在 springboot4-jpa 目录下,还是那台树莓派上的 MySQL,建表脚本一条命令跑完。
JPA、Hibernate、Spring Data JPA 这三层
这几个名字经常被混用,但它们是三件东西,各管一段。

最下面是 Hibernate,JDBC 之上的 ORM 实现,把对象图翻译成 SQL、管理持久化上下文、做脏检查。只引它加 JDBC 也能跑,配置和事务自己管。
中间是 Jakarta Persistence,常说的 JPA,规范,只定义接口和注解,EntityManager、@Entity、@Version、@ManyToOne 都是它定的。Hibernate 是实现之一,主流到大家默认 JPA 就是 Hibernate。
最上面是 Spring Data JPA,规范之上再包的一层,解决「怎么少写代码」:OrderRepository extends JpaRepository 白拿 CRUD 和分页,方法名写 findByOrderNo 就能生成查询,跟数据库方言没关系。
我排查问题时第一步就是分清在查哪一层:报错来自 Hibernate,注解来自 Jakarta Persistence,代码风格来自 Spring Data。搜索词带对了,能省一半时间。
改造成本也由分层决定:MyBatis-Plus 的注解(@TableName、@TableField、@TableLogic)全部失效;OrderStatus 状态机、OrderPageQuery 白名单、DTO 组装这些纯业务代码原封不动。

贴数据库的层重写,贴业务的层不动。把这条界线画出来之后,工作量就清楚了:要重写的只有中间一层。
4.1.1 的几个关键事实
版本号我先查清楚,spring-boot-dependencies:4.1.1 的 BOM 锁的是下面这些。别抄二手资料,mvn dependency:tree 跑一遍最快。
| 组件 | 版本 |
|---|---|
| Hibernate ORM | 7.4.5.Final |
| Jakarta Persistence | 3.2.0 |
| Hibernate Validator | 9.1.3.Final |
| Spring Data BOM | 2026.0.1 |
这一篇的重点:JPA 的自动配置拆成了三个模块。
| 模块 | 里面装了什么 |
|---|---|
spring-boot-jpa | JpaProperties、JpaBaseConfiguration、EntityManagerFactoryBuilder |
spring-boot-hibernate | HibernateJpaAutoConfiguration、HibernateMetricsAutoConfiguration、HibernateProperties |
spring-boot-data-jpa | DataJpaRepositoriesAutoConfiguration |
写成 org.springframework.boot.autoconfigure.orm.jpa.* 编译直接过不去;要自定义 EntityManagerFactory、继承 JpaBaseConfiguration、import JpaProperties,类都从上表三个模块里找。我第一次 import 就是按老包名敲的,IDE 没给提示,我还以为是索引没建好。
测试注解散成三个包,IDE 补全帮不上忙,只能去制品里翻。
| 注解 | 4.1.1 所在包 |
|---|---|
@DataJpaTest | ...boot.data.jpa.test.autoconfigure |
| TestEntityManager | ...boot.jpa.test.autoconfigure |
@AutoConfigureTestDatabase | ...boot.jdbc.test.autoconfigure |
@AutoConfigureMockMvc(第 1 篇的) | ...boot.webmvc.test.autoconfigure |
每换一个技术栈,测试注解的包名都得重新确认一遍,照抄上一篇的 import 每篇都编译失败,我在这篇就栽过。
两个配置项跟直觉不一样。spring.jpa.open-in-view 默认还是 true:EntityManager 绑定整个请求生命周期,Controller 里碰懒加载集合不报错,代价是每请求白占一个数据库连接,慢查询被藏起来。我在配置里关了它,然后撞上一个必踩的错,后面讲。
spring.jpa.defer-datasource-initialization 还在生效,但元数据搜不到:它不是 JpaProperties 的字段,由 JpaDependsOnDatabaseInitializationDetector 从 Environment 直读。按「字段没了加元数据搜不到」会推出「配置被删了」,写进 yml 照样起作用。我的结论:判断配置项还在不在,得看字节码。
还有 spring.jpa.bootstrap,取值 DEFAULT 或 ASYNC,控制 EntityManagerFactory 初始化方式;选 ASYNC 要自己提供 BootstrapExecutor,不然抛 BootstrapExecutorRequiredException。本工程没用到,提一句备查。
依赖和配置
JPA 不需要第三方 starter,官方第一个就够。
| |
四个依赖各管各的:webmvc 承载接口,validation 接住参数校验,data-jpa 把 Hibernate 和 Spring Data 一起带进来,MySQL 驱动只在运行期用。
测试依赖单独引。
| |
spring-boot-starter-data-jpa-test 自带四样:spring-boot-starter-data-jpa、spring-boot-starter-test、spring-boot-starter-jdbc-test、spring-boot-data-jpa-test,前面三个测试注解从这里出来。
配置的关键几行。
| |
ddl-auto 给 validate 而不是 none:启动时对一遍实体和表结构,对不上直接启动失败。错位通常跑到某条 SQL 才暴露,那时排查成本已经出去半天。
show-sql 配 false,SQL 日志走 logging.level;它的输出和 org.hibernate.SQL logger 是同一份,决定能不能看到 SQL 的是 logger 级别。
这里没有配 spring.sql.init:它会在启动时自动执行建表脚本,schema.sql 里有 DROP TABLE,配上就是每次启动清一次库。建表脚本手动执行一次,写在 README。第 2 篇提过这个坑,抄配置时容易顺手带上。
核心代码
实体上的注解
先看实体,跟上一篇并排一比就是两套规则。

| |
注解名字全换了,没变的是「不写会怎样」。
@GeneratedValue(strategy = GenerationType.IDENTITY) 不写主键不走自增。JPA 默认策略 AUTO,Hibernate 按方言选,MySQL 上通常落到 IDENTITY;写死的代价是拿不到预分配 id,批量插入退化成一条条发。
@Enumerated(EnumType.STRING) 不写会出事:默认 ORDINAL 存序号,status 列进的是 0、1、2,枚举中间插一个状态,历史数据语义全错位且无提示。
@Version 不写就是无保护的覆盖写,它还有个超出直觉的边界,后面讲。@EntityListeners(AuditingEntityListener.class) 配合 @CreatedDate、@LastModifiedDate,时间戳由框架回填。@SoftDelete(columnName = "deleted") 是这一篇最重要的注解,接管 delete 语义,后面单独一节讲。
关联是双向一对多:@OneToMany(mappedBy = "order") 表示订单是被动方,关联由 OrderItem.order 维护;cascade = CascadeType.ALL 让保存删除传导到明细;orphanRemoval = true 表示移出集合的明细当孤儿删掉。fetch = FetchType.LAZY 我显式写了,默认值就是 LAZY,后面讲抓取策略改的就是这一行。
addItem 我收成一个方法。
| |
setOrder 包级可见。双向关联必须两边引用同时维护:外部代码自己 order.getItems().add(item),明细的 order 字段是 null,落库外键就是 null,不报错,查的时候才发现明细丢了。
审计字段,以及那个精度坑
JPA 不用写 MetaObjectHandler,启动类加一个开关。
| |
@EnableJpaAuditing 是全局开关,带 @CreatedDate 的实体都走审计。
DateTimeProvider 必须有,这是我第一版漏掉后自己加回来的。AuditingEntityListener 默认取 LocalDateTime.now() 带完整纳秒,created_at 是 DATETIME(3) 只存毫秒。我第一版跑出来的原始数据:
| |
按 id 再查同一个订单:
| |
同一字段两个值,客户端拿创建时间戳做等值比较、算 If-Modified-Since 永远匹配不上。在源头截断到毫秒,加完两边一致:
| |
把列改成 DATETIME(6) 喂到微秒这条路我也想过,放弃了:差异只是从毫秒挪到微秒,两边取时间时机不同就对不上。根在「精度不一致」,不在「精度不够」。上一篇在 MetaObjectHandler 截断,这篇在 DateTimeProvider 截断,同一类问题换个框架会在另一个地方冒出来。
一对多和孤儿移除
orders 和 order_items 是两张表,不是第 2 篇那种一列 JSON,明细增删改单独处理。更新订单时我这样写:
| |
items 是 orphanRemoval = true 的集合,clear() 后移出的明细在 flush 时被删。用「全清再加」不做「比对差异」,PUT 的语义就是整体替换。
本篇特有现象:实体有 @SoftDelete,孤儿移除生成的是 UPDATE,不是 DELETE。我整轮验证下来 delete 0 条、update 6 条,其中 3 条长这样:
| |
明细剔除、级联删除全变成软删除,第一次看到我还是愣了一下。
四种查询方式,以及什么时候用哪种
查数据的路数有几条,文档分散在各处,我放一起对比。

派生查询,方法名就是查询:
| |
一行实现不用写,By 后跟条件,OrderBy 后跟排序,And、Or、Like、GreaterThan 都能拼。条件一多名字就长到读不下去,这时换下一种。
JPQL 写实体和属性名:
| |
Order 是实体名不是表名 t_order,o.totalAmount 是属性名不是列名 total_amount,和 MyBatis 原生 SQL 完全相反。
构造器表达式直接映射成 record:
| |
两处容易写漏:new 后必须全限定类名,JPQL 不认识 import;参数顺序必须和 record 构造器一致,写反了运行期才抛。coalesce(..., 0) 兜底不能省:空集时 sum() 返回 null,record 参数是 BigDecimal,没兜底就是 NPE,只在「某个状态恰好为空」时出现。
原生 SQL 原样发给数据库:
| |
SQL 里的 deleted = 0 是手写的,为什么留到软删除那节说。
接口投影适合列表页:
| |
方法名对应实体 getter,返回值是框架代理实现,SQL 只 select 这四列:
remark、时间戳、明细集合在 SQL 层面就没查,列表页能少查一列是一列。
投影方法名和仓储重名时,返回类型不同不算重载,Java 编译不过,所以另开 OrderSummaryRepository,继承最朴素的 Repository:
| |
顺带的好处:这个仓储只有 findByStatus,没有 save、delete,接口上没有就是调不出来。
Specification 组合查询
筛选条件可选时,JPQL 拼字符串不好看,if 分支一多也乱。Specification 把每个条件写成独立小函数,按需拼:
| |
root.get("orderNo") 里也是实体属性名。组合用 Specification.allOf(...):
| |
allOf 为空时返回「永真」条件而不是 null,省掉调用处判空。配套还有 anyOf(...)、unrestricted()、where(...)、not(...) 和只产出谓词的 PredicateSpecification。
乐观锁,以及它管不到的那一半
字段上加 @Version:
| |
生成的 UPDATE 自带版本条件:
| |
SET 里是新值,WHERE 里是旧值。被抢先改过就匹配不上,影响 0 行,Hibernate 抛 ObjectOptimisticLockingFailureException,全局异常处理器翻译成 409。
问题在保护范围。@Version 比对的是持久化上下文里的快照版本,管「同一事务读出来又写回去」的并发;客户端传来的版本号隔着一次 HTTP 往返,Hibernate 不知道有这回事。直接 setVersion 塞进实体是错的,我试过:生成 UPDATE 用的是快照版本,塞进去的过期值反而直接写进数据库,版本号倒退。服务层显式比一次:
| |
@Version保护的是同一个事务内的并发,跨 HTTP 往返的版本校验必须自己在服务层做。
@Version 还管同一请求里的「查一次、改几个字段、再更新」,中间被别的事务改过会拦下来。软删除这种框架内部写操作也带版本条件:
| |
and version=? 在,只是覆盖不到「跨请求」。
更新和状态流转之后我主动调一次 flush():
| |
不手动 flush 异常照样抛,只是发生在方法返回后的事务提交时,堆栈指向代理,不好定位业务代码。
软删除,和它管不到的地方
软删除一个注解:
| |
delete 语义被接管,我整轮验证的结果是 delete 0 条,update 6 条,删除订单那条连参数是这样:
| |
三处跟直觉不一样。set deleted=true 是布尔字面量,列类型建 BIT(1) 对应 boolean,ddl-auto 是 validate,对不上启动就失败。and deleted=false 是框架加的,只删没删过的;重复删第二次匹配不到行,影响 0 行,服务层据此返回 404。and version=? 在,@Version 对软删除同样生效。
删完我直接查了库:
| |
id=3 还在,deleted 0 变 1,物理行数还是 6。查库还有个小坑:BIT(1) 直接 SELECT deleted 打出来是不可见二进制,看着像空列,容易误判「没删掉」,我上面查询写的是 deleted+0。
必须记住的规律:
@SoftDelete的过滤只加在 Hibernate 自己生成的语句上。原生 SQL 原样发给数据库,框架一个字符都不会改。
留了个原生 SQL 方法演示:
| |
演示数据里 id=3 正好是 SHIPPED,刚被我删掉:
| |
接口返回 Map.of(...),键顺序在不同 JVM 启动之间会变,键换位置不是配置错了。返回 0 来自手写的 deleted = 0,去掉它被删订单会被算进来,不报任何错。这条和第 2 篇 @TableLogic 是同一类:框架的自动过滤只管它自己生成的 SQL。

N+1,和两种抓取策略
这部分我建议你亲手跑一遍,数字很直观。Order 和 OrderItem 一对多,fetch 是 LAZY:分页查出 10 个订单,每个调一次 order.getItems(),Hibernate 就为每个订单补一条查明细的 SQL。这就是 N+1,事务里不报错,只是悄悄多发 N 条 SQL,流量上来变成慢查询。
默认配置下的 SQL 条数:
| 请求 | 默认配置 | default_batch_fetch_size=50 |
|---|---|---|
GET /api/orders?size=1 | 3 | 3 |
GET /api/orders?size=3 | 5 | 3 |
GET /api/orders?size=5 | 7 | 3 |
GET /api/orders/1(@EntityGraph) | 1 | 1 |
默认列是 2 + 带明细的订单数:count 一条、分页一条,每个带明细的订单各补一条。日志现场:
| |
五条一样的 where order_id=?,一条一个值。加一行配置:
| |
| |
同一组请求再跑,size=1、size=3、size=5 全是 3 条,页大小不再影响 SQL 条数。挺有意思的是它没有把 LAZY 变 EAGER:集合还是懒加载,访问那一次才把同批集合一次抓回来,「不需要的明细不查」保住了。
单对象走第三条路:
| |
生成一条 join,一次就够:
| |
join 条件上的 i1_0.deleted=false 是软删除过滤,连 on 子句都加上了。
适用面:
| 场景 | 做法 | 结果 |
|---|---|---|
| 按主键查单个对象 | @EntityGraph | 1 条 SQL,join 抓取 |
| 分页或列表查多个对象 | hibernate.default_batch_fetch_size | 恒定少量 SQL,in 批量抓 |
| 不需要明细 | 接口投影或 DTO 查询 | 那一列不查,最省 |
@EntityGraph 不要用在分页上:left join 加 limit,一页 10 个订单带 30 条明细,SQL 返回 30 行,Hibernate 得在内存去重,Page 的 total 也可能错。列表场景用批量抓取或投影。

实体转 DTO 必须在事务里,因为 open-in-view 关了
回到前面埋的雷。OrderResponse.from(order) 要读 order.getItems(),懒加载集合,而 open-in-view 关了,没有「事务延伸到视图层」的兜底:
| |
实体转 DTO 必须在事务方法内完成:放 Service 里,写方法带 @Transactional,读方法带 @Transactional(readOnly = true)。挪进 Controller 出了事务,读 getItems() 就抛 LazyInitializationException,接口 500。这个错我就是这么撞出来的,挪回去就好了。
open-in-view 开着时问题照样在,只是被藏起来,代价是每请求多占一个连接,上线后以「偶发慢查询」冒出来。关掉它让错在开发阶段抛出来,比在线上挖强。
readOnly = true 也不只表明意图:Spring 把提示传给 JDBC 连接,Hibernate 收到后跳过脏检查、不保留快照,批量读省内存和 CPU。
完整案例,跑起来看
建库还是一条命令。
| |
启动,创建订单:
| |

orderNo、status、时间戳、version 都是服务端填的;一次请求一条主表 INSERT,靠 CascadeType.ALL 带上两条明细。
乐观锁我分两步测:
| |

409 是服务层拦的,UPDATE 没发出去:跨请求的版本校验只能自己做。
状态流转:
| |
聚合统计:
| |
响应体都是我从真实调用里抄下来的。浏览器直接打开接口:

Chrome 对 application/json 套一层自己的查看器,默认挤一行,点左上角「美观输出」才展开;开关在浏览器 UI 层,不在 DOM 里,脚本点不了,所以截图是单行渲染。
这一篇的测试怎么写
测试两个类,分工不同。
| |
切片测试只加载 JPA 自动配置,不起 Web 容器、不扫 Service 和 Controller,测仓储跑得快、定位准。import 包名就是前面表里的新包名。@AutoConfigureTestDatabase(replace = Replace.NONE) 必须写:切片测试默认换内存库,测试要跑真实 MySQL,项目里也没引 H2,不写会报「找不到可用的嵌入式数据库」。
全链路那个:
| |
26 个用例走完整 Web 层加事务:参数校验、事务边界、懒加载在视图层炸不炸、问题详情结构。
懒加载那条看着重复,守的是一道线:
| |
哪天有人把 OrderResponse.from(order) 挪进 Controller,这条立刻红;没有它,这个改动在开发环境可能看不出来,直到线上某个请求才炸。
两个类都真连 MySQL,不加 H2 是我刻意的:软删除、乐观锁、open-in-view 关闭后的行为都是数据库和事务层面的事,内存库是「测了个仿制品」。依赖外部数据库的代价用两道防护抵掉:application-test.yml 让 Hikari 懒加载,上下文创建阶段不碰库;用例执行前再探测,连不上整体 skip。
| |
没配 MySQL 的机器上跑 mvn test,看到的是 Skipped 不是红。类上 @Transactional 每用例回滚,@SoftDelete 的标记也一起回滚,用例互不污染。
最后,mvn test 的结果:
| |
38 个用例全绿。
避坑指南
坑一,Hibernate 7 的 SQL 日志没有 Hibernate: 前缀。 最坑的一条,坑的是前提。SQL 走 org.hibernate.SQL logger:
| |
没有前缀。 grep "Hibernate:" 数出来恒等于 0,接着得出「一条 SQL 都没发」的荒谬结论。参数格式是 binding parameter:
| |
开 format_sql 后一条语句跨多行,按行 grep 会切碎语句。我按日志行首时间戳把语句切块还原,工具不复杂,几十行 Python 就够了。
坑二,@Validated 类级别注解,第 1 篇的坑继续有效。 校验改由 AOP 代理处理,抛 ConstraintViolationException 而不是 HandlerMethodValidationException,全局异常处理器接不到,结果是 500 不是 400。不写就对了。
坑三,JPA 自动配置拆成三个模块,写旧包名编译不过。 org.springframework.boot.autoconfigure.orm.jpa.* 直接过不去,import 跟着模块表走。
坑四,测试注解包名每篇都不一样。 这篇三个注解三个包,第 1 篇的 @AutoConfigureMockMvc 又是第四个包,每篇都得重新查一遍。
坑五,spring.jpa.open-in-view 默认还是 true,defer-datasource-initialization 元数据查不到但还在。 后者从 Environment 直读。判断配置项还在不在,别只看元数据。
坑六,@Enumerated 默认 ORDINAL 存序号。 必须 @Enumerated(EnumType.STRING),不写不报错,枚举调整时历史数据语义全错位。
坑七,sum() 空集返回 null,不是 0。 映射到 BigDecimal 构造器参数就是 NPE,套 coalesce(..., 0)。
坑八,JPQL 构造器表达式参数顺序写反编译期不报错。 select new com.xxx.OrderStatusStat(...) 没有类型检查,运行期才抛;类名必须全限定。
坑九,Sort 收实体属性名,传数据库列名会抛异常。 MyBatis-Plus 的 OrderItem 拼 SQL 片段写 created_at,Spring Data 的 Sort 走属性路径写 createdAt,上一篇的 sort=created_at,desc 拿过来直接抛:
| |
报错说的是 created 不是 created_at:下划线是嵌套属性分隔符,字符串被拆成「created 下的 at」。处理分两层:OrderPageQuery 维护可排序字段白名单,不在就回退默认字段;方向写错报 422,字段错是笔误可以兜底,方向错是静默错误。
坑十,@Version 管不到跨 HTTP 往返的版本校验。 手动 setVersion 不触发检查,反而把过期值写进库。
坑十一,时间字段精度,第 2 篇踩过这篇又踩一次。 LocalDateTime.now() 带纳秒,DATETIME(3) 只存毫秒;截断位置从 MetaObjectHandler 换到 DateTimeProvider,道理没变。
坑十二,@EntityGraph 不要用在分页上。 left join 加 limit 放大结果集,分页和 total 都可能错;列表用 default_batch_fetch_size 或投影。
坑十三,@Where 写了编译不过,Hibernate 7 没有这个注解。 过滤 SQL 用 @SQLRestriction;软删除直接 @SoftDelete。
坑十四,BIT(1) 列在命令行里看着像空的。 SELECT deleted 打不出可见内容:二进制类型,mysql 客户端输出原始字节,改 SELECT deleted+0 显示 0 和 1。验证手段的坑,照样能让人白折腾半小时。
小结
这篇坑多的原因我自己的总结是:JPA 这条链上有三套元数据同时起作用,默认值和覆盖范围各不相同。选型上如果你要分层清楚、每层能单独换,JPA 这条路值得走,代价是得知道每件事归哪层管,跟 MyBatis-Plus「一个框架全包」的省心是两种东西。工程在 springboot4-jpa,测试 38 个全绿,27 个场景的 curl 实录在 README,SQL 日志和语句清单都是真实抓的。
4.1.1 上的关键事实,留一张表备查:
| 主题 | 4.1.1 的现状 |
|---|---|
| 自动配置 | 拆在 spring-boot-jpa / -hibernate / -data-jpa 三个模块里 |
| 测试注解 | 散在 data.jpa.test / jpa.test / jdbc.test 三个包 |
| 测试 starter | spring-boot-starter-data-jpa-test,4.1.1 新增 |
| 软删除 | @SoftDelete 接管 delete;@Where 不存在,过滤注解是 @SQLRestriction |
| SQL 日志 | org.hibernate.SQL logger,行首无前缀 |
| 参数日志 | binding parameter (N:TYPE) <- [value] |
| 审计时间 | 截断到毫秒,与 DATETIME(3) 对齐 |
open-in-view | 默认 true,要自己关 |
还没解决的事有两条。@SoftDelete 的过滤只管框架生成的 SQL,原生 SQL 自己写条件,和第 2 篇 @TableLogic 同一个规律,目前只能在代码评审里盯着;@Version 只保护同一事务内的并发,跨 HTTP 的版本校验必须在服务层自己做,我还没找到能让框架接管的干净写法,先用 checkVersion 顶着。