ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Data JPA更新操作全解析:从save()到@Modifying的避坑指南

Spring Data JPA更新操作全解析:从save()到@Modifying的避坑指南 做过几年 Spring 项目的人基本都会认同一个说法查询逻辑再复杂无非是多写几个 find 方法真正让人半夜起来看日志的往往是更新操作。Spring Data JPA 里的数据更新操作表面上看就一个 save()但它的隐式机制、缓存行为、批量更新限制、生命周期回调、动态字段处理每一个环节都有能让你线上翻车的细节。我接手过好几个带三年历史包袱的项目更新相关的坑几乎全踩了一遍save() 产生不必要的 update、批量更新后一级缓存脏数据、DynamicUpdate 在某些场景下失效、更新方法没加事务直接抛异常、并发下乐观锁版本号被覆盖……这篇文章就把 Spring Data JPA 更新操作从底层原理到实战写法完整梳理一遍包括 save() 的真实语义、Modifying 批量更新的正确姿势、动态字段更新的四种方案、生命周期和乐观锁的边界以及大家经常拿来对比的 Spring Data JPA 和 MyBatis-Plus 在更新操作上的差异与选型思路。1. save() 到底做了什么隐式更新机制与两个致命陷阱1.1 持久化上下文与快照机制JPA 的更新和 MyBatis 系列的更新有一个本质区别MyBatis 是你写明 SQL 就执行 SQL而 JPA 是围绕持久化上下文Persistence Context工作的。持久化上下文可以理解成 EntityManager 内部的一层一级缓存凡是你在事务里查询出来的实体都会被放进去同时 Hibernate 会为这些实体保存一份数据库快照。事务提交时Hibernate 遍历所有托管实体把当前属性值和快照做脏检查只要发现哪个字段变了就自动生成一条 update 语句同步到数据库。所以 JPA 更新是隐式的——你不需要显式调用 update 方法只需要修改一个托管实体的某个属性比如user.setNickname(新的昵称)提交时 JPA 就会自动把 nickname 更新进去。这个设计让简单的 CRUD 变得非常省事但也让很多不熟悉原理的人误以为我没调用更新方法数据怎么会自己变。我建议你在项目里把 JPA 的 SQL 日志长期打开到 debug 级别配置很简单spring: jpa: show-sql: true properties: hibernate: format_sql: true use_sql_comments: true这么做能让你直观地看到每次 save() 背后到底发了哪些 select、哪些 update。所有 JPA 更新逻辑在 SQL 日志面前都是透明的这个习惯比读十本书都管用。1.2 save() 的语义persist 还是 merge很多人读完源码前都以为 save() 就是保存并更新。其实 SimpleJpaRepository#save 内部做的是条件判断先判断实体是不是新实体。判定标准主要是主键是否为 null或者主键值是否为基本类型的默认值比如 Long 为 nullint 为 0。如果是新实体走em.persist(entity)返回原对象如果不是新实体走em.merge(entity)注意 merge 返回的是一个新的托管副本而不是你传入的那个对象。这就是第一个致命陷阱很多人写userRepository.save(user)后不接收返回值继续拿着原 user 变量做后续操作。如果 user 是分离实体比如前端传进来、带有 id 的 DTO 转实体merge 会从数据库重新加载一条记录并拷贝字段返回一个新的托管对象。而原来的 user 仍然是分离状态之后对它的任何修改都不会被 JPA 感知自然也不会同步到数据库。你以为是修改了数据库却纹丝不动。正确的做法是始终接收返回值User managedUser userRepository.save(user);后面所有需要同步到数据库的字段修改都必须基于 managedUser 来做而不是原来的 user。1.3 saveAndFlush() 在什么场景下必须用save() 默认不会立刻把 SQL 发到数据库而是在事务提交时统一 flush。这个机制在大多数业务里没有问题但有几个场景会导致你拿到过期数据或者干脆报错第一事务内后续查询依赖这次更新已经落库。比如你先改了订单状态同一个事务后面用加锁查询去查同一条订单如果没 flush查询可能命中一级缓存里的旧状态而不是数据库里的最新状态。这时候需要saveAndFlush()强制落库。第二你需要拿到数据库生成的值比如数据库默认值、触发器填充的字段、某些版本的 CreationTimestamp 等。persist 之后主键通常在内存里已经生成但数据库端生成的其他字段要在 flush 之后才能回填到实体里。第三异常排查阶段。save() 的 SQL 错误可能被推迟到事务提交时才抛出这会让你很难定位是哪条数据出了问题。saveAndFlush() 会立刻执行 SQL有错当场报出来测试阶段非常实用。Transactional public void markOrderPaid(Long orderId) { Order order orderRepository.findById(orderId).orElseThrow(); order.setStatus(OrderStatus.PAID); orderRepository.saveAndFlush(order); // 后续的加锁查询、状态校验都基于最新数据 }1.4 两段式更新问题select updatemerge() 在语义上是从数据库加载已有实体再把传入实体的字段值拷贝过去所以日志里经常会出现两条 SQLselect u.id, u.nickname, u.email from user u where u.id? update user set nickname?, email? where id?这个 select update 就是两段式更新。当你确定实体一定存在、只是为了更新某些字段时可以借助EntityManager.getReference()拿到一个代理引用避免这条 selectUser user entityManager.getReference(User.class, userId); user.setNickname(新昵称);getReference() 返回一个懒加载代理只要不访问主键以外的字段就不会触发 select。但注意如果后续你又读了这个对象的其他属性仍然会触发查询。所以这个优化只适合纯更新且不读其他字段的场景并且要在事务内使用否则会抛 LazyInitializationException。2. Modifying 批量更新一次 SQL 完成更新任务的正确姿势2.1 为什么必须加 Modifying如果说 save() 是单条更新的默认姿势那批量更新绝对是 Modifying Query 的天下。业务里经常出现把所有过期订单标记为已过期、把所有离职员工状态改成已禁用这类需求。如果写成 for 循环逐条 save()数据量一大就是灾难级性能问题。先看一个标准的批量更新方法Modifying Query(update User u set u.status :newStatus where u.status :oldStatus and u.lastLoginTime :cutoffTime) int batchUpdateUserStatus(Param(newStatus) int newStatus, Param(oldStatus) int oldStatus, Param(cutoffTime) LocalDateTime cutoffTime);注意两点第一方法上必须加 Modifying 注解否则 Spring Data JPA 会把这条 update 当成 select 来执行直接抛异常org.hibernate.hql.internal.QueryExecutionRequestException: Not supported for DML operations第二返回值是 int表示受影响行数。这个行数非常有用你在 Service 层可以根据返回值做没有记录需要更新之类的业务判断。2.2 clearAutomatically 和 flushAutomatically两个救命参数很多人写 Modifying 时只写一个裸注解这在只有一个更新语句、没有后续查询的场景下没问题。但在同一个事务里既执行批量更新又执行查询的场景你会遇到一级缓存脏数据问题。这两个参数就是救命的。flushAutomatically true表示执行这条 update 之前先把持久化上下文里尚未 flush 的变更同步到数据库。为什么要这样你在这个事务里可能先改了某个实体属性还没提交然后执行批量更新Hibernate 在大部分版本下执行 DML 前默认不一定自动 flush。如果前面的变更没落库后续批量更新又直接操作数据库两条更新的执行顺序就可能和你写代码的顺序不一致甚至出现部分更新被覆盖。加上这个参数语义就准确了。clearAutomatically true表示执行完 update 之后清空整个持久化上下文。因为批量更新是直接发 SQL 操作数据库的一级缓存中的旧对象并不知道数据已经变了。如果不清理事务后续再次查询同一个实体时Hibernate 会优先从一级缓存返回旧值你明明批量更新成功了读到的仍然是旧数据这种幽灵旧值特别难排查。我自己的习惯是所有 Modifying 方法都写成Modifying(clearAutomatically true, flushAutomatically true)除非有明确理由不这么做。这两个参数能解决我在生产环境遇到的绝大多数缓存不一致问题。2.3 批量更新不会触发实体生命周期回调这是个非常隐蔽的坑。Modifying 直接通过 JPQL 执行更新不会经过实体管理器所以实体的 PreUpdate 和 PostUpdate 回调完全不会触发。很多项目的审计字段就是在 PreUpdate 里自动填充的PreUpdate public void preUpdate() { this.updateTime LocalDateTime.now(); }如果你用 Modifying 批量更新updateTime 不会被填充除非你在 JPQL 里手动维护。最稳妥的做法是让团队形成统一约定批量更新的 JPQL 里手动把审计字段写进去比如Modifying(clearAutomatically true, flushAutomatically true) Query(update OrderEntity o set o.status :newStatus, o.updateTime current_timestamp where o.id :id) int updateStatus(Param(id) Long id, Param(newStatus) OrderStatus newStatus);这么做之后updateTime 才会被正确维护不会出现状态改了时间字段没动的诡异现场。2.4 批量更新的并发与版本问题如果你的实体用了 Version 乐观锁这里要特别小心。普通 save() 更新时JPA 会检查版本号不匹配就抛 OptimisticLockException。但 Modifying 批量更新完全绕过了实体加载和版本检查流程也就是说它不受乐观锁保护。怎么解决如果你确实需要批量更新带版本校验可以在 JPQL 里手动模拟Modifying(clearAutomatically true, flushAutomatically true) Query(update User u set u.nickname :nickname, u.version u.version 1 where u.id :id and u.version :version) int updateWithVersion(Param(id) Long id, Param(nickname) String nickname, Param(version) Long version);返回 0 就说明版本不匹配更新失败。这样既保留的批量更新的效率又找回了乐观锁的语义。注意这条语句依然不会触发 PreUpdate 回调所以审计字段还是要手动维护。2.5 使用索引参数还是命名参数在 Query 里写更新语句时我强烈建议使用命名参数Param不要用索引参数 ?1、?2。命名参数的好处是语义清晰字段重命名时不容易错位而且参数顺序怎么调都不会影响正确性。索引参数一旦有人改方法签名时调换了参数位置编译期不会报错运行期才会通过参数值错位表现得非常诡异排查起来极浪费时间。还有一个容易被忽略的点在 JPQL 的 update 语句里尽量避免在 where 条件上使用数据库函数比如function(date_format, u.createTime, %Y-%m-%d) :date。一旦用了这种写法数据库普通索引基本就失效了数据量大时更新会触发全表扫描甚至锁住大量行。这种问题在慢 SQL 日志里一眼就能看到但很多人一开始都不会想到是函数包字段导致的。3. 动态字段更新只更新非空字段的四种实现路径3.1 典型需求用户资料部分更新后台编辑用户资料是个很常见的场景前端传过来一个 DTO里面可能只修改了 nickname其他字段都是 null。如果用save()直接把 DTO 转实体后塞进去立刻就会出问题——null 会把数据库里已有的 email、phone 等字段全部覆盖整条记录被洗白。这种只更新非空字段的需求在 Spring Data JPA 里有多种实现方式我按推荐程度逐个说。3.2 方案一查询 非空判断最安全我最推荐的方案是先查出托管实体再对非空字段逐一赋值Transactional public User updateProfile(Long userId, UserProfileDTO dto) { User user userRepository.findById(userId) .orElseThrow(() - new BusinessException(用户不存在)); if (StringUtils.hasText(dto.getNickname())) { user.setNickname(dto.getNickname()); } Optional.ofNullable(dto.getEmail()) .filter(StringUtils::hasText) .ifPresent(user::setEmail); Optional.ofNullable(dto.getPhone()) .filter(StringUtils::hasText) .ifPresent(user::setPhone); return userRepository.save(user); }这种方案优点很明显只改需要改的字段不会意外覆盖 null实体是托管状态提交时只生成一条 update 语句且只有变化的字段会被更新Hibernate 默认会做字段级脏检查不是所有字段都 set生命周期回调正常触发审计字段自动维护。缺点就是字段多的时候代码啰嗦但在五六个字段以内的资料更新场景显式写判断的可读性和安全性是第一位的完全值得。3.3 方案二DynamicUpdate 注解的取舍Hibernate 提供了 DynamicUpdate标注在实体上后生成的 update 语句只包含发生变化的字段而不是把全部字段都 set 一遍Entity DynamicUpdate public class User { // ... }注意DynamicUpdate 是 Hibernate 的注解不是 JPA 标准注解。它的核心作用是哪个字段变了才更新哪个字段这确实能减轻大字段的 IO 开销也能避免一些 null 被覆盖问题——前提是你没有显式给字段赋 null。如果你调用了user.setNickname(null)null 依然会被更新进去。所以它并不能等价于 MyBatis-Plus 的null 不更新策略。另外DynamicUpdate 实际生效的前提是实体先被加载成托管实体。如果你是 new 一个分离实体直接 merge动态更新行为可能和你预期不一致。我的结论是DynamicUpdate 适合实体字段很多、经常只改一两个字段的模型但不要把它当成消灭 null 覆盖的神器。3.4 方案三从 DTO 映射到实体的极简写法如果 DTO 和 Entity 字段一一对应又不想写一堆 if可以用一点简单的封装public T void applyIfNotNull(SupplierT getter, ConsumerT setter) { T value getter.get(); if (value ! null) { setter.accept(value); } }然后在更新方法里applyIfNotNull(dto::getNickname, user::setNickname); applyIfNotNull(dto::getEmail, user::setEmail);这种写法比一连串 if 简洁。但坦白讲过度的零判断封装会让更新了哪些字段的意图变得不直观代码审查时容易引发争论。如果你的团队已经用 MapStruct 做对象映射还有一个更优雅的思路在 Mapper 的 Mapping 上配置nullValuePropertyMappingStrategy NullValuePropertyMappingStrategy.IGNORE让映射阶段自动跳过 null 字段Service 层代码就很干净。3.5 方案四投影 自定义 Repository 的极简更新对于只需要改某个或某两个字段的高频更新我更推荐直接在 Repository 里定义投影更新方法不加载整个实体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);Service 层组合if (StringUtils.hasText(dto.getNickname())) { userRepository.updateNickname(userId, dto.getNickname()); } if (StringUtils.hasText(dto.getEmail())) { userRepository.updateEmail(userId, dto.getEmail()); }这种写法性能最好没有 select没有实体加载没有脏检查开销一条 SQL 直达数据库。缺点是方法碎片化适合后台管理系统里的单个字段快速修改。如果一次要更新多个字段可以定义一个专门的局部更新接口把需要的字段作为参数传进去。3.6 动态更新到底该选哪种给你一个很实用的决策参考场景推荐方案原因普通资料更新字段 ≤ 5 个查询 非空判断安全、直观、触发生命周期回调字段很多每次只改少数DynamicUpdate 托管实体更新避免大更新语句减少 IO高并发、性能敏感Modifying 指定字段一条 SQL不加载实体大批量全量提交重新查询并完整赋值明确 null 语义避免隐藏覆盖说到底用对象状态驱动更新你享受的是托管实体的便利但要为 null 值语义付出心智成本用 SQL 驱动更新你获得的是对字段的精确控制但失去了生命周期回调和缓存一致性管理。没有标准答案取舍清楚即可。4. 生命周期与并发更新操作里最容易被忽略的两个大坑4.1 PreUpdate/PostUpdate 的触发时机很多实体上都有类似的审计代码PreUpdate PrePersist public void touch() { this.lastModifiedAt LocalDateTime.now(); }你以为每次更新都会自动维护 lastModifiedAt 字段但实际上 PreUpdate 只在托管实体通过 flush 更新时触发。凡是绕过实体管理的更新路径——Modifying 批量更新、EntityManager.createNativeQuery、数据库触发器和存储过程——都不会触发它。结果就是批量更新完数据lastModifiedAt 还停留在旧值看起来就像数据没有真正更新。处理方式有两种要么在批量更新的 JPQL 里手动维护时间字段要么统一约定时间字段由数据库侧负责应用不干预。最怕的是同一套代码里混用两种方式线上排查数据时会看到同一批数据有的 update_time 是新的有的没有特别让人头大。4.2 Version 版本冲突的运行时行为并发更新离不开乐观锁JPA 的 Version 是最轻量的实现Entity public class Account { Id private Long id; Version private Long version; private BigDecimal balance; }两个请求同时读到 version3 的账户各自修改余额后提交第一个请求更新成功version 变成 4第二个请求再提交时where 条件会带version 3数据库里已经是 4匹配不到记录JPA 就会抛乐观锁异常。这个机制在普通 save() 场景非常可靠。建议把 Version 字段的类型定义为包装类型 Long 或 Integer不要用原始类型 long/int。原始类型在有兜底默认值的情况下会干扰 JPA 对是否被修改过的判断虽然 JPA 规范没有强制要求但实践里包装类型更稳。另外用 Modifying 批量更新时乐观锁不生效如果需要版本校验要按前面第 2.4 节的方式手动维护版本号逻辑。4.3 级联更新与孤儿删除一对多更新的经典失误实体配置了 CascadeType.ALL 和 orphanRemoval true 时更新父实体的集合属性可能触发意想不到的效果。最常见的就是我只是想改订单备注结果子表记录被删了。如果你直接给 Order 的 items 属性 new 一个集合重新赋值Hibernate 会把它理解成集合整体被替换旧的子记录全部变成孤儿orphanRemoval 会触发删除新集合里的记录再 insert。整批删除加重插性能和逻辑都会出问题。最佳实践是更新 Order 本身时完全不要动 items 集合子表通过自己的 Repository 单独维护。如果确实要调整子表记录使用集合的 add/remove 方法让 Hibernate 能感知是增量变化而不是全量替换。这个规则应该写进团队的代码规范里能避免大量一对多更新事故。5. 更新性能与实战排雷从循环更新到缓存脏读5.1 循环更新 vs 批量更新我最不想在项目里看到的代码就是这种ListUser users userRepository.findAll(); for (User user : users) { user.setStatus(1); userRepository.save(user); }表面上是 1000 次 save()实际产生的 SQL 可能是 2000 条甚至 3000 条merge 的两段式查询 update。生产环境这么写数据库连接池基本就告警了。正确思路是凡是同一类条件、统一赋值的更新优先用 Modifying 批量更新。比如Modifying(clearAutomatically true, flushAutomatically true) Query(update User u set u.status 1 where u.departmentId :deptId) int activeUsersByDept(Param(deptId) Long deptId);如果每一条数据都需要独立计算那就接受逐条更新但一定要把循环放到一个事务里减少事务开销同时注意控制每次循环的实体数量避免一级缓存无限膨胀。5.2 saveAll() 批处理能不能提升更新性能saveAll() 本质上就是循环调用 save()它不会把多条 update 合并成一条更像是一个集合操作语法糖。想真正发挥批量提交能力需要配合 flush 策略手动控制。经典的批量更新写法是for (int i 0; i users.size(); i) { users.get(i).setStatus(1); if (i 0 i % 50 0) { entityManager.flush(); entityManager.clear(); } }flush 会把持久化上下文中的变更同步到数据库clear 清空一级缓存释放内存。这种方式能避免大批量数据长期积压在持久化上下文中导致的内存膨胀和 GC 压力。注意每次 clear 之后之前的实体变成分离状态后续不要再使用旧的实体变量。5.3 大字段更新的隐形开销如果你的实体包含 Lob 字段比如网站文章的正文动辄几百 KB那普通 save() 会有一个隐形陷阱Hibernate 默认执行 update 时会更新所有列哪怕你只改了状态字段正文这个大字段也会被重新 set 一遍。在 MySQL 里大字段更新会引起行锁时间变长、binlog 体积暴涨严重时拖垮主库。处理思路有三个把大字段拆到单独的表用 OneToOne(fetch FetchType.LAZY) 关联平时更新主表时不触碰它或者用投影更新只 update 需要修改的字段也可以给实体加 DynamicUpdate。我的实际体感是投影更新的收益最直接因为你既不用加载大字段也不更新无关列一条精确 SQL 就结束了。5.4 事务边界对更新失效的影响更新操作必须有事务。save() 自身确实带了 Transactional但如果你在 Service 层写了一段包含多个 Repository 调用的业务逻辑没有一个统一事务每个 Repository 方法都会独立开事务、提交事务整体的一致性和性能都不受控。Modifying 更新更严格如果 Repository 方法没有事务上下文会直接抛javax.persistence.TransactionRequiredException: Executing an update/delete query所以原则是更新逻辑一定要包在事务里事务注解最好打在 Service 方法上不要把事务无脑放到 Controller 上。事务粒度也要控制一个事务里执行太多更新操作时任何一条失败都会让整个事务回滚长期占用锁资源和数据库连接。尽量让事务边界覆盖真正需要原子性的那几行代码。5.5 更新后马上查询发现还是旧值这个问题在排查列表里出现频率极高。先用三步法定位第一步判断是不是一级缓存导致。同一个事务里先加载实体再通过 Modifying 更新然后 findById 查询同一记录。一级缓存还是旧值直接返回看起来就像没更新。解决方法是 Modifying 里配置 clearAutomatically true或者在事务内主动 entityManager.clear() 清缓存。第二步判断事务隔离级别。如果查询发生在另一个事务里而更新事务还没提交读到旧值是正常现象不是 bug。第三步在数据库客户端直接查真实值。很多时候数据已经更新成功是应用层的 Redis 缓存、本地缓存或二级缓存没刷新导致业务感知上像是没更新。这也是为什么我建议在批量更新后主动清除相关业务缓存而不是依赖缓存过期时间。5.6 时间字段的小坑时间字段在更新操作里出过的问题比想象中多。第一个坑是 current_timestamp 和 Java 时间不一致JPQL 里写 current_timestamp 走的是数据库服务器时间如果数据库和应用服务器有时区差异updateTime 会和预期差好几个小时。第二个坑是 Modifying 更新语句里如果不手动设置时间字段它就会保持旧值看起来像没更新。第三个坑是 LocalDateTime 与数据库 timestamp/date 类型映射不一致日期被截断。建议团队里约定业务时间统一用应用服务器时间手动赋值数据库每行再单独保留一个数据库生成时间用于排查和审计两者职责分开不互相替代。6. 和 MyBatis-Plus 更新操作的差异与选型思考6.1 更新哲学的差异隐式托管 vs 显式 SQLSpring Data JPA 和 MyBatis-Plus 的更新差异本质上是两种思想体系的碰撞。JPA 以对象状态为中心你操作对象框架在提交时通过快照对比自动生成 update 语句心智负担集中在理解持久化上下文、托管/分离实体和 flush 时机。MyBatis-Plus 以SQL为中心updateById(entity)直接帮你生成一条 UPDATE 语句心智负担集中在理解 null 策略、动态 SQL 符号和 XML 的标签语义。在原型开发阶段JPA 的收益非常明显因为大量 CRUD 不需要写 SQL模型定义完就能跑。但到了对 SQL 审查要求严格的团队MyBatis-Plus 更受 DBA 欢迎因为每一条 SQL 都在掌控之中慢 SQL 优化可以直接落到 XML 里。6.2 null 更新策略对比这是两者最直接的差异。MyBatis-Plus 的 updateById() 默认忽略 null 字段所以你只要传入一个部分字段的实体null 就不会覆盖数据库已有值这种天然的部分更新体验很好。JPA 的 save() 没有这个默认策略它遵循对象语义你赋值了什么就更新什么null 也是合法值所以很容易出现覆盖。很多从 MyBatis-Plus 迁移到 JPA 的团队第一周必踩的大坑就是把 updateById 的习惯直接换成 save()结果 null 字段被覆盖清空。这也是我在第 3 章花了大量篇幅讲动态字段更新的原因——JPA 里这是需要主动设计的事情而不是框架默认帮你做好。6.3 批量更新写法的直观对比MyBatis-Plus 的批量更新常用 LambdaUpdateWrapperLambdaUpdateWrapperUser wrapper new LambdaUpdateWrapper(); wrapper.eq(User::getStatus, 0) .lt(User::getLastLoginTime, cutoffTime) .set(User::getStatus, 1) .set(User::getUpdateTime, LocalDateTime.now()); userMapper.update(null, wrapper);JPA 的 Modifying 方法Modifying(clearAutomatically true, flushAutomatically true) Query(update User u set u.status :newStatus, u.updateTime current_timestamp where u.status :oldStatus and u.lastLoginTime :cutoffTime) int batchUpdateStatus(Param(newStatus) int newStatus, Param(oldStatus) int oldStatus, Param(cutoffTime) LocalDateTime cutoffTime);MyBatis-Plus 的优势是动态组装更新条件和 set 字段可以在 Service 层按业务逻辑自由变化不需要为每种组合新增一个方法。JPA 的优势是结构化一条 JPQL 像一条命名 SQL条件固定、意图明确更有利于代码审查和测试。如果你的系统里更新条件经常变化MyBatis-Plus 的 wrapper 确实更顺手如果更新的条件都是固定口径比如失效订单统一标记JPA 的 Query 写起来更干净。6.4 选型建议我不喜欢谁取代谁的论调。选型要看团队和业务的实际情况团队对 ORM 原理熟悉、有领域建模习惯、新产品以 CRUD 为主我建议用 Spring Data JPA开发效率极高。如果团队里有资深 DBA涉及大量复杂报表 SQL、多表 join 更新MyBatis-Plus 更友好因为 SQL 可控可调可优化。混合共存也不是不行但尽量别在一个模块里同时混用两套框架否则事务边界、缓存一致性、代码风格都会变得很难维护。我现在维护的两个项目恰好一个是纯 JPA、一个是纯 MyBatis-Plus核心原因就是项目各自的技术土壤不同。选择什么框架不是最关键的关键是团队有没有把它的底层机制吃透。最后分享一个我自己的习惯在 JPA 项目里我给每个 Repository 都定了一套更新底座——单个实体小范围更新用 Modifying 命名清晰的 updateXxx 方法需要业务校验的更新Service 里先 findById 再改非空字段然后 save()批量状态更新一律 Modifying 并带上 clearAutomatically 和 flushAutomatically。这个约定让团队在很长一段时间内都没再出过更新相关的线上事故。你拿去用的时候建议先把 SQL 日志打开观察几天更新语句是否符合预期再决定要不要关掉日志。这种体感建立起来后你对 JPA 更新机制的理解会比任何文章都扎实。
返回列表