首页 / 资讯中心 / 文章详情

SpringBoot+JPA核心实践:Repository查询、懒加载与N+1性能优化

SpringBoot+JPA核心实践:Repository查询、懒加载与N+1性能优化 ★ FEATURED ARTICLE
简介面向Spring Boot初学者和Java后端开发者这份资源围绕SpringBootJPA技术组合演示了从构建文件依赖引入、配置文件数据源设置到定义实体类、编写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. SpringBootJPA 是什么为什么值得用SpringBootJPA 这个组合本质是把 Spring Boot 的自动配置能力和 JPA 的实体关系映射绑在一起让持久层不再被一堆 DAO 实现类塞满。最近在模拟项目X里表结构二十来张关联关系绕来绕去用 JPA 的同学开发效率明显比隔壁手写 SQL 的组高前提是先搞清楚懒加载和查询边界。它解决的核心问题是CRUD 和基础关联查询不用写 SQL业务条件变化时靠 Repository 方法名或 Specification 动态组合真正需要调优时Query 又能把 SQL 控制权拿回来。适合业务以事务处理为主、查询条件可预期、想少写机械代码的中小项目。如果你追求每条 SQL 都手写可控或者团队对 Hibernate 的缓存机制没底也可以不选它但建议先看完这篇再下结论。2. SpringBootJPA 起步依赖、数据源与实体映射2.1 选依赖为什么是 spring-boot-starter-data-jpa我一般新建项目时持久层依赖只加一个官方 starter它已经把 Hibernate 核心和 Spring Data JPA 全部打包版本也由 Spring Boot 的 BOM 统一管理不用自己拼版本号。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency这段依赖正确引入后Spring Boot 的自动配置会扫描到 DataSource、EntityManagerFactory、TransactionManager 三类关键 Bean。数据库驱动要单独再加比如 MySQL 用mysql-connector-jH2 内存库用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;MODEMySQL 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?useSSLfalseserverTimezoneAsia/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或timestampBigDecimal对应decimalboolean对应bit或boolean。别再用java.util.Date了日期比较和 JSON 序列化都容易出问题。枚举字段用Enumerated(EnumType.STRING)存字符串这样枚举增加新值时不会打乱历史数据的数字编号可读性也好很多。这里有第一个隐藏坑类名Order没问题但数据库表名如果写成order它正好是 SQL 关键字。我在模拟项目X里第一次建表就报错后来统一在Table(name orders)里加上复数后缀绕开。字段名如果叫desc、rank、level同样要小心尽量避开保留字。默认命名策略下Java 的驼峰字段orderNo会映射成数据库下划线order_no这个转换由SpringPhysicalNamingStrategy自动完成。所以实体属性写orderNoColumn 里可以只写name order_no或者直接不写让默认转换。如果你在某处看到Column(name orderNo)这种写法在不同命名策略下结果可能不一致统一用下划线才是稳妥习惯。3. 用 Repository 把 CRUD 写清楚方法名、Query 与分页Repository 接口是 Spring Data JPA 的核心入口。继承JpaRepositoryT, ID之后save、findById、findAll、deleteById这些基础方法就自动有了不需要额外实现。真正需要花时间的是设计扩展查询方法。3.1 方法名派生查询命名即查询Spring Data JPA 会解析方法名自动生成 JPQL。比如下面这几个public interface OrderRepository extends JpaRepositoryOrder, Long { ListOrder findByStatus(OrderStatus status); ListOrder findByOrderNoAndStatus(String orderNo, OrderStatus status); ListOrder 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 效果AndfindByStatusAndType两个条件 AND 连接OrfindByStatusOrType两个条件 OR 连接BetweenfindByCreatedAtBetweenbetween 两个参数LessThanfindByPriceLessThan小于GreaterThanEqualfindByPriceGreaterThanEqual大于等于LikefindByNameLikelike参数里自己写通配符ContainingfindByNameContaining自动在参数前后加百分号InfindByIdIn参数是一个集合生成 inIsNullfindByDeletedIsNullis nullOrderByfindByStatusOrderByCreatedAtDescorder by 字段 desc注意Like和Containing的区别findByNameLike(张%)是按你给的 pattern 查findByNameContaining(张)会自动变成%张%。如果你要自定义前后模糊位置只能用Like。3.2 Query 与 Modifying复杂 SQL 时的出口方法名能覆盖简单查询但业务一旦复杂还是要写 JPQL。Query就是这里的主入口。public interface OrderRepository extends JpaRepositoryOrder, Long { Query(select o from Order o where o.status :status and o.createdAt :start) ListOrder searchAfter(Param(status) OrderStatus status, Param(start) LocalDateTime start); Query(value select * from orders where order_no :orderNo, nativeQuery true) OptionalOrder 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是实体名不是表名。第二个方法是原生 SQLvalue里必须写数据库真实列名因为已经绕过实体映射了。第三个方法是更新操作必须加Modifying。参数说明Param用来绑定方法参数和 JPQL 里的占位符。JPQL 里冒号占位符写法和Param名称要一致。Modifying不加的话执行 update 会直接报错加了默认只执行 SQL不清一级缓存所以我在这个示例里配了clearAutomatically true避免缓存里还是旧数据。方法返回int表示受影响行数。原生 SQL 的坑在于换数据库方言时可能不兼容。我一般只在复杂报表、批量统计这些 JPQL 表达吃力的场景用 nativeQuery普通业务查询优先 JPQL。3.3 分页排序Pageable 的写法和雷区分页查询是后台列表的标配Repository 方法接收一个Pageable参数就能自动分页PageOrder findByStatus(OrderStatus status, Pageable pageable);调用端这样写Pageable pageable PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, createdAt)); PageOrder page orderRepository.findByStatus(OrderStatus.PENDING, pageable); ListOrder orders page.getContent(); long total page.getTotalElements();逻辑说明PageRequest.of三个参数分别是页码、页大小、排序页码从 0 开始。Spring Data JPA 会执行一条带 limit 的查询再执行一条 count 查询统计总数。参数说明Sort.by后面写的是实体属性名createdAt不是数据库列名created_at。我第一次写反运行时直接抛PropertyReferenceException。前端传过来的页码往往从 1 开始后端接口要记得page - 1这是最容易漏的业务细节。如果列表只是前端滚动加载后端点“加载更多”不需要总数可以让方法返回ListOrder而不是PageOrder这样能省掉那条 count 查询数据量大时差别明显。Query和Pageable也能合起来用Query(select o from Order o where o.status :status) PageOrder 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 ListOrderItem items new ArrayList();逻辑说明JoinColumn(name order_id)指定外键列名指向orders表的主键。mappedBy order表示 OrderItem 里的order字段负责维护外键关系Order 这一侧只是镜像。参数说明ManyToOne默认 fetch 是EAGER必须像上面这样显式设为LAZY否则每次查 OrderItem 都会把关联的 Order 查出来形成不必要的 join 或 select。OneToMany默认是 LAZY保持默认就好。我还踩过一个坑如果OneToMany不写mappedByHibernate 会认为这是独立的一对多会在中间再建一张关系表数据插入时外键顺序完全不符合直觉。所以看到一对多第一反应就是确认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(); // 在事务内触发懒加载 ListOrderItem 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) OptionalOrder 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) ListOrderDetailDto findAllDetail();参数说明new后必须写 DTO 的全限定类名构造参数顺序要和 JPQL 里 select 的字段顺序一致。Java record 正好可以当这种不可变 DTO 用省去一堆样板代码。投影放在多表 join 场景尤其有效避免你为了拿一个关联字段把两张表所有列都查出来。5. SpringBootJPA 避坑与排查从 N1 到字段命名的 5 个现场5.1 N1日志里几十条 select 的真相现象查询 10 条订单控制台却打出 10 条查询订单项的 SQL外层再套一条主查询总共 11 条。数据量上来接口延迟翻倍。原因OneToMany默认懒加载代码在循环里访问了order.getItems()每个订单都触发一次额外查询。典型现场ListOrder orders orderRepository.findAll(); for (Order order : orders) { int size order.getItems().size(); // N1 源头 }解决查询阶段用join fetch或EntityGraph把集合一次性查出来Query(select distinct o from Order o left join fetch o.items) ListOrder findAllWithItems();另一个更易忽略的场景是一对多反向查询。比如查订单时ManyToOne没设 LAZY也会对每个订单项触发一次订单查询日志里全是低频 select。所以我前面强调多对一显式写FetchType.LAZY。如果集合数据量不大还可以在实体上配置批量抓取OneToMany(mappedBy order) BatchSize(size 20) private ListOrderItem items new ArrayList();BatchSize(size 20)会把 N1 变成 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的主键生成方式为了让插入后能拿到自增 IDHibernate 必须立即执行 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 SpecificationOrder statusIn(ListOrderStatus statuses) { return (root, query, cb) - root.get(status).in(statuses); } public static SpecificationOrder createdAtAfter(LocalDateTime start) { return (root, query, cb) - cb.greaterThanOrEqualTo(root.get(createdAt), start); } }调用时叠加条件SpecificationOrder spec Specification .where(OrderSpecs.statusIn(statuses)) .and(OrderSpecs.createdAtAfter(start)); orderRepository.findAll(spec, pageable);注意 Repository 要继承JpaSpecificationExecutorOrder才有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); ListOrder result orderRepository.findByStatus(OrderStatus.PENDING); assertFalse(result.isEmpty()); } }DataJpaTest只加载 JPA 相关配置默认用内存数据库替代外部真实数据源每个测试方法结束后自动回滚事务。要保留真实连接池时用AutoConfigureTestDatabase(replace Replace.NONE)。我自己的固定流程是写完一个 Repository 查询先看 show-sql 日志确认没有意外 join 和多次 select再写一个切片测试把核心查询固定住。这套习惯坚持下来持久层翻车概率直线下降。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站