写后端接口这些年,我见过太多人把大半精力耗在“给数据库表写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 直接提供了一个 JpaRepository<User, Long>,你传进实体类型和主键类型,save、findById、findAll、deleteById、count 这些方法就全都有了。
再比如分页。JDBC 时代 MySQL 写 LIMIT,Oracle 写 ROWNUM,SQL 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 负责常规 CRUD,MyBatis 专门承接复杂查询,两者各管一摊,并不冲突。我自己的项目里也一直是这样搭配的。
2. 环境搭建:5 分钟跑通第一个 Repository
2.1 用 Spring Initializr 生成项目,引入核心依赖
我不会带你从零手写 Spring Boot 项目,直接用 start.spring.io 生成,工程结构最省事。生成时选择 Java 17(Spring Boot 3.x)或 Java 8/11(Spring Boot 2.x),依赖勾上 Spring Web、Spring Data JPA,再根据本地数据库选一个驱动。
Maven 的坐标长这样,如果你用的是 Spring Boot 3.x,依赖不需要写版本号,父工程统一管理:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>如果只是想快速本地跑通,不想装数据库,可以把 MySQL 驱动换成 H2,零配置最方便:
<dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency>这里有个版本口径要提醒你:Spring Boot 3.x 用的 JPA 包名是jakarta.persistence.*,网上大量老教程和旧代码还在用javax.persistence.*。直接把旧代码粘到新工程里,报“程序包javax.persistence不存在”,十有八九就是这个原因。看到jakarta开头别慌,它和曾经的javax在用法上基本一一对应。
2.2 配置文件怎么写:数据源和 JPA 参数
我用 H2 做演示,配置文件直接放在application.yml:
spring: 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 JpaRepository<User, Long> { List<User> findByAge(Integer age); }JpaRepository<User, 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 List<User> findByUsernameOrEmail(String username, String email); // 模糊查询,等于 LIKE %keyword% List<User> findByNicknameContaining(String keyword); // 前缀匹配,等于 LIKE keyword% List<User> findByUsernameStartingWith(String prefix); // 数值比较 List<User> findByAgeGreaterThan(Integer minAge); // 区间查询 List<User> findByAgeBetween(Integer minAge, Integer maxAge); // 排序 List<User> findByAgeOrderByUsernameDesc(Integer age);这些关键词用表格整理会更清楚:
| 关键词 | 示例 | 生成的SQL语义 |
|---|---|---|
| And / Or | findByUsernameAndStatus / findByUsernameOrEmail | WHERE user=? AND status=? / WHERE user=? OR email=? |
| Containing | findByNicknameContaining | LIKE %keyword% |
| StartingWith / EndingWith | findByUsernameStartingWith | LIKE keyword% |
| GreaterThan / LessThan / Between | findByAgeGreaterThan / findByAgeBetween | age > ? / age BETWEEN ? AND ? |
| In | findByAgeIn(Collection ages) | age IN (?, ?, ?) |
| Not | findByAgeNot | age <> ? |
| IsNull / IsNotNull | findByEmailIsNull | email IS NULL / IS NOT NULL |
| OrderBy | findByAgeOrderByUsernameDesc | ORDER BY username DESC |
| IgnoreCase | findByUsernameIgnoreCase | WHERE UPPER(username)=UPPER(?) |
| Top / Top3 | findTop3ByOrderByScoreDesc | 限制返回前 3 条 |
还有一个容易忽略的细节:Containing本质是LIKE %keyword%,但如果 keyword 本身包含了通配符 %,也会被当作通配符参与匹配。代码里如果用了用户输入做模糊查询,要做一层通配符转义,不然可能出现和需求不符的结果,也存在一定的注入隐患。Spring Data JPA 的底层是预编译,主结构不容易注入,但通配符问题的确存在。
3.2 @Query 注解:方法名搞不定的时候,直接写 JPQL
方法名推导的好处是简洁,但条件一多,方法名会长得离谱,甚至可读性比 SQL 还差。这时就该 @Query 上场。
所谓 JPQL,长得和 SQL 很像,但查的是实体对象而不是表,写的是实体属性名而不是列名。先看一个完整例子:
public interface UserRepository extends JpaRepository<User, 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") List<User> findBetweenAge(Integer minAge, Integer maxAge); @Query("select u from User u where u.nickname like concat('%', :keyword, '%')") List<User> searchByKeyword(@Param("keyword") String keyword); }这里有两种参数绑定方式:?1是按位置从 1 开始,优点是省事,缺点是参数一多别人根本分不清谁是 1 谁是 2;:keyword是命名参数,配合 @Param 使用,可读性好得多。我强烈建议新项目统一用命名参数。
JPQL 查询里不必写select u也可以,很多时候直接写from User u更简洁,Spring Data JPA 会默认返回实体。
如果是复杂到 JPQL 也绕不过去的场景,可以退一步用原生 SQL:
@Query(value = "select * from t_user where age >= :minAge", nativeQuery = true) List<User> 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")); Page<User> page = userRepository.findAll(pageRequest); List<User> users = page.getContent(); long total = page.getTotalElements(); int totalPages = page.getTotalPages();PageRequest.of(0, 10)表示第 0 页,每页 10 条。这里有个经典坑:Page 的页码从 0 开始,而前端传的页码通常从 1 开始。接前端参数时一定要先page - 1,否则你会发现第一页数据永远查不到,或者一直显示第二页。
自定义查询也能直接接收 Pageable:
@Query("select u from User u where u.age >= :minAge") Page<User> findByMinAge(@Param("minAge") Integer minAge, Pageable pageable);Pageable 本质是接口,PageRequest 是它的一个实现类,建议业务方法参数统一声明成 Pageable,调用方想分页就传 PageRequest,不想分页就随便传一个 Pageable.unpaged(),灵活度更高。
还有一个性能向知识点:返回 Page 类型时,框架会额外执行一条 count 查询来统计总条数。如果你只需要当前页的数据,不在乎总条数和总页数,返回值可以直接用 List,配合 Pageable 参数,就不会执行那条 count。像移动端无限滚动场景,我常写成:
List<User> 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-auto=update 在生产环境自动改表,风险很大,我后续会给建议。还有个小细节:@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 次额外查询,这就是 N+1 问题的经典来源。改成 LAZY 后,你在业务里真正需要 author 时自己决定怎么加载,永远不会因为一句“顺手查的”把数据库拖垮。
反过来,在 User 实体上声明一对多,我建议不要急着加这段代码:
@OneToMany(mappedBy = "author", cascade = CascadeType.ALL) private List<Article> articles = new ArrayList<>();@OneToMany 默认就是 LAZY,问题不在加载策略,而在于很多人加了这段代码后,习惯通过user.getArticles()去拿列表。性能上稍不注意就触发 N+1,编写时还容易忽略 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 条件里 version=1 匹配不到任何行,就会抛乐观锁异常,由业务层决定是重试还是提示用户。对账、余额这类高并发敏感的数据,这个字段能兜住不少并发覆盖问题。
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(),此时已经没有可用的 Session,Hibernate 无法加载数据,直接抛懒加载异常。
我的处理优先级如下:
第一选择,彻底告别直接返回实体,改用 DTO。Controller 层返回 VO/DTO,把需要的字段明确列出来,不暴露实体内部结构。这个方案最干净,副作用最小。
第二选择,在实体的关联字段上做 JSON 忽略:
@JsonIgnoreProperties({"articles", "author"}) private List<Article> articles;或者用 @JsonIgnore 直接不序列化该字段。简单粗暴,但要注意可能把业务里本来需要返回的字段给弄丢了。
第三选择,在查询时就使用join fetch一次性把关联数据查出来,无论序列化还是后面的业务处理都不会触发懒加载。
网上还有一种方案是把spring.jpa.open-in-view设为 true,让 Session 跟请求生命周期绑定。我不推荐,这个配置会拉长数据库事务和连接占用时间,且高并发下性能隐患明显。Spring Boot 新版本默认输出 WARN 日志提醒不要长期开着,足以说明问题。
5.2 N+1 查询问题,最经典的性能坑
N+1 问题是 ORM 框架的“通病”,Spring Data JPA 也不例外。现象很统一:查询列表 10 条,结果控制台打了 11 条 SQL,1 条查主表,10 条查关联表。
产生原因就是懒加载循环访问。你在循环体里挨个访问user.getArticles(),每次访问触发一次查询,自然就是 N 次额外查询。
解决方案要看场景。Join Fetch 能直接预加载关联属性:
@Query("select u from User u join fetch u.articles") List<User> findAllWithArticles();这条 SQL 通过一条 JOIN 把 User 和 Article 一起查出来,循环访问时不再触达数据库。有一点要注意,join fetch 会对结果集做去重和组合,如果一对多产生的记录变多,分页时 count 的结果可能和预期不一致,需要单独处理。
更通用的是 @EntityGraph:
@EntityGraph(attributePaths = {"articles"}) @Query("select u from User u") List<User> findAllWithArticles();@EntityGraph 和 join fetch 思路类似,但语义更清晰,还能和分页组合使用。
另一个思路是 @BatchSize:
@BatchSize(size = 20) @OneToMany(mappedBy = "author") private List<Article> articles;加上 @BatchSize 后,Hibernate 会把多次单条查询合并成一条 IN 查询,例如查 20 个用户,每访问一个的 articles,会先攒够 20 个 ID 再一次性批量查出来,SQL 数量就从 20 次变成 1 次。这个方案适合懒加载已经写在代码里、不好改成 join fetch 的历史项目。
说到底,比所有优化手段更重要的,是写代码时意识到“循环里不要碰懒加载关联属性”。这条原则能避免九成的 N+1 问题。
5.3 save() 到底是新增还是更新,很多人都没搞懂
Spring Data JPA 的 save 方法经常让人困惑,它会根据实体主键是否存在来决定走 insert 还是 update。听起来很简单,实际坑很深。
看 SimpleJpaRepository 的实现逻辑:save 会把实体交给 EntityManager,EntityManager 判断实体的主键是不是 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 继承 JpaSpecificationExecutor:
public interface UserRepository extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> { }然后动态拼接条件:
Specification<User> spec = (root, query, cb) -> { List<Predicate> 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])); }; Page<User> 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 是不是你预期的样子。多试几次,这套方法名规则就像身体记忆一样了。