MyBatis 局部更新的静默覆写陷阱:复用实体对象一行代码毁掉并发写
某次微信登录自动绑定流程上线后,偶发用户昵称/头像「回滚」到几秒前旧值的投诉。数据库层面没有任何报错,业务日志一切正常,却在并发压测下稳定复现:另一个终端更新了昵称,随后登录完成时昵称又被刷回来了。
排查之后发现,根本没有任何「回滚」——是一次看似无害的局部更新,把整行旧值悄悄重写了一遍。
现象
- 仅修改
member_code(或phone)的接口调用完成后,nickname、headimgurl、integration等字段被回写为请求开始时加载的旧值。 - 开启 SQL 日志后发现,
UPDATE ums_member SET nickname=?, headimgurl=?, integration=?, member_code=?, ...包含了几乎所有列,而不只是目标列。 - 单线程下无问题,并发写时必现。
根因
MyBatis-Plus updateById(entity) 的默认字段策略是 FieldStrategy.NOT_NULL:实体对象上所有非 null 的字段,都会被拼进 SET 子句。
问题代码的结构是:
// 登录拦截器 / 上游服务已经 selectById,20 列全部加载
UmsMember member = memberMapper.selectById(id);
// 下游方法只想写回一个字段
member.setMemberCode(code);
memberMapper.updateById(member); // 实际 UPDATE 了 19 列!上游 selectById 填满了实体的所有字段。下游方法收到这个对象,只 set 了一个字段,但交给 mapper 时,所有非 null 字段都被当成「要更新的值」发往数据库。如果此时另一个请求(例如小程序修改昵称)在 selectById 之后、updateById 之前完成了写入,那次写入就被静默覆盖了。
自定义 XML 映射 <if test="xxx != null"> 同样中招——因为实体已被上游完整填充,每个 <if> 条件都为真,等效于全列更新。
实体在上游被完整加载,下游每一次 setXxx 都只是在已填满的对象上「追加标记」。MyBatis-Plus 并不知道你「只改了这一列」——它只看字段是否非 null。结果就是:你以为自己在做局部更新,实际上是一次全行快照回写,把并发写入的新值全部清零。
正解
方案 A(首选):构造隔离的更新实体
新建一个空对象,只设置主键 + 目标字段,绝不复用上游传入的实体:
private void doBind(UmsMember member, String memberCode, String phone) {
// 新建空对象,所有字段默认 null
UmsMember updateParam = new UmsMember();
updateParam.setId(member.getId()); // 必须:主键
updateParam.setMemberCode(memberCode); // 只有这两列会进 SET
if (StringUtils.isNotEmpty(phone)) {
updateParam.setPhone(phone);
}
memberMapper.updateById(updateParam);
// 同步内存对象供下游读取(不走 DB)
member.setMemberCode(memberCode);
if (StringUtils.isNotEmpty(phone)) member.setPhone(phone);
}空对象里 null 字段不会进 SET 子句,与 <if test="xxx != null"> 或默认 NOT_NULL 策略天然配合。
方案 B:LambdaUpdateWrapper 显式 set
new LambdaUpdateWrapper<UmsMember>()
.eq(UmsMember::getId, member.getId())
.set(UmsMember::getMemberCode, memberCode)
.set(StringUtils.isNotEmpty(phone), UmsMember::getPhone, phone);
memberMapper.update(null, wrapper); // 第一个参数传 null,不传实体!注意:update(entity, wrapper) 中若 entity 非 null,MyBatis-Plus 会触发 setEntity,把实体上的非 null 字段再次合并进 SET——bug 复现。必须传 null。
方案 C:字段级 updateStrategy(全局防护)
对于「创建后永远不应被覆写」的字段,在实体类注解上锁死:
@TableField(value = "nickname", updateStrategy = FieldStrategy.NEVER)
private String nickname;NEVER 让该列彻底从生成的 UPDATE 语句中排除,适合不变量字段,不适合一次性调用点。
方案 A 是最低侵入的修法:构造一个新的空实体,只设主键和目标字段,其余全部保持 null,交给 mapper 时 SET 子句精确对应你的意图。内存里的原始实体可以在 update 成功后手动镜像同步,供后续链路使用。
验证方法
开启 mapper 层 SQL 日志,确认 UPDATE 语句只包含目标列:
logging.level.com.example.mapper=DEBUG再做并发验证:两个线程同时跑,一个更新目标字段,另一个更新不相关字段,确认两列写入互不干扰。
一句话外卖
MyBatis 不做脏跟踪,它只看你传进去的对象——复用已加载的实体做局部更新,等于把整行快照刷回数据库;隔离一个只含 PK + 目标字段的新实体,才是真正的局部更新。