MyBatis-Plus 实战避坑指南

HYF Lv4

想象这样一个场景:你优雅地配置了 MyBatis-PlusMetaObjectHandler,创建、更新操作的时间戳、操作人信息都能自动填充,开发效率飞升。但很快挑战接踵而至——修改数据时 update_date 没更新?removeByIds 删除后数据库却没有更新人记录?甚至一次常规更新操作后,后台数据竟然“忘了”记录这次修改!这些困扰很多开发者的 updateFill “失灵”、更新及删除操作的填充缺失问题,往往源自对 MP 内部机制理解不够深入或特定方法的误用。本文将深挖这些问题根源,提供可落地的解决方案,并确保你在更新操作乃至逻辑删除时,也能如丝般顺滑地实现字段自动维护。掌握这些技巧,让你的数据层不仅健壮,而且智能高效!

疑难解析:updateFill(strictUpdateFill) 失效问题

问题分析

字段注解 @TableField

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
* 创建日期
*/
@TableField(fill = FieldFill.INSERT)
private Date createDate;
/**
* 创建人
*/
@TableField(fill = FieldFill.INSERT)
private String createBy;
/**
* 更新日期
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateDate;
/**
* 更新人
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateBy;
/**
* 逻辑删除标记
*/
@TableLogic
@TableField(fill = FieldFill.INSERT)
private Integer delFlag;

自定义实现MetaObjectHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 自定义填充器
* @author youfeng
*/
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {

@Override
public void insertFill(MetaObject metaObject) {
String currentUserId = UserUtils.getCurrentUserId();
Date currenteDate = new Date();
this.strictInsertFill(metaObject, "delFlag", Integer.class, 0);
this.strictInsertFill(metaObject, "createBy", String.class, currentUserId);
this.strictInsertFill(metaObject, "createDate", Date.class, currenteDate);
this.strictInsertFill(metaObject, "updateBy", String.class, currentUserId);
this.strictInsertFill(metaObject, "updateDate", Date.class, currenteDate);
}

@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "updateBy", String.class, UserUtils.getCurrentUserId());
this.strictUpdateFill(metaObject, "updateDate", Date.class, new Date());
}
}

业务代码

1
2
3
4
5
6
7
8
9
10
11
12
13
public void test () {
// 模拟插入操作
User user = new User();
user.setId("1");
user.setName("测试");
save(user);

// 模拟修改操作
User updateUser = new User();
updateUser.setId("1");
updateUser.setName("测试");
updateById(updateUser);
}

出现的问题:
在插入数据时,逻辑删除标记和新增、修改时间及操作人都被赋值了;但是在更新数据时,修改时间和操作人没有被成功更新。

问题原因:
在官方文档 自动填充字段|Mybatis-Plus 的注意事项中,明确提到了: MetaObjectHandler提供的默认方法的策略均为: 如果属性有值则不覆盖, 如果填充值为null则不填充
注意事项1

MetaObjectHandler 中,strictUpdateFill() 方法被设计为一种​“严格模式”​的更新填充。与基础的 fillStrategy() 或直接操作 setFieldValByName() 不同,它的核心行为是:

仅当目标字段严格符合 FieldFill.UPDATE 或 FieldFill.INSERT_UPDATE 策略,且该字段在 entity 对象中的值为 null 时,才会执行填充

这就是导致它看似“失效”的根本原因。你期望它更新字段(无论业务代码是否设置),而它却严格遵守了“只在字段为空时才填充”的规则。简而言之,MP的自动填充功能的前提是填充字段要求为null。

根治方案

方案一:更新前手动设置字段为 null(不推荐)

方案一

1
2
3
4
5
6
7
8
9
10
11
public void examplePlanOne() {
User user = getById("1");
// 修改业务字段示例
user.setName("测试");

// 核心步骤:清除严格填充字段,使其为空
user.setUpdateBy(null);
user.setUpdateDate(null);

updateById(user);
}

简单直接,立即见效,无需修改框架代码

侵入性强:​​ 需要在业务逻辑代码的多个地方重复编写清空字段的代码,​污染业务逻辑,与业务无关

​易遗漏:​​ 尤其是在多人协作或复杂业务流中,开发者很容易忘记这一步

潜在错误:​​ 如果清空操作与某些业务逻辑顺序依赖处理不当,可能导致意料之外的结果

方案二:重写 strictUpdateFill 方法逻辑(不推荐)

在你的自定义 MetaObjectHandler 类中,重写 strictUpdateFill 方法本身,改变其“非空跳过”的核心行为

重写

