尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Mybatis-Plus更新策略详解:从IGNORED到NOT_NULL,规避数据丢失风险

Mybatis-Plus更新策略详解:从IGNORED到NOT_NULL,规避数据丢失风险 1. 从一次诡异的“数据丢失”事件说起那天下午测试同事急匆匆地跑过来说后台管理界面上有个用户的手机号字段莫名其妙被清空了。我第一反应是“不可能这个字段在更新用户信息时是必填的前端有校验后端实体类也有NotNull注解。” 但查看数据库记录那个phone字段确实变成了NULL。排查代码更新逻辑用的是Mybatis-Plus的updateById方法传入的是一个从数据库查出来、只修改了nickname字段的User对象。理论上phone字段根本没动怎么会变NULL呢问题就出在Mybatis-Plus的全局更新策略上。默认情况下Mybatis-Plus的更新操作其SQL生成逻辑并非“只更新你显式set过的字段”而是“更新实体对象中所有非null的字段”。如果那个User对象的phone属性恰好是null比如从JSON反序列化时缺失或者你new User()后只set了nickname那么生成的SQL就会包含phonenull从而覆盖掉数据库里的原有值。这起“数据丢失”事件本质上是对Mybatis-Plus更新策略的误解和错误使用所导致的。这不仅仅是Mybatis-Plus的问题更是我们在使用任何ORM框架时都必须深刻理解的“对象状态”与“SQL语义”的映射关系。今天我们就来彻底拆解Mybatis-Plus的更新策略搞懂它的几种模式、背后的原理、适用场景以及如何规避我踩过的这个坑。2. Mybatis-Plus更新策略的三种模式与底层机制Mybatis-Plus通过com.baomidou.mybatisplus.annotation.FieldStrategy枚举类定义了字段在生成SQL时的处理策略。这个策略可以全局配置也可以针对单个字段通过TableField注解进行配置。理解这三种模式是掌握Mybatis-Plus更新行为的关键。2.1 IGNORED 模式最“直白”也最“危险”这是Mybatis-Plus3.x版本之前的默认全局策略也是很多老项目踩坑的根源。在这种策略下Mybatis-Plus的UpdateWrapper或update(T entity)方法会无视字段值是否为null将所有字段都纳入UPDATE语句。它的工作逻辑是这样的当你调用userMapper.updateById(user)时Mybatis-Plus内部会通过反射获取User对象的所有属性值。在IGNORED模式下无论属性值是null、空字符串还是其他任何值只要这个字段在数据库中有对应列它就会出现在生成的SQL的SET部分。例如User user new User(); user.setId(1L); user.setNickname(新昵称); // phone 属性为 null userMapper.updateById(user);生成的SQL将是UPDATE user SET nickname新昵称, phonenull WHERE id1;这就是我遇到的数据丢失问题的直接原因。对象中未被赋值的属性null被直接翻译成了SET columnnull。这种模式看似“忠实”地反映了对象状态但在实际业务中极其危险因为它很容易在部分更新的场景下误清空其他字段。那么什么情况下该用IGNORED呢理论上只有在你**明确需要将某个字段更新为NULL**时。例如有一个“审核备注”字段审核通过后需要清空之前的备注内容。即便如此更安全的做法也是通过UpdateWrapper的setSql(“remarknull”)来显式操作而不是依赖全局策略。2.2 NOT_NULL 模式谨慎的默认守卫从Mybatis-Plus3.1版本开始全局默认策略改为了NOT_NULL。这是一个重要的安全改进。在此策略下Mybatis-Plus在构造UPDATE语句时会跳过值为null的字段只更新非null的字段。沿用上面的例子User user new User(); user.setId(1L); user.setNickname(新昵称); // phone 属性为 null userMapper.updateById(user);在NOT_NULL全局策略下生成的SQL变为UPDATE user SET nickname新昵称 WHERE id1;phone字段因为值为null被成功地排除在了SET子句之外数据库中原有的手机号得以保留。这完美解决了IGNORED模式下的“误清空”问题也是目前大部分场景下的推荐配置。它符合“部分更新”的直觉我只想改我传了的字段。但是NOT_NULL模式也有其局限性。它无法处理“空字符串””或数字0的情况。例如如果你想把一个用户的积分(points)清零你setPoints(0)在NOT_NULL模式下0不是null所以这个更新会被包含进去这符合预期。但如果你想把用户的昵称清空设为空字符串setNickname(“”)也会触发更新。有时业务上需要区分“不更新此字段”和“将此字段更新为空值”NOT_NULL模式无法区分null不更新和空字符串更新为空。2.3 NOT_EMPTY 模式更严格的空值过滤NOT_EMPTY策略在NOT_NULL的基础上更进一步。它不仅会跳过null值还会跳过空字符串(””)。对于字符串类型的字段这通常是我们更想要的行为即不更新为空的字段。它的判断逻辑是如果字段值为null跳过。如果字段是CharSequence类型如String且长度为0isEmpty()为true跳过。其他情况纳入更新。例如User user new User(); user.setId(1L); user.setNickname(); // 空字符串 user.setEmail(null); user.setPoints(0); userMapper.updateById(user);在NOT_EMPTY策略下生成的SQL可能只有UPDATE user SET points0 WHERE id1;nickname因为为空字符串被跳过email因为为null被跳过。这个模式在需要严格防止空字符串覆盖数据库已有内容的场景下非常有用比如用户简介、地址详情等字段。然而NOT_EMPTY的“严格”也可能带来麻烦。想象一个“清空收货地址”的功能前端传回一个空字符串的address字段期望后端能清空数据库中的旧地址。如果该字段配置了NOT_EMPTY这次更新将会被静默忽略导致操作失效而用户和开发者可能都难以察觉。因此选择哪种策略必须紧密结合业务语义。2.4 策略配置的优先级与生效位置理解策略在哪生效、谁优先级更高是解决策略冲突的关键。Mybatis-Plus的更新策略配置有三个层次全局配置最低优先级在Mybatis-Plus的全局配置文件中设置影响所有实体类的所有字段除非被覆盖。mybatis-plus: global-config: db-config: update-strategy: not_null # 默认值实体类字段配置中等优先级通过TableField注解在实体类的特定字段上设置覆盖该字段的全局策略。public class User { TableField(updateStrategy FieldStrategy.IGNORED) private String phone; // 此字段总是可更新为null TableField(updateStrategy FieldStrategy.NOT_EMPTY) private String description; // 此字段忽略空字符串更新 }UpdateWrapper显式设置最高优先级通过UpdateWrapper的set方法你可以完全无视任何策略强制指定字段的值。这是最直接、最可控的方式。UpdateWrapperUser updateWrapper new UpdateWrapper(); updateWrapper.eq(“id”, 1L) .set(“nickname”, “新昵称”) .set(“phone”, null) // 明确设置phone为null .set(“description”, “”); // 明确设置description为空字符串 userMapper.update(null, updateWrapper);使用UpdateWrapper时生成的SQL会严格遵循set的内容FieldStrategy完全失效。因此当需要进行复杂、精确的更新时UpdateWrapper是比使用实体对象更可靠的选择。3. 实战中的经典“坑位”与排查指南理解了原理我们来看看实战中哪些场景最容易出问题以及如何系统地排查。3.1 坑位一新旧数据混合对象的更新这是最常见的场景。从数据库查出完整对象oldUser前端传来部分修改的DTOuserDTO我们用工具类如BeanUtils或MapStruct将DTO的属性拷贝到旧对象上然后调用updateById(oldUser)。风险点DTO中未修改的字段如果在前端JSON中未传参反序列化后可能是null。如果全局策略是IGNORED这些null字段会覆盖数据库。即使全局策略是NOT_NULL如果DTO中某个字段业务上允许为空如清空备注且前端正好传了空字符串或null这个“清空”操作可能会因为字段策略而被意外忽略NOT_EMPTY忽略空串或错误执行IGNORED误清空其他字段。排查与解决方案首先确认全局策略检查application.yml或Configuration类中的mybatis-plus.global-config.db-config.update-strategy。建议生产环境设置为NOT_NULL。审查实体类字段注解逐个检查实体类中是否有字段通过TableField指定了特殊的updateStrategy。这常常是局部行为不符合预期的根源。在拷贝后、更新前打印对象在调用updateById之前将对象用JSON序列化打印出来。一眼就能看到哪些字段是null哪些是空字符串。User userToUpdate ... // 从数据库查出并合并了DTO数据的对象 log.info(“准备更新的对象: {}”, JSON.toJSONString(userToUpdate)); userMapper.updateById(userToUpdate);启用Mybatis-Plus SQL日志在配置文件中设置mybatis-plus.configuration.log-impl: org.apache.ibatis.logging.stdout.StdOutImpl直接在控制台查看最终生成的SQL语句这是最直接的证据。终极方案使用UpdateWrapper进行精确更新放弃使用实体对象进行部分更新改用UpdateWrapper。你可以明确指定只更新哪些字段代码意图更清晰完全避免策略干扰。UpdateWrapperUser wrapper new UpdateWrapperUser().eq(“id”, id); if (StringUtils.isNotBlank(userDTO.getNickname())) { wrapper.set(“nickname”, userDTO.getNickname()); } if (userDTO.getPhone() ! null) { // 明确处理null值传null就清空 wrapper.set(“phone”, userDTO.getPhone()); } // 不set的字段绝对不会出现在SQL中 userMapper.update(null, wrapper);3.2 坑位二逻辑删除与乐观锁字段的更新干扰Mybatis-Plus的逻辑删除TableLogic和乐观锁Version功能其字段通常也有自己的更新策略并且可能与你的业务更新产生交互。案例逻辑删除字段的“意外”更新假设实体类Article有一个逻辑删除字段TableLogic(value “0”, delval “1”) private Integer deleted;默认情况下这个字段的updateStrategy很可能是NOT_NULL。当你执行一个普通的更新articleMapper.updateById(article)时如果article对象的deleted属性为null通常都是它不会被加入更新。这没问题。但如果你错误地将全局策略设为IGNORED并且你的更新对象deleted属性为null那么生成的SQL可能会包含deletednull这可能会破坏你的逻辑删除数据因为你的删除标识delval是“1”而null不等于“1”可能导致已删除的数据被错误地“恢复”。解决方案永远不要手动设置或操作逻辑删除/乐观锁字段。这些字段应该完全由Mybatis-Plus的插件机制管理。避免使用IGNORED全局策略防止意外包含这些字段。为这些系统字段加上TableField(updateStrategy FieldStrategy.NEVER)明确禁止通过普通更新操作修改。TableLogic(value “0”, delval “1”) TableField(updateStrategy FieldStrategy.NEVER) private Integer deleted; Version TableField(updateStrategy FieldStrategy.NEVER) private Integer version;3.3 坑位三使用LambdaUpdateWrapper时的类型安全与空值陷阱LambdaUpdateWrapper提供了类型安全的编程体验但它同样受更新策略影响并且有一个细微的差别。LambdaUpdateWrapperUser wrapper Wrappers.UserlambdaUpdate() .eq(User::getId, 1L) .set(User::getNickname, “新名字”) .set(User::getPhone, somePhone); // somePhone可能为null如果somePhone为null那么.set(User::getPhone, null)这个调用仍然会生效并进入Wrapper的SET列表。最终SQL是否包含phonenull取决于User实体类中phone字段上配置的TableField(updateStrategy)。如果该字段策略是IGNORED或未配置继承全局NOT_NULL那么null值会被处理更新为null或忽略。这里的坑在于开发者可能误以为LambdaWrapper的.set()方法会自动忽略null但实际上它不会它只是记录了设置操作最终决定权在字段策略。安全实践在使用LambdaUpdateWrapper时对可能为null的值进行判空避免记录不必要的设置。LambdaUpdateWrapperUser wrapper Wrappers.UserlambdaUpdate() .eq(User::getId, 1L) .set(User::getNickname, “新名字”); if (somePhone ! null) { wrapper.set(User::getPhone, somePhone); } // 如果somePhone为null我们就不set完全绕过策略判断确保不更新此字段。4. 高级场景自定义策略与批量更新的性能考量4.1 实现自定义字段更新策略FieldStrategy枚举是固定的但Mybatis-Plus的com.baomidou.mybatisplus.core.injector.methods.UpdateById等方法内部是通过com.baomidou.mybatisplus.core.toolkit.TableInfoHelper和com.baomidou.mybatisplus.core.metadata.TableFieldInfo来判断字段是否应该加入SQL的。我们可以通过实现com.baomidou.mybatisplus.core.injector.ISqlInjector接口或继承DefaultSqlInjector并重写相关方法来插入自定义的逻辑。但这种方式非常侵入不推荐。更实用的“自定义”是利用TableField的condition属性。虽然它主要用于查询条件但其SqlCondition模式给了我们启发。对于更新更简单的做法是结合UpdateWrapper和自定义工具方法。例如我们想要一个“只有非空且非默认值时才更新”的策略public class UpdateUtils { public static T, R void setIfNotDefault(LambdaUpdateWrapperT wrapper, SFunctionT, R column, R value, R defaultValue) { if (value ! null !value.equals(defaultValue)) { wrapper.set(column, value); } } } // 使用 UpdateUtils.setIfNotDefault(wrapper, User::getAge, dto.getAge(), 0); // 默认年龄0岁只有非0才更新 UpdateUtils.setIfNotDefault(wrapper, User::getStatus, dto.getStatus(), “active”); // 默认状态active4.2 批量更新Batch Update的性能与策略影响Mybatis-Plus的updateBatchById方法本质上是循环执行单条updateById。因此每条记录的更新都独立遵循上述字段策略。这带来两个考量性能如果批量更新1000条数据每条数据都需要Mybatis-Plus根据策略动态生成不同的SQL SET部分如果字段值不同然后执行1000次UPDATE。这在极端情况下可能有性能开销但通常不是瓶颈。真正的瓶颈在于数据库连接和网络IO。一致性由于是循环单条更新如果中间某条更新失败事务会回滚如果开启了事务。但字段策略处理是应用层行为对所有记录是一致的。对于超大批量更新一个更高效的模式是使用“Case When”动态SQL。Mybatis-Plus本身不直接支持生成这种SQL但你可以通过自定义SQL在XML中实现或者使用UpdateWrapper的setSql方法进行部分拼接。不过这需要你手动处理字段值和ID的映射牺牲了ORM的便利性来换取性能。在绝大多数业务场景下updateBatchById配合合理的字段策略已经足够。5. 我的配置清单与最佳实践总结经过多次踩坑和项目迭代我总结出一套关于Mybatis-Plus更新策略的配置和操作实践1. 全局配置坚守NOT_NULL在application.yml中始终将全局更新策略设置为NOT_NULL。这是安全与功能性的最佳平衡点。mybatis-plus: global-config: db-config: update-strategy: not_null insert-strategy: not_null # 插入策略也建议保持一致2. 实体字段注解审慎使用对于绝对不允许更新为NULL或空字符串的字段如用户名、主键可以加TableField(updateStrategy FieldStrategy.NEVER)但通常不需要因为你不应该去更新它们。对于**需要明确区分“不更新”和“更新为空”**的字段不要依赖策略而应在业务代码中显式判断。例如一个“备注”字段前端传空字符串表示清空备注传null表示不修改。在服务层就应该判断if (dto.getRemark() ! null) { // 注意这里判断的是是否传了这个参数而不是值是否为空 updateWrapper.set(User::getRemark, dto.getRemark()); // 传了就set即使是空字符串 }3. 优先使用UpdateWrapper/LambdaUpdateWrapper进行更新对于任何业务性的更新操作尤其是来自前端的请求放弃updateById(T entity)改用update(null, updateWrapper)。这样做的好处是意图清晰、完全可控、避免策略误判、方便地只更新部分字段。在Wrapper的set操作前进行判空是控制是否更新该字段的最直接手段。4. 严格隔离“查询实体”与“更新实体”不要将从数据库查出的实体对象直接用于更新。总是先创建新的空对象或使用UpdateWrapper。如果必须使用对象更新考虑为更新操作创建专用的DTO或UpdateCommand对象并在转换时小心处理null值。5. 日志与监控开发环境开启Mybatis-Plus的SQL日志定期审查生成的更新语句是否符合预期。对于核心的更新操作可以在切面或拦截器中记录更新前后的数据快照便于问题追踪。回顾开头那个“手机号变NULL”的问题根本原因就是使用了默认的IGNORED策略当时项目用的老版本MP并且错误地将一个部分属性的对象用于更新。将全局策略改为NOT_NULL并将更新方式重构为使用UpdateWrapper后这类问题再也没有发生过。理解工具的行为边界用符合其设计哲学的方式去使用它才能让它真正成为提升开发效率的利器而不是生产事故的导火索。
返回列表