ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot+JPA实战:事务管理、N+1查询与性能优化避坑指南

SpringBoot+JPA实战:事务管理、N+1查询与性能优化避坑指南 简介面向Java后端开发者这份SpringBootJPA实战资源包系统展示了如何在Spring Boot项目中集成JPA完成数据库操作。资源以Spring Data JPA为核心涵盖依赖配置、数据源连接、实体类映射、Repository接口、Service与Controller分层实现等完整流程配有可直接运行的User增删改查示例适合正在学习Spring Boot持久层开发或需要快速搭建项目骨架的初中级开发者。压缩包为rar格式共10个文件以6个Java源文件为主配合2个properties配置文件、1个project工程文件和1个xml配置整体仅6KB结构精简便于对照学习。资源目前已有253人学习浏览。通过这份资料读者可以快速掌握Spring Boot与JPA整合的关键步骤理解Entity、Id、JpaRepository等注解与方法的使用方式并可直接复用其中的代码结构减少查阅文档的时间成本为后续复杂业务场景下的数据持久化开发打下基础。 做后端开发这么多年SpringBootJPA 这个组合我一直觉得被很多人低估了。网上聊 SpringBoot 的教程一抓一大把但大部分都停留在“跑通 CRUD”的层面一旦遇到复杂查询、性能问题、事务边界这些真实业务场景很多人就开始懵了。刚好最近在带团队重构一个老项目又把 JPA 这套东西从头到尾梳理了一遍踩了些坑也沉淀了些经验今天干脆系统地写一篇把我实际项目里怎么用 SpringBootJPA、怎么避坑、怎么把性能调起来一次说清楚。这篇内容适合谁准备用 JPA 做新项目的或者已经在用但经常被 N1 查询、懒加载异常折磨的朋友都建议认真看看。我不会讲太多空泛的概念更多是实操层面的选型思考、配置细节和我在生产环境里真正遇到过的问题。1. 项目整体设计与技术选型思路1.1 为什么选了 JPA 而不是 MyBatis先说结论如果你的项目是领域模型驱动、业务逻辑复杂、表关系多JPA 会越用越顺手如果你的项目是纯 SQL 优化为王、报表查询满天飞那 MyBatis 可能更实在。我之前参与过一个电商中台项目订单、商品、库存、优惠券之间的关联关系极其复杂如果用 MyBatis光是写关联查询的 SQL 和 ResultMap 就能写到怀疑人生。而 JPA 的实体映射和关联关系管理天然就是为这种场景设计的。你定义好实体之间的关系查询的时候通过关联属性就能直接拿到数据不用手动拼 SQL开发效率确实高很多。另外JPA 是 Java 官方标准JSR 338Hibernate 是它的主流实现。选择 JPA 而不是直接选 Hibernate相当于在标准之上写代码未来如果想换实现比如 EclipseLink理论上成本会低一些。当然实际项目中很少有人会换但这个规范化的思路本身是有价值的。1.2 项目结构怎么划分我用 SpringBootJPA 做项目通常按这样的分层来组织com.example.project ├── controller // 接口层只做参数接收和结果封装 ├── service // 业务层事务边界在这里 ├── repository // 数据访问层继承 JpaRepository ├── entity // 实体类与数据库表映射 ├── dto // 数据传输对象避免实体直接暴露给前端 ├── config // 配置类如 JPA 审计、数据源配置 └── common // 通用工具、异常处理等这个结构最重要的是遵循一个原则实体类不能直接返回给前端。很多新手图省事Controller 里直接返回 Entity短期看没问题后期一旦实体结构变化接口返回结构就跟着变前端就要跟着改。我一般会定义 DTO用 MapStruct 或者手工转换虽然多写几个类但接口的稳定性会好很多。2. 核心配置与实体映射细节2.1 基础配置完整版一个相对完整的 JPA 配置长这样application.ymlspring: datasource: url: jdbc:mysql://localhost:3306/mall?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver hikari: minimum-idle: 5 maximum-pool-size: 15 connection-timeout: 30000 idle-timeout: 600000 jpa: hibernate: ddl-auto: update show-sql: true properties: hibernate: dialect: org.hibernate.dialect.MySQL8Dialect format_sql: true jdbc: batch_size: 20 order_inserts: true order_updates: true这里有几个细节重点说下。第一ddl-auto在生产环境我建议设成none然后用 Flyway 或 Liquibase 管理表结构。update模式虽然方便但生产环境它的行为是不可控的比如字段类型变更、索引变更它都可能处理不好。我的习惯是开发环境用update节省时间测试和生产环境全部none配合 Flyway 做版本化迁移。第二HikariCP 连接池的参数需要根据你的并发量来调。maximum-pool-size不是越大越好我见过有人直接配 100结果数据库连接被占满其他服务全被拖垮。一般经验值是核心线程数 * 2 1比如你的服务核心线程是 8那连接池 15-20 就够用了。第三hibernate.jdbc.batch_size这个参数很多人不知道它的作用是在批量插入时减少 SQL 执行次数。默认情况下 Hibernate 插入 1000 条数据要执行 1000 次 insert开启 batch_size 后会合并成 50 次左右性能提升非常明显。但要注意这个参数需要配合order_inserts: true一起使用否则 Hibernate 不会对插入顺序做优化效果会打折扣。2.2 实体映射的几个关键注解实体映射这块我把这些年用下来最核心的几个注解和细节整理一下。Table 注解的name属性我建议显式指定表名不要依赖 Hibernate 的命名策略。比如实体类叫UserInfo默认策略下映射的表名可能是user_info但如果你在数据库里建的表叫sys_user那就对不上了。显式声明表名避免后期因为改名或命名策略调整导致的一些坑。Column 注解可以设置字段长度、是否为空、是否唯一等属性。比如Entity Table(name sys_user) public class User { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(name username, nullable false, length 64, unique true) private String username; Column(name phone, length 20) private String phone; }GenerationType.IDENTITY这个策略我优先推荐。它依赖数据库自增主键插入效率高Hibernate 无需额外查询序列。GenerationType.AUTO不推荐因为 Hibernate 在不同数据库之间的行为不一致同一个代码在 MySQL 和 PostgreSQL 上可能会生成不一样的主键策略排查问题很费劲。日期时间类型有个重要细节。实体里用LocalDateTime映射数据库的datetime类型没问题但如果数据库用的是timestamp要注意时区问题。我遇到过 MySQL 的CURRENT_TIMESTAMP和 Java 的 LocalDateTime 差了 8 个小时就是因为时区没对齐。建议在 JDBC URL 里显式指定serverTimezoneAsia/Shanghai不要依赖服务器默认时区。2.3 关联关系映射的最佳实践关联关系是 JPA 的核心也是坑最多的地方。我的经验总结成一句话ManyToOne 用默认的 FetchType.EAGER 没问题OneToMany 和 ManyToMany 一定要改成 FetchType.LAZY。比如订单和订单项的关系Entity Table(name t_order) public class Order { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; OneToMany(mappedBy order, fetch FetchType.LAZY, cascade CascadeType.ALL, orphanRemoval true) private ListOrderItem items new ArrayList(); } Entity Table(name t_order_item) public class OrderItem { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; ManyToOne(fetch FetchType.LAZY) JoinColumn(name order_id) private Order order; }cascade CascadeType.ALL表示保存订单的时候关联的订单项也会一起保存不用手动一个个去调 saveorphanRemoval true表示从列表里移除的订单项会在数据库里被自动删除。这两个组合使用在维护父子关系时有奇效代码量能减少一大半。但这个设计也有需要注意的地方不能把CascadeType.ALL和orphanRemoval用在多对多关系上容易出现误删数据的情况。我自己之前在一个用户和角色的多对多关系上加了这两个配置结果有一次在代码里清空了某个用户的角色列表数据库里关联表的数据直接被删了还好是测试环境才没酿成大祸。3. Repository 层实战从基础到进阶3.1 方法命名查询Spring Data JPA 会根据方法名自动生成 SQL这是它最方便的特性之一。public interface UserRepository extends JpaRepositoryUser, Long { // 根据用户名查询返回第一个 OptionalUser findFirstByUsername(String username); // 根据手机号和状态查询 ListUser findByPhoneAndStatus(String phone, Integer status); // 模糊查询 ListUser findByUsernameContaining(String keyword); // 分页查询 PageUser findByStatus(Integer status, Pageable pageable); // 按创建时间倒序 ListUser findByCreatedAtAfterOrderByCreatedAtDesc(LocalDateTime time); }方法命名查询的规则其实不复杂核心就是把字段名和方法名对应起来比如findByPhoneAndStatus会生成where phone ? and status ?。我常用的关键字有Containing(模糊)、Between(区间)、In(集合)、IsNull(空值)、OrderByXxxDesc(排序)、Top10(前十条)。不过要提醒一句方法名千万别写太长太长比如findByUsernameAndPhoneAndStatusAndCreatedAtAfter这种看着就头大而且后面 SQL 一旦复杂了方法名根本表达不了。这种情况就该用 Query 注解了。3.2 Query 自定义查询当方法名表达不了复杂查询时Query 是最直接的解法public interface OrderRepository extends JpaRepositoryOrder, Long { Query(SELECT o FROM Order o JOIN FETCH o.items WHERE o.id :id) OptionalOrder findByIdWithItems(Param(id) Long id); Query(SELECT o FROM Order o WHERE o.status :status AND o.totalAmount :amount) PageOrder findByStatusAndAmount(Param(status) Integer status, Param(amount) BigDecimal amount, Pageable pageable); Query(SELECT new com.example.dto.OrderSummaryDTO(o.id, o.orderNo, SUM(i.quantity * i.price)) FROM Order o JOIN o.items i GROUP BY o.id HAVING SUM(i.quantity * i.price) :minAmount) ListOrderSummaryDTO findOrderSummaries(Param(minAmount) BigDecimal minAmount); }这里我至少踩过三个坑都分享出来。第一个是JOIN FETCH 的用法。如果查询订单的时候还想拿到订单项直接JOIN o.items是不够的因为它只会帮你过滤出有商品明细的订单但查询完订单后再访问订单项还会触发懒加载也就是经典的 N1 问题。正确的写法是JOIN FETCH o.items它会一次性把关联对象也查出来。不过要注意一旦用了JOIN FETCH就不能再用Page做分页了Hibernate 会直接报错因为它在内存里做关联集合的 fetch分页会不准。遇到这种场景我一般是先分页查出订单 ID 列表再用WHERE o.id IN :ids查带明细的订单。第二个是DTO 映射查询的写法。用SELECT new com.example.dto.OrderSummaryDTO(...)可以直接把查询结果映射成 DTO避免把实体暴露出去。这个 DTO 必须要有对应的构造函数参数名和顺序要和 JPQL 里写的一致。用这个方式比查出来 Entity 再手动转换性能更好代码也更简洁。第三个是Param 注解。新版 Spring Data JPA 支持不加 Param 也能通过编译期的-parameters参数识别名字但为了避免 IDE 和编译环境差异我建议还是显式加上 Param兼容性更好。3.3 Specification 动态查询项目做到后面动态查询几乎是逃不掉的。比如后台管理系统用户列表要支持按用户名、手机号、状态、创建时间范围、角色等任意组合筛选这种场景用 Query 很尴尬因为 SQL 的条件个数是不确定的。Spring Data JPA 提供了 JpaSpecificationExecutor配合 Specification 接口做动态查询public interface UserRepository extends JpaRepositoryUser, Long, JpaSpecificationExecutorUser { } // Service 层的写法 public PageUser searchUsers(UserSearchCriteria criteria, Pageable pageable) { SpecificationUser spec (root, query, cb) - { ListPredicate predicates new ArrayList(); if (StringUtils.hasText(criteria.getUsername())) { predicates.add(cb.like(root.get(username), % criteria.getUsername() %)); } if (StringUtils.hasText(criteria.getPhone())) { predicates.add(cb.equal(root.get(phone), criteria.getPhone())); } if (criteria.getStatus() ! null) { predicates.add(cb.equal(root.get(status), criteria.getStatus())); } if (criteria.getStartTime() ! null) { predicates.add(cb.greaterThanOrEqualTo(root.get(createdAt), criteria.getStartTime())); } if (criteria.getEndTime() ! null) { predicates.add(cb.lessThanOrEqualTo(root.get(createdAt), criteria.getEndTime())); } return cb.and(predicates.toArray(new Predicate[0])); }; return userRepository.findAll(spec, pageable); }这一段代码把查询条件的组装逻辑全部集中在一处后续想加条件比如“按会员等级筛选”只需要往链路上加一个if块和predicates.add即可可维护性非常高。同时因为返回的是 Page分页也是自动处理的。这种方式理解起来可能需要一点时间但只要用顺了后端接口的筛选逻辑大部分都能用这个套路统一搞定。4. 事务管理与常见实战问题4.1 事务边界的合理划分事务是 JPA 里最重要的一个概念没有之一。我在团队里定的规矩是事务只加在 Service 层的方法上Controller 层不涉及事务Repository 层的自定义方法除非特殊场景否则也不建议加事务。Service public class OrderServiceImpl implements OrderService { Override Transactional(rollbackFor Exception.class) public void createOrder(CreateOrderRequest request) { // 1. 保存订单 // 2. 保存订单项 // 3. 扣减库存 // 4. 记录操作日志 } }rollbackFor Exception.class这个属性很多人会忽略。Spring 默认只对运行时异常回滚受检异常不会回滚。如果你的业务方法里 catch 了一个 Exception 后又抛出自定义异常而且这个自定义异常继承的是 Exception 而不是 RuntimeException那事务就不会回滚数据就出现不一致了。所以我是统一约定了业务异常继承 RuntimeException同时 Transactional 上都加rollbackFor Exception.class双保险。还有一个容易忽略的点Transactional 默认只对 public 方法生效。如果类内部有个 private 方法被调用那么这个方法上的 Transactional 是不会生效的因为 Spring AOP 是基于代理的内部调用走的是this引用而不是代理对象。有个经典的坑是同一个类里一个方法调用另一个被 Transactional 标注的方法结果事务完全没生效数据还是照常写进去了。4.2 事务失效的常见场景这个和面试题里常考的内容高度重合我实际项目里也踩过整理几个典型的事务失效场景方法自调用导致事务失效如上文所说走this引用而不是代理对象。访问权限问题Transactional标记在非 public 方法上不生效。异常被 try-catch 吞掉了Spring 看不到异常自然不回滚。数据库引擎不支持事务比如 MySQL 的 MyISAM 引擎就完全不支持事务。多线程并发情况下两个线程各自拿到自己的连接事务互不可见。事务这种问题排查起来特别费劲因为代码看起来都正常但数据就是不对。我建议团队项目里写代码前先统一约定好事务规范并且在做 Code Review 时重点关注。4.3 事务不要包裹耗时操作这个是我特别想强调的一个点。有人习惯在 Service 层加一个大事务把方法里所有操作都包进去包括调用第三方接口的耗时操作Transactional(rollbackFor Exception.class) public void createOrder(CreateOrderRequest request) { orderRepository.save(order); // 调用微信支付接口耗时 3 秒 String payUrl wechatPayService.createOrder(request); // 保存支付单 payRepository.save(payRecord); }这样做的问题很严重这个事务从进入方法开始就持有数据库连接一直到方法结束才释放。假设这个方法被 100 个人同时调用每个连接被占用了 4 秒连接池本来就不大很容易就把连接资源耗尽了其他请求全部排队等着拿连接整个服务的响应时间就上去了。正确的做法是事务里只做数据操作外部接口调用放到事务之外。比如上面的场景可以拆成两步先保存订单事务内然后去调用支付接口事务外支付成功后再通过回调更新订单状态新的事务。如果支付接口调用失败订单可以处于待支付或取消状态而不是把整个创建订单都回滚。5. 常见问题与性能排查实战5.1 N1 查询问题真的是 JPA 最大的坑N1 问题是 JPA 被黑得最惨的一个点也是面试必问的一道题。它的本质是查询出 N 条主记录后遍历每条记录访问关联对象时又额外触发了 N 条查询整体变成了 1 N 条 SQL。// 反例查 100 个订单然后遍历每个订单的用户 ListOrder orders orderRepository.findAll(); for (Order order : orders) { User user order.getUser(); // 这里会触发额外的查询 // 总共 1 100 条 SQL }我用 Arthas 或者开启show-sql: true后看过这种代码在生产环境打印的 SQL 能刷屏几页。解决 N1 有几种方案JOIN FETCH用 JPQL 的JOIN FETCH一次性把关联对象查出来简单直接但要注意不能分页。EntityGraph这是我认为最好用的方案可以避免写复杂 JPQLpublic interface OrderRepository extends JpaRepositoryOrder, Long { EntityGraph(attributePaths {items, user}) Query(SELECT o FROM Order o WHERE o.id IN :ids) ListOrder findByIdsWithDetails(Param(ids) CollectionLong ids); }EntityGraph会在查询时自动做 left join fetch把关联对象一起加载出来不用手写 JOIN FETCH 语法代码更干净。批量抓取Batch Fetching配置BatchSize(size 20)或全局配置hibernate.default_batch_fetch_size: 20Hibernate 会在访问懒加载对象时用IN (...)批量查询同一类型的相关对象。虽然不能完全消除额外查询但能把 N1 变成 N/201性能提升也很明显。我目前团队里的规范是列表查询场景默认使用EntityGraph详情查询用JOIN FETCH如果是一次性加载所有关联数据用BatchSize兜底。5.2 批量插入性能优化另外一个容易被忽略的性能问题是大批量插入场景。比如做一个商品导入功能一次导入 5000 条商品数据。正常一条条 save 的话耗时可能要几十秒但经过优化几秒之内就能完成。除了 yml 配置里的batch_size、order_inserts参数外还有一个关键点不能循环调用save()要使用saveAll()或在持久化上下文里通过EntityManager.persist()累积插入// Service 层开启事务后批量插入 Transactional(rollbackFor Exception.class) public void batchImport(ListProduct products) { productRepository.saveAll(products); // 批量插入 }这背后有一个容易被忽略的机制Hibernate 的批量插入只有在同一个事务里才会生效因为persist()时实体是进入持久化上下文事务提交时 Hibernate 才会真正把累积的实体批量写库。如果数据量特别大我建议再按 500 条一组进行分批提交不要一把梭全塞进一个事务里否则数据量太大时有内存溢出和锁表的风险。5.3 主键生成策略的坑前面提到我推荐GenerationType.IDENTITY主要是简单可控。但IDENTITY也有坑由于插入时必须立即获取自增主键Hibernate 无法对它做 JDBC 批处理也就是说批量插入优化和 IDENTITY 策略是冲突的。如果要用SEQUENCE比如 Oracle、PostgreSQL可以配合SequenceGenerator设置批量增量Entity public class Product { Id GeneratedValue(strategy GenerationType.SEQUENCE, generator product_seq) SequenceGenerator(name product_seq, sequenceName seq_product, allocationSize 50) private Long id; }allocationSize建议设置成 50甚至更大这样批量插入才能拿到连续的 ID 段避免频繁访问数据库的序列。还有一个很经典的坑和 Flowable 工作流引擎一起使用时Flowable 有自己的一套表结构以及主键生成机制如果和 JPA 实体都被 Spring Boot 扫描到可能因为两个框架的主键生成策略不兼容导致建表冲突最常见的就是 Flowable 建好的表反过来被 JPA 的ddl-autoupdate误判、误改。真实项目中我见过有人把 Flowable 的数据源单独拆出去让 JPA 只管理业务表才彻底消停。如果你项目里同时用这两个建议数据源一定分离。5.4 只读事务的优化一个很实用的优化手段如果事务里只有查询操作加上Transactional(readOnly true)。这个注解有两个作用一是给数据库一个暗示查询可以被优化二是 Hibernate 会设置 FlushMode不会在查询前检查脏数据并刷写减少了不必要的 SQL 执行。Transactional(readOnly true) public PageOrder queryOrders(Pageable pageable) { return orderRepository.findAll(pageable); }我实测过的项目中加上readOnly true后复杂查询场景整体耗时能降低 10%-20%聊胜于无但成本几乎为零没有理由不加。5.5 慢 SQL 排查方法论我在项目中排查 JPA 慢 SQL 的步骤一般是这样在 yml 里开启show-sql: true然后跑一遍业务看 SQL 输出了几条、每条的参数是什么。如果 SQL 数量很多优先排查 N1找到循环访问懒加载的地方。如果单条 SQL 慢把这条 SQL 复制到数据库客户端用EXPLAIN看一下索引命中情况。检查实体里的关联配置看是不是该用懒加载却用了急加载。检查 JPA 自动生成的 SQL 和手写 SQL 的差异看能不能通过 Query 手动优化大 SQL。这个方法总的来说很通用适配大部分 Spring Boot 项目。有条件的可以加上 p6spy 或数据库的慢查询日志这样能更早地发现问题。6. 一些实用的个人体会最后再分享几个我实际项目中总结的经验不算系统性的知识但很实用。第一JPA 真不难学难的是读懂它生成的 SQL。我的建议是刚开始用 JPA 时一定开着show-sql: true每次写完查询后都盯一下打印出来的 SQL确认它和你预想的一致。这样能培养对 JPA 生成规则的直觉慢慢就能在写代码时预判到潜在的性能问题。第二用 JPA 不要害怕写 Query。有些人被“JPA 全自动”的理念带偏了认为只要用方法名查询就是标准做法结果一个 join 三个子查询的方法名写了一长串可读性极差。该用 JPQL 或原生 SQL 的时候别犹豫自动生成和手写 SQL 结合才是最佳实践。第三实体类一定要有创建时间和更新时间。用CreationTimestamp和UpdateTimestamp两个注解或者配置 JPA 审计功能省去手动赋值的时间EntityListeners(AuditingEntityListener.class) MappedSuperclass public abstract class BaseEntity { CreatedDate Column(name created_at, updatable false) private LocalDateTime createdAt; LastModifiedDate Column(name updated_at) private LocalDateTime updatedAt; }然后在启动类加EnableJpaAuditing注解所有实体继承这个 BaseEntity 就行了。这算是花的代价最小、长期收益最大的一个设计了。SpringBootJPA 这套组合用得好它是加速器用不好它就是个坑。但说实话大部分“坑”不是 JPA 造成的而是使用者不清楚底层 SQL 生成规则、事务边界和加载策略导致的。把基础打牢你已经战胜了大多数人。本文还有配套的精品资源点击获取
返回列表