1
2
3
4
5
6
7
8
9
10
11
@Override
public MetaObjectHandler strictFillStrategy(MetaObject metaObject, String fieldName, Supplier<?> fieldVal) {
//if (metaObject.getValue(fieldName) == null) 不判断空值情况即可
{
Object obj = fieldVal.get();
if (Objects.nonNull(obj)) {
metaObject.setValue(fieldName, obj);
}
}
return this;
}

(引用于:CSDN:《mybatis-plus updateFill更新失效》)

注意事项

这个方案确实也可以实现强制填充的功能,但违背 strict 语义:​​ strictFillStrategy 中的 strict 本身就蕴含了“严格检查条件(字段为空)才填充”的含义。且在上层源码:
Mybatis-Plus-strictInsertFill
Mybatis-Plus-strictFill
中可以发现,该方案影响范围过大,strictFillStrategy 是一个 protected 方法,它被 strictInsertFillstrictUpdateFill ​共同调用。该重写不仅会影响 updateFill 中的 strictUpdateFill,​也会影响 insertFill 中的 strictInsertFill!

后果可能会出现:原本 strictInsertFill 也只会在字段为 null 时填充(这是插入时通常期望的行为)。重写后,strictInsertFill 也会变成强制填充,可能会覆盖你在插入前设置的值(例如从 DTO 拷贝过来的值)。这通常不是插入操作期望的行为

故此,不推荐此方案!!

方案三:在 updateFill() 中使用 setFieldValByName (最常用、平衡的解决方案,推荐方案)​​

这是 MP 提供的强制填充方法。它不受字段在 entity 中是否为空值的限制。在你自定义的 MetaObjectHandlerupdateFill() 方法中,放弃 strictUpdateFill(),改用 setFieldValByName 对目标字段进行直接设置。​这实际上就是绕过 strictUpdateFill 逻辑的核心推荐方案。

推荐方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 自定义填充器
* @author youfeng
*/
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {

@Override
public void insertFill(MetaObject metaObject) {
String currentUserId = UserUtils.getCurrentUserId();
Date currenteDate = new Date();
this.strictInsertFill(metaObject, "delFlag", Integer.class, 0);
this.strictInsertFill(metaObject, "createBy", String.class, currentUserId);
this.strictInsertFill(metaObject, "createDate", Date.class, currenteDate);
this.strictInsertFill(metaObject, "updateBy", String.class, currentUserId);
this.strictInsertFill(metaObject, "updateDate", Date.class, currenteDate);
}

@Override
public void updateFill(MetaObject metaObject) {
this.setFieldValByName("updateBy", UserUtils.getCurrentUserId(), metaObject);
this.setFieldValByName("updateDate", new Date(), metaObject);
}
}

​实现强制填充: 最直接有效地解决您的问题,不管业务代码是否给字段赋值

符合框架接口: 使用的是 MP 公开提供的 setFieldValByName 方法,没有破坏框架结构或核心类

​代码集中可控: 填充逻辑依然集中在 MetaObjectHandler 中(符合职责单一),业务代码保持干净

​升级兼容性好: 只要 MP 不删除 setFieldValByName,这个方法通常非常稳定

设计权衡: 强制覆盖了业务代码显式设置的值(这就是我们需要的“失效”问题的解决方案,但本身是一种设计上的权衡)

​需谨慎考虑业务含义:在某些非常特殊的情况下,业务层可能需要显式控制该字段值(如数据迁移、特定回滚操作),强制填充可能会干扰这些场景。但记录“最后更新时间/更新人”这类元数据字段绝大部分时候都是期望强制更新的

疑难解析:LambdaUpdateWrapper 更新操作自动填充字段失效问题

问题分析

业务代码

1
2
3
4
5
6
7
8
9
10
11
12
public void test () {
// 案例一
lambdaUpdate()
.eq(BaseEntity::getId, 1)
.set(User::getName, "测试")
.update();

// 案例二
LambdaUpdateWrapper<User> lqw = new LambdaUpdateWrapper<>();
lqw.eq(User::getId, 1).set(User::getName, "测试");
update(lqw);
}

出现的问题:
当使用 LambdaUpdateWrapper 进行更新操作时,自动填充字段也无法生效:

问题原因:
在官方文档 自动填充字段|Mybatis-Plus 的注意事项中,明确提到了:

  • update(T entity, Wrapper<T> updateWrapper) 时,entity 不能为空,否则自动填充失效。
  • update(Wrapper<T> updateWrapper) 时不会自动填充,需要手动赋值字段条件。
    注意事项2

所以,MyBatis-Plus 的自动填充机制只对实体对象生效,当 LambdaUpdateWrapperupdate() 方法不接收实体参数,无法触发 MetaObjectHandler

笔者追了一下 Debug 简单验证了官方的这个说法:

业务代码

1
2
3
4
lambdaUpdate()
.eq(BaseEntity::getId, "1")
.set(User::getNo, "123")
.update(new User());

