尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
Spring Data JPA实战:从CRUD到分页、实体映射与常见坑
写后端接口这些年我见过太多人把大半精力耗在“给数据库表写CRUD”这件事上。Spring Data JPA 就是用来终结这种重复劳动的方案只要定义一个接口基础增删改查、分页、排序、条件查询框架在运行时直接把实现类生成给你省掉一大片重复的 DAO 代码。这篇文章不谈源码也不念规范就用一个能跑通的 Spring Boot 项目把 Spring Data JPA 最常用的能力完整拆一遍环境怎么搭、Repository 怎么写、方法名如何推导查询、Query 什么时候用、分页怎么做、实体映射有哪些坑。无论你是刚接触后端的新人还是从 MyBatis 转过来想对比一下的老手按这篇文章走一遍日常开发里最常用那一套基本就能拿下了。我会按实际使用顺序来写先理解它解决的问题再动手搭环境接着逐个拆查询方式最后把实体关系和最容易踩的坑集中清一遍。1. 项目概述Spring Data JPA 到底解决什么问题1.1 从 JDBC 到 JPA 再到 Spring Data JPA这条路是怎么走的先说一个很多初学者容易混淆的事情JPA 本身是 Java 官方制定的一套 ORM 规范不是具体产品。它规定了Entity、Id、Table这些注解长什么样也规定了orm.xml怎么配置但具体干活的是实现方最出名的就是 Hibernate。而在 Hibernate 之上Spring Data JPA 又包了一层。它没有改变 JPA 的任何底层语义只是把“怎么创建 EntityManager”“怎么写 Repository 实现类”“怎么把异常翻译成 Spring 的异常体系”这些繁琐的胶水代码全部接管了。我用 JDBC 时代的代码做对比你就知道省在哪里了。JDBC 查一个用户要先 Class.forName 加载驱动再 DriverManager 拿连接写 SQL预编译设置参数executeQuery遍历 ResultSet封装 Entity最后 finally 里一层一层关 ResultSet、Statement、Connection。七个步骤缺一不可任何一个连接没关连接池迟早搭进去。用 JPA 原生 API 写虽然省了连接管理但你还是得自己创建 EntityManagerFactory、自己管理事务边界、自己写 JPQL、自己处理结果集。到了 Spring Data JPA 这里上面这些几乎全被收敛了。你只需要声明一个接口继承一下 JpaRepository再写几个带规则的方法名剩下的交给框架。所以Spring Data JPA 不是帮你“少写几行 SQL”而是把数据访问层百分之八十的重复结构都消灭了。这也是我宁愿多花篇幅讲清楚这条技术演进路线的原因理解了它为什么存在后面学起来就不会觉得注解和方法名是一堆魔法。1.2 它真正解决的是“重复的样板代码”和“数据库方言差异”Spring Data JPA 给我最直观的感受不是某个功能多惊艳而是它把大量“长得很像”的代码收敛成一套统一规则。举个例子DAO 层的通用 CRUD。传统写法里每一个实体都要写一个 DaoImpl继承一个泛型 BaseDao然后把增删改查方法全部实现一遍。Spring Data JPA 直接提供了一个 JpaRepositoryUser, Long你传进实体类型和主键类型save、findById、findAll、deleteById、count 这些方法就全都有了。再比如分页。JDBC 时代 MySQL 写 LIMITOracle 写 ROWNUMSQL Server 写 OFFSET FETCH换个数据库整套分页逻辑要重写。Spring Data JPA 抽象出了一个 Pageable 对象你只需要传页码和每页条数框架会根据当前数据库方言自动生成对应的分页 SQL。这个能力对做产品型项目特别有价值底层数据库从 H2 切 MySQL再从 MySQL 切 PostgreSQL数据访问层代码基本不用动。还有查询条件的组合。以前用 StringBuffer 拼 SQL条件多的时候where 后面要不要加 and百分号拼在哪边错一个就是线上事故。Spring Data JPA 的方法名推导查询把条件直接用方法名表达出来findByUsernameAndStatus、findByAgeGreaterThan读名字就知道查的是什么编译期和启动时还能帮你做一层校验。1.3 它适合什么项目不适合什么项目Spring Data JPA 不是万金油选型前你得清楚边界。它擅长的是领域模型清晰、单表操作多、关联关系相对稳定的业务模块。典型的有用户中心、权限管理、订单主流程、内容管理这一类实体和表结构基本固定业务操作以 CRUD 和简单统计为主用 Spring Data JPA 能大幅提升开发速度代码可读性也很高。它不适合的场景主要有三类。第一类是报表类需求动不动就是多表 left join、子查询、按天按月做聚合这种 SQL 用 JPQL 写起来非常别扭调试成本高性能和可读性都不如直接用原生 SQL。第二类是海量数据的批量操作JPA 在批量更新和批量删除上的表现比较弱通常需要借助 Modifying 一条 SQL 完成真遇到几十万行级别的分批处理还是得回归 JDBC 或者 MyBatis。第三类是你对 SQL 执行计划有非常精细的要求比如想要特定索引、想控制 join 顺序ORM 自动构造的 SQL 很难做到极致。准确的说法是Spring Data JPA 和 MyBatis 不是二选一。很多团队都是 JPA 负责常规 CRUDMyBatis 专门承接复杂查询两者各管一摊并不冲突。我自己的项目里也一直是这样搭配的。2. 环境搭建5 分钟跑通第一个 Repository2.1 用 Spring Initializr 生成项目引入核心依赖我不会带你从零手写 Spring Boot 项目直接用 start.spring.io 生成工程结构最省事。生成时选择 Java 17Spring Boot 3.x或 Java 8/11Spring Boot 2.x依赖勾上 Spring Web、Spring Data JPA再根据本地数据库选一个驱动。Maven 的坐标长这样如果你用的是 Spring Boot 3.x依赖不需要写版本号父工程统一管理dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency如果只是想快速本地跑通不想装数据库可以把 MySQL 驱动换成 H2零配置最方便dependency groupIdcom.h2database/groupId artifactIdh2/artifactId scoperuntime/scope /dependency这里有个版本口径要提醒你Spring Boot 3.x 用的 JPA 包名是jakarta.persistence.*网上大量老教程和旧代码还在用javax.persistence.*。直接把旧代码粘到新工程里报“程序包javax.persistence不存在”十有八九就是这个原因。看到jakarta开头别慌它和曾经的javax在用法上基本一一对应。2.2 配置文件怎么写数据源和 JPA 参数我用 H2 做演示配置文件直接放在application.ymlspring: datasource: url: jdbc:h2:mem:testdb driver-class-name: org.h2.Driver username: sa password: jpa: hibernate: ddl-auto: create-drop show-sql: true properties: hibernate: format_sql: true这里面最值得解释的是ddl-auto它一共有五个取值直接影响 Hibernate 怎么处理表结构取值行为建议场景none不自动建表生产环境最推荐validate只校验实体和表结构是否匹配表结构由迁移脚本管理的项目update自动新增表或字段不删除开发环境临时用create启动时删表再建表演示项目但数据会丢create-drop启动建表关闭删除表H2 这类内存数据库演示或者测试show-sql: true只是把 Hibernate 生成的 SQL 打到日志里它显示的 SQL 是经过方言适配的最终 SQL能帮你确认实际执行逻辑。但它不等于 SQL 真的在“这里”执行了日志打印和数据库执行是两回事排查性能问题时要结合数据库自身的慢查询日志不能只靠应用日志。2.3 建一个 User 实体再写一个仓库接口先定义一个实体。这里我用t_user做表名实体名是 User注意实体属性名和表字段名并不要求完全一样后面会细讲映射规则Entity Table(name t_user) public class User { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(nullable false, unique true, length 64) private String username; Column(name nick_name) private String nickname; private Integer age; public User() { } public User(String username, String nickname, Integer age) { this.username username; this.nickname nickname; this.age age; } // getter / setter }对应的 Repository 接口一句话就建好了public interface UserRepository extends JpaRepositoryUser, Long { ListUser findByAge(Integer age); }JpaRepositoryUser, Long这个泛型声明第一个参数是实体类型第二个是主键类型。框架会动态生成一个实现类把 findAll、findById、save、deleteById、count 这些基础方法全部实现好。你额外声明的findByAge是 Spring Data JPA 根据方法名自动解析成查询的。我习惯用ApplicationRunner来验证项目能跑通启动时插入两条数据再查一次Component public class UserDataRunner implements ApplicationRunner { private final UserRepository userRepository; public UserDataRunner(UserRepository userRepository) { this.userRepository userRepository; } Override public void run(ApplicationArguments args) { userRepository.save(new User(zhangsan, 张三, 28)); userRepository.save(new User(lisi, 李四, 25)); System.out.println(年龄为28的用户); userRepository.findByAge(28).forEach(System.out::println); } }启动后看到日志里打印出了两条 insert 和一条 select说明整个链路已经通了。2.4 方法名不是随便写的启动时就会强校验这里我必须单独拎出来讲一个特性因为它是新手的第一个大坑。Spring Data JPA 的方法名解析不是运行到那一行才发生的而是在 Spring 容器启动阶段就会做校验。方法名里引用的属性名必须能在实体类中找到找不到就抛异常应用直接起不来。比如你写成findByPasswd而实体里只有password属性启动时会报PropertyReferenceException: No property passwd found for type User。这玩意儿看起来吓人实际是好事因为错误被前置了。你的名字写错不用等线上业务触发才暴露。代价是你得适应这个规则方法名里写的永远是 Java 实体的属性名不是数据库表的字段名。表字段是user_name实体属性是username那方法名只能是findByUsername表字段是user_name实体属性是userName方法名才是findByUserName。字段名和属性名的映射问题我放到实体映射那一章细说。3. Repository 查询方式拆解方法名、Query 与分页3.1 方法名推导查询的完整规则Spring Data JPA 最让人上头的功能就是方法名自动转 SQL。它有一套固定的解析逻辑方法名前缀findBy、readBy、getBy、countBy、deleteBy 等后面跟着条件表达式框架把表达式拆成语义树再生成 JPQL。我用最常用的几个场景做示例// 等值查询 User findByUsername(String username); // 多条件 AND User findByUsernameAndAge(String username, Integer age); // 多条件 OR ListUser findByUsernameOrEmail(String username, String email); // 模糊查询等于 LIKE %keyword% ListUser findByNicknameContaining(String keyword); // 前缀匹配等于 LIKE keyword% ListUser findByUsernameStartingWith(String prefix); // 数值比较 ListUser findByAgeGreaterThan(Integer minAge); // 区间查询 ListUser findByAgeBetween(Integer minAge, Integer maxAge); // 排序 ListUser findByAgeOrderByUsernameDesc(Integer age);这些关键词用表格整理会更清楚关键词示例生成的SQL语义And / OrfindByUsernameAndStatus / findByUsernameOrEmailWHERE user? AND status? / WHERE user? OR email?ContainingfindByNicknameContainingLIKE %keyword%StartingWith / EndingWithfindByUsernameStartingWithLIKE keyword%GreaterThan / LessThan / BetweenfindByAgeGreaterThan / findByAgeBetweenage ? / age BETWEEN ? AND ?InfindByAgeIn(Collection ages)age IN (?, ?, ?)NotfindByAgeNotage ?IsNull / IsNotNullfindByEmailIsNullemail IS NULL / IS NOT NULLOrderByfindByAgeOrderByUsernameDescORDER BY username DESCIgnoreCasefindByUsernameIgnoreCaseWHERE UPPER(username)UPPER(?)Top / Top3findTop3ByOrderByScoreDesc限制返回前 3 条还有一个容易忽略的细节Containing本质是LIKE %keyword%但如果 keyword 本身包含了通配符 %也会被当作通配符参与匹配。代码里如果用了用户输入做模糊查询要做一层通配符转义不然可能出现和需求不符的结果也存在一定的注入隐患。Spring Data JPA 的底层是预编译主结构不容易注入但通配符问题的确存在。3.2 Query 注解方法名搞不定的时候直接写 JPQL方法名推导的好处是简洁但条件一多方法名会长得离谱甚至可读性比 SQL 还差。这时就该 Query 上场。所谓 JPQL长得和 SQL 很像但查的是实体对象而不是表写的是实体属性名而不是列名。先看一个完整例子public interface UserRepository extends JpaRepositoryUser, Long { Query(select u from User u where u.username ?1) User findByUsernameDirect(String username); Query(select u from User u where u.age between ?1 and ?2 order by u.age desc) ListUser findBetweenAge(Integer minAge, Integer maxAge); Query(select u from User u where u.nickname like concat(%, :keyword, %)) ListUser searchByKeyword(Param(keyword) String keyword); }这里有两种参数绑定方式?1是按位置从 1 开始优点是省事缺点是参数一多别人根本分不清谁是 1 谁是 2:keyword是命名参数配合 Param 使用可读性好得多。我强烈建议新项目统一用命名参数。JPQL 查询里不必写select u也可以很多时候直接写from User u更简洁Spring Data JPA 会默认返回实体。如果是复杂到 JPQL 也绕不过去的场景可以退一步用原生 SQLQuery(value select * from t_user where age :minAge, nativeQuery true) ListUser nativeQuery(Param(minAge) Integer minAge);nativeQuery true意味着括号里写的是数据库原生 SQL表名和列名也得按数据库真实结构写。返回值可以是实体也可以用接口做投影。原生 SQL 是最后手段用了就绑定具体数据库方言换库兼容性就差了。3.3 分页和排序Pageable 的正确打开方式Spring Data JPA 的分页 API 设计得相当优雅。先看基础用法PageRequest pageRequest PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, createTime)); PageUser page userRepository.findAll(pageRequest); ListUser users page.getContent(); long total page.getTotalElements(); int totalPages page.getTotalPages();PageRequest.of(0, 10)表示第 0 页每页 10 条。这里有个经典坑Page 的页码从 0 开始而前端传的页码通常从 1 开始。接前端参数时一定要先page - 1否则你会发现第一页数据永远查不到或者一直显示第二页。自定义查询也能直接接收 PageableQuery(select u from User u where u.age :minAge) PageUser findByMinAge(Param(minAge) Integer minAge, Pageable pageable);Pageable 本质是接口PageRequest 是它的一个实现类建议业务方法参数统一声明成 Pageable调用方想分页就传 PageRequest不想分页就随便传一个 Pageable.unpaged()灵活度更高。还有一个性能向知识点返回 Page 类型时框架会额外执行一条 count 查询来统计总条数。如果你只需要当前页的数据不在乎总条数和总页数返回值可以直接用 List配合 Pageable 参数就不会执行那条 count。像移动端无限滚动场景我常写成ListUser findTop20ByOrderByIdDesc(Pageable pageable);传入PageRequest.of(0, 20)拿到的就是最新 20 条还省了一次 count。3.4 更新和删除操作Modifying 与事务是必须配对的方法名的能力不只有查询还有更新和删除但它们和查询有本质区别。看这段常用的批量更新Modifying Query(update User u set u.nickname :nickname where u.id :id) int updateNickname(Param(id) Long id, Param(nickname) String nickname);注意两点。第一这个方法是修改操作实体状态已经改变必须让方法被 Spring 事务管理否则调用时会抛TransactionRequiredException。你可以在 Repository 方法上加 Transactional也可以在 Service 层调用处加。第二Modifying 告诉框架这不是一条 select 语句不要走查询流程。Spring Data JPA 对继承自 JpaRepository 的方法默认做了事务处理比如save、deleteById都能直接工作但自己写的 Query 修改方法事务边界一定要自己明确。很多新手第一次跑批量更新报错原因都是忘了加事务。更隐蔽的问题是更新后的一级缓存。默认情况下Modifying 执行的是数据库层面的操作不会自动刷新 EntityManager 里的一级缓存。这时候如果同一个事务里再次调用 findById拿到的可能还是更新前的旧数据。解决办法是在 Modifying 上把自动清理和自动刷新打开Modifying(clearAutomatically true, flushAutomatically true) Query(update User u set u.nickname :nickname where u.id :id) int updateNickname(Param(id) Long id, Param(nickname) String nickname);clearAutomatically true会清空一级缓存flushAutomatically true会在查询前把 pending 的修改刷到数据库。这个参数组合我在生产环境用过很多次强烈建议批量更新的方法默认加上。4. 实体映射与关联关系的细节处理4.1 基础映射注解一个实体对应一张表实体映射是 Spring Data JPA 最容易出幺蛾子的地方看起来注解不多每一处都有讲究。最基础的是 Entity Table。Entity 标记这个类是一个被 JPA 管理的实体Table 指定对应表名。注意 Table 可以省略省略时表名由 Hibernate 根据实体名推导比如 User 对应 user 表。我习惯显式写清楚避免默认规则带来的惊喜。Id 标记主键GeneratedValue(strategy GenerationType.IDENTITY) 表示主键由数据库自动生成对应 MySQL 的 AUTO_INCREMENT。还有 SEQUENCE、TABLE、AUTO 等策略跨库项目要仔细设计单库项目直接用 IDENTITY 最省心。Column 的常用属性要记牢Column(name nick_name, nullable false, length 32, unique true) private String nickname;name指定列名nullable对应 NOT NULL 约束length只对字符串类型生效unique生成唯一约束。这些属性如果只靠 ddl-autoupdate 在生产环境自动改表风险很大我后续会给建议。还有个小细节Column 里标注了updatable false的字段在更新时不会进入 SET 子句。这一点常被用来锁创建时间不允许修改。从 Spring Boot 2.x 开始默认的物理命名策略会把 Java 属性的驼峰命名转换成下划线命名。比如实体属性createTime默认映射列名是create_time。我用 Column 显式控制列名双保险哪怕团队里有人改了全局命名策略也不影响。4.2 一对多、多对一的正确姿势实体关联映射是 ORM 里的深水区这里我不展开所有关联形式只讲最常用的多对一和一对多以及它们的最佳实践。假设你有文章和用户两个实体Entity Table(name t_article) public class Article { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String title; ManyToOne(fetch FetchType.LAZY) JoinColumn(name user_id) private User author; // getter / setter }ManyToOne 表示多篇文章对应一个用户JoinColumn 指定外键列名。这里我显式加了fetch FetchType.LAZY不是因为默认不好而是 ManyToOne 默认是 EAGER立即加载。为什么我建议一律改成 LAZY假设查 10 篇文章默认情况下每篇文章都会立即查出关联的 User直白地说就是 10 次额外查询这就是 N1 问题的经典来源。改成 LAZY 后你在业务里真正需要 author 时自己决定怎么加载永远不会因为一句“顺手查的”把数据库拖垮。反过来在 User 实体上声明一对多我建议不要急着加这段代码OneToMany(mappedBy author, cascade CascadeType.ALL) private ListArticle articles new ArrayList();OneToMany 默认就是 LAZY问题不在加载策略而在于很多人加了这段代码后习惯通过user.getArticles()去拿列表。性能上稍不注意就触发 N1编写时还容易忽略 mappedBy 的指向是否正确。我的经验是一对多关系能不声明就不声明。你需要某个用户的文章列表时直接在 ArticleRepository 里写findByAuthorId查出来思路清晰SQL 可控。等确实遇到要级联操作或复杂对象图的场景再补上 OneToMany 也不迟。级联操作也是一个大坑。很多人喜欢cascade CascadeType.ALL图省事。但你要知道ALL 包含 REMOVE你在 User 上删一条记录会连它的文章一起删掉。很多线上事故就是这么来的轻轻一个 deleteById业务认为只删了用户数据库却少了一片关联业务数据。级联权限尽量收窄能用 MERGE 和 PERSIST 就不要上 REMOVE。4.3 审计字段与乐观锁默认就给我加上我基于个人习惯强烈建议每一个业务实体都要包含创建时间、修改时间和版本号。这个建议适用于所有表不只 JPA 项目。Spring Data JPA 提供了现成的审计支持。在实体上配合 EntityListeners 使用Entity Table(name t_user) EntityListeners(AuditingEntityListener.class) public class User { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String username; CreatedDate Column(updatable false) private LocalDateTime createTime; LastModifiedDate private LocalDateTime updateTime; Version private Long version; // getter / setter }注意光有注解还不够要启动类上加 EnableJpaAuditing审计功能才会生效SpringBootApplication EnableJpaAuditing public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }CreatedDate 会在插入时自动填充LastModifiedDate 在每次更新时自动刷新你不用手工 set。Column(updatable false) 保证创建时间永远不会被 UPDATE 语句改写。Version 是 JPA 的乐观锁机制。更新时 Hibernate 会把 version 一起放到 UPDATE 的 WHERE 条件里例如where id? and version?。如果两条并发事务同时读取了版本号 1先提交的把 version 改成 2后提交的再更新时发现 where 条件里 version1 匹配不到任何行就会抛乐观锁异常由业务层决定是重试还是提示用户。对账、余额这类高并发敏感的数据这个字段能兜住不少并发覆盖问题。4.4 字段类型细节枚举、时间、大字段不容小视实体字段映射时最容易被忽略的是类型选择的细节我在这里集中清一遍。枚举字段必须显式说明存储格式Enumerated(EnumType.STRING) private UserStatus status;不写 Enumerated 时默认是EnumType.ORDINAL本质就是存枚举的下标。后果是什么你的枚举顺序千万别调整一调整历史数据全错新加枚举也只能加在末尾不然所有旧数据全乱套。存 STRING意思明确就是存枚举名比数字安全得多。时间字段用 Java 8 的 LocalDateTime 或 LocalDate不要用 java.util.Date。在本地开发可能看不出差别一旦涉及时区转换、反序列化格式新 API 的可控性高很多。数据库字段类型上MySQL 里 LocalDateTime 对应 datetime 或 timestamp一般没问题但如果你要存日期精度注意 LocalDate 只对应 date。大文本字段要思考 Lob 的影响Lob private String content;加 Lob 后Hibernate 在 MySQL 下会把它映射成 LONGTEXT适合存很长的文章内容。如果不加 Lob默认按 varchar(255) 处理存超长文本直接报错或静默截断。Lob 还影响查询行为这种字段通常不应该出现在列表查询的 select 里必要时考虑把大文本单独拆一张表或者查询时用投影避开。最后一个建议金额字段一定用 BigDecimal不是 double不是 float。浮点数的二进制表示会导致精度问题涉及钱的计算尤其致命。JPA 里 BigDecimal 会映射成 decimal这是正确的落点。5. 常见问题与排查技巧实录这一部分是我实际开发中的血泪经验。所有问题都不只是现象描述我把原因和解法一起给出来。5.1 懒加载字段序列化导致报错返回 JSON 时遇到InvalidDefinitionException或者could not initialize proxy - no Session十有八九是实体里的懒加载关联字段被 Jackson 访问到了。原因很简单实体在事务里时关联字段是懒加载的没有真正加载事务结束、Session 关闭后Jackson 序列化时调用user.getArticles()此时已经没有可用的 SessionHibernate 无法加载数据直接抛懒加载异常。我的处理优先级如下第一选择彻底告别直接返回实体改用 DTO。Controller 层返回 VO/DTO把需要的字段明确列出来不暴露实体内部结构。这个方案最干净副作用最小。第二选择在实体的关联字段上做 JSON 忽略JsonIgnoreProperties({articles, author}) private ListArticle articles;或者用 JsonIgnore 直接不序列化该字段。简单粗暴但要注意可能把业务里本来需要返回的字段给弄丢了。第三选择在查询时就使用join fetch一次性把关联数据查出来无论序列化还是后面的业务处理都不会触发懒加载。网上还有一种方案是把spring.jpa.open-in-view设为 true让 Session 跟请求生命周期绑定。我不推荐这个配置会拉长数据库事务和连接占用时间且高并发下性能隐患明显。Spring Boot 新版本默认输出 WARN 日志提醒不要长期开着足以说明问题。5.2 N1 查询问题最经典的性能坑N1 问题是 ORM 框架的“通病”Spring Data JPA 也不例外。现象很统一查询列表 10 条结果控制台打了 11 条 SQL1 条查主表10 条查关联表。产生原因就是懒加载循环访问。你在循环体里挨个访问user.getArticles()每次访问触发一次查询自然就是 N 次额外查询。解决方案要看场景。Join Fetch 能直接预加载关联属性Query(select u from User u join fetch u.articles) ListUser findAllWithArticles();这条 SQL 通过一条 JOIN 把 User 和 Article 一起查出来循环访问时不再触达数据库。有一点要注意join fetch 会对结果集做去重和组合如果一对多产生的记录变多分页时 count 的结果可能和预期不一致需要单独处理。更通用的是 EntityGraphEntityGraph(attributePaths {articles}) Query(select u from User u) ListUser findAllWithArticles();EntityGraph 和 join fetch 思路类似但语义更清晰还能和分页组合使用。另一个思路是 BatchSizeBatchSize(size 20) OneToMany(mappedBy author) private ListArticle articles;加上 BatchSize 后Hibernate 会把多次单条查询合并成一条 IN 查询例如查 20 个用户每访问一个的 articles会先攒够 20 个 ID 再一次性批量查出来SQL 数量就从 20 次变成 1 次。这个方案适合懒加载已经写在代码里、不好改成 join fetch 的历史项目。说到底比所有优化手段更重要的是写代码时意识到“循环里不要碰懒加载关联属性”。这条原则能避免九成的 N1 问题。5.3 save() 到底是新增还是更新很多人都没搞懂Spring Data JPA 的 save 方法经常让人困惑它会根据实体主键是否存在来决定走 insert 还是 update。听起来很简单实际坑很深。看 SimpleJpaRepository 的实现逻辑save 会把实体交给 EntityManagerEntityManager 判断实体的主键是不是 null。主键是 null 就执行 persist走插入主键不是 null 就执行 merge走合并。merge 就不是“更新”两个字能概括的了。它会先发一条 select 查数据库里有没有这条记录有记录就把传入实体的状态覆盖到持久化对象上然后 flush 时生成 update。如果记录不存在它也可能会执行一次插入。最麻烦的是它不会帮你判断“这条记录是不是真的存在”只要主键不为 null它就可能先查一次。所以开发时要养成两个习惯。第一新增操作的实体不要手动设置主键让 IDENTITY 策略自动生成。第二更新操作尽量先查出来再改属性或者用前面说的 Modifying 批量更新。别拿不确定主键是否存在的实体直接调 save因为它可能给你带来意料之外的 select 和性能损耗。5.4 方法名解析失败卡住启动启动报 PropertyReferenceException是最具有 Spring Data JPA 特色的一种错误。原因我前面提过方法名里的属性名在实体类里找不到。举个例子你写findByUserName(String name)但实体属性叫username启动一定会报错因为属性名解析时区分大小写且要求完全匹配不计大小写但结构必须存在。Spring Data JPA 解析方法名时会把User和name拼起来尝试在实体属性列表里找userName找不到就抛异常。排查顺序是这样的先看实体里属性到底叫什么再对照方法名。尤其注意后端 Developers 喜欢把表格字段改成下划线命名例如表字段user_name实体属性却是username你在方法名里写成findByUser_name完全是错的方向。还有一个小规则方法名里的属性引用支持多级路径比如findByAddressCity(String city)前提是 User 里有一个address属性Address 里有一个city属性。但这种写法嵌套多了可读性很差不如直接用 Query。5.5 自调用导致事务失效Transactional 加了但事务没生效这种问题排查起来最闹心因为代码看起来完全没问题。核心机制是Spring 的事务基于 AOP 动态代理。外部调用某个 Bean 的方法时调用的是代理对象代理会先开启事务再执行原始逻辑。但如果是同一个类里一个普通方法调用另一个 Transactional 方法这个调用发生在原始对象内部绕过了代理事务自然不生效。最典型的是批量更新时碰到TransactionRequiredException: Executing an update/delete query。解决方案有三个把事务方法挪到另一个 Service Bean 里由外部调用或者注入自身代理Spring 4 之后可以Autowired自身但在构造器里自引用容易循环依赖最简单省事的方法是把事务注解放在外部入口方法上而不是内部方法上。这和你项目里的 Repository 没有直接关系但几乎每个用 Spring Data JPA 做复杂更新的项目都会碰到值得专门记一笔。5.6 多条件动态查询方案选择很重要方法名推导和 Query 都是写死条件的业务系统里最常见的需求却是“用户传了哪个条件就按哪个条件筛”。这是 Spring Data JPA 被吐槽最多的点。官方给出的答案是 Specification。先让 Repository 继承 JpaSpecificationExecutorpublic interface UserRepository extends JpaRepositoryUser, Long, JpaSpecificationExecutorUser { }然后动态拼接条件SpecificationUser spec (root, query, cb) - { ListPredicate predicates new ArrayList(); if (username ! null) { predicates.add(cb.equal(root.get(username), username)); } if (minAge ! null) { predicates.add(cb.greaterThanOrEqualTo(root.get(age), minAge)); } return cb.and(predicates.toArray(new Predicate[0])); }; PageUser page userRepository.findAll(spec, PageRequest.of(0, 10));Specification 的 API 风格偏底层条件多了代码会有点啰嗦。比它更优雅的是 QueryDSL类型安全、链式写法但要额外引入 apt 插件配置学习成本高一些。如果你的项目里面有大量动态查询场景MyBatis-Plus 的 Wrapper 也是一个非常务实的替代选择。这个方法没有标准答案我提供的经验是如果动态筛选不多就老老实实用 Specification 或 Query 拼条件如果动态查询是业务主体形态就别硬用 Spring Data JPA 了考虑混合方案更稳妥。说回我自己在新项目里的分工方式单表、关联稳定的领域模型走 Spring Data JPA报表和复杂统计走 MyBatis两边用一个事务管理器协调。这套组合实践下来的维护成本比逼着 Spring Data JPA 单扛所有查询低得多。最后分享一个小技巧初学阶段如果不确定方法名该怎么写大胆起一个名字启动一次利用 Spring Data JPA 启动时的强校验快速试错。启动报错提示会精确告诉你属性名不对然后打开 Hibernate 的 SQL 日志看一眼生成的 SQL 是不是你预期的样子。多试几次这套方法名规则就像身体记忆一样了。
RELATED

相关推荐

Claude Opus 5.5 直出视频实测:用 HTML/CSS 动画实现代码即视频

Claude Opus 5.5 直出视频实测:用 HTML/CSS 动画实现代码即视频

1. 当模型开始"画"视频:一个反直觉的实测发现第一次看到"Claude Opus 5.5 直出视频"这个说法,我的反应和大多数人一样——不信。大语言模型输出的是文本 token,视频是像素帧序列,这两者之间隔着一整套渲染管线…

📅 2026/10/6 4:59:52
LabVIEW中FFT/IFFT信号处理:从频谱分析到频域滤波实操指南

LabVIEW中FFT/IFFT信号处理:从频谱分析到频域滤波实操指南

做振动信号分析或者音频处理的朋友,应该都有这种体验:数据采回来了,时域波形在界面上密密麻麻,光靠肉眼根本看不出故障特征在哪,只能先看着大概趋势,然后心里默念“要是有个频谱就好了”。LabVIEW在这个场景…

📅 2026/10/6 4:59:52
示波器上的电压、振幅、幅值、幅度到底是什么关系?

示波器上的电压、振幅、幅值、幅度到底是什么关系?

最早被示波器屏幕上的参数卡住,是我刚接触电源测试那会儿:信号源明明出来一个干净的1Vpp正弦波,我按下Measure键之后整个人都愣了。屏幕上同时跳出来Vpp、Vmax、Vmin、Vamp、Vrms,翻遍菜单也没找到一个叫“电压”的,更…

📅 2026/10/6 4:54:51
MORE NEWS

更多资讯

📰

基于Android的校园二手拍卖平台:Java+Spring Boot实战

校园二手竞拍这类毕业设计,我前前后后带过不少学生做,Java Android 是里面最稳的选题组合之一。表面看它就是个商城加拍卖的CRUD,但真上手之后你会发现,拍卖这种业务和普通电商完全不是一个难度层级——它要处理出价并发、截止时…

📰

基于Android的校园网上拍卖平台:并发控制与自动成交实战

1. 选题背景与需求拆解做了这么多年Java开发,也带过不少毕业设计,说实话校园二手交易这个方向每年都有人做,但真正做得有含金量的不多。大部分人的思路就是做个列表页加个详情页,能增删改查就完事了。但如果是基于Android的校园网…

📰

Django技术栈全解析:从模型设计到性能优化的实战指南

从后端折腾到前端、再从数据库摸到部署,这些年我用Django搭过内容站、做过API服务、接过小程序后端,也帮团队把老项目从“能跑”重构到“扛得住”。每次有人问我Django技术栈到底该怎么搭,我都会说同一句话:Django本身只是一半&am…

📰

HCNR201模拟光耦的线性隔离原理与系统级设计要点

1. 为什么HCNR201不是“普通光耦”,而是一块需要重新理解的“模拟信号搬运工”你手头那张HCNR201的数据手册,第一页就写着“高线性度模拟光耦”,但翻到典型应用电路图时,却看到一堆电阻、运放、反馈路径,甚至还有双运放…

📰

OpenShell详解:用开源工具重塑高效Windows开始菜单

说起OpenShell,玩Windows系统折腾的老玩家应该都不陌生。这项目以前叫Classic Shell,后来改名Open-Shell,本质上是给Windows重新做一个“开始菜单”——对,就是那个从Windows 8开始被微软一刀砍掉、又在Windows 10/11里以各种奇怪…

📰

高光谱变化检测实战:多重形态学与PCA构建扩展形态学剖面

简介:变化检测是遥感图像分析中的核心任务,这份项目源码面向遥感研究者与开发者,聚焦基于多重形态学的高光谱图像变化检测算法实现。压缩包共 12 个文件,约 1.3MB,主体为 6 个 MATLAB 脚本(.m)&…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