资讯动态

MyBatis Plus字段自动填充:原理、实现与避坑指南

发布时间:2026/10/9 11:16:09 来源:尧图企业网站定制
做后端这几年凡是和CRUD打交道的项目基本都逃不过一堆公共字段的重复赋值创建时间、更新时间、创建人、更新人有时候还有逻辑删除标记。以前我还见过有人在每个业务表手动维护create_time和update_time漏一个就是线上事故。后来切到MyBatis Plus发现它有字段自动填充的能力通过实现MetaObjectHandler接口在insert/update的SQL真正执行之前自动把公共字段的值塞进实体对象业务代码里再也不用到处setCreateTime、setUpdateTime了。这篇文章我会从原理到实现再到我实际部署中踩过的坑把MyBatis Plus字段自动填充完整讲一遍适合正在做后端开发、想少写重复代码或者对框架源码有好奇心的同学。1. 自动填充到底解决什么核心问题1.1 没有自动填充时公共字段维护有多痛先看一个非常常见的场景。假设用户表有6个字段主键id、姓名name、创建时间create_time、更新时间update_time、创建人create_by、更新人update_by。用传统MyBatis写法每次insert都要这样User user new User(); user.setName(张三); user.setCreateTime(new Date()); user.setUpdateTime(new Date()); user.setCreateBy(currentUserId); user.setUpdateBy(currentUserId); userMapper.insert(user);这还不算什么真正麻烦的是update。一次update你只改了name一个字段但大概率还是得把update_time、update_by再set一遍。一个项目少说几十张表每张表都有这样的模板代码CtrlC、CtrlV多了总有一天会漏。漏掉的结果很隐蔽update_time没变排查问题时无法判断数据到底是什么时候改的update_by是null做操作审计的时候查不到责任人。这类字段恰恰是“平时不起眼、出事要命”的类型。1.2 自动填充和数据库默认值怎么选可能有人会说这有什么难的数据库表里给字段加DEFAULT CURRENT_TIMESTAMP不就行了这种方案确实常见但和自动填充相比有几个绕不开的短板对比维度数据库默认值MyBatis Plus自动填充更新时间是否自动刷新update时不会自动刷新除非额外写脚本或触发器insert和update都能自动写入操作人字段数据库完全感知不到当前操作人可从应用上下文取当前登录用户跨数据库迁移不同数据库对默认值的语法支持有差异应用层统一逻辑跨库一致可控性走数据库脚本动态性和灵活性差代码可控支持运行时动态判断最典型的例子就是update_time。数据库默认值的语法一般只在insert时生效update的时候你得自己手写update_time now()一旦写漏就踩坑。自动填充则是在每次update操作前由框架统一把当前时间写进实体对象只要使用了MyBatis Plus的内置更新方法update_time就不会被落下。另外“操作人”这种字段数据库层面是无法填的。就算你用触发器也只能填数据库连接的用户名而不是业务系统里真正登录的那个人。所以这类字段必须由应用层在代码里写入而自动填充就是最省事的入口。1.3 自动填充的适用范围边界要搞清楚自动填充不是万能的。它最擅长的场景是“所有表都一致、和业务逻辑无关”的公共字段比如创建时间、更新时间、创建人、更新人、逻辑删除标记、乐观锁版本号这些。这类字段有一个共同点不管哪张表、哪个业务赋值逻辑完全相同。但如果是带业务含义的字段比如订单表根据订单类型不同要填不同的状态码或者商品表根据库存变化要填不同的来源渠道这些就不适合放到自动填充里做。自动填充是一个全局钩子它拿不到当前Service层的业务上下文硬塞进去只会让代码越来越难维护。我的建议是公共字段用自动填充业务字段在Service层显式赋值。别图省事把什么东西都往填充器里塞否则三个月后你看着那个越写越长的insertFill方法会非常痛苦。2. 原理剖析MetaObjectHandler是怎么被触发的2.1 一次insert背后的完整调用链很多人用了很久自动填充但不知道它到底是在哪一步生效的。我最初也以为MyBatis Plus生成SQL时会带出来这些字段后来翻了源码才发现真正干活的是MyBatis的ParameterHandler。MyBatis Plus的核心处理逻辑在MybatisParameterHandler类里它继承了MyBatis自带的DefaultParameterHandler在构造时额外接收了MetaObjectHandler和SqlCommandType两个参数。当MyBatis执行一条insert或update语句时执行器会调用ParameterHandler的setParameters方法设置SQL参数而MyBatis Plus在这个方法里做了手脚关键代码逻辑大致是这样的protected void process(Object parameter) { if (parameter ! null) { MetaObject metaObject this.metaObject(); if (this.metaObjectHandler ! null) { if (SqlCommandType.INSERT this.sqlCommandType) { this.metaObjectHandler.insertFill(metaObject); } else if (SqlCommandType.UPDATE this.sqlCommandType) { this.metaObjectHandler.updateFill(metaObject); } } // 后续继续执行原本的参数设置逻辑 } }也就是说在真正把实体属性绑定到SQL参数之前MyBatis Plus把实体对象包装成了MetaObject先让填充器把公共字段写进去然后再生成完整SQL执行。整个过程对业务代码完全透明你只需要调用userMapper.insert(user)剩下的框架自动完成。需要特别注意的是这里的MetaObject是MyBatis反射工具包里的元数据对象它并不是实体类本身而是一个能动态读写对象属性的门面。填充器修改metaObject里的值本质上就是在修改你传入的实体对象。2.2 字段是如何被找到和写入的MetaObjectHandler接口的核心就两个抽象方法insertFill(MetaObject metaObject)和updateFill(MetaObject metaObject)。我们要做的事情就是在这两个方法里调用框架提供的setFieldValByName或strictInsertFill方法完成字段写入。但这里有个问题框架怎么知道实体里哪些字段需要填充答案是TableField注解的fill属性。MyBatis Plus在启动时会扫描实体类的字段把带fill标注的字段收集起来做好映射关系。等到真正执行填充时它并不需要额外判断该不该填而是看我们的实现方法里具体写的是哪个字段。再往深一层看setFieldValByName的底层是用反射去调实体字段的setter方法。假如实体类里没有为createTime字段生成setter比如用了Data但字段名写错填充时就会出现找不到属性之类的异常。这也是为什么很多人加上填充功能后明明方法写了但是运行时报错说找不到setter。2.3 strictInsertFill为什么更安全在MyBatis Plus 3.3.0之前大家用的都是setFieldValByName它有一个隐患不会校验字段类型。举个例子实体字段是LocalDateTime你传入的却是Date对象虽然编译期不报错但反射赋值时很可能抛出ClassCastException或者在最底层被类型擦除“带偏”数据写进去变成乱值。3.3.0版本之后官方提供了strictInsertFill和strictUpdateFill。这两个方法会先获取实体字段的setter入参类型再和你传入的Class做比对类型一致才赋值不一致就抛异常提醒你这地方写错了。源码逻辑大致如下default MetaObjectHandler strictInsertFill(MetaObject metaObject, String fieldName, ClassT fieldType, T fieldVal) { Class? getFieldType metaObject.getSetterType(fieldName); if (fieldType.equals(getFieldType)) { setFieldValByName(fieldName, fieldVal, metaObject); } return this; }这样做的好处是把类型不匹配的问题提前暴露出来而不是等数据写错之后才排查。我的建议很直接新项目一律用strictInsertFill和strictUpdateFill老项目如果还在用setFieldValByName升级版本后也尽量迁移过来。2.4 openInsertFill与openUpdateFill的作用很多人不知道这两个默认方法的存在。MetaObjectHandler里还有openInsertFill()和openUpdateFill()默认返回true。它们的作用是控制当前填充器是否参与填充。比如某些极端场景下你希望全局关闭insert填充但保留update填充可以重写openInsertFill返回false。不过说实话我在实际项目里很少用到这个开关因为不同表之间已经用TableField(fill FieldFill.XXX)做了细粒度控制没必要在填充器级别再关一遍。知道有这个东西就行真遇到“整个实例都不填充”的需求时不至于摸不着头脑。3. 生产级实现方案从Demo到一套靠谱的落地代码3.1 实体类注解的正确姿势先看一个标准的实体类配置我把常见的几个公共字段都放进去Data TableName(sys_user) 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; Version TableField(fill FieldFill.INSERT) private Integer version; }FieldFill枚举有四个值DEFAULT默认不填充INSERT插入时填充UPDATE更新时填充INSERT_UPDATE插入和更新都填充创建时间和创建人只用INSERT更新时间、更新人用INSERT_UPDATE这个容易理解。逻辑删除标记deleted插入时默认给0所以标INSERT。乐观锁version插入时默认给1也标INSERT。注意Version和TableLogic本身就带有MyBatis Plus的特殊处理逻辑和自动填充搭配时我建议初始值仍然由填充器负责写入这样能保证每次insert都有确定的值不会因为对象没显式设置而出现null。实体类还有一个容易踩坑的点字段类型统一用包装类不要用基本类型。比如Integer deleted而不是int deleted否则填充器写入null时底层会直接报错非常难排查。3.2 自定义填充器的完整实现接下来是核心实现MetaObjectHandler接口并注册成Spring BeanComponent public class MyMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { LocalDateTime now LocalDateTime.now(); this.strictInsertFill(metaObject, createTime, LocalDateTime.class, now); this.strictInsertFill(metaObject, updateTime, LocalDateTime.class, now); this.strictInsertFill(metaObject, createBy, Long.class, UserContext.getUserId()); this.strictInsertFill(metaObject, updateBy, Long.class, UserContext.getUserId()); this.strictInsertFill(metaObject, deleted, Integer.class, 0); this.strictInsertFill(metaObject, version, Integer.class, 1); } Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); this.strictUpdateFill(metaObject, updateBy, Long.class, UserContext.getUserId()); } }UserContext是我在项目里封装的一个基于ThreadLocal的普通类用来保存当前登录用户ID一般配合拦截器或过滤器在请求进入Controller之前把用户信息放进去业务执行完清掉。这样填充器才能拿到“操作人是谁”。这里有个细节strictInsertFill虽然会做类型校验但如果实体字段正好为null它也会写入如果实体字段已经有值strictInsertFill依然会覆盖。所以如果你有特殊需求比如某些SQL场景下希望保留实体里已有的createTime值就需要先通过metaObject.getValue(createTime)手动判断一下再决定是否填充。这一点官方文档没有明说但生产环境很实用。3.3 与逻辑删除、乐观锁、多租户字段的协同很多项目不是只有创建时间和更新时间还会有逻辑删除、乐观锁、租户ID这些字段。自动填充和它们的协同关系我总结成一张表字段类型推荐处理方式原因逻辑删除标记自动填充初始值插入时统一写0最简单可靠乐观锁版本号自动填充初始值插入时写1后续靠框架自增租户ID推荐TenantLineInnerInterceptor查询和删除也要自动加租户条件自动填充只管写入不够分布式ID推荐IdType.ASSIGN_ID主键ID的生成交给ID生成器不走填充器租户ID是一个容易犯迷糊的点。有人觉得“既然插入时要填租户ID那我用自动填充不就行了”确实insert这一步自动填充能解决但select和delete怎么办多租户系统要求所有查询都自动带上租户条件理赔时少了租户条件数据隔离直接失效。MyBatis Plus提供了TenantLineInnerInterceptor专门解析SQL并在select/update/delete时自动拼接租户条件它和自动填充的职责并不重叠。所以租户ID我更推荐用专门的租户插件而不是填充器。3.4 经验哪些字段值得填充哪些不建议做过了几个项目之后我总结了一套自己的字段填充“原则”只填充“和业务无关的、全局统一的、可以认为是基础设施字段”的公共字段。值得填充的create_time、update_timecreate_by、update_by逻辑删除初始值乐观锁初始值如果系统有统一的tenant_id且查询侧也有插件配合可以填充不建议填充的订单状态、审核状态等业务状态字段依赖前置业务计算结果的字段字段值需要根据不同表/不同模块变化的字段举一个反例我曾经在某个项目里把“来源渠道”字段也塞进填充器因为当时觉得“几乎所有表插入时都要填这个”。后来来源渠道从两个变成五个不同表的默认值还不一样填充器里只能写一堆if判断最后越写越乱不得不重构。公共字段之所以叫公共字段就是因为它对所有表一视同仁。4. 常见问题与避坑实录4.1 updateFill不触发的三种常见原因先说最常遇到的问题insert的时候字段自动填充了但update的时候死活不生效。第一种原因实体字段上的TableField(fill FieldFill.UPDATE)或INSERT_UPDATE没写对。很多人只在insert时验证了功能update时直接用了updateById发现字段没变化一看注解原来createTime标了INSERTupdateTime也标了INSERT压根没有UPDATE。这个最好排查去看注解就行。第二种原因修改用的是LambdaUpdateWrapper并且通过set手动指定了某些字段。比如LambdaUpdateWrapperUser wrapper new LambdaUpdateWrapper(); wrapper.eq(User::getId, 1) .set(User::getName, 李四); userMapper.update(null, wrapper);这种情况下MyBatis Plus走的是wrapper更新参数对象其实是一个MapperMethod.ParamMap而不是实体对象。填充器在处理时拿不到实体字段的setterstrictUpdateFill很可能直接跳过update_time也就不会被自动填上。第三种原因使用了自定义SQL比如在Mapper里写Update或XML里的update语句。自动填充只在MyBatis Plus内置方法里有效自定义SQL不会走MetaObjectHandler的处理链路。这个问题我见得太多了很多团队初期用内置方法验证过没问题之后一写复杂更新就发现公共字段不填了。解决办法有两个思路一是能用内置方法和Wrapper更新解决的问题尽量不用自定义SQL二是一般也不用自己改字段了。4.3 数据迁移和跨库兼容性我之前遇到一个项目要从MySQL迁移到PostgreSQL数据库默认值的写法变了DEFAULT CURRENT_TIMESTAMP在PostgreSQL里要改成DEFAULT now()还有很多字段默认函数完全不一样迁移脚本改到怀疑人生。而自动填充方案把公共字段写入逻辑放在应用层换数据库时这部分代码一行不用动。这也是我把自动填充作为公共字段首选方案的重要原因。4.3 高并发写入下的性能取舍有人担心自动填充会影响写入性能。从原理看strictInsertFill每次都要通过反射获取字段的setter和类型确实比纯手写set要慢一点点。但在绝大多数业务系统里单次数据库insert的时间在1ms到几十ms之间自动填充多出来的反射时间往往连0.01ms都不到完全构不成瓶颈。真正需要注意的是高频批量插入场景比如数据导入、日志采集。这种时候如果每一条记录都走一次填充器积少成多还是会有额外开销。我的做法是批量导入场景直接绕开自动填充在批量插入前手动把公共字段构造在实体列表里然后一次性批量执行。这也是为什么我不建议“用自动填充承载业务逻辑”的原因之一一旦填充器里业务逻辑变重批量场景也会跟着变慢。4.4 版本升级时的API兼容性问题MyBatis Plus 3.3.0之前和之后的MetaObjectHandler写法有差异。老版本只有setFieldValByName新版本推荐用strictInsertFill。如果你是从老版本升级代码里还全是setFieldValByName功能上其实也能用但就享受不到类型校验的便利了。还要注意早期版本里openInsertFill和openUpdateFill方法可能没有直接重写的话编译不通过。升级时最好先看下当前版本对应的接口定义。另外如果你同时用了MyBatis Plus的自动填充和TableId(type IdType.AUTO)这种数据库自增主键二者互不干扰。真正需要留意的是IdType.ASSIGN_ID和ASSIGN_UUID这类ID生成器也发生在insert前和填充器的执行顺序需要确认。MyBatis Plus的处理顺序是先执行ID生成再执行insertFill所以填充器里拿到的id字段已经有值。这个顺序通常不会出问题但知道它能帮你更好地理解整个insert的先后链路。5. 一些额外的扩展想法最后我想聊一个不太常见的用法用自动填充做数据权限审计。比如很多系统要求记录每条数据的创建部门、创建人岗位、最后操作人。这些字段和具体的业务字段无关但又需要从登录上下文里取非常适合用填充器统一处理。不过这里要注意填充器里取得的上下文是全局的如果你的系统里一个请求可能涉及多个用户切换比如后台管理员模拟用户操作就必须先确认当前Context里的用户信息已经被正确切换。我在有一个多租户后台管理系统里碰到过这个问题管理员“切换身份”后填充器里取的还是管理员ID导致审计记录变成管理员操作排查了很久才发现是上下文切换的时机不对。所以在做这类扩展前先把用户上下文的生命周期理清楚再动手写填充器。还有一个思路是可以基于自动填充实现“最后一次访问时间”“最后登录时间”之类的扩展字段。只要这些字段对所有实体都是公共含义用同样的方式填充即可。不过这类字段如果伴随高频更新要评估下对数据库压力是否可接受。总的来说自动填充是一个“小而有用”的框架特性但它背后牵扯到MyBatis参数处理链路、反射机制、ORM行为边界。理解它、规范使用它能让你的公共字段维护成本大幅下降无视它的边界把所有字段都塞进去就会得到一个越来越难维护的填充器。我的最终建议是界定清楚哪些是真正的公共字段用strictInsertFill统一处理维护好用户上下文不在填充器里塞业务逻辑——做到这四件事这个功能基本就不会再坑你了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