debug1
debug2

根治方案

方案一:手动传入实体对象(麻烦,不推荐)

方案一

1
2
3
4
5
6
7
8
9
10
11
12
public void test () {
// 案例一解决方案
lambdaUpdate()
.eq(BaseEntity::getId, 1)
.set(User::getName, "测试")
.update(new User());

// 案例二解决方案
LambdaUpdateWrapper<User> lqw = new LambdaUpdateWrapper<>();
lqw.eq(User::getId, 1).set(User::getName, "测试");
update(new User(), lqw);
}

实现简单,无需额外配置

符合框架设计理念

需修改所有历史代码

业务代码中出现技术性占位符

tip:这种写法之所以能够触发自动填充,是因为它巧妙地利用了 MyBatis-Plus 的填充机制核心原理

能实现自动填充的机制解析

调用正确的重载方法

.update(new User()) 实际调用的是 MyBatis-Plus 的这个重载方法:
重载方法
这等价于:

1
2
3
4
5
6
getBaseMapper().update(
// 实体参数
new User(),
// 您构造的Wrapper
lambdaUpdateWrapper
);

符合官方文档注意事项中:「在 update(T entity, Wrapper<T> updateWrapper) 时,entity 不能为空,否则自动填充失效。」的描述

触发填充机制的黄金条件​

MyBatis-Plus 执行 update(T entity, Wrapper<T> updateWrapper) 时:

实体参数非空​ → 触发 MetaObjectHandler.updateFill()

​Wrapper 包含 SET 条件​ → 决定更新哪些字段

字段优先级规则

当实体字段和 Wrapperset 字段不冲突时 → 两者都会包含在 SQL

当实体字段和 Wrapper 的 set 字段冲突时 → Wrapperset 值优先

方案二:类型安全的增强版 UpdateWrapper

对于新项目或允许修改 Service 层的项目,我们可以创建一个类型安全的增强版 Wrapper

支持填充的Lambda更新构造器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
/**
* 支持自动填充的Lambda更新构造器
*
* @author youfeng
* @date 2025/7/20
* @Description
*/
public class AutoFillLambdaUpdateWrapper<T> {

private final IService<T> service;

private final LambdaUpdateWrapper<T> wrapper;

private final Class<T> entityClass;

public AutoFillLambdaUpdateWrapper(IService<T> service, Class<T> entityClass) {
this.service = service;

this.entityClass = entityClass;

this.wrapper = new LambdaUpdateWrapper<>();
}

/*
条件方法
*/
public AutoFillLambdaUpdateWrapper<T> eq(SFunction<T, ?> column, Object val) {
wrapper.eq(column, val);
return this;
}

public AutoFillLambdaUpdateWrapper<T> ne(SFunction<T, ?> column, Object val) {
wrapper.ne(column, val);
return this;
}

public AutoFillLambdaUpdateWrapper<T> in(SFunction<T, ?> column, Collection<?> coll) {
wrapper.in(column, coll);
return this;
}

// 添加其他条件方法...

/*
SET方法
*/
public AutoFillLambdaUpdateWrapper<T> set(SFunction<T, ?> column, Object val) {
wrapper.set(column, val);
return this;
}

public AutoFillLambdaUpdateWrapper<T> setSql(String sql) {
wrapper.setSql(sql);
return this;
}

/*
链式调用支持
*/
public AutoFillLambdaUpdateWrapper<T> and(Consumer<LambdaUpdateWrapper<T>> consumer) {
wrapper.and(consumer);
return this;
}

public AutoFillLambdaUpdateWrapper<T> or(Consumer<LambdaUpdateWrapper<T>> consumer) {
wrapper.or(consumer);
return this;
}

public AutoFillLambdaUpdateWrapper<T> apply(String applySql, Object... values) {
wrapper.apply(applySql, values);
return this;
}

/**
* 执行更新(自动触发填充)
*/
public boolean update() {
try {
T entity = entityClass.getDeclaredConstructor().newInstance();
return service.update(entity, wrapper);
} catch (Exception e) {
throw new RuntimeException("创建实体实例失败", e);
}
}
}

业务代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* @author youfeng
* @date 2025/7/20
* @Description
*/
public class UserServiceImpl extends ServiceImpl<UserMapper, User> {

public AutoFillLambdaUpdateWrapper<User> autoFillUpdate() {
return new AutoFillLambdaUpdateWrapper<>(this, User.class);
}

public void test () {
// 解决方案
autoFillUpdate()
.eq(BaseEntity::getId, 1)
.set(User::getName, "测试")
.update();

// otherService.autoFillUpdate().xxx
}
}

保持链式调用风格,与MyBatis-Plus原生API高度相似

