简介:面向Spring Boot初学者和Java后端开发者,这份资源围绕SpringBoot+JPA技术组合,演示了从构建文件依赖引入、配置文件数据源设置,到定义实体类、编写Repository接口、实现Service与Controller的完整流程,可帮助读者快速搭建基于JPA的CRUD数据访问层。压缩包以RAR格式提供,共计10个文件,包含6个Java源码、2个properties配置文件、1个project工程文件和1个xml文件,整体仅6KB,结构紧凑、便于按模块研读。目前已有254人学习,适合正在学习Spring Data JPA自动配置、Repository接口用法或RESTful API开发的人群。示例代码完整覆盖了save、findAll、findById、deleteById等常用操作,并展示了@RestController、@RequestMapping等注解的实际应用,有助于理解Spring Boot整合ORM框架的核心思路,为后续扩展分页查询、条件查询和事务管理等高级特性打下基础。
1. SpringBoot+JPA 是什么,为什么值得用
SpringBoot+JPA 这个组合,本质是把 Spring Boot 的自动配置能力和 JPA 的实体关系映射绑在一起,让持久层不再被一堆 DAO 实现类塞满。最近在模拟项目X里,表结构二十来张,关联关系绕来绕去,用 JPA 的同学开发效率明显比隔壁手写 SQL 的组高,前提是先搞清楚懒加载和查询边界。
它解决的核心问题是:CRUD 和基础关联查询不用写 SQL;业务条件变化时靠 Repository 方法名或 Specification 动态组合;真正需要调优时,@Query 又能把 SQL 控制权拿回来。适合业务以事务处理为主、查询条件可预期、想少写机械代码的中小项目。如果你追求每条 SQL 都手写可控,或者团队对 Hibernate 的缓存机制没底,也可以不选它,但建议先看完这篇再下结论。
2. SpringBoot+JPA 起步:依赖、数据源与实体映射
2.1 选依赖:为什么是 spring-boot-starter-data-jpa
我一般新建项目时,持久层依赖只加一个官方 starter,它已经把 Hibernate 核心和 Spring Data JPA 全部打包,版本也由 Spring Boot 的 BOM 统一管理,不用自己拼版本号。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency>这段依赖正确引入后,Spring Boot 的自动配置会扫描到 DataSource、EntityManagerFactory、TransactionManager 三类关键 Bean。数据库驱动要单独再加,比如 MySQL 用mysql-connector-j,H2 内存库用h2,连接池不用管,Spring Boot 默认集成 HikariCP。
参数说明:starter 本身不写版本号,跟着 Spring Boot 版本走。Spring Boot 3.x 里 JPA 的包名是jakarta.persistence.*,2.x 是javax.persistence.*。如果你在翻旧项目,看到代码里用javax不要慌,只是包名迁移,写法规则一致。用spring-boot-starter-data-jpa而不直接引 Hibernate 单体,最大优势是避免版本不一致导致的NoSuchMethodError。
2.2 数据源与 JPA 核心配置:每个 yaml 参数到底在管什么
依赖加完,先把配置放在application.yml里。这是我常用的开发环境配置:
spring: datasource: url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=MySQL username: sa password: driver-class-name: org.h2.Driver jpa: hibernate: ddl-auto: update show-sql: true open-in-view: false properties: hibernate: format_sql: true dialect: org.hibernate.dialect.H2Dialect数据源部分就是普通 JDBC 四件套。切到 MySQL 时,url 改成jdbc:mysql://localhost:3306/db?useSSL=false&serverTimezone=Asia/Shanghai,驱动类名改成com.mysql.cj.jdbc.Driver。
JPA 部分有几个参数值得盯住。ddl-auto控制 Hibernate 启动时如何处理表结构,不同环境要换策略。
| ddl-auto 值 | 行为 | 适合场景 |
|---|---|---|
| none | 完全不做任何建表操作 | 生产环境,表结构由 DBA 脚本管理 |
| validate | 校验实体和表字段是否匹配,不修改表 | 生产环境,推荐 |
| update | 实体有变化就自动 ALTER TABLE | 开发阶段,改实体方便 |
| create | 启动先删表再建表 | 测试环境,丢数据不要意外 |
| create-drop | 启动建表,关闭时删表 | 单测或临时验证 |
show-sql: true只是把 SQL 打到控制台,不走日志框架。想让日志更规整,可以配合logging.level.org.hibernate.SQL: debug。format_sql: true让多行 SQL 更容易读,排查问题时我一般两个都开。
open-in-view: false是我强烈建议改掉的默认值。Spring Boot 默认把这个开关设为 true,意味着每个 HTTP 请求从进入 Controller 到返回响应,数据库会话都开着,Controller 里访问懒加载集合不会报错,但代价是事务边界被撑到整个请求周期,数据库连接占用时间长,还会掩盖 Service 层设计问题。我在新项目里第一件事就是把它关掉,逼自己在事务内完成查询。
2.3 实体映射:从 @Entity 到字段类型的基本功
一个实体类就是一张表的映射。下面这个示例基本覆盖了日常 90% 的字段写法:
package com.example.demo.domain; import jakarta.persistence.*; import java.math.BigDecimal; import java.time.LocalDateTime; @Entity @Table(name = "orders") public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "order_no", nullable = false, length = 64) private String orderNo; @Column(nullable = false) private LocalDateTime createdAt = LocalDateTime.now(); @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) private OrderStatus status; @Column(nullable = false) private BigDecimal totalAmount; protected Order() { } public Order(String orderNo, OrderStatus status, BigDecimal totalAmount) { this.orderNo = orderNo; this.status = status; this.totalAmount = totalAmount; } // getter/setter 由 IDE 生成 }逻辑说明:JPA 规范要求实体必须有一个无参构造器,Hibernate 通过反射创建实例需要它。我习惯把无参构造器设为protected,防止业务代码不小心 new 一个空实体,又保留给 Hibernate 使用。
参数说明:主键生成策略GenerationType.IDENTITY适合 MySQL 的自增列,插入后需要回查主键。如果是 Oracle 或 PostgreSQL,优先用GenerationType.SEQUENCE,这一点到第 5 章批量插入时会再展开。
字段类型方面,LocalDateTime要对应数据库的datetime或timestamp,BigDecimal对应decimal,boolean对应bit或boolean。别再用java.util.Date了,日期比较和 JSON 序列化都容易出问题。枚举字段用@Enumerated(EnumType.STRING)存字符串,这样枚举增加新值时不会打乱历史数据的数字编号,可读性也好很多。
这里有第一个隐藏坑:类名Order没问题,但数据库表名如果写成order,它正好是 SQL 关键字。我在模拟项目X里第一次建表就报错,后来统一在@Table(name = "orders")里加上复数后缀绕开。字段名如果叫desc、rank、level同样要小心,尽量避开保留字。
默认命名策略下,Java 的驼峰字段orderNo会映射成数据库下划线order_no,这个转换由SpringPhysicalNamingStrategy自动完成。所以实体属性写orderNo,@Column 里可以只写name = "order_no",或者直接不写让默认转换。如果你在某处看到@Column(name = "orderNo"),这种写法在不同命名策略下结果可能不一致,统一用下划线才是稳妥习惯。
3. 用 Repository 把 CRUD 写清楚:方法名、@Query 与分页
Repository 接口是 Spring Data JPA 的核心入口。继承JpaRepository<T, ID>之后,save、findById、findAll、deleteById这些基础方法就自动有了,不需要额外实现。真正需要花时间的,是设计扩展查询方法。
3.1 方法名派生查询:命名即查询
Spring Data JPA 会解析方法名,自动生成 JPQL。比如下面这几个:
public interface OrderRepository extends JpaRepository<Order, Long> { List<Order> findByStatus(OrderStatus status); List<Order> findByOrderNoAndStatus(String orderNo, OrderStatus status); List<Order> findByStatusOrderByCreatedAtDesc(OrderStatus status); boolean existsByOrderNo(String orderNo); long countByStatus(OrderStatus status); }逻辑说明:findByStatus解析成where status = ?,findByOrderNoAndStatus解析成where order_no = ? and status = ?,OrderByCreatedAtDesc拼到 SQL 尾部作order by created_at desc。
参数说明:方法名里的属性名必须和实体属性一一对应,大小写不敏感但拼写必须对。一旦写错,应用启动时会直接抛PropertyReferenceException,这不是运行时玄学,而是启动期就能发现的错误,反而是 JPA 的优点。
这个方法名机制支持很多关键字,我用得最多的是这些:
| 关键字 | 方法名片段 | 生成的 SQL 效果 |
|---|---|---|
| And | findByStatusAndType | 两个条件 AND 连接 |
| Or | findByStatusOrType | 两个条件 OR 连接 |
| Between | findByCreatedAtBetween | between 两个参数 |
| LessThan | findByPriceLessThan | 小于 |
| GreaterThanEqual | findByPriceGreaterThanEqual | 大于等于 |
| Like | findByNameLike | like,参数里自己写通配符 |
| Containing | findByNameContaining | 自动在参数前后加百分号 |
| In | findByIdIn | 参数是一个集合,生成 in |
| IsNull | findByDeletedIsNull | is null |
| OrderBy | findByStatusOrderByCreatedAtDesc | order by 字段 desc |
注意Like和Containing的区别:findByNameLike("张%")是按你给的 pattern 查;findByNameContaining("张")会自动变成%张%。如果你要自定义前后模糊位置,只能用Like。
3.2 @Query 与 @Modifying:复杂 SQL 时的出口
方法名能覆盖简单查询,但业务一旦复杂,还是要写 JPQL。@Query就是这里的主入口。
public interface OrderRepository extends JpaRepository<Order, Long> { @Query("select o from Order o where o.status = :status and o.createdAt >= :start") List<Order> searchAfter(@Param("status") OrderStatus status, @Param("start") LocalDateTime start); @Query(value = "select * from orders where order_no = :orderNo", nativeQuery = true) Optional<Order> findByOrderNoNative(@Param("orderNo") String orderNo); @Modifying(clearAutomatically = true) @Query("update Order o set o.status = :status where o.id = :id") int updateStatus(@Param("id") Long id, @Param("status") OrderStatus status); }逻辑说明:第一个方法是 JPQL,操作的是实体和属性,注意select o from Order o里的Order是实体名,不是表名。第二个方法是原生 SQL,value里必须写数据库真实列名,因为已经绕过实体映射了。第三个方法是更新操作,必须加@Modifying。
参数说明:@Param用来绑定方法参数和 JPQL 里的占位符。JPQL 里冒号占位符写法和@Param名称要一致。@Modifying不加的话,执行 update 会直接报错;加了默认只执行 SQL,不清一级缓存,所以我在这个示例里配了clearAutomatically = true,避免缓存里还是旧数据。方法返回int表示受影响行数。
原生 SQL 的坑在于换数据库方言时可能不兼容。我一般只在复杂报表、批量统计这些 JPQL 表达吃力的场景用 nativeQuery,普通业务查询优先 JPQL。
3.3 分页排序:Pageable 的写法和雷区
分页查询是后台列表的标配,Repository 方法接收一个Pageable参数就能自动分页:
Page<Order> findByStatus(OrderStatus status, Pageable pageable);调用端这样写:
Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt")); Page<Order> page = orderRepository.findByStatus(OrderStatus.PENDING, pageable); List<Order> orders = page.getContent(); long total = page.getTotalElements();逻辑说明:PageRequest.of三个参数分别是页码、页大小、排序,页码从 0 开始。Spring Data JPA 会执行一条带 limit 的查询,再执行一条 count 查询统计总数。
参数说明:Sort.by后面写的是实体属性名,createdAt,不是数据库列名created_at。我第一次写反,运行时直接抛PropertyReferenceException。前端传过来的页码往往从 1 开始,后端接口要记得page - 1,这是最容易漏的业务细节。
如果列表只是前端滚动加载,后端点“加载更多”,不需要总数,可以让方法返回List<Order>而不是Page<Order>,这样能省掉那条 count 查询,数据量大时差别明显。
@Query和Pageable也能合起来用:
@Query("select o from Order o where o.status = :status") Page<Order> findStatusPaged(@Param("status") OrderStatus status, Pageable pageable);注意:如果 JPQL 里有left join fetch集合属性,同时又要分页,Hibernate 会先把所有 join 结果查出来,然后在内存里截取页码。表数据量一大,这条路就会把数据库连接拖死。第 5 章我会给出这种场景的替代方案。
4. 关联映射与懒加载:多表场景下的建模与查询
4.1 一对多与多对一:先定方向再定 fetch
订单和订单项是典型的一对多关系。我在建模时习惯先把多对一那一侧写好,因为它才持有外键:
package com.example.demo.domain; import jakarta.persistence.*; import java.math.BigDecimal; @Entity @Table(name = "order_items") public class OrderItem { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) private String productName; @Column(nullable = false) private BigDecimal price; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "order_id") private Order order; }然后在 Order 实体里加上反向集合:
@OneToMany(mappedBy = "order") private List<OrderItem> items = new ArrayList<>();逻辑说明:@JoinColumn(name = "order_id")指定外键列名,指向orders表的主键。mappedBy = "order"表示 OrderItem 里的order字段负责维护外键关系,Order 这一侧只是镜像。
参数说明:@ManyToOne默认 fetch 是EAGER,必须像上面这样显式设为LAZY,否则每次查 OrderItem 都会把关联的 Order 查出来,形成不必要的 join 或 select。@OneToMany默认是 LAZY,保持默认就好。
我还踩过一个坑:如果@OneToMany不写mappedBy,Hibernate 会认为这是独立的一对多,会在中间再建一张关系表,数据插入时外键顺序完全不符合直觉。所以看到一对多,第一反应就是确认mappedBy指向对方实体的关联属性名。
4.2 懒加载与事务边界:Lazy 不是玄学
懒加载的底层是一个延迟初始化代理。order.getItems()拿到的不是真正的ArrayList,而是一个PersistentBag,只有你真正遍历它时,Hibernate 才会去数据库执行 select。这个 select 必须在 Hibernate Session 还活着的时候发生,也就是要在@Transactional事务内。
一个正确的查询服务长这样:
@Service public class OrderDetailService { private final OrderRepository orderRepository; public OrderDetailService(OrderRepository orderRepository) { this.orderRepository = orderRepository; } @Transactional public OrderDetailDto getOrderDetail(Long orderId) { Order order = orderRepository.findById(orderId) .orElseThrow(() -> new IllegalArgumentException("订单不存在")); int itemCount = order.getItems().size(); // 在事务内触发懒加载 List<OrderItem> items = new ArrayList<>(order.getItems()); return toDto(order, items); } }逻辑说明:@Transactional让这段查询和集合初始化在同一个事务里完成,事务提交后 Session 关闭,DTO 已经构造好,后续逻辑不再碰实体关联。
常见错误是 Service 方法不加事务,直接返回 Order 实体,Controller 里再调order.getItems(),于是抛LazyInitializationException。从经验看,根因不是懒加载本身,而是把实体对象当 DTO 用。
如果你确实暂时想返回实体,可以用@EntityGraph在查询阶段把集合一次性抓出来:
@EntityGraph(attributePaths = "items") @Query("select o from Order o where o.id = :id") Optional<Order> findWithItemsById(@Param("id") Long id);参数说明:attributePaths = "items"告诉 Hibernate 在这条查询里立刻 left join 查出 items,返回的 Order 对象就算离开事务,items 也是可用的。
4.3 用投影只查必要的列
列表页经常只需要 id、名称、状态、创建时间,不需要把整个实体连带大字段全查出来。JPA 的投影机制就是为这个场景设计的。
先定义一个接口:
public interface OrderSummary { Long getId(); String getOrderNo(); OrderStatus getStatus(); LocalDateTime getCreatedAt(); }Repository 里返回这个接口:
@Query(""" select o.id as id, o.orderNo as orderNo, o.status as status, o.createdAt as createdAt from Order o where o.id = :id """) OrderSummary findSummaryById(@Param("id") Long id);逻辑说明:Spring Data JPA 会在运行时动态生成这个接口的实现,只查投影里声明的列,不会把 Order 整行数据加载出来。JPQL 里每个字段的别名要和接口 getter 名对应,大小写不敏感。
另一种方式是构造投影:
public record OrderDetailDto(Long id, String orderNo, OrderStatus status) { }@Query("select new com.example.demo.dto.OrderDetailDto(o.id, o.orderNo, o.status) from Order o") List<OrderDetailDto> findAllDetail();参数说明:new后必须写 DTO 的全限定类名,构造参数顺序要和 JPQL 里 select 的字段顺序一致。Java record 正好可以当这种不可变 DTO 用,省去一堆样板代码。
投影放在多表 join 场景尤其有效:避免你为了拿一个关联字段,把两张表所有列都查出来。
5. SpringBoot+JPA 避坑与排查:从 N+1 到字段命名的 5 个现场
5.1 N+1:日志里几十条 select 的真相
现象:查询 10 条订单,控制台却打出 10 条查询订单项的 SQL,外层再套一条主查询,总共 11 条。数据量上来,接口延迟翻倍。
原因:@OneToMany默认懒加载,代码在循环里访问了order.getItems(),每个订单都触发一次额外查询。典型现场:
List<Order> orders = orderRepository.findAll(); for (Order order : orders) { int size = order.getItems().size(); // N+1 源头 }解决:查询阶段用join fetch或@EntityGraph把集合一次性查出来:
@Query("select distinct o from Order o left join fetch o.items") List<Order> findAllWithItems();另一个更易忽略的场景是一对多反向查询。比如查订单时@ManyToOne没设 LAZY,也会对每个订单项触发一次订单查询,日志里全是低频 select。所以我前面强调多对一显式写FetchType.LAZY。
如果集合数据量不大,还可以在实体上配置批量抓取:
@OneToMany(mappedBy = "order") @BatchSize(size = 20) private List<OrderItem> items = new ArrayList<>();@BatchSize(size = 20)会把 N+1 变成 1 + N/20 条查询,在不想改查询方法的场景下是一个性价比很高的兜底方案。
5.2 LazyInitializationException
现象:Service 方法返回 Order 实体,Controller 里访问order.getItems(),直接抛could not initialize proxy - no Session。新同学第一次遇到基本都懵,因为代码看起来没毛病。
原因:@Transactional在 Service 方法结束时,Hibernate Session 已经关闭,实体从库里带出来的只是关联集合的代理,代理初始化需要 Session,但 Session 已经不在了。
解决:最干净的办法是 Service 内完成查询并转成 DTO,不要把实体直接抛给 Controller。
如果只是想快速返回 JSON,有人会配jackson-datatype-hibernate模块,让 Jackson 序列化时忽略未初始化的代理,但这治标不治本。懒加载异常是设计问题的信号,正确解法是明确事务边界和 DTO 边界。
关掉 Spring Boot 默认的open-in-view会让这个问题尽早暴露,而不是上线后偶尔出现在高并发请求里。我现在的习惯就是默认false,宁可开发时多遇到几次报错,也不想在生产环境看随机超时。
5.3 字段名踩过的保留字和命名策略
现象:Hibernate 建表或查询时报 SQL 语法错误,提示order、desc、user等位置有问题;或者发现代码里@Column(name = "orderNo")生成出来的列名和自己预期不一致。
原因:这些词在 MySQL 或 H2 里是保留字,普通 SQL 语句用它们做表名/字段名必须加反引号。Hibernate 默认物理命名策略会把驼峰转下划线,所以orderNo变成order_no,这个过程对显式设定的name也会做同样处理。
解决:
@Table(name = "orders") @Column(name = "`order`")表名加复数绕开大多数保留字,字段名必须使用保留字时,用反引号包住。团队规范里我会直接约定:实体命名一律避开 SQL 关键字,表名用复数,字段用下划线。这样 Hibernate 生成的 DDL 在任何数据库下都能稳定执行。
5.4 批量插入慢与 ID 生成策略
现象:循环调用orderRepository.save(order)插入 1 万条数据,耗时几十秒,控制台 SQL 是一条一条 insert,看不到 batch。
原因:两个条件没满足。第一,Hibernate 批处理开关默认关闭,需要配置hibernate.jdbc.batch_size。第二,GenerationType.IDENTITY的主键生成方式,为了让插入后能拿到自增 ID,Hibernate 必须立即执行 insert,没办法等到攒一批再执行。这两个原因叠加,批量插入直接退化成逐条提交。
解决:在配置里打开批处理:
spring: jpa: properties: hibernate: jdbc: batch_size: 50 order_inserts: truebatch_size是攒多少条 SQL 再执行,order_inserts让 Hibernate 按实体类型排序整合 insert,避免同类插入之间夹着其他类型导致的批处理失效。
主键生成策略在批量导入场景下更关键。MySQL 的自增列只能用 IDENTITY,想靠 Hibernate 批量 insert 是做不到的。这类场景我一般直接用JdbcTemplate手写批量 SQL,或者在表设计时改用应用层生成主键,比如分布式 ID,再用GenerationType.ASSIGNED。先把batch_size配好,再看日志确认有没有batch关键字。
5.5 事务回滚不生效的典型翻车
现象:方法里抛了异常,数据库却还是写进去了;或者外层事务什么都没做,最后报UnexpectedRollbackException。
原因:Spring 事务默认只对RuntimeException和Error回滚,如果你在事务方法里把异常 catch 住吞掉,Spring 认为事务正常完成,直接提交。另一个经典是同类内this调用,比如:
public void updateOrder(Order order) { doUpdate(order); // 同类调用,代理不参与 } @Transactional public void doUpdate(Order order) { // 这个事务注解根本不会生效 }解决:事务方法要暴露给代理调用,不能同类自调用。写业务 Service 时,我通常会拆出独立方法或者直接让事务注解加在公开入口上:
@Transactional(rollbackFor = Exception.class) public void updateOrder(Long orderId, OrderStatus status) { orderRepository.updateStatus(orderId, status); if (status == OrderStatus.CANCELLED) { throw new IllegalStateException("状态异常"); } }参数说明:rollbackFor = Exception.class把受检异常也纳入回滚范围。如果确实要在事务方法里处理异常,要么重新抛出回滚,要么用TransactionTemplate把需要回滚的代码单独包起来,别用 try-catch 吞掉再返回。血泪经验:吞异常是面前一时爽,月底对账火葬场。
6. 进阶:动态查询、审计字段与切片测试
6.1 Specification 动态查询:条件多时不拼 SQL
系统查询页面常常有一堆可选筛选条件,方法名派生查询写不了那么多组合,拼 SQL 字符串又容易 SQL 注入。用JpaSpecificationExecutor配合Specification是很稳的路子:
public class OrderSpecs { public static Specification<Order> statusIn(List<OrderStatus> statuses) { return (root, query, cb) -> root.get("status").in(statuses); } public static Specification<Order> createdAtAfter(LocalDateTime start) { return (root, query, cb) -> cb.greaterThanOrEqualTo(root.get("createdAt"), start); } }调用时叠加条件:
Specification<Order> spec = Specification .where(OrderSpecs.statusIn(statuses)) .and(OrderSpecs.createdAtAfter(start)); orderRepository.findAll(spec, pageable);注意 Repository 要继承JpaSpecificationExecutor<Order>才有findAll(Specification)方法。简单条件我还是用方法名,条件超过三个再切 Specification,可读性更好。
6.2 审计字段:@CreatedDate 和 @LastModifiedDate
创建时间、修改时间这类字段,不值得在每个 Service 方法里手动赋值。加一个审计基类:
@EntityListeners(AuditingEntityListener.class) public abstract class BaseEntity { @CreatedDate @Column(updatable = false, nullable = false) private LocalDateTime createdAt; @LastModifiedDate @Column(nullable = false) private LocalDateTime updatedAt; }然后启动配置上加注解:
@Configuration @EnableJpaAuditing public class JpaAuditingConfig { }逻辑说明:@CreatedDate在 insert 时自动填,@LastModifiedDate在 update 时自动更新。@EnableJpaAuditing是总开关,忘了加这两个字段永远是 null。实体继承 BaseEntity 之后,领域对象会清爽很多。
6.3 @DataJpaTest 切片测试
Repository 测试不需要启动整个 Web 环境,用切片测试更快:
@DataJpaTest class OrderRepositoryTest { @Autowired private OrderRepository orderRepository; @Test void findByStatusShowsResult() { Order order = new Order("NO-001", OrderStatus.PENDING, new BigDecimal("100")); orderRepository.save(order); List<Order> result = orderRepository.findByStatus(OrderStatus.PENDING); assertFalse(result.isEmpty()); } }@DataJpaTest只加载 JPA 相关配置,默认用内存数据库替代外部真实数据源,每个测试方法结束后自动回滚事务。要保留真实连接池时用@AutoConfigureTestDatabase(replace = Replace.NONE)。
我自己的固定流程是:写完一个 Repository 查询,先看 show-sql 日志,确认没有意外 join 和多次 select;再写一个切片测试把核心查询固定住。这套习惯坚持下来,持久层翻车概率直线下降。希望帮到你。
本文还有配套的精品资源,点击获取