
1. 项目概述当Update语句“失灵”时如果你用过MyBatis-Plus后面简称MP大概率对它的updateById或者update方法爱不释手。把实体对象一扔框架自动帮你生成SQL省去了手写update标签的繁琐。但不知道你有没有遇到过这样的场景你只想更新用户表的“最后登录时间”字段于是你new了一个User对象只设置了id和lastLoginTime然后满怀信心地调用了updateById。结果一查数据库不仅时间没变连用户的用户名、邮箱这些字段都莫名其妙被清空成了NULL或者反过来你明明给实体对象的某个字段赋了值希望它更新到数据库执行后却发现这个字段在SQL里压根没出现更新了个寂寞。这不是灵异事件也不是MP的Bug而是它的**字段更新策略FieldStrategy**在“作祟”。这个策略是MP设计中的一个核心特性初衷是为了避免误操作但在不了解其机制的情况下它就成了一个隐蔽的“坑”。今天我们就来彻底拆解这个“MyBatis-Plus Update更新策略问题”。这不仅仅是解决一个API怎么用的问题更是理解MP如何帮你做决策以及当你需要完全掌控SQL时如何从框架手中“夺回”控制权。无论你是刚接触MP的新手还是已经踩过几次坑的老鸟搞清楚这里的门道都能让你在数据持久层操作上更加得心应手避免生产环境出现数据被意外覆盖或丢失的严重问题。2. 核心策略解析FieldStrategy的三副面孔要解决问题首先得知道问题从哪来。MP的字段更新策略主要通过TableField注解的insertStrategy和updateStrategy属性来控制它们都是FieldStrategy枚举的实例。这个枚举定义了当MP构建INSERT或UPDATE语句时如何处理实体类中某个字段的值。理解这几个策略是掌握MP更新行为的关键。2.1 策略枚举详解NOT_NULL, IGNORED, NEVERFieldStrategy主要有四种策略但最常用、也最容易引发问题的就是前三种2.1.1 NOT_NULL (默认策略)这是MP全局配置的默认策略也是绝大多数“坑”的来源。它的逻辑是只有当字段值不为null时才会被包含进SQL语句中。在UPDATE中的行为如果你调用updateById(user)MP会检查user对象里每个字段。假设username字段是null那么生成的SQL就不会包含username这一部分。这听起来很合理对吧但问题在于你new出来的User对象除了你主动set的字段其他字段默认就是nullMP认为你不想更新这些null的字段于是跳过了它们。但数据库期望的是如果你不提供某个字段它应该保持原值。然而在某些误配置或特定写法下框架或数据库驱动可能会产生非预期的行为虽然MP自身生成的SQL是正常的但开发者容易误解。更关键的是如果你的意图是“将某个字段更新为null”这个策略会直接阻止你因为值为null它就被忽略了。2.1.2 IGNORED (无视策略)这是最“霸道”也最直接的模式。无论字段值是什么即使是null都会被拼接到SQL语句中。在UPDATE中的行为继续上面的例子如果username字段被标记为IGNORED那么updateById(user)生成的SQL一定会包含username。如果你set了值就用你的值如果你没set它就是null那么SQL就会变成usernamenull从而将数据库中的该字段真正更新为NULL。这个策略把控制权完全交给了开发者你需要对自己传入的对象值负责。2.1.3 NEVER (永不策略)这个策略比较特殊。无论字段值是什么都不会被包含在INSERT或UPDATE语句中。在UPDATE中的行为一个标记为NEVER的字段比如create_time创建时间在更新操作中会被完全忽略。即使你错误地给它赋了值MP也不会让它出现在SET子句里这非常适合用于保护那些不应该被更新的字段。2.1.4 其他策略FieldStrategy还有DEFAULT跟随全局配置等但理解上述三个就足以应对99%的场景。为了更直观我们用一个表格来对比策略枚举在UPDATE中的行为适用场景风险提示NOT_NULL (默认)字段值不为null时才加入SET子句。通用场景防止意外用null覆盖字段。最容易踩坑。无法实现“更新为null”的需求新手容易误以为没set的字段会被保留实则可能因对象状态引发问题。IGNORED无论字段值是否为null都加入SET子句。1. 需要将字段显式更新为null。2. 希望完全自主控制每个字段的更新行为。风险最高。如果对象字段未正确赋值极易导致数据被意外覆盖为null。NEVER永远不加入UPDATE的SET子句。保护字段如create_time,version乐观锁字段通常有单独处理等。较为安全明确禁止更新。2.2 策略生效的优先级注解、全局与默认知道了策略是什么还要知道谁说了算。MP中字段策略的生效遵循一个明确的优先级第一优先级TableField注解在实体类的字段上直接使用TableField(updateStrategy FieldStrategy.IGNORED)那么这个字段的更新策略就以注解为准。这是最精细、最推荐的控制方式。第二优先级全局配置在MP的配置类中可以通过MybatisPlusProperties或GlobalConfig.DbConfig设置全局的默认策略。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 添加分页插件等 return interceptor; } // 通过配置类设置全局策略 (Spring Boot 方式) Bean public ConfigurationCustomizer configurationCustomizer() { return configuration - { GlobalConfig globalConfig new GlobalConfig(); GlobalConfig.DbConfig dbConfig new GlobalConfig.DbConfig(); // 设置全局更新策略为 IGNORED慎用 // dbConfig.setUpdateStrategy(FieldStrategy.IGNORED); // 设置全局插入策略 // dbConfig.setInsertStrategy(FieldStrategy.NOT_NULL); globalConfig.setDbConfig(dbConfig); configuration.setGlobalConfig(globalConfig); }; } }注意将全局策略设置为IGNORED是非常危险的行为它会让所有未显式声明TableField策略的字段都采用IGNORED极大增加误更新数据为null的风险。除非你非常清楚整个项目的实体类状况否则不建议这么做。第三优先级MP默认值如果既没有字段注解也没有全局配置那么就会采用MP内置的默认值也就是FieldStrategy.NOT_NULL。理解这个优先级至关重要。当你发现某个字段的更新行为不符合预期时就应该按照这个顺序去排查先看字段注解再看全局配置最后想到默认行为。3. 典型问题场景与深度剖析理论说完了我们来看实战中具体会撞上哪些墙。下面这几个场景我相信不少人都遇到过。3.1 场景一部分更新失效字段未按预期更新这是最经典的“坑”。假设有一个商品Product实体你只想更新它的库存stock。Product product new Product(); product.setId(1L); product.setStock(100); productMapper.updateById(product);你的期望SQL是UPDATE product SET stock 100 WHERE id 1但实际可能在默认NOT_NULL策略下且其他字段为null时生成的SQL从语法上是正确的。然而开发者的困惑点在于他们担心MP会生成一个包含所有字段的SET并将未设置的字段设为null。实际上在默认NOT_NULL策略下MP不会将值为null的字段加入SET。真正的风险点不在这里而在下面两种认知误区或延伸场景误用QueryWrapper进行部分更新有些人会这样写Product product new Product(); product.setPrice(new BigDecimal(99.9)); productMapper.update(product, new QueryWrapperProduct().eq(id, 1));如果price字段的更新策略是NOT_NULL而product对象只有price有值其他为null那么生成的SQL确实是只更新price。这里的“坑”在于如果QueryWrapper没有匹配到任何记录更新操作会影响0行但程序不会报错容易让人误以为更新成功了。“动态”更新认知偏差开发者常常有一种错觉认为MP的updateById是“动态”的只会更新变化了的字段。这其实不准确。MP的“动态”是指根据字段值的是否为null来决定是否参与SQL构建而不是根据字段值是否“发生了变化”。如果你从数据库查出一个对象修改了某个字段但其他字段保持原值非null那么更新时所有非null字段都会被加入SET。这可能导致并发下的乐观锁问题或者更新了不需要更新的字段。解决方案与实操心得明确意图如果确定是部分更新最清晰的做法是使用UpdateWrapper。productMapper.update(null, new UpdateWrapperProduct() .set(stock, 100) .eq(id, 1) );这种方式完全绕开了实体对象和字段策略直接指定SET内容意图明确SQL可控。善用TableField注解对于确实需要通过实体对象进行部分更新的字段可以将其策略设置为IGNORED但务必谨慎并清楚知道传入的对象状态。查询后更新先根据id查询出完整的实体对象修改需要改的字段再更新。这能保证其他字段有正确的值不会被误判为null。但要注意数据一致性和性能开销。3.2 场景二想设null却无效策略的“保护”成了“阻碍”业务上常有将某个字段置空的需求。比如用户解绑微信需要将wx_openid字段设为null。User user new User(); user.setId(1L); user.setWxOpenid(null); // 意图设为null userMapper.updateById(user);在默认的NOT_NULL策略下无论你set成null还是不set默认就是nullMP检查到wxOpenid为null都会直接忽略这个字段。生成的SQL根本没有wx_openid这个条件更新自然无效。解决方案与实操心得局部方案推荐给这个特定的字段加上TableField(updateStrategy FieldStrategy.IGNORED)注解。这样当你显式set为null时它就能被更新到数据库。public class User { TableField(updateStrategy FieldStrategy.IGNORED) private String wxOpenid; }全局方案不推荐如之前所述修改全局配置为IGNORED风险极高。使用UpdateWrapper这是最安全、最推荐的方式完全不受实体类策略影响。userMapper.update(null, new UpdateWrapperUser() .set(wx_openid, null) .eq(id, 1) );3.3 场景三误用IGNORED导致数据意外覆盖这是另一个极端。有的开发者为了“省事”或者遇到了NOT_NULL的坑一怒之下将全局策略或某个关键字段改成了IGNORED。public class Order { TableField(updateStrategy FieldStrategy.IGNORED) // 危险操作 private String address; // ... 其他字段 }然后在某次业务更新中Order order new Order(); order.setId(1001L); order.setStatus(2); // 只想更新状态 orderMapper.updateById(order);由于address字段是IGNORED且order对象中address是null生成的SQL就会包含address null。于是订单1001的收货地址就被清空了这是一个非常严重的生产事故。解决方案与实操心得原则IGNORED策略是一把锋利的双刃剑必须慎用。绝对不要为了“方便”而将其作为全局默认策略。精确注解如果某个字段确实需要接受null值更新只给这个字段加IGNORED注解而不是一片区域。防御性编程在Service层构建用于更新的实体对象时要非常清楚每个字段的状态。对于部分更新优先考虑UpdateWrapper。或者采用“查询-修改-保存”的全量更新模式虽然有一定性能损耗但数据安全性更高。代码审查将TableField(updateStrategy FieldStrategy.IGNORED)的使用纳入代码审查重点确保其必要性。4. 最佳实践与精准配置方案理解了问题和场景我们来系统性地看看如何安全、高效地使用MP的更新功能。4.1 策略选择指南何时用何策这没有一个绝对标准但可以遵循以下原则绝大多数普通字段保持默认的NOT_NULL即可。它能防止你在大多数情况下意外地用null覆盖数据库已有值。允许被置空的可选字段例如备注(remark)、外键ID(foreign_id)等可以使用TableField(updateStrategy FieldStrategy.IGNORED)。这样你既能更新为具体值也能在业务需要时将其更新为null。永远不应该被更新的字段如创建时间(create_time)使用TableField(updateStrategy FieldStrategy.NEVER)。这是最安全的保护锁。有特殊逻辑的字段比如乐观锁版本号(version)MP有内置的乐观锁插件处理通常不需要单独设置更新策略。又比如逻辑删除标志(deleted)通常由删除操作管理更新时也不应触碰。4.2 推荐配置模式注解为主Wrapper为辅我个人的经验是在实体类设计阶段就做好规划通过TableField注解明确每个字段在更新时的“性格”。Data TableName(sys_user) public class User { TableId(type IdType.AUTO) private Long id; private String username; // 默认 NOT_NULL 更新时必须提供 private String email; // 默认 NOT_NULL TableField(updateStrategy FieldStrategy.IGNORED) private String nickname; // 昵称允许置空 TableField(updateStrategy FieldStrategy.NEVER) private LocalDateTime createTime; // 创建时间永不更新 Version private Integer version; // 乐观锁由插件处理 // ... 其他getter/setter }而在业务代码的Service层根据不同的更新场景选择最合适的工具全量更新先查后改适用于需要确保数据一致性的复杂对象更新。public void updateUser(UserDTO userDTO) { User user userMapper.selectById(userDTO.getId()); // 使用BeanUtils或MapStruct等工具将DTO的非空属性拷贝到user实体 BeanUtils.copyProperties(userDTO, user, id, createTime); // 忽略不能拷贝的字段 userMapper.updateById(user); // 此时user是一个全字段都有值的对象 }精确部分更新首选绝大多数场景下的最佳选择。public void updateUserEmail(Long userId, String newEmail) { userMapper.update(null, new UpdateWrapperUser() .set(email, newEmail) .eq(id, userId) ); }动态部分更新基于实体在明确字段策略且对象状态可控时使用。public void unbindWechat(Long userId) { User user new User(); user.setId(userId); user.setWxOpenid(null); // 依赖该字段的 TableField(updateStrategy IGNORED) userMapper.updateById(user); }4.3 全局配置的思考保持克制除非你在开发一个非常明确、所有实体行为都高度一致的小型项目否则我强烈建议不要轻易修改全局的updateStrategy。默认的NOT_NULL是一个相对安全的设定。如果确实有大量字段需要IGNORED那更应该反思实体设计是否合理而不是通过全局配置来掩盖问题。全局配置更适合用来设置一些通用的、无风险的规则比如db-config.id-type: assign_id设置主键ID生成策略db-config.table-underline: true开启下划线映射db-config.logic-delete-field: deleted配置逻辑删除字段名5. 高级技巧与深度排查指南掌握了基础我们再看一些进阶玩法和遇到复杂问题时的排查思路。5.1 使用Lambda表达式与条件构造器进行类型安全更新UpdateWrapper的set方法需要传入字符串形式的列名容易写错。MP提供了Lambda表达式方式在编译期就能检查类型安全。public void updateUserStatus(Long userId, Integer status) { userMapper.update(null, Wrappers.UserlambdaUpdate() .set(User::getStatus, status) // 编译期检查避免拼写错误 .set(User::getUpdateTime, LocalDateTime.now()) // 可以链式调用多个set .eq(User::getId, userId) ); }这种方式结合了UpdateWrapper的精确控制和Lambda的类型安全是当前MP中最优雅的更新方式之一。5.2 排查字段更新行为的完整流程当你发现更新不对时不要慌按这个步骤来开启SQL日志这是第一步也是最重要的一步。在application.yml中配置mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 控制台打印完整SQL执行你的更新方法查看控制台输出的最终SQL语句。一看SQL问题就清楚了一半。检查实体类字段注解对照输出的SQL看哪个字段没出现或者不该出现。然后去实体类里检查这个字段的TableField注解确认其updateStrategy。检查全局配置如果字段没有注解去项目的配置类如MybatisPlusConfig里检查是否设置了全局策略。检查传入的实体对象状态在更新代码前打上断点或者打印日志确认你构建的实体对象里目标字段的值是否如你所想。特别是当你是从其他地方如RPC参数、前端DTO转换而来时要确保转换过程没有丢失值或误置null。考虑MP插件的影响检查是否配置了乐观锁插件、自动填充插件MetaObjectHandler。这些插件可能会在更新时自动修改某些字段的值如version、update_time干扰你的判断。5.3 与“动态取消租户隔离”等热词的关联思考最近社区里“mybatis-plus 动态取消租户隔离”是个热点。这其实和更新策略在思想上有相通之处都是对MP默认行为的精细化控制。租户插件默认会给所有SQL加上租户ID条件但在某些全局管理场景下需要临时取消。这就像字段更新策略默认是NOT_NULL但在特定字段上你需要IGNORED或NEVER。这种“默认行为局部覆盖”的设计模式是MP这类增强框架的核心哲学。理解这一点不仅能解决更新策略问题也能举一反三更好地理解和使用MP的其他特性比如分页插件、性能分析插件等。它们的本质都是在提供便利的同时通过配置给你留出掌控细节的后门。6. 总结与个人体会绕了一大圈其实MyBatis-Plus的Update更新策略问题核心就是理解框架的默认行为并学会在需要时精确地覆盖它。NOT_NULL是MP给你的一把安全锁防止你误操作。但当你需要更灵活的控制时TableField注解和UpdateWrapper就是你手中的钥匙。我个人在实际项目中的体会是约定大于配置但配置必须清晰尽量使用MP的默认行为NOT_NULL这能形成团队共识。任何对默认行为的偏离使用IGNORED或NEVER都必须通过清晰的TableField注解来标明并且要在设计评审中说明理由。UpdateWrapper是好朋友对于业务Service层的方法我越来越倾向于使用UpdateWrapper尤其是Lambda方式来进行更新。它意图明确不受实体对象复杂状态的影响SQL一目了然非常适合在团队协作中减少误解。全量更新并非洪水猛兽在事务边界内对于核心的、状态复杂的领域对象先selectById再修改最后updateById的全量更新模式在数据一致性上的收益往往大于其带来的额外一次查询开销。特别是在使用乐观锁时这种模式几乎是标配。日志是你的眼睛遇到任何MP相关的诡异问题第一时间打开完整的SQL日志。框架生成的SQL不会说谎它能直接告诉你MP是如何理解你的意图的。最后记住一点任何框架的便利性都伴随着一定的“黑盒”复杂度。MyBatis-Plus通过更新策略这样的设计试图在智能和可控之间找到平衡。作为开发者我们的任务不是抱怨它的“坑”而是深入理解其设计原理从而驾驭它让它真正成为提升开发效率的利器而不是生产事故的源头。花点时间搞清楚FieldStrategy你在使用MP进行数据操作时会更有底气代码也会更加健壮可靠。