无反射操作,性能高

需修改所有历史代码

每一个 service 都需要创建工具方法

方案三:全局拦截器自动注入

全局拦截器自动注入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.TableInfo;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.core.toolkit.Constants;
import lombok.Data;
import lombok.experimental.Accessors;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.SqlCommandType;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Signature;
import org.springframework.stereotype.Component;

import java.lang.reflect.Field;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.stream.Collectors;

/**
* 自动填充拦截器
* 功能:Wrapper更新操作注入实体参数
*
* @author youfeng
* @date 2025/7/20
* @Description
*/
@Component
@Intercepts({
@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})
})
public class AutoFillUpdateInterceptor implements Interceptor {

private static final String WRAPPER = "ew";

private static final String PARAM2 = "param2";

/*
缓存每个 Mapper 类对应的实体 Class,以及实体上需要自动填充的字段元信息
*/
private final ConcurrentMap<Class<?>, EntityMeta> metaCache = new ConcurrentHashMap<>();

@Override
public Object intercept(Invocation invocation) throws Throwable {
// 只对 Executor.update 且为 UPDATE SQL 生效
MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
if (ms.getSqlCommandType() != SqlCommandType.UPDATE) {
return invocation.proceed();
}

Object parameter = invocation.getArgs()[1];
if (parameter == null || !isUpdateCandidate(parameter)) {
return invocation.proceed();
}

Class<?> entityClass = resolveEntityClass(ms);
if (entityClass == null) {
return invocation.proceed();
}

EntityMeta meta = metaCache.computeIfAbsent(entityClass, this::buildMeta);

if (parameter instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> paramMap = (Map<String, Object>) parameter;
Wrapper<?> wrapper = extractWrapper(paramMap);
Object et = paramMap.getOrDefault(Constants.ENTITY, null);

if (et != null) {
// 已有实体对象(update(entity) 或 update(et, ew))
meta.fillIntoEntity(et);
} else if (wrapper != null) {
// Wrapper,手动补 et, ew
Object etInstance = entityClass.getDeclaredConstructor().newInstance();
meta.fillIntoEntity(etInstance);
paramMap.put(Constants.ENTITY, etInstance);
}
} else {
// 直接传实体的 update(entity)
meta.fillIntoEntity(parameter);
}

return invocation.proceed();
}

/**
* 判断是否需要处理的参数:实体、Wrapper,或 Map 包含 Wrapper/实体
*
* @param param
* @return
*/
private boolean isUpdateCandidate(Object param) {
if (param instanceof Wrapper) {
return true;
}
if (param instanceof Map) {
Map<?, ?> m = (Map<?, ?>) param;
return m.getOrDefault(Constants.ENTITY, null) != null
|| m.get(WRAPPER) instanceof Wrapper
|| m.get(PARAM2) instanceof LambdaUpdateWrapper;
}

return param.getClass().isAnnotationPresent(TableName.class);
}

/**
* 从 Map 或直接参数中提取 Wrapper
*
* @param map
* @return
*/
private Wrapper<?> extractWrapper(Map<String, Object> map) {
Object ew = map.get(WRAPPER);
if (ew instanceof Wrapper) {
return (Wrapper<?>) ew;
}
Object p2 = map.get(PARAM2);
if (p2 instanceof LambdaUpdateWrapper) {
return (Wrapper<?>) p2;
}
return null;
}

/**
* 解析实体类型:优先从 Mapper 泛型推断,其次 TableInfoHelper
* @param ms
* @return
*/
private Class<?> resolveEntityClass(MappedStatement ms) {
try {
String mapperId = ms.getId();
String className = mapperId.substring(0, mapperId.lastIndexOf('.'));
Class<?> mapper = Class.forName(className);

for (Type t : mapper.getGenericInterfaces()) {
if (t instanceof ParameterizedType) {
ParameterizedType pt = (ParameterizedType) t;
if (!BaseMapper.class.isAssignableFrom((Class<?>) pt.getRawType())) {
continue;
}
Type ent = pt.getActualTypeArguments()[0];
if (ent instanceof Class) {
return (Class<?>) ent;
}
}
}
TableInfo ti = TableInfoHelper.getTableInfo(mapper);
return null == ti ? null : ti.getEntityType();
} catch (Exception e) {
return null;
}
}

/**
* 构建实体的自动填充元信息
* @param entityCls
* @return
*/
private EntityMeta buildMeta(Class<?> entityCls) {
List<BaseUpdateInfo> updates = Arrays.asList(
BaseUpdateInfo.of("update_by", "updateBy", UserUtils.getCurrentUserId()),
BaseUpdateInfo.of("update_date", "updateDate", new Date())
);

List<Field> annotated = Arrays.stream(entityCls.getDeclaredFields())
.filter(f -> {
TableField tf = f.getAnnotation(TableField.class);
return null != tf && (FieldFill.UPDATE == tf.fill() || FieldFill.INSERT_UPDATE == tf.fill());
})
.peek(f -> f.setAccessible(true))
.collect(Collectors.toList());
return new EntityMeta(updates, annotated);
}

/**
* 存储一个实体的自动填充逻辑
*/
private static class EntityMeta {

final List<BaseUpdateInfo> updates;

final List<Field> fieldsToFill;

EntityMeta(List<BaseUpdateInfo> updates, List<Field> fields) {
this.updates = updates;
this.fieldsToFill = fields;
}

/**
* 反射给实体对象赋值
* @param et
*/
void fillIntoEntity(Object et) {
for (BaseUpdateInfo u : updates) {
try {
Field f = et.getClass().getDeclaredField(u.entityName);
f.setAccessible(true);
if (f.get(et) == null) {
f.set(et, u.value);
}
} catch (NoSuchFieldException | IllegalAccessException ignored) {
}
}
for (Field f : fieldsToFill) {
updates.stream()
.filter(u -> f.getName().equalsIgnoreCase(u.entityName))
.findFirst()
.ifPresent(u -> {
try {
f.set(et, u.value);
} catch (IllegalAccessException ignored) {
}
});
}
}
}

/**
* 实体对象属性信息
*/
@Data(staticConstructor = "of")
@Accessors(chain = true)
static class BaseUpdateInfo {

private final String fieldName;

private final String entityName;

private final Object value;
}
}

