MyBatis-Plus按条件更新单个字段:LambdaUpdateWrapper最佳实践

发布时间:2026/9/18 8:41:19
MyBatis-Plus按条件更新单个字段:LambdaUpdateWrapper最佳实践 1. 只改一个字段90% 的团队都在用低效写法先说说这个需求有多常见。业务系统里到处是这种操作用户点个上架你要把商品的status从 0 改成 1用户取消订单你要把订单的cancel_flag置为 1后台审核通过你要把文章的audit_status改成 2。表面上看是更新一条记录但本质都是同一件事——按某个条件修改实体的单个字段。我接手过不少项目发现很多团队处理这类需求时用的还是最原始的三板斧// 写法一查出整个实体改一个字段再全量更新 Goods goods goodsMapper.selectById(goodsId); goods.setStatus(1); goodsMapper.updateById(goods); // 写法二先 new 一个空实体set 主键和要改的字段 Goods goods new Goods(); goods.setId(goodsId); goods.setStatus(1); goodsMapper.updateById(goods); // 写法三构造一个 UpdateWrapper但只用来当 where 条件 UpdateWrapperGoods wrapper new UpdateWrapper(); wrapper.eq(id, goodsId); Goods goods new Goods(); goods.setStatus(1); goodsMapper.update(goods, wrapper);这三种写法在功能上都能跑通小项目里也看不出什么毛病。但一旦碰到性能要求、并发场景、或者字段特别多的表问题就全暴露出来了。写法一的问题最明显selectById先把整行数据捞出来传输一次再updateById把整行所有字段更新一遍。如果这张表有 30 个字段你只改其中 1 个剩下 29 个字段全都白读白写了。数据库的 IO、网络传输、日志量、undo log 空间全都在为这次只改一个字段的需求买单。字段越多、调用越频繁浪费越明显。写法二相对好一些至少没有先查一次。但它有一个非常隐蔽的陷阱updateById默认策略是非空字段才更新。也就是说实体里为 null 的字段会被忽略。这个特性在某些场景下是好事防止覆盖已有数据但如果你要修改的字段本身就是要被置为 null 呢比如清空用户的备注、把商品的某个扩展字段置空updateById直接就无能为力了。你必须额外加TableField(updateStrategy FieldStrategy.IGNORED)或者改用其它方式否则那个字段死活更新不上去。写法三看着用了 Wrapper但本质还是实体 条件的模式。它依然受实体字段非空策略的限制同样处理不了把字段置空的需求。而且update(entity, wrapper)这种方式还有个语义上的误导新手经常搞不清楚到底哪些字段被更新了哪些字段只是条件。我见过最离谱的线上事故是有人用写法一更新订单状态结果因为在updateById之前不小心改了实体的某个字段把不该动的数据也一起覆盖了。这种幽灵更新排查起来极其痛苦因为你根本想不到是自己的代码在某个角落动了那个字段。所以真正符合 MyBatis-Plus 设计哲学的做法是丢掉实体直接用 UpdateWrapper 的 set 方法 eq 方法组合。这也是我今天重点要讲的内容。2. 单字段按条件修改的正解UpdateWrapper 的 set 与 eq 组合2.1 最基础的标准写法先看一段最标准的单字段条件更新代码UpdateWrapperGoods updateWrapper new UpdateWrapper(); updateWrapper.eq(id, goodsId) .set(status, 1); goodsMapper.update(null, updateWrapper);这段代码干了三件事eq(id, goodsId)声明更新范围——只更新id等于传入值的这一行。set(status, 1)声明要修改的字段和值——把status字段改为 1。goodsMapper.update(null, updateWrapper)执行更新第一个参数传 null 表示不依赖实体的字段值。生成的 SQL 很简单就是UPDATE goods SET status 1 WHERE id ?注意这个 SQL 的精妙之处SET子句里只有status一个字段没有其它任何字段的冗余更新。这就是它和updateById的根本区别。有人可能觉得反正更新一个字段和更新多个字段成本差不多。真不是。数据库更新一行数据时如果字段值没有变化InnoDB 可能还能走一些优化路径但 MyBatis-Plus 生成的 SQL 却是实打实把所有非空字段全部写进SET子句。测试环境看不出来一旦到了大表、热点行差异会非常明显。再说说为什么第一个参数传null。BaseMapper.update(T entity, WrapperT updateWrapper)的语义是entity 里的字段值作为SET的来源Wrapper 里的set方法和条件拼进 SQL。如果你传了实体两个来源会合并容易造成混乱。只改单个字段的场景下直接传 null、所有修改都在 Wrapper 里用set声明语义最清晰。2.2 lambda 写法告别魔法字符串上面那种写法有个小隐患eq(id, goodsId)里的id是魔法字符串。如果实体类字段改名、或者数据库列名做了映射调整编译期根本发现不了运行期才报错。推荐用 lambda 写法LambdaUpdateWrapperGoods updateWrapper Wrappers.lambdaUpdate(); updateWrapper.eq(Goods::getId, goodsId) .set(Goods::getStatus, 1); goodsMapper.update(null, updateWrapper);用Goods::getId方法引用替代id字符串编译期就能发现字段名拼写错误。而且 lambda 写法会自动处理数据库列名和实体属性名之间的驼峰下划线映射不用你自己关心createTime到底对应create_time还是createTime。如果你觉得两行代码还是啰嗦可以链式写成一行实际上还是多行goodsMapper.update(null, Wrappers.GoodslambdaUpdate() .eq(Goods::getId, goodsId) .set(Goods::getStatus, 1));还有一种更短的链式写法直接通过LambdaUpdateChainWrapper操作new LambdaUpdateChainWrapper(goodsMapper) .eq(Goods::getId, goodsId) .set(Goods::getStatus, 1) .update();这里有个细节要注意LambdaUpdateChainWrapper.update()返回的是boolean表示是否更新成功但它只代表 SQL 执行是否报错不代表影响行数大于 0。如果你需要根据是否真的改到了数据做后续逻辑得改用别的方式获取影响行数。2.3 影响行数怎么拿有些业务场景要求更新了才返回成功没更新比如条件不匹配就提示数据不存在。这时update方法的返回值很重要。LambdaUpdateWrapperOrder updateWrapper Wrappers.lambdaUpdate(); updateWrapper.eq(Order::getOrderNo, orderNo) .eq(Order::getStatus, 0) // 只有待支付状态才能取消 .set(Order::getStatus, 2) // 改为已取消 .set(Order::getCancelTime, new Date()); int rows orderMapper.update(null, updateWrapper); if (rows 0) { // 说明订单号不存在或者订单状态已经不是待支付 throw new BizException(订单不存在或状态已变更); }这里其实是把条件判断和更新操作合并成了一条 SQL用影响行数来充当业务校验的结果。rows 0可能就是条件不满足。这种写法既避免了先查再改的并发竞态问题也减少了一次数据库往返是单字段更新里很实用的技巧。3. 条件不只有 id动态拼接过滤条件的实战姿势3.1 多条件组合的 where 子句很多需求并不是简简单单按id更新而是要按照一组条件定位到某条记录。比如把某个用户在某段时间内创建的、状态为待支付的订单统一标记为已超时。LambdaUpdateWrapperOrder updateWrapper Wrappers.lambdaUpdate(); updateWrapper.eq(Order::getUserId, userId) .lt(Order::getCreateTime, expireTime) .eq(Order::getStatus, 0) .set(Order::getStatus, 4) // 标记为已超时 .set(Order::getTimeoutTime, new Date()); orderMapper.update(null, updateWrapper);eq是等于还有ne不等于、gt大于、ge大于等于、lt小于、le小于等于、in在集合中、notIn不在集合中、like模糊匹配、isNull字段为空、isNotNull字段不为空等一整套方法用法和 QueryWrapper 完全一致。它们之间是 AND 关系一层层拼接在 where 子句里。3.2 动态条件条件可能为 null 怎么办实际开发里条件经常是前端传过来的有可能是 null。比如一个批量操作接口用户可能只选了部分过滤条件。用condition参数可以优雅处理LambdaUpdateWrapperOrder updateWrapper Wrappers.lambdaUpdate(); updateWrapper.eq(StringUtils.isNotBlank(orderNo), Order::getOrderNo, orderNo) .eq(userId ! null, Order::getUserId, userId) .ge(startTime ! null, Order::getCreateTime, startTime) .le(endTime ! null, Order::getCreateTime, endTime) .set(Order::getStatus, targetStatus); orderMapper.update(null, updateWrapper);每个条件方法的第一个参数是boolean condition为true时才拼接这个条件为false就跳过。这样一来一个方法就能应对多种筛选组合不用写一堆 if-else。这里必须强调一个安全红线动态条件拼接时一定要保证至少有一个确定的条件兜底否则set会作用于全表。MyBatis-Plus 为了防止误操作在 Wrapper 中如果没有eq之类的条件生成的 SQL 是UPDATE table SET ...不带 where直接把整张表全改了。这种事故我听说过不止一次测试环境还好一上生产本来只想改一条数据结果所有用户都被改成 VIP 了。保险做法是在方法开头做一个防御性校验if (userId null StringUtils.isBlank(orderNo)) { throw new BizException(至少需要一个筛选条件); }或者用一个工具方法统一校验 Wrapper 里是否有条件。别嫌麻烦这种防御值得写。3.3 批量更新in 条件一次改多条把选中的 100 个用户的状态改为禁用这种需求最直接的做法是循环 100 次单条更新。但循环更新在大数据量下性能很差还会产生大量日志。正确姿势是用inListLong userIds batchRequest.getUserIds(); LambdaUpdateWrapperUser updateWrapper Wrappers.lambdaUpdate(); updateWrapper.in(User::getId, userIds) .set(User::getStatus, 1); userMapper.update(null, updateWrapper);这里唯一要注意的是userIds的规模。如果是几千几万个 idin后面的占位符会非常多某些数据库对in列表长度有限制比如 Oracle 的 1000 个限制MySQL 虽然没有硬性 1000 限制但超过一定量级 SQL 解析和索引优化也会变慢。稳妥的做法是分批处理每批 500 个ListListLong partition Lists.partition(userIds, 500); for (ListLong ids : partition) { LambdaUpdateWrapperUser updateWrapper Wrappers.lambdaUpdate(); updateWrapper.in(User::getId, ids) .set(User::getStatus, 1); userMapper.update(null, updateWrapper); }3.4 set 多个字段和 setSql 的场景有时候需求说只改一个字段但实际落到数据上可能还要附带更新一个时间戳。比如更新状态的同时要把update_time也刷新。MyBatis-Plus 默认有自动填充功能可以处理update_time但如果你没用自动填充就可以用多个setLambdaUpdateWrapperOrder updateWrapper Wrappers.lambdaUpdate(); updateWrapper.eq(Order::getId, orderId) .set(Order::getStatus, 1) .set(Order::getPayTime, new Date());多个set会组装成SET status ?, pay_time ?整体依然只有一条更新 SQL。还有一种更进阶的用法setSql它允许你直接拼接 SQL 片段到 SET 子句中。典型场景是把某个字段在原值基础上自增LambdaUpdateWrapperGoods updateWrapper Wrappers.lambdaUpdate(); updateWrapper.eq(Goods::getId, goodsId) .setSql(stock stock - {0}, buyCount) .setSql(sold_count sold_count {0}, buyCount);注意我用的是{0}占位符而不是直接字符串拼接配合setSql方法会自动转义处理避免 SQL 注入。用setSql时尤其要小心如果你传入的是外部参数一定要走占位符。4. 单字段更新最容易踩的四个隐藏坑4.1 逻辑删除字段也被当条件带上了如果你的实体类加了TableLogic逻辑删除注解MyBatis-Plus 会在所有update和delete操作上自动追加WHERE deleted 0条件这本身是个保护机制但也会带来一个很容易困惑的现象// 假设 User 有逻辑删除字段 deleted LambdaUpdateWrapperUser updateWrapper Wrappers.lambdaUpdate(); updateWrapper.eq(User::getId, userId) .set(User::getStatus, 1); userMapper.update(null, updateWrapper);生成的 SQL 并不是你以为的UPDATE user SET status 1 WHERE id ?而是UPDATE user SET status 1 WHERE id ? AND deleted 0如果你传入的userId对应的是已经被逻辑删除的数据这一行不会被更新影响行数为 0。很多同学排查了半天以为代码没生效其实是数据已经被删了。这个行为是合理的保护不用去掉但要知道它的存在。4.2 乐观锁插件对 update(entity, wrapper) 的约束项目里如果配置了OptimisticLockerInnerInterceptor乐观锁插件那就得注意乐观锁只在通过实体更新updateById或者update(entity, wrapper)且实体里带有Version字段的值时才会触发版本自增和WHERE version ?条件。如果你用update(null, wrapper)的方式然后手动在 wrapper 里.set(Version::getVersion, newVersion)那乐观锁插件不会帮你自动处理版本校验你得自己写版本条件LambdaUpdateWrapperAccount updateWrapper Wrappers.lambdaUpdate(); updateWrapper.eq(Account::getId, accountId) .eq(Account::getVersion, oldVersion) // 手动拼版本条件 .set(Account::getBalance, newBalance) .set(Account::getVersion, oldVersion 1); // 手动版本 1 int rows accountMapper.update(null, updateWrapper); if (rows 0) { // 版本冲突重试或提示 }这种方式其实也能实现乐观锁的效果而且更灵活。但要注意纯 wrapper 模式下Version字段的自动处理逻辑不会介入别再指望插件帮你做那些事了。4.3 自动填充在这里还生效吗如果你有MetaObjectHandler自动填充配置并且update_time标了TableField(fill FieldFill.INSERT_UPDATE)那么在update(entity, wrapper)传入实体时自动填充会往实体的updateTime塞当前时间。但是如果你像我们前面那样用update(null, wrapper)自动填充根本不会触发因为自动填充依托的是实体对象的字段。此时update_time不会自动刷新。这算是个不大不小的坑。很多人从updateById切到 wrapper 写法后发现update_time不更新了。解决办法要么是在 wrapper 里手动.set(User::getUpdateTime, new Date())要么在设计表时让数据库字段ON UPDATE CURRENT_TIMESTAMP来自动维护。我个人更推荐后者数据库层面的维护比应用层更可靠。4.4 wrapper 条件里的字段被 set 同时命中一个容易逻辑混乱的场景如果 where 条件用了某个字段同时set又要改这个字段生成的 SQL 是什么LambdaUpdateWrapperOrder updateWrapper Wrappers.lambdaUpdate(); updateWrapper.eq(Order::getStatus, 0) .set(Order::getStatus, 1);生成的 SQLUPDATE order SET status 1 WHERE status 0MySQL 对单表 UPDATE 的 WHERE 子句使用的是更新前的快照所以语义上没问题能正确把状态为 0的行改为 1不会出现改完又满足条件导致无限更新的情况。但我还是建议在代码里把条件字段和修改字段分开命名或加注释否则同事读代码容易懵以为这里写错了。4.5 全表更新的最后一道防线前面提到过没有 where 条件的 wrapper 会更新全表。其实为了防这个我在自己的项目里习惯封装一个统一入口public static T boolean safeUpdate(BaseMapperT mapper, LambdaUpdateWrapperT wrapper) { // 简单判断wrapper.getExpression() 里有没有 where 条件 // 具体实现可以看 SqlWrapper 的内部结构或者用 wrapper.getSqlSegment() 判断 if (StrUtil.isBlank(wrapper.getSqlSegment())) { throw new BizException(禁止无条件的更新操作); } return mapper.update(null, wrapper) 0; }wrapper.getSqlSegment()返回的是 WHERE 条件的 SQL 片段如果为空说明没有任何条件。所有业务代码强制走这个入口从根上杜绝全表更新。5. 延伸排查MyBatis-Plus 更新场景里的两个高频事故社区里经常看到几个热搜词mybatisplus 分页失效、接触mybatisplus单页500条限制、mybatisplus irepository。这些和按条件修改单个字段看起来不搭边但我在实际排查中发现它们往往出现在同一个项目的同一个阶段——当团队从IService切换到BaseMapper或者叠加多个插件后各种幽灵问题就冒出来了。这里一起说清楚。5.1 分页失效的根因往往是 update 语句走了自定义 SQL很多人碰到分页失效时第一反应是分页插件配置错了。但排查到最后发现真正的问题是他们在同一个 Mapper 接口里写了自定义的更新 SQL比如Update(UPDATE goods SET status #{status} WHERE id #{id}) int updateStatus(Param(id) Long id, Param(status) Integer status);然后分页查询返回的总条数对不上。这其实不是分页插件失效而是分页插件默认只对select开头的 SQL 做拦截优化自定义的 update 语句有个返回值是影响行数被业务代码误当成了分页查出来的条数统计自然不对。还有一种分页失效也很常见UPDATE语句里用子查询关联分页结果。MyBatis-Plus 的分页插件在遇到UPDATE语句时会尝试做优化改写但碰到复杂子查询时可能分析不准。如果业务上确实需要先查出符合条件的 id 再根据 id 批量改状态建议拆成两步先用分页查 id 列表再用in条件更新。这也是为什么我强烈推荐用update(null, wrapper)这种写法因为它的 SQL 结构简单分页插件不会碰它update 就是 update互不干扰。5.2 单页 500 条限制不是 MP 的限制是数据库驱动和 SQL 长度的综合问题有人问MyBatis-Plus 是不是有单页 500 条限制这个问题其实是个误传。MyBatis-Plus 本身不限制单页条数你Page里传 10000 它也能执行。真正限制来自两层第一层是 MySQL 的max_allowed_packet如果一条 SQL 太大比如in里塞了上万个 id报文超过限制MySQL 直接报错。第二层是很多人用LIMIT实现单页条数时如果每页条数太大MySQL 需要扫描大量行再丢弃性能急剧下降。那为什么会把500 条和 MP 扯上关系我猜是因为PaginationInnerInterceptor有个maxLimit属性默认是Integer.MAX_VALUE但有些团队自己配了 500PaginationInnerInterceptor interceptor new PaginationInnerInterceptor(DbType.MYSQL); interceptor.setMaxLimit(500L); // 单页最大 500 条 mybatisPlusInterceptor.addInnerInterceptor(interceptor);这个配置一旦加上你分页查询size传 1000会被悄悄压成 500。这其实是团队自己加的约束不是框架的默认行为。如果你真的需要放开就把这个值调大或者不设置。但说实话单页 500 条是个合理的保护值大多数场景下没必要放开。真正该做的是优化查询条件而不是让一次查询返回大量数据。5.3 IService 和 BaseMapper 在更新场景下的差异再说说irepository其实是IService和BaseMapper的选择。很多新手喜欢直接继承IService因为updateById一行代码就完事。但 IService 的update(Wrapper)和 BaseMapper 的update(Wrapper)行为是有差异的IService.update(Wrapper)如果传入的是LambdaUpdateWrapper会直接走 BaseMapper 的update(null, wrapper)但如果传入的是普通Wrapper且内部没有set子句它会尝试用实体字段填充 set逻辑更复杂。IService.update(Wrapper)返回boolean这个 boolean 是 SQL 是否执行成功不是影响行数。如果你使用lambdaUpdate()链式写法this.lambdaUpdate().eq(...).set(...).update()实际上走的是LambdaUpdateChainWrapper最终也是 BaseMapper 的update(null, wrapper)加一层返回值包装。所以我的结论是单字段条件更新这种操作直接用BaseMapperLambdaUpdateWrapper最直接、语义最清晰。IService 那层封装适合updateById这种简单场景一旦涉及按条件改单个字段wrapper 才是正主。5.4 更新失败后的排查链路最后分享一个实战排查顺序。如果你发现按条件修改单个字段没生效不要上来就开始改代码按这个顺序查先看日志里真实执行的 SQL。MyBatis-Plus 打印出的 SQL 如果是UPDATE table SET ... WHERE ...先检查 where 条件是否符合预期。这里能看到有没有被逻辑删除条件、乐观锁条件悄悄追加。把 SQL 拷到数据库客户端手动执行。排除数据库权限、连接串、事务隔离级别的影响。检查影响行数。影响行数为 0 时基本可以确定是条件不匹配而不是代码没走。用我的话说先确认 SQL 对不对再怀疑代码写没写对。检查实体类的 TableField 策略。如果字段上标了updateStrategy FieldStrategy.NOT_NULL之类的注解可能影响更新行为。检查是否开了多数据源。多数据源场景下同一个update可能路由到了错误的数据库导致看起来没生效。这五步走完90% 的更新异常都能定位到具体环节。6. 一条贯穿始终的个人建议写了这么多年 MyBatis-Plus我自己的习惯已经固定下来了凡是按条件改字段一律不用实体一律用LambdaUpdateWrapperset 明确条件。为什么实体方式受字段非空策略影响语义不清晰而且存在意外覆盖别的字段的风险。Wrapper 方式把改什么和改哪些行拆得明明白白代码即文档。生成的 SQL 最小化性能最好日志最少。还有一个小技巧是团队里最好统一封装一个UpdateWrapperBuilder或者直接约定所有动态条件更新必须走统一入口把update(null, wrapper)这种调用收敛起来方便做防御性校验和审计。我在上一个项目里就是因为做了这个约束全表更新事故一次都没出现过。如果你正被只改一个字段却总是不得其法困扰照着文中的写法试一试把updateById换成update(null, lambdaUpdateWrapper)很快就能感受到差别。遇到分页、逻辑删除、乐观锁这些插件组合问题也别慌先看 SQL再看影响行数基本都能定位。