1. 为什么这套JPA增删改查值得单独写一篇
先把这个系列的位置理顺一下。上一篇我们完成了Spring与JPA的集成,把实体类、配置文件、数据源这些基础设施都跑通了。这一篇专门讲CRUD,也就是日常开发中占比最高、最无聊但也最容易写歪的核心操作。很多同学在入门阶段都会有这种感觉:照着文档敲了findByUsername、save、delete这样的方法名,发现好像能用,但一旦遇到修改数据不生效、删除报外键约束、查询查出来是null这类问题,就完全不知道问题出在哪一层。这篇就是来解决这些问题的。
还有一个被问烂了的热搜词叫“jpa findone用法”,这里单独说明一下:Spring Data JPA在2.0版本之后,findOne()就已经不再返回实体对象,而是返回Optional 。我在实际工作里见过不少老项目升级Spring Boot版本时,在这里直接编译报错或者运行时疯狂踩空指针,原因就是没有跟上这个API变化。这篇教程会用当前主流的写法来讲,同时也会把老版本和新版本的差异点指出来,方便你们在维护旧代码时快速定位问题。
这篇内容适合谁呢?比如你刚把Spring和JPA的环境搭好,正准备写第一个带数据库交互的接口;或者你已经能跑通几个简单的查询,但对Repository方法命名规则、事务边界、批量操作这块还是一知半解;又或者你是在Spring Boot 3.x这种比较新的版本上做开发,想确认现在的写法是否还和以前一致。不管哪种情况,这篇文章的代码都是可以直接复制的,思路也是可以直接套用到你自己的业务表上的。
2. Repository层:整个CRUD操作的核心枢纽
2.1 三种核心接口怎么选
Spring Data JPA最大的魔力不在Hibernate本身,而在Spring Data那一层自动生成的Repository实现。你只要写一个接口,继承某个现成的泛型接口,就能凭空获得一大堆操作方法,连实现类都不用写,运行时容器会给你动态生成代理对象。
常见的接口继承路径有这么几条,我把区别列清楚:
| 接口 | 提供的主要能力 | 典型场景 |
|---|---|---|
| CrudRepository<T, ID> | save、findById、findAll、delete等基础CRUD | 只需要最基础的增删改查 |
| PagingAndSortingRepository<T, ID> | 在CrudRepository基础上增加分页与排序 | 需要列表分页展示 |
| JpaRepository<T, ID> | 在PagingAndSortingRepository基础上增加批量操作、flush等 | 日常开发最推荐 |
我个人的建议是,除非你有特殊的理由,不然直接在业务Repository接口上继承JpaRepository就好。JpaRepository不仅继承了上面所有能力,还补充了deleteAllInBatch、saveAllAndFlush这类批量操作,以及getReferenceById这种懒加载获取引用的方法。即使你当前用不到,后续扩展也不至于再改接口继承结构。
在实际项目中,一个典型的最小Repository长这样:
public interface UserRepository extends JpaRepository<User, Long> { // 这里可以根据需要追加查询方法 }到这里,基础CRUD就已经具备了,不需要写任何实现代码。你可以在Service里直接注入这个接口,然后调用findAll、findById、save、deleteById这些方法。
2.2 方法名派生查询:约定大于配置的核心
Spring Data JPA有一个非常方便的特性:你只要按照它规定的命名规则来定义方法名,框架就会自动帮你生成SQL。比如findByUsername表示按照username字段查询,deleteByAgeLessThan表示删除某个年龄以下的记录。规则看起来简单,但有几个关键点容易写错,我平时帮同事看代码时发现最常见的是下面这几种:
属性名拼写错误。方法名里的属性必须和实体类里的字段名完全一致,包括大小写。User实体里是createTime,你写成findByCreateTime没问题,但写成findByCreate_Time或者findByCreatetime就直接启动报错。启动时如果看到"Property xxx does not exist"这类错误,先检查属性名。
多条件连接的关键字。And和Or用来连接多个条件。比如findByUsernameAndPassword表示用户名和密码同时匹配,findByUsernameOrEmail表示任意一个匹配。这里面还有一个优先级问题:And和Or同时出现时,框架会按从左到右的顺序处理,如果业务逻辑复杂,建议直接拆方法或者改用@Query,免得语义混乱。
支持的关键字远不止And和Or。还有IsNull、IsNotNull、Like、NotLike、Containing、Between、LessThan、GreaterThan、In、NotIn、OrderBy等。比如findByAgeBetween(Integer start, Integer end)这样的方法,看着就像自然语言,实际生成的SQL也是between and,非常直观。下面这个表格很重要,建议大家收藏:
| 关键字 | 方法名示例 | 生成的SQL语义 |
|---|---|---|
| And | findByUsernameAndPassword | where username = ? and password = ? |
| Or | findByUsernameOrEmail | where username = ? or email = ? |
| Containing | findByNameContaining(String keyword) | where name like concat('%', ?, '%') |
| Between | findByAgeBetween(int start, int end) | where age between ? and ? |
| OrderBy | findByAgeOrderByCreateTimeDesc() | where age = ? order by create_time desc |
| NotNull | findByNameNotNull() | where name is not null |
方法名派生查询的好处是代码非常简洁,基本上看一眼方法名就知道查询意图,不需要额外维护SQL。但坏处是一旦条件多了,方法名会变得特别长,比如findByUsernameAndPasswordAndStatusAndCreateTimeBetween这种,读起来就费劲了。遇到这种情况,我建议直接用下面的@Query注解来写JPQL,可读性会好很多。
3. 数据增删改查的完整实战
3.1 新增:save方法的三种写法差异
新增操作看起来最简单,就是save一个实体进去,但这里其实藏着好几个坑。先看最基本的写法:
@Service public class UserService { private final UserRepository userRepository; public UserService(UserRepository userRepository) { this.userRepository = userRepository; } public User createUser(String username, String password, String email) { User user = new User(); user.setUsername(username); user.setPassword(password); user.setEmail(email); return userRepository.save(user); } }这里要强调的是,save方法本身并不“只会新增”。当传入的实体主键为空时,Spring Data JPA会执行persist,也就是新增;当主键有值时,会先根据主键判断这条记录是否存在,存在就执行merge做更新,不存在则新增。这个机制导致了一个经典开发事故:前端传了一个带id的对象,id在数据库里不存在,调用save之后并没有报错,而是直接插入了一条新记录,结果这个“凭空出现”的id把数据搞乱了。
我自己处理这个问题的习惯是:新增和更新的入口要区分清楚。新增接口不接收id字段,或者接收了也要主动置空;更新接口才接收id,并且在更新前先校验记录是否存在。把save当作Insert来用的前提,是你非常确定传入的对象主键必为空。
还有一点需要注意:save返回的是合并后的实体对象。在新增场景下,这个返回对象包含了数据库自动生成的主键值。如果你在调用save之后想直接拿对象的id去拼别的数据,记得要用返回值,而不是继续用原来传入的那个user变量。原对象的主键在save执行前是没有的,执行后也不会自动回填到原变量里。
3.2 查询:findById、findBy规则与@Query三种查询方式
查询是日常开发中使用频率最高的操作,我把三种常用方式放在一起对比,方便你根据场景来选择。
第一种是findById。这个方法返回的是一个Optional 。我见过太多人拿到Optional之后直接调get(),这其实很危险,一旦记录不存在就会抛NoSuchElementException。正确的做法是先判断isPresent,或者用orElse、orElseThrow来兜底。比如这样:
public User findUserById(Long id) { return userRepository.findById(id) .orElseThrow(() -> new RuntimeException("用户不存在,id:" + id)); }这里再说回热搜词里的“jpa findone用法”。老版本的写法是userRepository.findOne(id),直接返回User,但集合里查不到就返回null。升级到2.0之后不建议继续用,因为返回类型变成了Optional,编译就过不去了。如果你在旧代码里看到findOne的用法,随手改成findById+orElse就对了。另外补充一点,一些旧项目还在用getOne(id),这个方法在新版本里也已经被标记为过时,因为它默认走懒加载,如果事务外访问属性会抛LazyInitializationException,建议改用findById或者getReferenceById。
第二种是方法名派生查询。这个用法上面已经详细说过,最典型的场景是登录校验这种条件不多的查询:
Optional<User> findByUsername(String username); Optional<User> findByUsernameAndPassword(String username, String password);第三种是@Query注解。当查询条件比较复杂、或者需要联表查询、或者需要额外的查询性能优化时,可以用JPQL来写。JPQL的语法和SQL非常像,只不过操作的是实体对象的属性名,而不是数据库表的字段名:
@Query("select u from User u where u.email = :email and u.status = :status") Optional<User> findByEmailAndStatus(@Param("email") String email, @Param("status") Integer status);如果你确实需要写原生SQL(比如要用数据库特有的函数、要执行复杂的多表关联且用JPQL表达不优雅),也可以设置nativeQuery属性:
@Query(value = "select * from t_user where email = :email limit 1", nativeQuery = true) User findRawUserByEmail(@Param("email") String email);这里有个细节很多人忽略:JPQL里的表名是实体类名,列名对应属性名,但原生SQL用的是数据库里的真实表名和字段名。两个模式别混用,一旦混了,启动时可能不报错,但运行时就是语法异常。
3.3 修改:先查后改还是直接update
修改操作是CRUD里面最容易出问题的一环。JPA的更新逻辑和普通的update SQL不太一样,不是你想改哪条就直接update set,而要先加载实体、修改属性、再调用save方法。这种“先查后改”的模式和JPA的持久化上下文机制是绑在一起的。
推荐的基础写法是这样:
@Transactional public User updateUser(Long id, String newEmail) { User user = userRepository.findById(id) .orElseThrow(() -> new RuntimeException("用户不存在,id:" + id)); user.setEmail(newEmail); // 不需要调用save,事务提交时自动更新 return user; }这里有一个关键点:方法加了@Transactional,所以Hibernate会持续跟踪这个实体对象的状态变化,当你修改了实体的属性并最终在事务提交时,Hibernate会把这些变化同步到数据库里,而不需要再次调用save。save在这里是可选的,你调了也没事,但如果不加事务,修改操作就会失效,或者报出各种奇怪的异常。
还有一个常见的效率问题:如果修改的数据量比较大,或者修改的字段比较多,就不适合走“先查后改”了。比如要批量把某类用户的状态改为禁用,正确姿势是使用@Modifying配合@Query写一个批量update:
@Modifying @Query("update User u set u.status = :status where u.id in :ids") int batchUpdateStatus(@Param("status") Integer status, @Param("ids") List<Long> ids);调用这个方法的Service方法上一定要加@Transactional,否则会报javax.persistence.TransactionRequiredException。@Modifying告诉JPA这是一条修改类型的语句,它默认不会返回实体集合,返回值是受影响的行数。还有一个很容易踩的细节:执行完批量update之后,当前持久化上下文里的旧实体可能还是修改前的状态。如果后续代码马上要查询这些实体,最好在调用更新方法后调用entityManager.clear()或者repository.flush()配合清缓存,否则你会读到“脏”数据,这个问题在真实项目里排查起来非常费劲。
关于修改操作,我再分享一个实战判断标准:如果更新场景只涉及单个或少数几个实体,且更新字段不多,优先使用“先查后改”,因为这种方式具备实体字段自动校验能力(比如非空验证、长度验证都由实体注解控制);如果更新场景是大批量、多条件、不需要关心实体生命周期,就用@Modifying+@Query,性能优势非常明显,但要注意事务边界和缓存清理。
3.4 删除:deleteById、delete与批量删除
删除操作同样有几种写法,需要注意的细节不比修改少。
最简单的单条删除:
@Transactional public void deleteUser(Long id) { userRepository.deleteById(id); }很多人会问deleteById和findById之后delete有什么区别。deleteById内部会先根据id查一次实体,然后调用delete方法,整体逻辑上没有额外的性能负担,但有一个坑:如果id不存在,deleteById在旧版本中会直接抛EmptyResultDataAccessException,新版本的行为则视是否配置了忽略规则而定。如果你希望删除时容忍“待删除记录不存在”这种情况,可以先findById判断,存在才删,不存在就跳过,这样接口的语义更友好。
还有一个需要重点关注的问题:关联数据删除。比如User关联了Order,默认情况下删除User时,如果Order表里存在外键引用,数据库会报外键约束冲突。解决办法有三种思路:
- 设置删除策略为级联删除(JPA的CascadeType.REMOVE),适用于父子关系强绑定的场景;
- 先删除子表数据,再删除主表数据,事务里按顺序执行;
- 数据库层面设置on delete cascade,JPA侧不关心。
我在项目里最常用的是第二种,也就是手动控制删除顺序,因为级联删除隐藏了太多数据库层面的行为,一旦误删关联数据,问题排查成本很高。对于批量删除,JpaRepository提供了deleteAllInBatch或者deleteAllByIdInBatch,这些方法是一次性发送一条批量delete语句,比循环调用deleteById高效得多:
@Transactional public void batchDeleteUsers(List<Long> ids) { userRepository.deleteAllByIdInBatch(ids); }这里再补充一个关于删除的冷门坑:如果实体上配置了@OptimisticLocking(乐观锁版本号字段),删除操作也要带版本号条件,一旦版本号不一致就会抛OptimisticLockException。这种问题在正常CRUD入门阶段不常见,但在并发量高的业务里会经常碰到,提前知道有这回事,排查问题时不至于一头雾水。
4. 事务:CRUD里最容易被忽视的边界问题
4.1 不该在大事务里做的事
JPA的CRUD操作不是孤立执行的,它们都挂在事务上下文中。对于入门阶段,一个最简单的原则:每个修改操作都放在@Transactional方法里。但事务不能随便乱加,加得太大反而会带来性能问题。
我见过不少同事写Service方法时,不管有没有必要,先加上@Transactional。这会导致大批量查询或批量导入场景全部挤在一个事务里执行,事务时间被无限拉长。Hibernate的一级缓存也在同一个持久化上下文里堆积,最终内存占用飙升,数据库连接也迟迟不释放。在入门阶段,你要记住两点:只在需要修改(新增、修改、删除)的方法上加事务;只加在Service层,而不是Controller层。Controller里加事务会让HTTP请求整个生命周期都占用数据库连接,这是实打实的性能隐患。
4.2 事务传播行为与懒加载异常
事务传播行为是Spring面试里的高频考点,但入门阶段可以先掌握一个核心区别:REQUIRED和REQUIRES_NEW。
默认的@Transactional传播级别就是REQUIRED,意思是如果当前已经有事务,就直接加入当前事务,不再新开;如果没有事务,就新开一个。绝大部分CRUD场景用默认的就行。REQUIRES_NEW则是不管当前有没有事务,都强制新开一个独立事务,常用于“不管主事务是否回滚,都要记录日志”这种场景。
另外一个跟事务密切相关的坑是LazyInitializationException。入门阶段的很多同学会遇到这样一个问题:在事务外调用一个返回实体的方法,接着访问这个实体的一个关联集合属性,然后直接报LazyInitializationException。根本原因是,实体的某些关联属性被配置为懒加载,事务关闭后,持久化上下文已经不可用,再访问未加载的关联属性就会抛异常。解决方式有几个:
- 在事务范围内完成关联数据的访问,比如Service方法里返回组装好的VO,而不是直接返回实体;
- 查询时使用join fetch立即加载关联属性;
- 配置Open Session In View(但通常不建议在生产环境开启,因为它会让数据库连接占用的时间更长)。
这里我特别想强调第一种方式:尽量少直接返回实体。尤其是Controller层直接返回JPA实体,这虽然入门教程经常这么写,但实际项目里会导致实体被序列化时触发懒加载、暴露不该暴露的内部字段、以及接口与表结构耦合过重等问题。入门阶段可以先用实体返回,但要有这个意识:后续逐步用DTO/VO替代实体返回。
4.3 事务回滚规则:RuntimeException会自动回滚
最后补充一个很多人不知道但很重要的细节:Spring默认只对RuntimeException和Error进行回滚,而对受检异常(Exception的子类)不会回滚。也就是说,如果你在Service方法里写了一个try catch捕获并吞掉了异常,Spring就认为这个方法“成功执行”了,事务自然不会回滚。
我遇到过一个典型的线上事故:批量导入用户数据时,代码捕获了数据库唯一键冲突异常,打了一条日志之后继续跑,最后事务正常提交,但库里有一批数据其实是重复的。这个问题的根源就是异常被吞,事务没有感知到需要回滚。如果你需要在捕获受检异常时也能回滚,可以这么写:
@Transactional(rollbackFor = Exception.class) public void importUsers(List<User> users) throws Exception { // 业务处理 }这种写法在对异常类型要求比较严格的场景下会用到,但更推荐的做法是只在代码里抛出RuntimeException,不要自己处理受检异常,让Spring统一捕获并回滚。这样代码更干净,事务边界也更清晰。
5. 常见问题与排查技巧实录
5.1 高频报错速查表
我把在带新人过程中遇到最多的JPA CRUD问题整理成了一张速查表,每个问题都写了对应的排查方向和解决思路。我建议你看完这张表后,保存到自己的笔记里,遇到问题先对照一遍。
| 报错信息 | 出现原因 | 排查思路 |
|---|---|---|
| Property xxx does not exist | 方法名里属性拼写与实体字段不一致 | 核对实体属性名,注意大小写 |
| NoSuchElementException | 直接对Optional调用get(),但记录不存在 | 改用orElse、orElseThrow等方法 |
| TransactionRequiredException | 修改/删除操作没有加@Transactional | 在Service方法上加上事务注解 |
| LazyInitializationException | 事务外访问懒加载关联属性 | 在事务内组装好数据,或使用join fetch |
| DataIntegrityViolationException | 触发数据库约束(唯一键、外键、非空等) | 检查数据库约束与实体映射是否一致 |
| OptimisticLockException | 乐观锁版本号冲突 | 检查并发更新场景,重试或抛出业务异常 |
| EmptyResultDataAccessException | deleteById时记录不存在 | 先findById判断,存在再删除 |
| StackOverflowError | 实体间双向关联导致无限递归序列化 | 使用DTO/VO返回,或用@JsonIgnore断开循环 |
每次报错,第一件事不是去看业务代码,而是先去看日志里最底部的那句Caused by。很多异常的外层信息是Spring封装后的提示,真正的原因藏在根因里。比如外层报TransactionSystemException,根因可能是LazyInitializationException,如果你只盯着外层,很容易走错排查方向。
5.2 三个冷门但实用的实战技巧
第一个是控制台打印SQL的问题。入门阶段建议把JPA生成的SQL打印出来,能帮你快速验证自己的方法名到底生成了什么样的SQL。在Spring Boot的application.yml里加:
spring: jpa: show-sql: true properties: hibernate: format_sql: true这里我想说show-sql在生产环境一定不要开,只在本地开发调试时用。生产环境如果真的要排查SQL,更多是通过数据库慢日志和日志组件里单独配置的SQL日志级别来做。
第二个技巧是表单提交时的字段校验。CRUD操作不仅仅是把数据存进去,还要保证数据的合法性。在实体字段上加javax.validation的注解,比如@NotBlank、@Email、@Size,然后在Controller的入参对象上加@Valid注解。这样可以在进入Service层之前就拦截非法数据,不用每种异常都自己在代码里判断。
第三个技巧是这个:在JPA里写时间范围查询时,建议用LocalDateTime,不要用java.util.Date。LocalDateTime和JPA 2.2之后的兼容性很好,而且配合@Query的between条件写法非常干净。如果你还在用Date类型处理时间条件,建议在重构时顺手改掉。同理,实体上的createTime、updateTime字段,可以直接用@CreationTimestamp和@UpdateTimestamp注解让Hibernate自动维护,省去在Service层手动赋值的麻烦。这两个注解来自org.hibernate.annotations包,用起来很顺手,CRUD入门阶段就应该养成这个习惯。
5.3 Oracle SQL常用查询:从JPA原生SQL到性能排查
这一小节我自己折腾过很久,因为很多项目初期用JPA的自动探测SQL去查问题,总觉得隔了一层。等到你掌握了原生SQL的写法,排查效率会高很多。这里整理几条我自己在JPA CRUD调试过程中常用的SQL,配合show-sql来看,能非常直观地定位到具体语句的问题。
先看如何在JPA里排查慢查询SQL。在JPA原生SQL模式下,你可以直接对表执行Explain或者查看执行计划。把日志里打印出来的SQL复制到数据库客户端,手工加一个Explain关键字,就能看到走的是全表扫描还是索引扫描。配合索引优化,CRUD性能会有非常明显的改善,尤其是数据量过百万之后。
再看一条常用的多表查询SQL模板。JOIN查询在JPA里用JPQL写不直观时,我会直接用原生SQL:
select u.username, count(o.id) as order_count from t_user u left join t_order o on u.id = o.user_id where u.status = 1 group by u.username having count(o.id) > 0 order by order_count desc;这类查询用JPA的@Query(nativeQuery = true)执行,返回List<Object[]>,再手动把结果映射成DTO。入门阶段可能用不上,但等你做到报表类接口、统计类接口,这个写法能解决很大一部分效率问题。这里还有一个常见误区:不要以为JPQL只能做CRUD,实际上JPQL对多表查询的支持也很好,但前提是实体间有正确的关联映射。如果你的实体没有配置@ManyToOne、@OneToMany等关联关系,JPQL里就只能退而求其次用原生SQL了。
另外,如果是特别复杂的汇总分析类查询,建议不要非用JPA不可。架构设计上要允许MyBatis、JpaTemplate或者干脆JDBC在特定场景下共存。我在实际项目里就见过JPA为主、但统计报表模块单独用MyBatis的场景,运行几年下来都很稳定,并没有出现“两套持久层维护成本高”的问题。只要在代码层面分清楚边界,这种混用在生产中是完全可行的。
5.4 批量操作和分页查询别总是all in
很多入门同学会把所有列表查询都写成findAll(),然后在Java代码里做过滤和分页。对于数据量小的内部系统来说,这确实不会出大问题,但一旦数据量增长到几十万甚至上百万,一次性把所有数据加载到内存,轻则内存溢出,重则接口超时拖垮整个服务。JPA里正确的分页做法是使用Pageable:
Page<User> pageUsers = userRepository.findAll(PageRequest.of(0, 10, Sort.by("createTime").descending()));PageRequest.of的三个参数分别是页码(从0开始)、每页条数、排序规则。返回的Page对象里除了包含当前页的数据列表,还包含总记录数和总页数,方便前端做分页组件的渲染。如果你的查询带有条件,可以在Repository方法名里加上条件并接收Pageable参数:
Page<User> findByStatus(Integer status, Pageable pageable);Spring Data JPA会自动生成带条件的count查询和分页查询,不需要你手动写总记录数统计。这个方法在实际项目里使用频率极高,属于必会技能。
批量操作方面,不要写for循环里一条条save这种代码。虽然CRUD入门教程里为了演示方便会这么做,但真实的性能差距非常大。假设你要插入一万条用户数据,用单条save循环,可能需要几十秒甚至几分钟,而用saveAll批量插入,通常几秒内就能完成。实体类的写法是这样的:
public void batchSaveUsers(List<User> users) { userRepository.saveAll(users); }如果数据量真的很大(比如几百万条),saveAll也不够用了,这时候可以考虑JdbcTemplate的batchUpdate,或者按批次切分后分多次提交。在入门阶段,知道saveAll比循环save高效,就已经足够应付绝大多数场景了。
5.5 从“能用”到“好用”:日志、索引与代码层面的细节
CRUD操作很容易做到“能用”,但“好用”涉及到日志是否清晰、索引是否合理、代码是否易于维护三个层面。
日志方面,我习惯在Service层记录关键的业务操作日志,操作前记录入参,操作后记录结果。比如新增用户时记录“createUser操作,username=xxx,返回id=123”;批量删除时记录“batchDeleteUsers操作,删除N条记录”。这样出了问题,翻日志能快速定位到哪段业务逻辑出了问题,而不是对着控制台里的SQL日志干瞪眼。JPA自动打印的SQL是Hibernate生成的,日志里很难直接对应到业务方法,所以业务日志和SQL日志要配合着看。
索引方面,JPA不会替你创建索引,建索引需要在数据库端手动执行。对于CRUD查询比较频繁的字段,比如username、email、status这种,强烈建议加普通索引。索引是提升JPA查询性能最立竿见影的手段,成本几乎为零,但收益非常高。如果你发现一条按username查询的语句执行时间超过几百毫秒,十有八九是没建索引,建了索引之后通常能降到几毫秒。
代码层面,额外补充一个关于Controller和Service分层的建议:Controller层只负责接收参数和返回结果,不直接操作Repository。业务处理统一放到Service层,Controller层的代码就变得非常薄。后面做单元测试、事务控制、异常处理都方便。这是一个入门阶段就应该养成的习惯,不要图省事直接把Repository注入到Controller里面。短期内看起来代码少了几行,但后续的业务膨胀会让你不得不回过头来重构。
回到那一开始的问题:JPA的CRUD到底难不难?掌握“方法名规则、save语义、事务边界、清理一级缓存、批量操作选择”这几条主线之后,日常的数据访问基本就通了。但真正好用还需要一个长期积累的过程:遇到一个坑,记录一个坑,解决一个坑,JPA就会从一个“会用”的框架变成你手里真正顺手的数据访问工具。后面再有问题,随时可以把日志和报错信息拿来对照这篇的速查表,大多数场景都能找到对应的解法。