目前仅测试了mybatis-plus-boot-starter:3.4.2~3.5.12版本,均支持

零侵入业务代码

自动处理所有历史调用

统一维护填充逻辑

需要处理版本兼容性(支持3.4.2~截止目前最新3.5.12版本,其余版本微测试,后续可能不支持)

核心思路:​​在执行更新操作前进行拦截,​动态补充缺失的实体对象或更新字段值

主要解决方案:对于纯 Wrapper 更新​:创建一个空实体对象,​自动填充所需字段值​(如 updateByupdateDate),然后将该实体对象放入参数中,让 MyBatis-Plus 能生成包含这些字段的 SET 语句

总结图

graph TD
    A[LambdaUpdateWrapper更新] --> B{是否传入实体?}
    B -->|是| C[触发自动填充]
    B -->|否| D[填充字段缺失]
    D --> E[解决方案]
    E --> F[方案一:传入空实体]
    E --> G[方案二:增强版 UpdateWrapper]
    E --> H[方案三:全局拦截器]

疑难解析:RemoveById、RemoveByIds 等删除操作的自动填充失效问题

历程与思路

起初,笔者想当然地认为拦截器已实现填充逻辑,删除操作理应“手到擒来”,但现实很快泼了冷水。查阅官方文档删除接口自动填充功能失效怎么办深入了解了一下才明白原因:
描述图片

因为逻辑删除走的是 SqlCommandType.DELETE 在执行 removeById(id) 或者 lambdaUpdate().remove() 时,MP 拦截器会把 DELETE 操作转换为 UPDATE,但是它仍然在内部以 SqlCommandType.DELETE 来标记,因此 MP 的自动填充(updateFill) 逻辑只对 SqlCommandType.UPDATE 生效,不会对这种“伪 UPDATE”走自动填充流程。

Mybatis Plus 低版本(3.5.0 以下)中,deleteById 方法本身不支持自动填充,此外,遍历调用 deleteById 实现批量删除也并非高效之选。随后虽在文档中看到过时的方法四,但出于兼容性考虑(官方已在 3.5.0+ 废弃该方案)及批量删除的效率问题,不得不另寻他法。

随后又有了新思路:借鉴 LogicDeleteByIdWithFill 的思路,尝试定制 CustomizeDeleteByIdCustomizeDeleteBatchIds 等以覆盖默认方法。然而,在即将完成了这个思路时,突然想起来这些实现类在 ​Mapper 接口初始化阶段就被注册并缓存,此时填充逻辑只能获取到 Id,无法注入除固定时间外的动态值(如操作人 Id),此路再度受(bai)阻(gei)。
自定义类 代码
咱中国人老话说得好:写(lai)都写(lai)了…那就把他完成了吧,代码贴一个出来(如有需要其他可以联系笔者):

