先抛个结论:MyBatis Plus 的字段自动填充,不是帮你少写两行 setter 的小工具,而是把数据审计、创建时间、更新时间、操作人这些横切字段的赋值逻辑,统一收敛到一个可复用、可维护的地方。只要你的项目要落库,基本都绕不开这张“谁在什么时候改了什么”的表。标题里的“生产级实现方案与原理分析”八个字,拆开来看就是两件事:第一,搞清楚框架到底在哪个环节替你做了填充;第二,把这套能力用到真实业务里,别在批量插入、异步线程、字段覆盖这些场景下翻车。这篇文章,我按自己实际接入时的路径来写,先讲清楚原理,再给一份可以直接抄的写法,最后把我在生产环境踩过的坑和排查思路一并说掉。
1. 先别急着写代码:自动填充到底解决了什么问题
1.1 你项目里的“审计字段”重复劳动
绝大多数业务表都会带这么几个字段:create_time、update_time,讲究一点还会加上create_by、update_by,甚至逻辑删除的deleted。以前没接自动填充的时候,代码里到处都是这种片段:
User user = new User(); user.setName("张三"); user.setCreateTime(LocalDateTime.now()); user.setUpdateTime(LocalDateTime.now()); user.setCreateBy(UserContext.getUserId()); user.setUpdateBy(UserContext.getUserId()); userMapper.insert(user);一个两个表还能忍,几十张表、上百个插入更新点之后,你一定会遇到三个问题。第一,漏填,总有业务代码急着写核心字段,忘了给审计字段赋值,结果线上数据时间戳是 null,排查数据链路的时候根本说不清这条记录是什么时候产生的。第二,乱填,不同开发同学写出来的取值方式不一致,有的用LocalDateTime.now(),有的用new Date(),有的甚至直接拼字符串,时间字段的类型和精度乱成一锅粥。第三,没法统一改,一旦审计需求变化(比如要记录操作来源端是 PC 还是 App),你得把所有插入更新点捞出来改一遍,纯体力活还容易漏。
字段自动填充,本质就是把“审计字段到底怎么取值”这个规则给收口了。你只需要在实体字段上标一个注解,再写一个全局的处理器,MyBatis Plus 就会在 SQL 真正执行之前,把该赋的值帮你填进去。后续业务代码只管业务字段,审计字段的赋值逻辑集中在一个类里维护。
1.2 自动填充 VS 数据库默认值
有人可能会说:这玩意用数据库的 DEFAULT 不也能实现吗?我在create_time上设置DEFAULT CURRENT_TIMESTAMP,插入的时候不写这个字段,数据库也会自动给我填。这话对一半,但生产环境里你很快会发现数据库默认值有几个硬伤。
首先是update_time的自动更新。MySQL 确实可以用ON UPDATE CURRENT_TIMESTAMP,但如果你用的是 MyBatis Plus 来执行更新 SQL,框架默认只更新非 null 字段,这条记录只要没有其他的列被修改,update_time根本不会变。更麻烦的是,团队里如果混用数据库和框架两种方案,数据库迁移的时候一不小心就把这段给丢了,测试环境跑得好好的,上生产才发现时间戳不对。
其次是审计字段通常不只是时间,还有操作人。数据库默认值写不了“当前登录用户是谁”这种应用层信息,除非你在数据库连接里做文章,但那明显是没事找事。
所以我的判断很简单:时间类型的默认值,可以让数据库兜底;但凡是涉及业务语义的字段(操作人、租户、逻辑删除标记),都应该走应用层自动填充。这两者不冲突,甚至可以双保险,后面讲方案的时候我会再展开。
2. 核心原理:字段填充是怎么在 SQL 层面生效的
2.1 三个关键角色:注解、枚举、MetaObjectHandler
说到原理,先得认识三个角色。第一个是@TableField注解,你把它标在实体的某个字段上,同时指定fill属性:
@TableField(fill = FieldFill.INSERT) private LocalDateTime createTime;第二个是FieldFill枚举,它决定了这个字段在哪种操作下要被处理。枚举值就那么几个:
| 枚举值 | 含义 | 典型使用场景 |
|---|---|---|
| DEFAULT | 默认策略,不参与自动填充 | 普通业务字段 |
| INSERT | 只在插入时填充 | 创建时间、创建人 |
| UPDATE | 只在更新时填充 | 更新时间、更新人,单独用的场景较少 |
| INSERT_UPDATE | 插入和更新时都填充 | 更新时间,或者你希望创建时间更新时也被覆盖的情况 |
第三个是MetaObjectHandler接口,这才是真正干活的类。你实现这个接口,重写insertFill和updateFill两个方法,框架会在合适的时机调过来。
@Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } }这里插一句,很多新手会把这三个角色拆开记,实际上它们是一条链路的三段。注解是“声明需求”,枚举是“指定时机”,Handler 是“执行动作”。理解了这个顺序,后面对着源码看会非常顺。
2.2 insert 和 update 的执行链路
那 MyBatis Plus 到底是在哪个节点调用 Handler 的呢?我以你自己的 Mapper 接口UserMapper.insert(user)为例,一步步拆。
你调用的insert是 MyBatis Plus 在BaseMapper里预定义好的方法,它内部会走SqlHelper拿到实体对应的TableInfo。这个TableInfo里存了实体的所有字段信息,包括哪个字段是主键、哪个字段有fill属性、字段名和数据库列名的映射关系。然后框架开始构建 INSERT SQL,它会遍历实体字段列表,把非 null 字段、主键字段、以及带fill属性的字段都放进 SQL 的列清单里。
但这里有个关键点:SQL 的列清单是在执行前动态生成的,而字段值是在 SQL 生成之后、参数绑定之前被赋值进去的。具体说,当你调用insert时,MyBatis Plus 会先对你传入的实体做一次“加工”:走到MetaObjectHandler.insertFill方法,把createTime、updateTime这些字段的值 set 回实体对象里。实体对象里有了值,后续 SQL 参数绑定自然就能拿到。
所以你在 Handler 里执行strictInsertFill之后,当前实体实例上对应字段就已经有了新值。这一点特别重要,因为很多同学以为自动填充只是“SQL 里多了一个值”,其实它是直接把你的入参对象给改了。这意味着如果调用方在插入完之后,直接拿同一个对象去干别的事,它会发现这个对象上的时间字段已经被填上了,这是正常现象,不用害怕。
update 链路的思路类似。updateById(user)内部会构建 UPDATE SQL,先把组装好的字段列表和 SQL 片段确定下来,然后调用updateFill给实体的updateTime赋新值,最后真正发出 UPDATE。逻辑上是“先算好 set 哪些列,再给实体补上这些列的值”。
2.3 为什么 Handler 能拿到实体字段值
MetaObjectHandler的方法签名里有一个MetaObject,很多人第一次看到这个参数会比较懵。它不是实体对象本身,而是 MyBatis 对对象的一层元数据封装,有点像一个“反射增强包装器”。通过它,你可以在不知道具体类型的情况下读取、修改对象的属性值。
框架之所以不用实体对象做参数,是因为MetaObject可以处理各种复杂的对象结构,包括嵌套对象、集合元素,甚至 Map。这样MetaObjectHandler的适用范围就更广。你在实现里写的getFieldValByName("createTime", metaObject),本质上是利用MetaObject去反射获取字段当前值;setFieldValByName("createTime", value, metaObject)则是反射写入字段值。
明白了这一层,你就不会好奇“为什么我的实体类没有继承任何基类也能被填充”了。只要字段名对得上,类型匹配得上,框架就能通过MetaObject把值塞进去。这也是为什么后面我要重点强调字段命名一致性的原因:字段名对不上,Handler 里写一百遍也没有用,因为反射找不到这个属性。
顺带补充一个 3.3.0 版本之后的变化。老版本的insertFill写法是:
this.setFieldValByName("createTime", LocalDateTime.now(), metaObject);这行代码的逻辑是“无脑覆盖”,不管实体里这个字段是不是已经有值了,都强制覆盖。而 3.3.0 之后推荐的strictInsertFill会先检查字段当前是否有值,只有为 null 时才填充。这一改,业务代码里手工赋值的优先级就保住了,不再被自动填充反杀。后面我会细说这个区别。
3. 生产级实现方案(可直接抄作业)
3.1 字段清单设计
真正动手之前,先把“哪些字段需要自动填充”理清楚。我根据自己的项目经验,列一张通用的字段设计表,你拿到就能用:
| 字段名 | Java 类型 | 数据库列类型 | fill 策略 | 说明 |
|---|---|---|---|---|
| createTime | LocalDateTime | DATETIME | INSERT | 创建时间,一旦生成不再变动 |
| updateTime | LocalDateTime | DATETIME | INSERT_UPDATE | 更新时间,插入和修改都刷新 |
| createBy | Long | BIGINT | INSERT | 创建人 ID,取自登录态 |
| updateBy | Long | BIGINT | INSERT_UPDATE | 最后修改人 ID |
| deleted | Integer | TINYINT | INSERT | 逻辑删除标记,默认填充 0 |
| tenantId | Long | BIGINT | INSERT | 多租户场景下填充租户 ID |
看到这里你应该会发现,字段清单设计不是乱拍脑袋,它是和数据权限、审计需求强相关的。创建时间创建人、修改时间修改人,这四项是刚需;逻辑删除标记和租户 ID,属于“要看你的业务有没有”的可选项。注意deleted字段的 fill 策略要选 INSERT,因为逻辑删除的值变更由@TableLogic的 SQL 去处理,不需要自动填充来干预,updateFill里压根不用管它。
另一个建议是:如果你用的是逻辑删除,尝试把deleted的默认值也交给自动填充来做。这样做的好处是,当你直接往数据库里灌测试数据、手工 SQL 插入时可能漏了这个字段,但走应用层插入时绝对不会漏,避免出现“查询死活查不出来,单看数据库又有记录”的诡异问题。
3.2 实体字段注解写法
字段清单定了,实体类里的写法就比较固定了。我给一个组合示例,直接把字段声明、fill 注解、逻辑删除注解放一起:
public class User { @TableId(type = IdType.ASSIGN_ID) private Long id; private String name; @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; @TableField(fill = FieldFill.INSERT) private Long createBy; @TableField(fill = FieldFill.INSERT_UPDATE) private Long updateBy; @TableLogic @TableField(fill = FieldFill.INSERT) private Integer deleted; @TableField(fill = FieldFill.INSERT) private Long tenantId; }这里有两个细节值得展开。
第一个是@TableField(fill = FieldFill.INSERT_UPDATE)的语义:它既在 insert 时触发,也在 update 时触发。创建时间字段千万别这么标,否则你在执行更新操作时,框架会把createTime也刷新成当前时间,审计语义直接崩掉。创建时间只标INSERT。
第二个是createBy和updateBy的类型。这里我用Long举例,如果你的用户主键是 String 或者别的类型,只要 Handler 里strictInsertFill传入的类型匹配就行。但我要提醒一句:这个类型设计要和整个项目的用户上下文类型对齐,不要实体里是Long,你 Handler 里却用String去填充,反射校验类型不一致会直接报错。
3.3 MetaObjectHandler 完整实现
实体类有了,下面给一份完整的处理器实现,我平时项目里就是这套,稍微改下字段名就能用:
@Component public class AuditMetaObjectHandler implements MetaObjectHandler { /** * 插入时的填充策略:所有审计字段都先判断是否已有值,已有值不覆盖。 */ @Override public void insertFill(MetaObject metaObject) { LocalDateTime now = LocalDateTime.now(); Long currentUserId = UserContext.getUserIdIfPresent(); this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, now); this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, now); this.strictInsertFill(metaObject, "createBy", Long.class, currentUserId); this.strictInsertFill(metaObject, "updateBy", Long.class, currentUserId); this.strictInsertFill(metaObject, "deleted", Integer.class, 0); this.strictInsertFill(metaObject, "tenantId", Long.class, TenantContext.getTenantId()); } /** * 更新时的填充策略:只填更新时间、更新人。 */ @Override public void updateFill(MetaObject metaObject) { LocalDateTime now = LocalDateTime.now(); Long currentUserId = UserContext.getUserIdIfPresent(); this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, now); this.strictUpdateFill(metaObject, "updateBy", Long.class, currentUserId); } }关于UserContext和TenantContext,我不展开具体实现,但你要理解它们的用法。它们通常是基于 ThreadLocal 封装的一个上下文对象,在请求进来的时候由拦截器或者过滤器把当前登录用户、当前租户塞进去,业务代码没有感知。到了 Handler 这里,直接从上下文里取就行。
这里有个生产环境必须要注意的点:异步线程里拿不到主线程的 ThreadLocal。比如你用@Async异步发送消息,或者用线程池处理批量任务,线程切过去之后UserContext.getUserId()很可能返回 null。所以在写 Handler 时,我习惯把“获取用户 ID”封装成一个不会抛异常的方法,拿不到就用一个默认的“system”用户 ID,或者直接留 null,具体看你的审计需求。
再补充一个和数据源相关的点。strictInsertFill里传入的LocalDateTime.class是类型标记,告诉反射工具你要填充的字段是什么类型。如果你的实体字段用了java.util.Date,这里就要改传Date.class,同时now也要换成new Date()。我建议新项目统一用LocalDateTime,原因下一个小节会讲。
3.4 版本兼容与时间类型选择
先说版本。如果你用的是 MyBatis Plus 3.3.0 之前的版本,strictInsertFill和strictUpdateFill这两个方法是不存在的,只能退回去用:
if (getFieldValByName("createTime", metaObject) == null) { setFieldValByName("createTime", LocalDateTime.now(), metaObject); }这样写也能跑,但代码会长一点。我建议条件允许就升到 3.3.0 以上,一方面是自动填充 API 更规范,另一方面是框架修了不少老版本在字段填充上配合逻辑删除、乐观锁时的边界问题。
再说时间类型。用LocalDateTime还是java.util.Date,看起来只是 API 偏好,实际上牵涉到三个问题。第一是精度,Date虽然底层有毫秒,但如果你配合MyBatis的javaType映射,某些版本下到 MySQL 的DATETIME会丢掉毫秒;LocalDateTime配合DATETIME(3)能更好保留精度。第二是格式化,Date在不同时区下 toString 会不一样,排错的时候容易看着时间点犯迷糊。第三是 Java 8 之后的编程习惯,LocalDateTime有一大堆日期运算方法,团队协作时心智负担更小。我目前接触过的生产项目里,凡是用LocalDateTime的,在自动填充这条链路里几乎没有因为类型不匹配翻过车。
当然,如果数据库列设置的是TIMESTAMP,还要留意数据库时区配置。MySQL 的TIMESTAMP会做时区转换,如果应用层的LocalDateTime和数据库时区不一致,会造成“写入后查出来差了 8 个小时”。这不是自动填充的问题,是连接参数serverTimezone的问题。排查这类时间偏移问题,先看 JDBC 连接串,再看数据库时区,千万别一上来就怀疑 Handler。
4. 常见问题与排查实录
4.1 批量插入时填充失效
这是我被问过最多的问题:为什么单条 insert 自动填充好用,换成分批插入就没值了?
先说结论:如果你用的是 MyBatis Plus 自带的分批插入(比如insertBatchSomeColumn这类自定义 SQL 注入方法,或者循环里逐条insert),自动填充是能生效的。真正失效的场景是:你在 XML 里自己写了一个foreach批量 INSERT 语句,入参是List<User>,然后期望 MyBatis Plus 自动帮你填充集合里每个元素的审计字段。
很遗憾,框架做不到。原因前面已经说过了,自动填充发生在BaseMapper.insert(entity)这个入口,它知道你当前要处理的实体对象是哪一个,可以调用 Handler 去填充。但你自己写的foreach批量 SQL,MyBatis 只会把整个List当作一个参数传给你,框架没有机会去逐个调用insertFill。
解决方案有三个,按推荐程度排序。第一个,用 MyBatis Plus 的SqlInjector扩展一个批量插入方法,让它在内部逐条填充后再批量执行 SQL,这是最优雅的。第二个,在 XML 的foreach之前,自己在 Service 层遍历 List,逐个调用一个填充辅助方法,把审计字段手动 set 进去,然后用insertBatch批量执行。第三个,改造成单条循环 insert,性能差点,但填充逻辑能正常工作。
我自己的习惯是:超过几百条的数据,直接在业务层显式填充审计字段后再走自定义批量 SQL;几十条以内的,用逐条insert反而更清晰。没必要为了一个批量插入把 Handler 的封装绕晕。
4.2 update 不触发/值不更新
另一个高频问题是:我明明执行了updateById(user),为什么updateTime没有被刷新?
先说第一种情况:实体里updateTime是 null,数据库列是DATETIME DEFAULT NULL,执行更新后数据库还是 null。这种现象的根源多半不是自动填充没触发,而是框架的 update 策略。MyBatis Plus 默认的字段更新策略是NOT_NULL,即实体中为 null 的字段不参与 SET 子句生成。自动填充是在 SQL 构建之后、执行之前,往实体里塞值,注意这里有个顺序细节:updateFill的调用时机是在 SQL 的 SET 片段生成之前还是之后,不同版本存在差异。在我的使用经验里,更新填充通常能正常写入 SET 片段,但如果你用的是非常老的版本,存在“SET 列表已经定死,填充的值没进去”的情况。升级到 3.4 以后的版本基本都能解决。
第二种情况更隐蔽:你在实体上只标了@TableField(fill = FieldFill.INSERT),也就是创建时间那种,期望它更新时也能刷一下。这是不可能的,FieldFill枚举已经限定死了,更新时框架只会处理UPDATE和INSERT_UPDATE。所以更新字段要刷新,必须用INSERT_UPDATE或者UPDATE。
第三种情况:你手工把user.setUpdateTime(null)之后执行更新,希望 Handler 能兜底补上当前时间。注意strictUpdateFill的语义是“只有字段为 null 才填充”,所以这个场景下 Handler 反而会补上,但如果你用的是老式setFieldValByName,无论有没有值都会无脑覆盖。这里最容易出问题的点是:框架本身采用NOT_NULL策略,生成 SQL 时都把整列排除了,那填充还有什么意义?所以我才反复强调,执行更新操作前,确保实体里相应字段的状态是可控的,或者干脆用独立UpdateWrapper手动 set 更新时间。
4.3 手工赋值被覆盖或没生效
再讲一个和 Handler 覆盖策略强相关的问题:业务代码里手工给createTime赋了一个特殊值,执行 insert 之后,发现数据库里存的时间变成了系统的当前时间。
这个现象通常意味着你用的是老版本写法:
this.setFieldValByName("createTime", now, metaObject);这行代码不带任何判断,无脑覆盖。所以你的手工赋值在框架看来就是“不存在的”,反正最后还是会被覆盖掉。解决方式是换成strictInsertFill,它内部会先调用getFieldValByName判断字段当前值是否为 null,只有 null 才写入新值。
反过来还有一个方向的问题:手工赋值了,但数据库里还是 null。这种情况十有八九是字段名写错了。你实体里定义的是createTime,Handler 里写的是create_time,MetaObject的反射取名是按属性名来的,不是按数据库列名,框架在填充时找的是属性名映射。所以同名不同名这种低级错误,排查方式是开 SQL 日志,看生成的 INSERT 列里到底有没有 createTime,没有就是没填充成功;有但值不对,再找覆盖逻辑的问题。
4.4 异步线程拿不到操作人
生产环境做异步化改造之后,最常见的翻车现场是:异步线程里执行updateById,自动填充确实触发了,但updateBy变成了 null,或者变成了你 Debug 时看到的默认值。
原因就是前面提过的 ThreadLocal 在线程切换时丢失。UserContext里的用户 ID 是请求线程独有的,线程池里的工作线程并不持有这份数据。
我的做法是在 Handler 里加一层“拿不到就兜底”的逻辑:
private Long getCurrentUserId() { try { Long userId = UserContext.getUserId(); return userId != null ? userId : SYSTEM_USER_ID; } catch (Exception e) { return SYSTEM_USER_ID; } }SYSTEM_USER_ID通常用一个常量表示,比如 0L 或者 1L,这样至少审计数据里能区分“系统操作”和“用户操作”。如果你不想把“系统”也记成一个伪造的用户 ID,那就允许它为 null,但数据库列要保持可空,并且你们得约定好审计查询时 null 的含义。我更推荐落一个明确的兜底值,因为后续做数据对账、权限复核,完全是未知用户的 null 会很麻烦。
更高的方案是,在把任务提交给线程池之前,在任务体里显式绑定一份用户上下文的快照,线程执行时再去解析快照。这就不只是自动填充的范畴了,属于异步链路上下文传递的设计问题,但最终效果是让 Handler 在任何线程里都能拿到正确的操作人。这层改造我建议在项目一开始就做,等线上出现“不知道谁改了数据”的工单再补,代价就大了。
4.5 常见问题速查表
顺手整理一个排查速查表,以后遇到问题照着看:
| 现象 | 可能原因 | 排查思路与处理办法 |
|---|---|---|
| insert 后审计字段为 null | Handler 没注册为 Bean;注解没有写 fill 属性;字段名不一致 | 看 Spring 启动日志有没有注入 Handler;打印完整 SQL,看 INSERT 列里是否包含该字段 |
| update 后 updateTime 没变 | fill 策略没配INSERT_UPDATE;实体对象 updateTime 为 null 且 SQL 生成时机早于填充 | 把 fill 策略改成INSERT_UPDATE;升级到新版 MyBatis Plus |
| 手工赋值被框架覆盖 | 使用了老版setFieldValByName无脑覆盖 | 改用strictInsertFill/strictUpdateFill |
| 批量 foreach 插入没有填充 | 自己写的 XML 批量 SQL 不经过 BaseMapper 入口 | 在 Service 层手动填充;用 SqlInjector 扩展批量插入 |
| 异步线程 updateBy 为空 | ThreadLocal 主线程上下文未传递 | Handler 里做默认值兜底;改造异步链路,传上下文快照 |
| 时间差 8 小时 | JDBC 时区参数与数据库时区不一致 | 检查serverTimezone参数,确认数据库会话时区 |
| 实体对象插入后时间字段被改了 | Handler 直接修改了入参对象 | 这是正常行为,如需对外返回原始入参,用副本 |
这里面每一条都是我真实遇到或者陪同事排查过的。说实话,自动填充的报错机制不如普通业务代码那么直接,因为问题往往不出在语法上,而是出在“SQL 生成了,但列没进去”或者“值进去了,但被覆盖了”。所以我的排查口诀永远是:开 SQL 日志,先看 SQL 里到底有没有这个字段,再看执行参数里有没有值。两步定位,九成问题都能找到答案。
最后再说点实际操作中的体会
这套方案我用下来最大的感受是,它真正解决的不是“少写代码”,而是“让审计字段这件事变成默认值”。你不需要在每条插入更新逻辑里思考“要不要维护 createTime、updateTime”,框架已经把它变成了像主键一样自然的存在。但也不要把它当成银弹,批量插入要绕开、异步线程要兜底、版本升级要测,这些生产细节都是要自己扛一遍的。最后分享一个低成本小技巧:Handler 里不要写死字段值,所有取值都走方法封装,这样后续加字段、调整租户策略、换用户上下文实现,你只需要改动一个方法,而不是在一堆 Handler 逻辑里翻找。自动填充这东西,越往后维护,你就会越庆幸当初把规则收口了。