JPA实战:CRUD操作、查询与事务的完整指南
发布时间:2026/10/1 10:47:09来源:尧图网络
1. 环境与模型准备CRUD 的前提功课这篇是 JPA 实战系列的第二篇。上一篇我们把 Spring Boot Spring Data JPA 的环境搭了起来数据源、连接池、基础配置都跑通了数据库也能正常连上。但光能连上数据库没有意义真正落到业务上第一步就是把这四个动作玩熟新增、查询、修改、删除。这一篇我打算用最直接的方式把一个用户表的增删改查从零写到能跑顺便把那些文档里不会明说、但实际写代码一定会遇到的坑一并交代清楚。1.1 先确认依赖和配置没跑偏在使用 JPA 之前先检查一遍项目的依赖。我用的是 Maven 构建核心依赖就两个一个是 Spring Data JPA 的 starter一个是数据库驱动。这里以 MySQL 为例dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependencySpring Boot 2.x 和 3.x 在 JPA 使用上差别不大唯一要留意的是 Spring Boot 3.x 用的是jakarta.persistence包Spring Boot 2.x 是javax.persistence。如果代码是从旧项目拷过来的第一件事就是检查 import 路径别在编译期浪费十分钟。接着看application.yml里的 JPA 配置spring: datasource: url: jdbc:mysql://localhost:3306/jpa_demo?useSSLfalseserverTimezoneAsia/Shanghai username: root password: root jpa: hibernate: ddl-auto: update show-sql: true open-in-view: falseddl-auto: update的开发期很好用表结构不用手动建实体类一改重启项目表就自动变了。但它只适合本地开发生产环境我建议改成validate或者干脆none用专门的数据库迁移工具去管表结构否则哪天手一滑实体字段被误删线上数据就出事了。1.2 设计 User 实体的几个细节CRUD 的第一步是把实体类建出来。我接下来整篇都用用户表做例子实体很简单package com.example.demo.entity; import jakarta.persistence.*; import java.time.LocalDateTime; Entity Table(name t_user) public class User { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(nullable false, unique true) private String username; Column(length 50) private String nickName; private Integer age; private String email; private LocalDateTime createTime; // getter / setter 省略 }几个值得说道的点主键用Long包装类型而不是long因为 JPA 判断实体是不是新对象主要就看主键是否为 null。long默认值是 0一个 id 为 0 的用户实体很容易被误判成已存在。GeneratedValue(strategy GenerationType.IDENTITY)适合 MySQL自增主键插入后马上能拿到回填的 id。如果换用AUTOHibernate 会按数据库方言自己选策略MySQL 下实际也是 IDENTITY但显式写出来更清晰。字段命名不需要写Column(name user_name)也能用Spring Boot 默认的SpringPhysicalNamingStrategy会自动把驼峰nickName转成下划线nick_name。自己定义Column(name ...)反而容易和团队规范打架。createTime这种字段如果希望插入时自动填充可以在实体里加PrePersist回调PrePersist public void prePersist() { if (createTime null) { createTime LocalDateTime.now(); } }这里多说一句实体上尽量不用Data。虽然 Lombok 很方便但实体对象在 JPA 里会涉及 equals、hashCode、toString 的语义问题尤其是后续加了关联关系以后全自动生成的toString很容易触发懒加载异常。折中办法是只用Getter Setter。2. 写一个空的 Repository 接口CRUD 就送上门了实体建好以后接下来是 JPA 最魔法的部分。新建一个接口继承JpaRepository一行实现都不写增删改查的方法就全都有了package com.example.demo.repository; import com.example.demo.entity.User; import org.springframework.data.jpa.repository.JpaRepository; public interface UserRepository extends JpaRepositoryUser, Long { }然后随便找个地方注入它Autowired private UserRepository userRepository;接下来想怎么操作就怎么操作userRepository.save(user)插入userRepository.findById(1L)查询userRepository.deleteById(1L)删除。第一次用的时候确实会有种这不科学的感觉但原理说出来其实很简单。2.1 空接口为什么能干活Spring Data JPA 在项目启动的时候会扫描所有继承了Repository的接口然后为每个接口生成一个动态代理对象。代理对象没有业务逻辑但它在运行时可以解析接口里声明的方法名把方法名翻译成对应的 JPQL 或者 SQL。这就像你去饭店点菜只需要喊鱼香肉丝后厨自然会按约定好的菜谱把菜做出来不用你把从切肉到勾芡的全过程交代一遍。这也是为什么 Repository 一定是接口而不是类。你写抽象类、写实现类Spring Data 都没法替你做代理那一套方法名翻译 SQL的机制就完全失效了。我见过有同事为了效率非要手写实现类最后绕了一大圈还丢掉了很多内置功能属实没必要。2.2 JpaRepository 自带的方法清单继承JpaRepository以后内置方法大致可以分成四类我列个表类别方法说明新增/修改save(S entity)主键为空则插入非空则合并更新新增/修改saveAll(IterableS entities)批量保存本质是循环 save查询findById(ID id)按主键查询返回OptionalT查询findAll()查全部查询findAllById(IterableID ids)按一组主键查询查询count()/existsById(ID id)计数和存在性判断删除deleteById(ID id)先查后删删不到会报异常删除delete(T entity)删除实体删除deleteAll()/deleteAllInBatch()全删后者用一条 JPQL 直接删JpaRepository还额外继承了PagingAndSortingRepository和QueryByExampleExecutor所以分页排序、按示例查询这些能力也是天然就有的。日常开发建议直接选JpaRepository它是方法最全的一个接口省得以后想加分页能力还得回头改继承关系。3. 查询的两种玩法方法名派生和 Query内置方法只能应付按 id 查这种最基础的场景。实际业务里需求往往长这样按用户名查用户、查年龄大于多少的用户、查用户名和邮箱同时匹配的用户。这时候就要自己往 Repository 里加方法了。3.1 方法名派生查询把条件写进方法名Spring Data JPA 允许你通过方法名直接声明查询条件。比如public interface UserRepository extends JpaRepositoryUser, Long { User findByUsername(String username); ListUser findByAgeGreaterThan(Integer age); ListUser findByUsernameAndAge(Integer age, String username); ListUser findByNickNameContaining(String keyword); ListUser findByCreateTimeBetween(LocalDateTime start, LocalDateTime end); ListUser findByAgeIn(CollectionInteger ages); ListUser findTop10ByOrderByCreateTimeDesc(); }规则很简单方法名以findBy也可以是getBy、readBy、queryBy开头后面跟字段名字段名之间用And、Or连接字段后面还可以跟条件关键字。常见的组合关键字有这些关键字含义示例And/Or且 / 或findByUsernameAndEmailIs/Equals等于可省略findByUsernameIsBetween区间findByAgeBetweenLessThan/GreaterThan小于 / 大于findByAgeGreaterThanLike/NotLike模糊匹配findByNickNameLikeContaining包含自动加 %findByNickNameContainingIn/NotIn在集合内 / 外findByAgeInIsNull/IsNotNull为空 / 非空findByEmailIsNullOrderByXxxAsc/Desc排序findByAgeOrderByCreateTimeDescTop/First限制条数findTop5ByAge方法名解析的时候Spring Data 会把方法名按驼峰拆开逐个去匹配实体里的属性。所以字段名如果写错启动阶段直接报错不会等到运行期。这也算是个隐性优点查询命名的正确性在项目启动时就能被验证掉一半。但方法名派生的缺点也很明显——条件一多方法名就长得没法看。比如查用户名包含某关键字且年龄在某个区间且按时间倒序方法名分分钟变成findByNickNameContainingAndAgeBetweenOrderByCreateTimeDesc。这种时候就别硬撑了换成Query。3.2 Query把查询拉回自己手里Query可以写在 Repository 的方法上完全由你掌控 JPQL 或原生 SQL。先看 JPQL 的写法public interface UserRepository extends JpaRepositoryUser, Long { Query(select u from User u where u.email :email) User findByEmail(Param(email) String email); Query(select u from User u where u.username like concat(%, :keyword, %)) ListUser searchByUsername(Param(keyword) String keyword); }注意 JPQL 操作的是实体类名和字段名不是表名和列名。select u from User u里的User是实体类Useru.email对应的是实体的email属性而不是数据库的email列。SQL 基础好的朋友最容易在这里栽跟头脑子里想的是select * from t_user where email ?写出来却牛头不对马嘴。Query也支持原生 SQL把nativeQuery true打开就行Query(value select * from t_user where age :age, nativeQuery true) ListUser findOlderThan(Param(age) Integer age);原生 SQL 适合执行那种用 JPQL 很难表达的查询比如复杂的多表 join、数据库特有的函数。但用了原生 SQL 也就意味着脱离了 JPA 的方言层以后数据库从 MySQL 换到 PostgreSQL这些 SQL 就都得翻出来改一遍。能用 JPQL 解决的尽量别上原生。3.3 findById 和 Optional从 findOne 的演变说起很多人第一次接触 JPA 时会在网上搜到findOne这个用法然后发现怎么都调不出来——这其实是个版本演进的故事。Spring Data JPA 1.x 时代按主键查一个实体用的是T findOne(ID id)查不到返回 null。到了 2.x这个方法被废弃了替换成了OptionalT findById(ID id)。我做过一次统计办公室里第一次迁移到 2.x 的人十个有七个在编译报错后会先把 import 改掉然后继续到处解引用 null最后被 NPE 教做人。Optional的好处是强迫你用代码表达查不到怎么办。三种最常见的消费方式都写一下// 查不到给默认值 User user userRepository.findById(1L).orElse(null); // 查不到抛异常自己定义业务异常 User user userRepository.findById(1L) .orElseThrow(() - new RuntimeException(用户不存在)); // 查到了才做操作 userRepository.findById(1L).ifPresent(u - { // 业务处理 });代码里如果出现一长串.orElse(null)说明你还没习惯 Optional查不到的场景多半也没仔细想过。我的习惯是在 Service 层直接orElseThrow抛业务异常让上层感知这条数据不存在比静默返回 null 然后到处判空要清爽得多。顺带提一下getOne和getById。JpaRepository早期有个getOne(ID id)它返回的是一个懒加载的代理对象并不真正去数据库查。这个特性在事务外面访问实体字段时百分百抛LazyInitializationException。Spring Data JPA 3.x 里面getOne被换成了getReferenceById行为没变坑也没变。入门阶段别用它老老实实findById。3.4 修改和删除也能走 QueryQuery不仅能写 select还能写 update 和 delete。使用Modifying标记即可public interface UserRepository extends JpaRepositoryUser, Long { Modifying Query(update User u set u.email :email where u.username :username) int updateEmailByUsername(Param(username) String username, Param(email) String email); Modifying Query(delete from User u where u.age :age) int deleteYoungerThan(Param(age) Integer age); }要注意两点。一是Modifying查询必须配事务最直接的办法是 Service 层或方法上加Transactional否则运行时会抛InvalidDataAccessApiUsageException。二是批量修改后JPA 的一级缓存里可能还留着旧数据最好在注解里把缓存清理开关打开Modifying(clearAutomatically true, flushAutomatically true)clearAutomatically会在更新后清空持久化上下文flushAutomatically会在更新前先把缓存里的变更刷进数据库。这两个开关一起开着能省掉不少明明改了数据库查出来还是旧值的灵异问题。4. 新增和修改的细节save 不是万能的4.1 save 到底是插入还是更新save这个方法看起来很省心但它的行为其实是分叉的。判断依据是主键是否为空主键为 null执行persist走数据库插入主键非 null执行merge把当前对象的状态合并到数据库记录上问题就出在第二种情况。很多人图省事写更新的时候直接new User()把 id 塞进去再把要改的字段 set 上然后丢给saveUser user new User(); user.setId(1L); user.setEmail(newexample.com); userRepository.save(user);后果是相当隐蔽的merge会把整个实体状态都合过去你没 set 的字段会被当成空值覆盖掉。用户名没了昵称没了年龄变成 null——一次更新直接清空半张表记录。正确做法是先查出来再改让User对象处于托管状态User user userRepository.findById(1L) .orElseThrow(() - new RuntimeException(用户不存在)); user.setEmail(newexample.com); userRepository.save(user);其实在事务里只要实体是从findById查出来的托管对象save这步都可以省事务提交时 JPA 会自动把字段变更同步到数据库这叫脏检查机制。但为了代码语义清晰大多数人还是习惯显式调一下save也无妨。4.2 实体生命周期和托管状态上面不断提到托管状态这里把这个概念讲透。JPA 的实体有四种状态new瞬时态刚new出来的对象没有主键不受持久化上下文管理。managed托管态通过findById、save等方式交到了持久化上下文手里的对象事务提交时 JPA 会比对它的字段快照自动把变化更新到数据库。detached游离态持久化上下文关闭之后的对象。比如事务提交完了你手里还攥着那个 User 对象JPA 已经不管它了。removed删除态调用了delete等待事务提交后真正从数据库移除。打个比方托管态就相当于你住进酒店前台登记了你的身份证。只要在退房之前你跟前台说帮我加个枕头服务员就会照做。游离态就是你退房之后再说什么都跟这家酒店没关系了。理解了这一点前面那个先查后改的问题就迎刃而解你需要的不是把一堆数据塞给服务员而是先住进去再吩咐事儿。4.3 删除操作的三个坑删除是最容易出幺蛾子的操作我列三个最常见的坑一deleteById 查不到会抛异常。Spring Data JPA 的deleteById内部会先findById如果记录不存在直接抛EmptyResultDataAccessException。业务上删除一个不存在的 id 到底算不算错误得看场景。如果希望删除不存在则静默通过先判断一下if (userRepository.existsById(1L)) { userRepository.deleteById(1L); }坑二delete 传入瞬时态对象会出问题。直接userRepository.delete(user)时JPA 会尝试把这个状态为 new 的对象丢进持久化上下文然后进入删除流程这往往会导致异常。所以你要删一个对象前提是你先把它查出来或者它本身已经在托管状态。坑三deleteAll 和 deleteAllInBatch 行为差很多。deleteAll()内部是先findAll查出来再逐条 delete性能差但实体的生命周期回调方法会被触发。deleteAllInBatch()直接用一条 JPQL 批量删除快但不会触发任何回调。大批量清理的时候请用 InBatch 版本。4.4 批量新增的性能问题循环里逐条调save是新手最爱干的事也是性能杀手。每条save都可能触发一次数据库交互一千条数据就是一千次 insert事务再一开那酸爽只有数据库知道。实测两万条数据循环save耗时几十秒很常见。先把批量配置加上spring: jpa: properties: hibernate: jdbc: batch_size: 30 order_inserts: true然后尽量用saveAll且在一个事务里完成Transactional public void batchSave(ListUser users) { userRepository.saveAll(users); }saveAll底层也是循环save但批处理配置生效以后Hibernate 会把多条 insert 攒在一起用 JDBC 的 addBatch 提交性能提升非常明显。配合order_inserts: trueHibernate 还会按主键排序插入进一步提高批处理命中率。5. 分页和排序CRUD 进阶必会数据量一上去findAll()就不好使了。一次查出十万条记录塞到内存里不仅慢还容易把应用搞崩。这时候分页就该上场了。5.1 PageRequest 和 Page 的基本用法分页查询见得最多的是这种写法public PageUser getUsers(int page, int size) { PageRequest pageRequest PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, createTime)); return userRepository.findAll(pageRequest); }PageRequest.of(0, 10)表示查第 0 页、每页 10 条页码从 0 开始。前端传过来的页码经常是 1 起步的一定要记得在 Service 层减掉一个否则第一页数据永远查不到这种小事排查起来特别费劲。PageT对象里除了数据本身还带着一堆分页元信息。常用的有这些page.getContent(); // 当前页数据 page.getTotalElements(); // 总记录数 page.getTotalPages(); // 总页数 page.getNumber(); // 当前页码从 0 开始 page.getSize(); // 每页大小自定义查询方法同样支持分页只要在方法参数里加一个PageablePageUser findByAgeGreaterThan(Integer age, Pageable pageable);这个不需要额外写实现Spring Data 会自动在生成的 SQL 末尾拼上limit ? offset ?再执行一条 count 查询。5.2 深分页的隐患分页虽好但有个问题越到后面越明显offset太大了会非常慢。PageRequest.of(100000, 10)意味着数据库要跳过前一百万行再去取数据这个成本是实打实的。入门阶段不用急着解决这个问题但心里要有根弦分页参数别让用户随便传。有人传个size1000000你的数据库和内存就一起遭殃。最好在 Service 层对 page 和 size 做个上限限制比如 size 最大不超过 200。真要处理千万级数据的分页再去研究游标分页、keyset 分页这些方案那又是另一个话题了。5.3 排序的写法排序有两种方式一种是嵌入方法名比如findByAgeGreaterThanOrderByCreateTimeDesc一种是用Sort对象动态传Sort sort Sort.by(createTime).descending().and(Sort.by(id).ascending()); PageRequest.of(0, 10, sort);第一种适合排序规则固定的场景第二种适合排序规则由前端控制的场景。个人偏爱第二种排序和查询条件解耦方法名也不会长到没法看。6. 事务与并发入门阶段最该注意的事6.1 写操作必须了解事务JPA 的很多 CRUD 方法单看一个虽然自带事务但业务逻辑从来不会只是一个方法。拿用户注册来说可能要先后做这几件事先查用户名是否重复、再插入用户记录、再写一条操作日志。三步操作如果不在同一个事务里第二步刚插完、第三步还没来得及执行系统正好崩了用户表就多了一条半成品数据。这个问题的标准解法就是给 Service 层方法加TransactionalService public class UserService { Transactional public User register(String username, String password) { // 1. 查重 // 2. 插入用户 // 3. 写日志 return userRepository.save(user); } }Transactional默认的传播行为是REQUIRED如果当前没有事务就新建一个有就加入当前事务。所以 Service 方法嵌套调用时会自动合并到同一个事务里这也是日常最常用的配置。查询方法就没必要手工加事务了吗其实 Spring Data JPA 对 Repository 的查询方法默认就加了Transactional(readOnly true)所以只读操作不会产生脏写问题。只有当你需要在 Service 层把多次查询包在一个事务里或者查询方法调用了其他写操作才需要自己动手加。6.2 事务的三个高频坑第一个坑是自调用事务失效。事务是基于 Spring AOP 代理实现的而同类的this调用不会经过代理Transactional就直接失效了。常见写法如下事务压根没生效Service public class UserService { public void methodA() { this.methodB(); // 事务无效 } Transactional public void methodB() { // 写操作 } }要么把methodB拆到另一个 Service要么注入自己要么换成AopContext.currentProxy()反正别用this调。第二个坑是大事务。一个事务里塞了太多操作尤其是跨网络调用锁的持有时间就会被无限拉长并发一大系统性能直线下降。这里没有银弹只能说尽量保持事务范围小像推送邮件调用外部接口这种耗时操作别放在事务里。第三个坑是并发下的覆盖更新。两个请求同时读出同一用户各自改了自己的字段后提交的会把先提交的覆盖掉。入门阶段先知道有个东西叫乐观锁就够用在实体上加一个Version字段Version private Long version;这样每次更新时 JPA 都会带上where version 旧值一旦版本对不上就抛OptimisticLockException由上层决定是重试还是报错。等到真正做高并发业务时再去深入乐观锁和悲观锁的取舍。6.3 事务配合 Modifying 的经典组合文章前面提过Modifying必须配事务这里给出一个完整形态Transactional public void updateUserEmail(String username, String email) { userRepository.updateEmailByUsername(username, email); }如果一个方法里既要做批量Modifying更新又要立刻查询更新后的数据记得把Modifying(clearAutomatically true)加上否则一级缓存可能返回旧数据。实测下来凡是改完查不到新值的诡异问题八成都是缓存没有清理导致的。最后补一个我自己的习惯写 CRUD 之前先把 Repository 的继承接口和ddl-auto定死环境统一写查询方法时先想想能不能用方法命名解决不能就上Query涉及修改一律先思考是全字段替换还是局部更新。这三个想清楚了JPA 的入门 CRUD 基本就不会翻车。整篇下来的代码量不大但每一步都踩过坑、趟过雷照着敲一遍比我当年对着文档啃半天要快得多——这也是 JPA 这类框架最典型的写照上手不难想用好全靠细节堆出来的经验。
网站建设高端定制企业官网