重写方法(废案)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
/**
* @author youfeng
* @date 2025/7/20
* @Description 重写 deleteById 方法
*/
public class CustomizeDeleteById extends AbstractMethod {

@Override
public MappedStatement injectMappedStatement(Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) {
SqlMethod sqlMethod = SqlMethod.LOGIC_DELETE_BY_ID;
String sql;

if (tableInfo.getLogicDeleteFieldInfo() != null) {
List<TableFieldInfo> fieldInfos = tableInfo.getFieldList().stream()
.filter(TableFieldInfo::isWithUpdateFill)
.collect(Collectors.toList());

if (CollectionUtils.isNotEmpty(fieldInfos)) {
StringBuilder sqlSet = new StringBuilder("SET ");

// 处理每个填充字段
for (TableFieldInfo field : fieldInfos) {
String property = field.getProperty();

// 处理特定字段
if ("updateDate".equals(property) || "createDate".equals(property)) {
sqlSet.append(field.getColumn()).append("=").append(getCurrentTimeFunction()).append(", ");
} else if ("updateBy".equals(property) || "createBy".equals(property)) {
// 使用当前用户ID
String currentUser = getCurrentUserId();
sqlSet.append(field.getColumn()).append("='").append(escapeSqlValue(currentUser)).append("', ");
} else if ("delFlag".equals(property)) {
sqlSet.append(field.getColumn()).append("=")
.append(tableInfo.getLogicDeleteFieldInfo().getLogicNotDeleteValue()).append(", ");
} else {
// 其他类型字段,使用参数绑定
sqlSet.append(field.getColumn()).append("=#{et.").append(field.getProperty()).append("}, ");
}
}

if (sqlSet.length() > 4) {
sqlSet.setLength(sqlSet.length() - 2);
}

sql = String.format(sqlMethod.getSql(),
tableInfo.getTableName(),
sqlSet,
tableInfo.getKeyColumn(),
tableInfo.getKeyProperty(),
tableInfo.getLogicDeleteSql(true, true));
} else {
sql = String.format(sqlMethod.getSql(),
tableInfo.getTableName(),
this.sqlLogicSet(tableInfo),
tableInfo.getKeyColumn(),
tableInfo.getKeyProperty(),
tableInfo.getLogicDeleteSql(true, true));
}
} else {
sqlMethod = SqlMethod.DELETE_BY_ID;
sql = String.format(sqlMethod.getSql(),
tableInfo.getTableName(),
tableInfo.getKeyColumn(),
tableInfo.getKeyProperty());
}

SqlSource sqlSource = this.languageDriver.createSqlSource(this.configuration, sql, modelClass);
return this.addUpdateMappedStatement(mapperClass, modelClass, this.getMethod(sqlMethod), sqlSource);
}

/**
* 获取数据库当前时间函数
*/
private String getCurrentTimeFunction() {
String dbType = configuration.getVariables().getProperty("dbType", "mysql");
switch (dbType.toLowerCase()) {
case "oracle": return "SYSDATE";
case "sqlserver": return "GETDATE()";
case "postgresql": return "CURRENT_TIMESTAMP";
default: return "NOW()";
}
}

/**
* 获取当前用户ID
*/
private String getCurrentUserId() {
return UserUtils.getCurrentUserId();
}

/**
* 转义SQL值
*/
private String escapeSqlValue(String value) {
if (value == null) {
return "";
}
return value.replace("'", "''");
}

/**
* 获取方法名
*/
@Override
public String getMethod(SqlMethod sqlMethod) {
return "deleteById";
}
}

自动填充的终极突破:编译前动态修改 SQL

在自定义 Mapper 方案碰壁后,笔者意识到问题的本质:​MP 的删除操作在初始化阶段就已固化,运行时无法注入动态值。既然运行时处处受限,何不釜底抽薪——直接在 SQL 诞生前完成改造?(咳咳、怎么感觉有点中二了呢…)

于是就诞生了下面这个方案:StatementHandler.prepare 阶段拦截(此时 SQL 已构建但未编译),随后在 SET 后进行注入,使用 ParameterMapping.Builder 保持预编译安全性,防止 SQL 注入(值都是其他安全方法赋的,好像有点脱裤子放屁了…),通过判断原 SQL 中是否已经包含这些列,如果已经包含则不添加,避免重复添加,修改后通过 invocation.proceed() 继续执行原逻辑(使用这个方案,就不需要上面的(AutoFillUpdateInterceptor)方案了)

预编译处理方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
import com.baomidou.mybatisplus.core.toolkit.PluginUtils;
import com.baomidou.mybatisplus.extension.handlers.AbstractSqlParserHandler;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.*;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.SystemMetaObject;
import org.apache.ibatis.session.Configuration;
import org.springframework.stereotype.Component;

import java.sql.Connection;
import java.util.*;

