1. 问题现场还原:为什么updateById会“吞掉”我的空值
先说结论:updateById不更新空值,不是 bug,是 MyBatis-Plus 默认字段策略在起作用。很多人第一次遇到这个现象时,第一反应是“框架坏了”或者“数据库出问题了”,实际上框架的行为完全符合设计预期,只是这个预期和业务直觉之间存在偏差。
我先把典型现场还原一下。假设有一张用户表,实体类里有个phone字段,某天业务要求把某个用户的手机号清空,也就是置为null。你信心满满地写下user.setPhone(null); userMapper.updateById(user);,跑完一看数据库,手机号纹丝不动。再试一次,还是不动。于是开始怀疑人生:是不是事务没提交?是不是主键传错了?是不是 SQL 没执行?打开日志一看,SQL 确实执行了,但SET子句里压根没有phone这个字段。
这就是问题的核心:MyBatis-Plus 在生成动态 SQL 时,默认会跳过值为null的字段。它背后的机制是实体类字段上的@TableField注解,以及全局配置里的field-strategy(字段策略)。在较新的版本中,这个配置项叫update-strategy和insert-strategy,默认值都是NOT_NULL,意思是“只有字段值不为 null 时才纳入 SQL”。所以当你把字段设为 null,框架认为“这个字段用户没打算更新”,直接忽略。
这个设计初衷其实很合理:在大多数更新场景里,我们只想改用户传过来的那几个字段,不想把没传的字段全部覆盖成 null。比如前端只提交了昵称修改,后端如果无脑全量更新,就会把头像、签名等字段全部清空,这是灾难性的。所以NOT_NULL策略是一种保护机制。但问题在于,当业务确实需要“主动置空”时,这个保护就变成了阻碍。
理解了这个背景,后面的所有解决方案才有意义。你需要做的不是“绕过框架”,而是“告诉框架这一次我确实要更新 null”。下面我会把几种主流方案逐一拆开讲,包括它们的适用边界、坑点和实测表现。
1.1 先搞清楚你的版本和默认策略
在动手改之前,先确认两件事,否则后面容易改错地方。第一是 MyBatis-Plus 的版本,第二是当前的字段策略配置。不同版本配置项名称有差异,3.1.2 之前用field-strategy,之后拆成了insert-strategy、update-strategy、select-strategy三个。如果你在配置文件里写了旧名称,新版本可能不认,导致你以为改了其实没生效。
查看版本很简单,看pom.xml里mybatis-plus-boot-starter的版本号即可。查看当前策略,可以全局搜索配置文件里的strategy关键字,或者看实体类上有没有@TableField注解。我见过不少项目是“全局配置 + 局部注解”混用的,这种最容易出问题,因为局部注解优先级高于全局配置,你以为全局改了,结果某个字段上还挂着@TableField(strategy = FieldStrategy.NOT_NULL),照样不更新。
提示:排查这类问题时,先把日志级别调到
DEBUG,让 MyBatis 打印出实际执行的 SQL。看到 SQL 长什么样,比猜一百次都管用。
2. 四种解决方案逐一拆解:从全局到局部怎么选
解决“更新空值”这件事,市面上流传的方案有好几种,但它们的粒度、影响范围和副作用完全不同。我按“影响面从大到小”的顺序来讲,你可以根据自己的场景对号入座。这里先给一张总览表,方便你快速定位。
| 方案 | 粒度 | 影响范围 | 适用场景 | 主要风险 |
|---|---|---|---|---|
全局配置update-strategy改为IGNORED | 全局 | 所有实体的所有更新 | 小项目、字段少、全量更新为主 | 误清空其他字段 |
实体字段加@TableField(updateStrategy = FieldStrategy.IGNORED) | 字段级 | 单个字段 | 少数几个字段需要置空 | 需逐字段标注 |
使用UpdateWrapper显式 set | 调用级 | 单次更新 | 精确控制某次更新 | 代码略啰嗦 |
| 自定义 SQL / 手写 Mapper | 方法级 | 单个方法 | 复杂更新逻辑 | 失去框架便利 |
2.1 全局策略调整:一把双刃剑
最省事的做法是把全局的update-strategy改成IGNORED,意思是“忽略 null 判断,所有字段都参与更新”。配置方式如下:
mybatis-plus: global-config: db-config: update-strategy: ignored改完之后,updateById会把实体里所有字段都拼进SET子句,包括 null 值。听起来很爽,但我必须提醒你:这是最危险的一种改法。因为一旦全局放开,你之前依赖“跳过 null”保护的所有更新逻辑都会失效。举个真实场景:你有个更新接口只接收前端传来的昵称,构造实体时其他字段都是 null,以前框架帮你跳过,现在全部覆盖,用户的头像、积分、状态瞬间被清空。这种事故在生产环境里是要背锅的。
所以全局策略我只推荐给两类项目:一是字段很少、更新逻辑简单、每次都是全量提交的小型项目;二是你已经在业务层做了严格的字段校验,确保每次更新前实体是完整的。除此之外,老老实实往下看字段级方案。
2.2 字段级注解:精准打击,推荐首选
如果你只是某几个字段需要支持置空,最稳妥的方式是在实体字段上加注解:
@TableField(updateStrategy = FieldStrategy.IGNORED) private String phone;这样只有phone这个字段在更新时会忽略 null 判断,其他字段依然受保护。粒度刚刚好,风险可控。我个人的项目里,凡是“允许被清空”的字段,比如备注、签名、头像地址、扩展属性,都会显式加上这个注解,相当于给字段打了一个“可置空”的标记,语义也很清晰。
这里有个细节要注意:FieldStrategy枚举在不同版本里取值略有差异,常见的有IGNORED、NOT_NULL、NOT_EMPTY、DEFAULT。IGNORED是“完全忽略判断”,NOT_EMPTY是“空字符串也不更新”。如果你用的是老版本,可能叫FieldStrategy.IGNORED没问题,但如果你发现编译不过,去查一下当前版本的枚举定义,别硬套网上的代码。
注意:
@TableField的updateStrategy只影响更新操作,不影响插入。插入时的空值行为由insertStrategy控制,两者要分开配置,别搞混了。
2.3 UpdateWrapper 显式 set:临时需求的利器
有些场景你不想动实体注解,也不想改全局配置,只是某一次调用需要把某个字段置空。这时候用UpdateWrapper最合适:
UpdateWrapper<User> wrapper = new UpdateWrapper<>(); wrapper.eq("id", userId) .set("phone", null); userMapper.update(null, wrapper);set方法会无条件把字段拼进 SQL,不管值是不是 null。这种写法的好处是“所见即所得”,SQL 里有什么一目了然,不依赖任何隐式策略。缺点是代码量比updateById多,而且字段名是字符串硬编码,重构时容易漏改。我的习惯是:临时性的、一次性的置空需求用UpdateWrapper;长期稳定的置空字段用注解。两者结合,既灵活又不失可维护性。
2.4 手写 SQL:最后的兜底手段
如果更新逻辑特别复杂,比如要根据条件动态决定是否置空、要配合多表操作、要用数据库函数,那前面几种方案都不够用,直接在手写 Mapper 里写 SQL 最干脆:
<update id="clearPhone"> UPDATE user SET phone = NULL WHERE id = #{id} </update>手写 SQL 完全绕过了框架的字段策略,你想怎么改就怎么改。代价是失去了updateById的便利性,字段增减时要手动同步。所以这只适合少数复杂场景,不要滥用。我一般把这类方法命名为updateXxxByXxx这种明确语义的名字,避免和框架方法混淆。
3. 实操全流程:从复现到验证的完整走查
光讲方案不够,我把一次完整的排查和修复过程走一遍,你可以照着在自己的项目里复现。这个流程我用了很多次,基本能覆盖 90% 以上的“更新无效”问题。
3.1 第一步:构造最小复现
先别急着改代码,先确认问题能稳定复现。建一个最简单的实体和表,字段三个:id、name、remark。插入一条数据,remark有值。然后写一个测试方法,把remark设为 null,调用updateById,看数据库里remark是否变化。如果没变,问题复现成功;如果变了,说明你的项目配置和默认值不同,得先查配置。
这一步的意义在于排除干扰。很多项目里问题复现不了,是因为某个全局配置早就被改过,或者某个拦截器在偷偷改 SQL。最小复现能帮你把变量控制到最少。
3.2 第二步:打开 SQL 日志看真相
在配置文件里加上日志配置:
mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl再次执行更新,控制台会打印出实际 SQL。你会看到类似这样的输出:
UPDATE user SET name=? WHERE id=?注意SET后面只有name,没有remark。这就实锤了:框架在生成 SQL 时把 null 字段过滤掉了。看到这一步,问题定性完成,接下来就是对症下药。
3.3 第三步:选择方案并验证
根据前面的分析选方案。假设我选择字段级注解,给remark加上@TableField(updateStrategy = FieldStrategy.IGNORED),重启应用,再跑一次测试。这次日志里应该出现:
UPDATE user SET name=?, remark=? WHERE id=?参数里remark是 null,数据库里对应字段被清空。验证通过。如果还是不行,检查注解是否加对位置、是否被其他注解覆盖、版本是否支持该枚举值。
3.4 第四步:回归测试其他更新场景
改完之后千万别只测置空场景,一定要回归测试“正常更新”和“部分更新”。比如只改name不改remark,确认remark没有被误清空。这一步是很多人忽略的,结果修好了置空问题,却引入了误清空的新问题。我一般会准备一组测试用例:全字段更新、单字段更新、置空更新、空字符串更新,四个场景全过才算完。
4. 踩坑实录与高频问题速查
这一节是我这些年攒下来的“血泪账”,很多问题文档里不会写,但实际项目中一定会遇到。我整理成速查表加逐条说明的形式,方便你对号入座。
| 现象 | 可能原因 | 排查方向 | 解决方式 |
|---|---|---|---|
| 加了注解仍不更新 | 注解被覆盖或版本不匹配 | 看编译版本、看是否有重复注解 | 确认枚举值、清理重复配置 |
| 空字符串也不更新 | 策略是NOT_EMPTY而非NOT_NULL | 查全局和字段策略 | 改为IGNORED或单独处理 |
| 更新后其他字段被清空 | 全局改成了IGNORED | 查全局配置 | 回退全局,改字段级 |
UpdateWrapper的 set 不生效 | 字段名拼写错误或列名不匹配 | 看 SQL 日志 | 核对数据库列名 |
| 更新返回成功但数据没变 | 主键不匹配或条件不命中 | 看WHERE条件 | 核对主键值 |
4.1 空字符串和 null 是两回事
这是最容易混淆的点。NOT_NULL策略只判断 null,空字符串""是会被更新的;而NOT_EMPTY策略会把 null 和空字符串都跳过。如果你发现“传空字符串也不更新”,那说明策略是NOT_EMPTY,不是NOT_NULL。这两个策略在业务上的语义完全不同:前者是“没填”,后者是“填了空”。搞清楚你的业务到底要哪种,再选策略。
4.2 注解优先级高于全局配置
我遇到过好几次“全局改了没用”的情况,最后发现是实体字段上残留了@TableField(strategy = ...)的老注解。局部注解优先级更高,全局配置被它盖掉了。排查时用 IDE 的全局搜索,把实体类里所有@TableField都过一遍,别只看你改的那个字段。
4.3 逻辑删除字段的特殊性
如果你的表用了逻辑删除,updateById会自动带上deleted = 0的条件,这个字段的更新策略通常是独立的。有时候你发现更新不生效,其实是逻辑删除字段的值不对,导致WHERE条件不命中。这种情况看 SQL 日志里的WHERE子句就能发现,别一味盯着SET部分。
4.4 批量更新的坑
updateById是单条更新,如果你用saveOrUpdateBatch或者循环调用,每个实体的字段策略是独立生效的。批量场景下如果混用了置空和非置空的数据,行为可能和你预期不一致。我的建议是批量更新尽量用UpdateWrapper配合in条件,或者明确每个字段的策略,别指望框架帮你猜。
5. 我的实操心得:策略选择的三条经验法则
聊了这么多方案,最后说说我自己的取舍逻辑,这些是踩坑踩出来的直觉,不一定写在文档里,但实战中很好用。
第一条,默认不动全局。全局策略是项目的“地基”,动它影响面太大。除非项目从零开始且明确知道全量更新的后果,否则我永远优先用字段级注解。字段级注解的语义也更好,看代码就知道这个字段“允许置空”,相当于自文档化。
第二条,置空字段要有明确清单。我在项目里会维护一个“可置空字段列表”,凡是列表里的字段统一加IGNORED注解,列表外的字段一律受保护。这样新人接手时不会误改,代码评审时也有据可依。这个习惯帮我避免了好几次潜在的数据事故。
第三条,测试用例要覆盖四种更新形态。全量更新、部分更新、置空更新、空字符串更新,这四个场景是字段策略的“试金石”。每次调整策略后跑一遍,基本能拦住所有回归问题。我甚至把这四个场景写成了模板测试类,新项目直接复制,省事又保险。
另外补充一个小技巧:如果你不确定某个字段当前生效的策略是什么,可以在启动时打印一下 MyBatis-Plus 的配置,或者临时在字段上加日志断点,看框架读取到的策略值。比翻文档猜要快得多。这个内容后续还可以往“多数据源下的策略隔离”方向扩展,不同数据源配置不同策略时,优先级和覆盖关系会更复杂,值得单独拎出来讲。