
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实现。你只要写一个接口继承某个现成的泛型接口就能凭空获得一大堆操作方法连实现类都不用写运行时容器会给你动态生成代理对象。常见的接口继承路径有这么几条我把区别列清楚接口提供的主要能力典型场景CrudRepositoryT, IDsave、findById、findAll、delete等基础CRUD只需要最基础的增删改查PagingAndSortingRepositoryT, ID在CrudRepository基础上增加分页与排序需要列表分页展示JpaRepositoryT, ID在PagingAndSortingRepository基础上增加批量操作、flush等日常开发最推荐我个人的建议是除非你有特殊的理由不然直接在业务Repository接口上继承JpaRepository就好。JpaRepository不仅继承了上面所有能力还补充了deleteAllInBatch、saveAllAndFlush这类批量操作以及getReferenceById这种懒加载获取引用的方法。即使你当前用不到后续扩展也不至于再改接口继承结构。在实际项目中一个典型的最小Repository长这样public interface UserRepository extends JpaRepositoryUser, 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语义AndfindByUsernameAndPasswordwhere username ? and password ?OrfindByUsernameOrEmailwhere username ? or email ?ContainingfindByNameContaining(String keyword)where name like concat(%, ?, %)BetweenfindByAgeBetween(int start, int end)where age between ? and ?OrderByfindByAgeOrderByCreateTimeDesc()where age ? order by create_time descNotNullfindByNameNotNull()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的用法随手改成findByIdorElse就对了。另外补充一点一些旧项目还在用getOne(id)这个方法在新版本里也已经被标记为过时因为它默认走懒加载如果事务外访问属性会抛LazyInitializationException建议改用findById或者getReferenceById。第二种是方法名派生查询。这个用法上面已经详细说过最典型的场景是登录校验这种条件不多的查询OptionalUser findByUsername(String username); OptionalUser findByUsernameAndPassword(String username, String password);第三种是Query注解。当查询条件比较复杂、或者需要联表查询、或者需要额外的查询性能优化时可以用JPQL来写。JPQL的语法和SQL非常像只不过操作的是实体对象的属性名而不是数据库表的字段名Query(select u from User u where u.email :email and u.status :status) OptionalUser 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写一个批量updateModifying Query(update User u set u.status :status where u.id in :ids) int batchUpdateStatus(Param(status) Integer status, Param(ids) ListLong ids);调用这个方法的Service方法上一定要加Transactional否则会报javax.persistence.TransactionRequiredException。Modifying告诉JPA这是一条修改类型的语句它默认不会返回实体集合返回值是受影响的行数。还有一个很容易踩的细节执行完批量update之后当前持久化上下文里的旧实体可能还是修改前的状态。如果后续代码马上要查询这些实体最好在调用更新方法后调用entityManager.clear()或者repository.flush()配合清缓存否则你会读到“脏”数据这个问题在真实项目里排查起来非常费劲。关于修改操作我再分享一个实战判断标准如果更新场景只涉及单个或少数几个实体且更新字段不多优先使用“先查后改”因为这种方式具备实体字段自动校验能力比如非空验证、长度验证都由实体注解控制如果更新场景是大批量、多条件、不需要关心实体生命周期就用ModifyingQuery性能优势非常明显但要注意事务边界和缓存清理。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 cascadeJPA侧不关心。我在项目里最常用的是第二种也就是手动控制删除顺序因为级联删除隐藏了太多数据库层面的行为一旦误删关联数据问题排查成本很高。对于批量删除JpaRepository提供了deleteAllInBatch或者deleteAllByIdInBatch这些方法是一次性发送一条批量delete语句比循环调用deleteById高效得多Transactional public void batchDeleteUsers(ListLong 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(ListUser 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 fetchDataIntegrityViolationException触发数据库约束唯一键、外键、非空等检查数据库约束与实体映射是否一致OptimisticLockException乐观锁版本号冲突检查并发更新场景重试或抛出业务异常EmptyResultDataAccessExceptiondeleteById时记录不存在先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写不直观时我会直接用原生SQLselect 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)执行返回ListObject[]再手动把结果映射成DTO。入门阶段可能用不上但等你做到报表类接口、统计类接口这个写法能解决很大一部分效率问题。这里还有一个常见误区不要以为JPQL只能做CRUD实际上JPQL对多表查询的支持也很好但前提是实体间有正确的关联映射。如果你的实体没有配置ManyToOne、OneToMany等关联关系JPQL里就只能退而求其次用原生SQL了。另外如果是特别复杂的汇总分析类查询建议不要非用JPA不可。架构设计上要允许MyBatis、JpaTemplate或者干脆JDBC在特定场景下共存。我在实际项目里就见过JPA为主、但统计报表模块单独用MyBatis的场景运行几年下来都很稳定并没有出现“两套持久层维护成本高”的问题。只要在代码层面分清楚边界这种混用在生产中是完全可行的。5.4 批量操作和分页查询别总是all in很多入门同学会把所有列表查询都写成findAll()然后在Java代码里做过滤和分页。对于数据量小的内部系统来说这确实不会出大问题但一旦数据量增长到几十万甚至上百万一次性把所有数据加载到内存轻则内存溢出重则接口超时拖垮整个服务。JPA里正确的分页做法是使用PageablePageUser pageUsers userRepository.findAll(PageRequest.of(0, 10, Sort.by(createTime).descending()));PageRequest.of的三个参数分别是页码从0开始、每页条数、排序规则。返回的Page对象里除了包含当前页的数据列表还包含总记录数和总页数方便前端做分页组件的渲染。如果你的查询带有条件可以在Repository方法名里加上条件并接收Pageable参数PageUser findByStatus(Integer status, Pageable pageable);Spring Data JPA会自动生成带条件的count查询和分页查询不需要你手动写总记录数统计。这个方法在实际项目里使用频率极高属于必会技能。批量操作方面不要写for循环里一条条save这种代码。虽然CRUD入门教程里为了演示方便会这么做但真实的性能差距非常大。假设你要插入一万条用户数据用单条save循环可能需要几十秒甚至几分钟而用saveAll批量插入通常几秒内就能完成。实体类的写法是这样的public void batchSaveUsers(ListUser users) { userRepository.saveAll(users); }如果数据量真的很大比如几百万条saveAll也不够用了这时候可以考虑JdbcTemplate的batchUpdate或者按批次切分后分多次提交。在入门阶段知道saveAll比循环save高效就已经足够应付绝大多数场景了。5.5 从“能用”到“好用”日志、索引与代码层面的细节CRUD操作很容易做到“能用”但“好用”涉及到日志是否清晰、索引是否合理、代码是否易于维护三个层面。日志方面我习惯在Service层记录关键的业务操作日志操作前记录入参操作后记录结果。比如新增用户时记录“createUser操作usernamexxx返回id123”批量删除时记录“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就会从一个“会用”的框架变成你手里真正顺手的数据访问工具。后面再有问题随时可以把日志和报错信息拿来对照这篇的速查表大多数场景都能找到对应的解法。