/**
*
* @author youfeng
* @date 2025/7/20
* @Description
*/
@Component
@Intercepts({@Signature(
type = StatementHandler.class,
method = "prepare",
args = {Connection.class, Integer.class}
)})
public class UpdateSqlInterceptor2 extends AbstractSqlParserHandler implements Interceptor {

@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler handler = PluginUtils.realTarget(invocation.getTarget());
MetaObject metaObj = SystemMetaObject.forObject(handler);

this.sqlParser(metaObj);

MappedStatement ms = (MappedStatement) metaObj.getValue("delegate.mappedStatement");
if (ms.getSqlCommandType() != SqlCommandType.UPDATE
|| ms.getStatementType() == StatementType.CALLABLE) {
return invocation.proceed();
}

BoundSql boundSql = (BoundSql) metaObj.getValue("delegate.boundSql");
String originalSql = boundSql.getSql();
if (!originalSql.toUpperCase().contains(" SET ")) {
return invocation.proceed();
}

Map<String, Object> additionalParams = new HashMap<>(2);
additionalParams.put("update_by", UserUtils.getCurrentUserId());
additionalParams.put("update_date", new Date());

int setIndex = originalSql.toUpperCase().indexOf(" SET ");
StringBuilder newSql = new StringBuilder(originalSql);
StringBuilder injectedSql = new StringBuilder();

List<ParameterMapping> injectedMappings = new ArrayList<>();
Configuration configuration = ms.getConfiguration();

for (Map.Entry<String, Object> entry : additionalParams.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();

injectedSql.append(key).append(" = ?, ");
injectedMappings.add(new ParameterMapping.Builder(configuration, key, value.getClass()).build());

boundSql.setAdditionalParameter(key, value);
}

newSql.insert(setIndex + 5, injectedSql);

List<ParameterMapping> finalMappings = new ArrayList<>(injectedMappings);
finalMappings.addAll(boundSql.getParameterMappings());

metaObj.setValue("delegate.boundSql.sql", newSql.toString());
metaObj.setValue("delegate.boundSql.parameterMappings", finalMappings);

return invocation.proceed();
}

@Override
public Object plugin(Object target) {
return target instanceof StatementHandler ? Plugin.wrap(target, this) : target;
}
}

示例输出

当你执行了如下 SQL :

1
2
3
UPDATE table1 SET del_flag = 1 WHERE field = ? AND del_flag = 0;

UPDATE table2 SET field1 = ?, field2 = ? WHERE field3 = ?;

拦截器将自动转化为:

1
2
3
UPDATE table1 SET update_by = ?, update_date = ?, del_flag = 1 WHERE field = ? AND del_flag = 0;

UPDATE table2 SET update_by = ?, update_date = ?, field1 = ?, field2 = ? WHERE field3 = ?;

参数信息示例:

1
2
3
==> Parameters: 1(String), 2025-07-20 23:52:18.037(Timestamp), 29(String)

==> Parameters: 1(String), 2025-07-25 17:52:18.037(Timestamp), 29(String), 30(Integer), abc(String)

当然,我们系统的数据库设计中,可能不是所有表都会有审计字段(修改时间、修改人),所以上述代码还可以进行一个扩展:

设计扩展

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
/**
*
* @author youfeng
* @date 2025/7/20
* @Description
*/
@Component
@Intercepts({@Signature(
type = StatementHandler.class,
method = "prepare",
args = {Connection.class, Integer.class}
)})
public class UpdateSqlInterceptor2 extends AbstractSqlParserHandler implements Interceptor {

private static final Set<String> NO_AUDIT_TABLES = new HashSet<>(Arrays.asList("test_table_one", "test_table_two"));

@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler handler = PluginUtils.realTarget(invocation.getTarget());
MetaObject metaObj = SystemMetaObject.forObject(handler);

this.sqlParser(metaObj);

MappedStatement ms = (MappedStatement) metaObj.getValue("delegate.mappedStatement");
if (ms.getSqlCommandType() != SqlCommandType.UPDATE
|| ms.getStatementType() == StatementType.CALLABLE) {
return invocation.proceed();
}

BoundSql boundSql = (BoundSql) metaObj.getValue("delegate.boundSql");
String originalSql = boundSql.getSql();

String table = originalSql.substring(originalSql.indexOf("update ") + 7, originalSql.indexOf(" ", originalSql.indexOf("update ") + 7));
if (NO_AUDIT_TABLES.contains(table)) {
return invocation.proceed();
}

if (!originalSql.toUpperCase().contains(" SET ")) {
return invocation.proceed();
}

Map<String, Object> additionalParams = new HashMap<>(2);
additionalParams.put("update_by", UserUtils.getCurrentUserId());
additionalParams.put("update_date", new Date());

int setIndex = originalSql.toUpperCase().indexOf(" SET ");
StringBuilder newSql = new StringBuilder(originalSql);
StringBuilder injectedSql = new StringBuilder();

List<ParameterMapping> injectedMappings = new ArrayList<>();
Configuration configuration = ms.getConfiguration();

for (Map.Entry<String, Object> entry : additionalParams.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();

injectedSql.append(key).append(" = ?, ");
injectedMappings.add(new ParameterMapping.Builder(configuration, key, value.getClass()).build());

boundSql.setAdditionalParameter(key, value);
}

newSql.insert(setIndex + 5, injectedSql);

List<ParameterMapping> finalMappings = new ArrayList<>(injectedMappings);
finalMappings.addAll(boundSql.getParameterMappings());

metaObj.setValue("delegate.boundSql.sql", newSql.toString());
metaObj.setValue("delegate.boundSql.parameterMappings", finalMappings);

return invocation.proceed();
}

