最近接手了一个老项目,数据库表四十多张,光一个用户表就关联了角色、组织、地址好几张子表。别看业务复杂,真正常写的代码就是单表增删改查,说白了全是重复劳动。正因如此,MyBatis-Plus 这个增强工具我用了差不多两年,它不改变 MyBatis 本身,只是把单表 CRUD 的活基本干完了,让你能从 Mapper XML 里腾出手来,去处理真正需要动脑的关联查询和复杂逻辑。这篇内容适合刚接触 MyBatis-Plus、或者虽然用过但只是照抄配置、没搞清楚背后原理的人。我会从选型思路、环境搭建、核心 API、常见坑点一条线讲下来,所有代码都基于当前主流的 3.5.x 版本,在 Spring Boot 工程里直接能跑。
1. 为什么是 MyBatis-Plus:看懂增强工具的价值点
1.1 没有 MyBatis-Plus 之前,我在写什么
如果只用原生 MyBatis,写一个最简单的用户列表接口,代码量其实很吓人。项目里要建实体类,再写一个 xxxMapper 接口,对应一个 xxxMapper.xml。XML 上得写 resultMap 处理字段映射,写 select 语句,写 insert 语句,为了支持条件筛选还得判断参数是否为空。用户表加了删除标记字段之后,所有 select 语句都要背着“deleted = 0”这个尾巴改一遍。更别提分页,要么写方言 SQL,要么用 PageHelper 这种插件另外维护一套配置。
这些工作在业务系统里属于“没有技术含量但每天都要做”的部分。真正要投入精力的,是多表关联、报表统计、数据一致性这类问题。可现实往往是,简单 CRUD 占据了八成的工作量,留给核心逻辑的时间反而被挤掉了。MyBatis-Plus 的出现,本质上是把“单表 CRUD”从业务代码中抽成一个通用能力,让我只需要声明一个继承 BaseMapper 的接口,常用 SQL 自动就具备了。
1.2 它的核心能力可以分成四层
第一层是最基础的 BaseMapper 内置方法,insert、deleteById、selectById、updateById、selectList 这些,覆盖了单表操作最常用的情况。第二层是条件构造器,也就是 Wrapper 体系,用代码来代替 XML 里拼 SQL,可以动态组装 where 条件,这是日常开发中使用频率最高的一层。第三层是内置插件和扩展功能,分页插件、乐观锁插件、逻辑删除、全局ID生成、字段自动填充,这些都是解决实际业务痛点的功能。第四层是代码生成器,可以一键生成实体类、Mapper、Service、Controller 的骨架代码,配合 ActiveRecord 模式还能让实体类直接操作数据库。
我用一个简单类比:原生 MyBatis 像一家餐厅的后厨,从洗菜切菜到掌勺都是你自己安排;MyBatis-Plus 则像是给你配了一套标准化的切配流水线,常见的配菜工作它已经按行业习惯给你备好了,你只需要把注意力放在最后调味和烹饪上。如果连配菜都不需要,甚至可以直接用生成器把整套菜谱模板都生成出来。
1.3 什么场景适合用,什么场景要慎重
MyBatis-Plus 的定位决定了它擅长单表操作。如果你项目里大部分业务是一个主表加上简单关联,那它能让开发效率提升非常明显。很多后台管理系统、运营平台、中小型电商项目都符合这种特征。反过来,如果系统核心是复杂的多表 join、复杂子查询、或者存储过程,这类操作依然需要在 XML 里手写 SQL,MP 的 BaseMapper 方法在这种场景下帮不了太多。我的做法是折中:MP 负责标准 CRUD,复杂查询继续用 MyBatis 的 XML,两者可以共存于同一个工程里,不冲突。毕竟 MP 只是增强,没有替换掉 MyBatis,原来怎么用 XML 还怎么用。
还有一点需要说明,如果你的团队对 MyBatis 本身都不熟悉,直接上 MP 可能会掩盖掉很多底层问题。比如字段映射异常、SQL 性能问题,排查起来会多绕一圈。所以建议基础团队先把 MyBatis 原理搞明白,再用 MP 提升效率。这也解释了为什么很多公司的校招技术方案里,会把 MP 定位为“入门友好工具”而不是“万能银弹”。
2. 环境准备与最小可用集成
2.1 Spring Boot 集成的最小闭环
话不多说,直接看依赖。我用的是 Spring Boot 2.7.x,MyBatis-Plus 的 starter 用 3.5.3.2 版本,这个组合在 Java 8 环境下非常稳定。
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.2</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>如果你是 Spring Boot 3.x,需要改用 mybatis-plus-spring-boot3-starter 这个新坐标,版本从 3.5.x 开始提供,这个坑很多人第一次接触时容易踩。引入依赖只是第一步,关键是理解自动配置原理:MP 的 starter 会利用 MyBatis 的自动配置机制,在项目启动时自动注册 SqlSessionFactory,然后把 MybatisPlusInterceptor、参数处理器这些核心组件挂进去。所以你只是引入依赖,不写任何 MP 专用配置,项目也是能跑的,只不过分页、乐观锁这类插件功能不会生效。
连接配置还是标准的 Spring Boot 数据源配置。我习惯把配置写在 application.yml 里:
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/demo_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: root mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: banner: false db-config: id-type: auto这里有两个小细节。第一,map-underscore-to-camel-case 必须打开,否则数据库的 create_time 不会自动映射到实体的 createTime 属性上。MP 默认是开启的,但如果你在 configuration 里手动覆盖了配置,别把这个属性搞丢了。第二,log-impl 设成 StdOutImpl 可以把 SQL 打到控制台,调试阶段非常好用,正式环境需要去掉,否则日志量会很大。
2.2 第一张表、第一个 Mapper、第一个测试
建一张非常简单的用户表:
CREATE TABLE `user` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', `name` VARCHAR(30) DEFAULT NULL COMMENT '姓名', `age` INT DEFAULT NULL COMMENT '年龄', `email` VARCHAR(50) DEFAULT NULL COMMENT '邮箱', `create_time` DATETIME DEFAULT NULL COMMENT '创建时间', `update_time` DATETIME DEFAULT NULL COMMENT '更新时间', `deleted` TINYINT DEFAULT 0 COMMENT '逻辑删除标记,0未删除,1已删除', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';对应的实体类:
@Data @TableName("user") public class User { @TableId(type = IdType.AUTO) private Long id; private String name; private Integer age; private String email; private LocalDateTime createTime; private LocalDateTime updateTime; @TableLogic private Integer deleted; }然后是 Mapper 接口,这个接口可以什么都不写,光继承 BaseMapper 就能获得完整的单表 CRUD 能力:
public interface UserMapper extends BaseMapper<User> { }在启动类上或者任意配置类上加上 MapperScan 扫描:
@MapperScan("com.example.demo.mapper") @SpringBootApplication public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }写个测试类验证一下。我用 @Autowired 注入 UserMapper,然后直接调用:
@SpringBootTest class UserMapperTest { @Autowired private UserMapper userMapper; @Test void testInsertAndSelect() { User user = new User(); user.setName("张三"); user.setAge(28); user.setEmail("zhangsan@example.com"); int rows = userMapper.insert(user); System.out.println("插入成功,影响行数=" + rows + ", 回填ID=" + user.getId()); User queried = userMapper.selectById(user.getId()); System.out.println("查询结果=" + queried); } }执行结果里有一行很有价值的控制台日志,就是 MP 生成的 SQL。你可以看到 insert 语句自动包含了 create_time 和 update_time 字段,虽然实体类里设置了 @TableLogic,但它不会出现在普通 insert 里。select 语句则会自动带上WHERE id=? AND deleted=0。这说明逻辑删除的全局过滤已经在底层生效了。
2.3 实体映射的核心规范
第一次用完 MP 的人常问一个问题:为什么我的表名和实体类名对不上?比如表名是 t_user,实体叫 User,这之间怎么配置?答案是用 @TableName("t_user")。类名默认对应表名时靠的是驼峰转下划线,User 对 user,UserInfo 对 user_info。不匹配时务必显式声明。
字段也是有讲究的。create_time 对 createTime 属于自动驼峰映射,不需要额外配置。但有些表字段是反义词级别的,比如数据库字段叫 is_deleted 或 is_enabled,Java 属性叫 deleted 或 enabled,这种字段名如果不一致,就需要 @TableField 指定:
@TableField("is_deleted") private Integer deleted;这里有一个非常经典的坑:如果你把数据库字段定义成 is_deleted,而实体属性也定义成 isDeleted,这个时候 JavaBean 序列化规则会把 isDeleted 的 getter 生成为 isDeleted(),部分框架解析时会把属性名当成 deleted,组件之间传值就会出现丢失或者是不一致。真正稳妥的参数规范是数据库里就叫 deleted,实体里也叫 deleted,不要用 is_ 开头做布尔字段名。很多老项目的包袱就是这么背下来的。
3. 核心能力拆解:CRUD 接口与条件构造器
3.1 BaseMapper 内置方法的使用边界
BaseMapper 的方法设计是按日常操作习惯来的,常用的包含这几类:
- 插入:insert 支持主键回填。如果你配置了数据库自增 ID,insert 成功后 ID 会直接写回实体对象。
- 删除:deleteById、deleteBatchIds、deleteByMap。注意 deleteByMap 只能做等值匹配,不能做范围删除。
- 修改:updateById 是按主键更新;update(entity, wrapper) 是按条件更新,第一个参数是更新后的实体,第二个是 where 条件。MP 的 update 方法默认不会把 null 字段更新进去,这背后是 FieldStrategy 的默认策略在起作用。
- 查询:selectById、selectBatchIds、selectOne、selectCount、selectList、selectMaps、selectObjs 这些方法各有使用场景。selectOne 在查询结果多于一条时会直接抛异常,所以多条件查询时建议用 selectList,再手动判断 size。
单表操作确实快,但有一个点必须提醒:selectList 默认返回所有字段,如果你只需要少量列,用 LambdaQueryWrapper 的 select 方法只查需要的列,这样可以减少网络传输和内存消耗。比如用户列表页只需要 id 和 name,就不要把 email、create_time 全带出来。
3.2 用 LambdaQueryWrapper 动态拼条件
Wrapper 是 MP 里最值得花时间学的一个点。它的核心思路是:在 Java 代码里用链式方法描述 where 子句,MP 再把这些描述翻译成 SQL。推荐使用 Lambda 版本,因为字段名是用方法引用传进去的,编译期就能发现字段名拼写错误。传统 QueryWrapper 用字符串列名,如果列写错了,要等到运行期才能发现。列名被重构的时候就变成了隐患,所以我在团队里明确规定:新代码一律用 LambdaQueryWrapper。
示例场景:查询年龄大于等于 18 且邮箱非空,按创建时间倒序:
List<User> userList = userMapper.selectList( new LambdaQueryWrapper<User>() .ge(User::getAge, 18) .isNotNull(User::getEmail) .orderByDesc(User::getCreateTime) );再复杂一点,要实现一个搜索逻辑:用户传了关键字就按名字模糊查,传了年龄就精确筛年龄,没传就返回全部。如果只传部分条件,可以用 condition 参数来控制。重载的第一个参数是 boolean:
String keyword = "张"; Integer age = null; List<User> users = userMapper.selectList( new LambdaQueryWrapper<User>() .like(StringUtils.hasText(keyword), User::getName, keyword) .eq(age != null, User::getAge, age) );这个写法把条件是否拼进 SQL 的控制权交给了开发人员手写布尔表达式。比起在 XML 里用<if test="age != null">判断要直观得多,还省掉了 resultMap 和一套 XML 标签。
查询条件只是 Wrapper 的一部分,它还能控制 select 字段和 group by、having。举个例子,统计每个年龄段的用户数:
List<Map<String, Object>> maps = userMapper.selectMaps( new LambdaQueryWrapper<User>() .select(User::getAge, "count(*) as cnt") .groupBy(User::getAge) );select 方法里既可以传 Lambda 列,也可以传字符串全局常量。不过说实话,分组统计这类需求我更倾向直接写 XML SQL,Wrapper 的可读性在这种场景会下降。Wrapper 最适合的还是“动态 where 条件 + 排序 + 字段筛选”,这也印证了它的定位就是单表查询的瑞士军刀。
3.3 Service 层封装:从 Mapper 到 IService
实际项目里接口层一般不会直接注入 Mapper,而是通过 Service 再转一层。MP 也提供了一个 Service 层的增强体系。定义接口继承 IService:
public interface UserService extends IService<User> { }实现类继承 ServiceImpl:
@Service public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService { }这样 UserService 就自动拥有了 save、saveBatch、list、getById、updateById、removeById 等能力,并且内部已经封装好了事务和批量操作的逻辑。
有个细节值得注意:ServiceImpl 里注入的 baseMapper 是泛型绑定的 Mapper,所以子类里如果需要用到 Mapper 特有的方法,可以直接通过 baseMapper.xxx 调用,也可以注入自己的具体 Mapper。但如果你写了@Resource UserMapper userMapper然后又继承 ServiceImpl,那等于持有两份 Mapper 实例,问题不大但是没必要。
Service 的方法和 Mapper 的方法很多同名,重点区分它们的语义。service.getById(id) 里已经带了逻辑删除过滤,底层走 selectById,但是 selectById 其实是 BaseMapper 的方法,也会带逻辑删除过滤。二者没有语义差异。真正不一样的是批量操作:service.saveBatch(list) 默认会把 list 切成 1000 条一批做批处理预编译,而 Mapper 没有直接的 batch 方法。
3.4 批量插入与大数据量操作的注意点
批量插入是业务开发里最常见的性能优化点。只推荐两种方式:第一种是 service.saveBatch,第二种是分批手动循环 insert,但不推荐在循环里单条 insert。saveBatch 底层虽然做了预编译复用,但还不是真正的 JDBC Batch 一次提交多行,它是把每条 insert 语句预编译后多次 addBatch,然后统一执行。它的效率提升来源于减少了 SQL 解析和网络往返,但对于超大数据量,比如一次导几万条,还是建议手动拆批,每批 500 到 1000 条,并且用编程式事务包裹,不然内存溢出的风险会很大。
如果要追求极致速度,可以在 JDBC URL 上追加 rewriteBatchedStatements=true,让 MySQL 把多条 insert 重写成一条多值 insert,实测提升非常明显。MP 不负责配置这个参数,但是它可以配合 saveBatch 使用,效果叠加。
4. 进阶能力:分页、逻辑删除、自动填充、乐观锁
4.1 分页插件的配置与原理
分页可以说是后台管理系统需求最频繁的功能。MP 的分页用法和 PageHelper 类似,但两者的实现机制完全不同。PageHelper 是在运行时拦截 SQL,自动改写 limit,需要依赖线程上下文传页码;MP 则是通过 MybatisPlusInterceptor 插件机制拦截 Executor 执行,拿到分页参数后生成 COUNT 查询和带 LIMIT 的查询。
配置方式分两步。第一步,注入分页插件:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }第二步,在业务代码里这样用:
Page<User> page = new Page<>(1, 10); Page<User> result = userMapper.selectPage(page, new LambdaQueryWrapper<User>().ge(User::getAge, 18));程序返回的 result 里包含 records、total、current、size 等属性。这里的 total 是通过额外的 COUNT 查询得到的,MP 会自动把 COUNT 查询的 SQL 中对 order by 做优化剔除,减少不必要的性能损耗。不过这个优化也有边界,如果查询条件非常复杂,比如带 group by 的,MP 的 count 优化可能不能完全去掉所有多余的排序逻辑,极端情况下 count SQL 可能仍比预期慢。你需要通过控制台日志观察自动生成的 count 语句,必要时可以自己实现一个不带 order by 的查询再手动统计。
还有两个分页相关的细节。第一,Page 对象不能跨线程复用,分页参数是通过 ThreadLocal 传递的,异步线程里用会有脏数据。第二,PaginationInnerInterceptor 内部有 maxLimit 参数,默认是 Long.MAX_VALUE 不限制,线上如果存在用户可输入的页码和大小,建议设置一个合理的最大 limit,防呆防异常:
new PaginationInnerInterceptor(DbType.MYSQL).setMaxLimit(500L);4.2 逻辑删除:配置简单,坑也不含糊
逻辑删除是现在业务系统的标配。实际开发中没人愿意真去 delete 数据,而是用 deleted 字段标记。MP 的逻辑删除设计非常轻松,只要实体字段上加了 @TableLogic,并且全局配置里声明了删除值,之后所有 BaseMapper 的 deleteXxx 方法都会自动变成 update,所有 select 方法都会自动带上 deleted = 0 过滤条件。
全局配置长这样:
mybatis-plus: global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0这样写之后,实体类上的 @TableLogic 注解其实可以省略,MP 会根据全局配置自动匹配名为 deleted 的字段。不过我还是建议实体类上保留注解,因为显式声明比依赖全局规则更安全,尤其当项目里有多套表结构、字段命名风格不统一的时候。
逻辑删除有一个非常隐蔽的坑:如果你在 XML 里手写了复杂的多表 join SQL,而且这些 SQL 没有接逻辑删除条件,那么这个手写 SQL 不会被 MP 拦截,deleted=0 的条件也不会自动拼接。很多人用 MP 一段时间后突然发现数据出“错乱”,排查到最后都是 XML 里漏了删除过滤。所以团队规范里必须规定:手写 SQL 一律自行加上逻辑删除条件。另外,逻辑删除字段最好建索引,因为查询频率高,否则表数据大了之后,每个 select 都带着 deleted=0 的过滤会让索引失效。
4.3 自动填充,解决创建时间和更新时间的重复劳动
另一个高频重复字段是 create_time 和 update_time。如果每个 insert 都要 set 创建时间、每个 update 都要 set 更新时间,代码既冗余又容易漏。MP 提供了字段自动填充机制,我一般这么配。实体类字段标注填充策略:
@TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime;然后提供一个 MetaObjectHandler 的实现:
@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()); } }strictInsertFill 这个方法会先检查实体里对应字段是否已经赋值,只有为 null 时才填充。这个设计很关键,可以让少量需要指定时间的场景依然能手工传值,不会被自动填充覆盖掉。
这里有个容易误用的地方:填充只发生在 MP 的 insert 或 update 方法链路中。如果你自己写了一个 XML 的 insert 语句,MP 根本不知道,自动填充自然也不会发生。还有一个细节,update(Wrapper) 这种条件更新,MP 会先看 update 实体的字段有没有值,值存在就填充,不存在则不填,它不会为 update 语句生成单独的 set 子句。
4.4 乐观锁:用一个字段避免并发覆盖
并发版本更新导致数据覆盖,一直是后台系统容易忽略的问题。最经典的场景是库存扣减:两个请求同时读到库存为 10,各自扣 1,最后都更新为 9,实际应该剩下 8。悲观锁太重,MP 提供了轻量的乐观锁方案,实现机制非常简单:更新前查一次版本号,更新时把原来的版本号作为 where 条件,再 set version = version + 1。如果 where 条件里版本号匹配不上,说明数据已经被其他线程改过,更新行数为 0,业务层再决定是重试还是失败。
配置方法分两步。实体类加 @Version 字段:
@Version private Integer version;在拦截器里注册乐观锁插件:
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());注意,@Version 字段必须支持数字或者时间戳类型,我一般用 Integer。另外,使用乐观锁时有一个逻辑细节:update(entity, wrapper) 里设置实体字段时,版本号会自动参与,业务代码里不需要手动 set version,也不用手动执行 version+1,MP 会读取实体里已有的 version 作为旧值。如果实体里 version 是 null,那乐观锁就不生效,等同于普通更新。还有一点,乐观锁只适用于单表更新,多表关联更新就别指望它了。
需要特别提醒的是,逻辑删除 + 乐观锁 + 自动填充这三个插件的叠加顺序在拦截器里是有讲究的。多租户插件应该排在分页插件之前,乐观锁插件在接近 SQL 解析的位置。如果你的项目同时用到了多租户这类插件,最好参考官方推荐的添加顺序:
Interceptor.addInnerInterceptor(new TenantLineInnerInterceptor()); interceptor.addInnerInterceptor(new PaginationInnerInterceptor()); interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());顺序不对,轻则功能无效,重则生成的 SQL 有逻辑错误。
5. 实战中高频踩坑与排查思路
5.1 明明删除了,却还能查到数据
这是逻辑删除刚开始用的时候最容易遇到的。原因是全局配置和实体注解不一致。比如全局配置里写的是 logic-delete-field: delete_flag,但实体类的字段叫 deleted,或者注解里写的是 @TableLogic 但表里字段名是 is_deleted,这几种情况 MP 的过滤条件就不会生效。排查方法很简单:打开 SQL 日志,看生成的 select 是否为WHERE deleted=0。如果没有,优先检查实体字段和全局配置是否有冲突。还有一种情况是,你手写的 SQL 走了自定义方法,而不是 BaseMapper 内置方法,那逻辑删除确实不会生效。
5.2 分页查总数一直不准,或者总是 0
分页总数不对的场景,我排查过两次。一次是数据源配置了多个,分页插件被注册了两次,造成 count 被算了两遍;另一次是分页插件在 MybatisPlusInterceptor 中没有被添加,结果 selectPage 方法压根不解析,返回的 total 是 0,但 records 正常。第二个坑很迷惑,因为列表数据看着是对的,就是 total 不对,很多开发者会下意识去查 SQL 而不是查配置。遇到这种问题,直接确认拦截器是否注入,是最快的排查路径。
5.3 自动填充为什么不生效
自动填充不生效,常见原因有三个:一是用了自定义 XML 的 insert/update 语句,MP 拦截不到;二是 MetaObjectHandler 实现类没有被 Spring 扫描到,比如把组件类放在了启动类扫不到的包里;三是填充字段的 @TableField 没有配置 fill 属性,只加了 fill 在全局配置里的填充字段名和实体属性名对不上。还有一种不太常见但确实有的情况,工程里存在多个 MetaObjectHandler 实现,Spring 注入时类型匹配失败导致启动报错。这种建议一个工程只保留一个填充处理器。
5.4 Mapper 扫描与实体类位置的几个经典报错
很多新手启动就报Invalid bound statement或者userMapper is not found,十有八九是 MapperScan 没扫到 Mapper 接口。如果是多模块项目,注意 MapperScan 路径要具体到 Mapper 包,不能简单扫到父包就以为万事大吉。还有一种情况是 Mapper 和 XML 同名,但 XML 路径没在配置里指定,比如自定义 SQL 时明明写了 mapper.xml 却一直没有生效。这时需要在 yml 里配置:
mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml如果是 Spring Boot 的自动配置模式,MP 默认扫描 classpath*:/mapper/**/*.xml,所以如果你放在 resource 下的 mapper 目录里,一般没问题。如果是自定义目录,一定要自己配置。
5.5 实体继承与 BaseMapper 泛型错配
最后说一个很多人敲代码时不注意的细节。BaseMapper 里的泛型必须和 Mapper 接口对应。如果 Mapper 接口写的是extends BaseMapper<User>,但实体类继承了一个基类,基类里也有数据库字段,比如 createTime、creator,这些字段如果不加 @TableField(exist = false),MP 会自动把它当成表字段去生成 SQL,结果 SQL 执行时报 Unknown column,报错信息非常迷惑。凡是实体里没有对应表字段的 Java 属性,必须标注:
@TableField(exist = false) private String extraInfo;这个问题在写 VO、DTO 扩展实体时非常常见,几乎每个人都踩过。
5.6 常见问题速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 启动报 invalid bound statement | MapperScan 未扫到或 XML 位置错误 | 配置 @MapperScan 和 mapper-locations |
| 查询结果里 createTime 为 null | 驼峰映射未开启 | 设置 map-underscore-to-camel-case=true |
| 插入记录主键始终为 0 | 数据库自增但实体 IdType 配置为 INPUT | 将 @TableId 设置为 IdType.AUTO |
| 逻辑删除未生效 | 手写 SQL 或注解缺失 | 检查实体注解和 XML 条件 |
| 分页 total 一直为 0 | 分页拦截器未注册 | 确认 MybatisPlusInterceptor 已注入 |
| 批处理很慢 | 缺少 JDBC 批量参数 | 连接串加 rewriteBatchedStatements=true |
| updateById 更新不了字段 | 实体 null 字段被忽略 | 了解默认字段策略,必要时用 UpdateWrapper.set |
| SQL 中出现未知列 | 实体多余字段被当成表字段 | 加 @TableField(exist = false) |
这个表基本上覆盖了团队工作中九成以上的 MP 问题。如果你遇到不在表里的问题,我提供一个通用排查建议:把 log-impl 设置为 StdOutImpl,观察 MP 实际生成的 SQL,也就是把黑盒变成白盒,一切问题都会更清晰地暴露。
6. 关于使用节奏和工程规范的一些建议
虽然 MyBatis-Plus 的定位是快速开发,但使用它的节奏真的不能一味求快。我见过一些项目把 MP 的代码生成器跑了一遍,Controller、Service、Mapper 全套生成出来,结果三个月后数据结构一调整,光是同步生成代码就已经消耗了大量精力。我的经验是做适度抽象:实体类可以生成,Mapper 接口可以生成,Service 层只生成接口和空实现;而 Controller 里的业务逻辑应该自己写,不要把生成出来的“模板化接口层”直接搬进生产代码。
对于 Wrapper 的使用,也要控制复杂度。如果 where 条件非常多,超过五六个,我建议改用 XML 里的手写 SQL。Wrapper 虽然灵活,但最长最让人头疼的 SQL 往往就是那种嵌套了 or、and、in 的组合条件,类型松散、可读性极差。拆成 XML SQL 加上注释,反而更利于维护。
工程规范层面,我推荐团队约定三件事。第一,所有 Mapper 继承 BaseMapper 但不应该往里塞过多自定义方法,自定义 SQL 集中在 XML 中,MP 的 BaseMapper 方法专门用于单表。第二,实体类的字段必须和表结构保持一致,临时字段一律标注 @TableField(exist = false)。第三,任何涉及 Wrapper 的查询条件,优先使用 Lambda 版本;涉及分页的调用,Controller 参数和 Page 对象的转换统一封装成工具方法。
从个人实际项目的经验来说,MyBatis-Plus 是一个越用越顺手但也要保持警惕的工具。顺手的地方在于,你不需要花精力去重复写那些无人关注的单表操作;警惕的地方在于,团队里每个人都要清楚 MP 帮你做了什么,尤其 SQL 生成逻辑、插件执行顺序、字段填充策略,这些容易被默认行为掩盖的机制,一旦出现问题,不能因为“原来 MP 是这么工作的”才追悔莫及。你在排查问题上花的时间往往比你写 CRUD 花的还多,所以从项目初期就把 MP 的配置和规范建立清楚,是最值得做的一笔投入。