@Override
public Object plugin(Object target) {
return target instanceof StatementHandler ? Plugin.wrap(target, this) : target;
}
}

这个方案主要是维护一个不填充审计字段的”黑名单”,在拦截逻辑第一步检查,新增或去掉不需要审计的表,只要往集合里加/删就行,维护成本也低。

也有人可能会觉得,在我们能确认数据来源是绝对安全的时候,ParameterMapping 其实并没有必要,增加创建开销,属于是吃力不讨好了,那笔者再写一个纯字符串拼接的方案

字符串拼接方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import com.baomidou.mybatisplus.core.toolkit.PluginUtils;
import com.baomidou.mybatisplus.extension.handlers.AbstractSqlParserHandler;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.*;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.SystemMetaObject;
import org.springframework.stereotype.Component;

import java.sql.Connection;
import java.util.*;

/**
*
* @author youfeng
* @date 2025/7/20
* @Description
*/
@Component
@Intercepts({@Signature(
type = StatementHandler.class,
method = "prepare",
args = {Connection.class, Integer.class}
)})
public class UpdateSqlInterceptor extends AbstractSqlParserHandler implements Interceptor {

@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler handler = PluginUtils.realTarget(invocation.getTarget());
MetaObject metaObj = SystemMetaObject.forObject(handler);

this.sqlParser(metaObj);

MappedStatement ms = (MappedStatement) metaObj.getValue("delegate.mappedStatement");
if (ms.getSqlCommandType() != SqlCommandType.UPDATE
|| ms.getStatementType() == StatementType.CALLABLE) {
return invocation.proceed();
}

BoundSql boundSql = (BoundSql) metaObj.getValue("delegate.boundSql");
String sql = boundSql.getSql();
if (!sql.toUpperCase().contains(" SET ")) {
return invocation.proceed();
}

List<ParameterMapping> mappings = new ArrayList<>(boundSql.getParameterMappings());

Map<String, Object> params = new HashMap<>(2);
params.put("update_by", UserUtils.getCurrentUserId());
params.put("update_date", DateUtils.formatDateTime(new Date()));

for (Map.Entry<String, Object> map : params.entrySet()) {
if (!sql.contains(map.getKey())) {
sql = sql.replace("SET", "SET " + map.getKey() + " = '" + map.getValue() + "',");
}
}

metaObj.setValue("delegate.boundSql.sql", sql);
metaObj.setValue("delegate.boundSql.parameterMappings", mappings);

return invocation.proceed();
}

@Override
public Object plugin(Object target) {
return target instanceof StatementHandler ? Plugin.wrap(target, this) : target;
}
}

当你执行了如下 SQL

1
UPDATE table SET update_date = '2025-07-20 23:58:31', update_by = '1', code=? WHERE del_flag=0 AND (id = ?)

参数信息示例:

1
==> Parameters: 121253(String), 23(String)

虽然字符串拼接 replace 方法几乎没有额外开销,性能最好,但不支持类型自动绑定(如 Date、Long 转换),笔者个人还是比较推荐 ParameterMapping + BoundSql.setAdditionalParameter 的方案。该方案和 MyBatis 参数机制一致,插入参数可控,类型自动绑定,性能损耗上也仅是增加了构建参数和拼接 SQL 的开销。

总结

通过本次对 updateFill 严格模式失效、LambdaUpdateWrapper 更新未触发字段填充以及 removeById 删除操作忽略自动填充这三大疑难场景的剖析,相信笔者和读者都对这个框架有了更深的认知和理解,当然​没有最好的方案,只有最适合的方案,希望这些经验能帮助你在实际项目中少走弯路!

技术之路,从理解“为什么失效”开始,以掌握“如何创造”为终。

  • 标题: MyBatis-Plus 实战避坑指南
  • 作者: HYF
  • 创建于 : 2025-07-20 20:50:19
  • 更新于 : 2025-07-21 01:25:50
  • 链接: https://youfeng.ink/MP-Troubleshooting-1cc0acd361ff/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。