
如果你也写过UserMapper extends BaseMapperUserEntity而且一直没仔细想过这行代码为什么有魔法那我接下来说的应该是你需要的。我第一次看 MyBatis-Plus 文档时只觉得这是固定写法跟着抄就是了。直到后来遇到一次启动报错报错内容直接指向实体类解析失败我才把注意力放回这一行代码上然后把整个框架的底层逻辑翻了个底朝天。这篇就把“extends BaseMapper ”这行代码拆开来讲三个组成分别是什么、为什么能免写 SQL、以及继承它之后哪些坑必须避开。适合刚接触 MyBatis-Plus 的初学者也适合用了两三年但还没深究过原理的开发者。1. 先弄清楚这行代码里的三个角色extends、BaseMapper、UserEntity1.1 extends接口之间的继承而不是类实现在 Java 里“extends”最常见于类继承比如class Admin extends User。但这里它出现在两个接口之间UserMapper是一个接口它继承的是另一个接口BaseMapperUserEntity。接口与接口之间用 extends接口与类之间才用 implements。所以这行代码写完之后UserMapper就自动拥有了BaseMapper接口里定义的全部方法签名。注意我说的是“方法签名”真正的实现要由框架在运行时提供。MyBatis 传统开发里一个 Mapper 接口通常对应一个 XML 文件XML 的 namespace 指向接口全限定名。MyBatis-Plus 把这一步大幅简化了你只需要继承 BaseMapperXML 可以不写SQL 由框架动态生成。那“动态生成”的 SQL 是谁来做的是 MyBatis-Plus 在启动阶段扫描到 Mapper 接口后把 BaseMapper 内置的这些方法逐一注册为 MappedStatement。所以这个 extends 不只是语法层面的继承更是框架识别“这个接口需要被增强”的标记。这里有个容易被忽略的小细节BaseMapper 的父接口MapperT是一个空标记接口。你写 extends BaseMapper 时UserMapper 既继承了 BaseMapper本质上也是一个 MyBatis 的 Mapper 接口扫描器能识别它并完成注册。反过来说如果你新建一个接口不继承任何东西也没有 XML namespace 对应那它就是个“三无接口”——启动时通常不报错一旦调用方法就会报Invalid bound statement (not found)。所以“继承了什么”这件事直接决定了框架认不认你。1.2 BaseMapper 框架预置好的通用方法库BaseMapper 翻译过来就是“基础 Mapper”。这个 Base 的命名思路和 BaseService、BaseController 一样把通用能力下沉到基类子接口按需继承。不同的是 BaseMapper 是接口你要做的是“继承约定”而不是复制粘贴代码。打开源码你看到的是这样一组方法public interface BaseMapperT extends MapperT { int insert(T entity); int deleteById(Serializable id); int delete(T entity); int deleteByMap(MapString, Object columnMap); int delete(WrapperT queryWrapper); int deleteBatchIds(Collection? extends Serializable idList); int updateById(T entity); int update(T entity, WrapperT updateWrapper); T selectById(Serializable id); ListT selectBatchIds(Collection? extends Serializable idList); ListT selectByMap(MapString, Object columnMap); T selectOne(WrapperT queryWrapper); Long selectCount(WrapperT queryWrapper); ListT selectList(WrapperT queryWrapper); ListMapString, Object selectMaps(WrapperT queryWrapper); ListObject selectObjs(WrapperT queryWrapper); P extends IPageT P selectPage(P page, WrapperT queryWrapper); }方法虽多核心就一句话单表 CRUD。为什么强调“单表”因为 BaseMapper 的一切 SQL 都是根据你的实体类推导出来的它只知道一张表的元数据。跨表 join、复杂子查询这些需要你自己写 XML。这个边界非常重要忘掉它后面很容易写出“让 BaseMapper 强行干多表活”的代码最后反而把框架用得很别扭。另外提一句不同版本的方法名有细微差别比如老版本是deleteBatchIds新版本可能推荐deleteByIds。理解核心思想即可实际代码以你项目里的版本为准。1.3 UserEntity泛型参数告诉框架“我管的是哪张表”这里的 UserEntity 是泛型 T 的具体类型。很多初学者会忽略泛型但它恰恰是 MyBatis-Plus 的“信息源”。框架通过反射拿到 UserMapper 接口上的泛型参数确定 T UserEntity然后去解析 UserEntity 这个类上标注的TableName、TableId、TableField最终生成操作某张表的 SQL。如果 UserEntity 上没有任何注解呢MyBatis-Plus 会按默认规则处理类名 UserEntity 转下划线得到表名 user_entity属性 userName 转下划线得到列名 user_name。所以网上很多 Demo 里实体类啥注解都不写也能跑通因为表设计恰好跟默认规则一致。但真实项目里表名往往和类名不一致比如类叫 UserEntity、表叫 t_user你不写TableName(t_user)启动时不会报错运行所有通用方法时却都在查 user_entity 表。这是一个非常隐蔽的坑第 5 章我会专门展开。到这里三个角色的作用就清楚了extends 表示继承关系BaseMapper 提供通用方法集合UserEntity 指定要操作的表和字段。2. BaseMapper 源码里到底藏着哪些方法适合用什么场景2.1 增删改全家桶insert、delete、update除了查询之外BaseMapper 的写入方法可以分成插入、删除、更新三类。插入方向最简单只有一个insert(T entity)。注意它没有批量插入方法。很多人以为saveBatch是 BaseMapper 提供的其实不是那是 Service 层的 IService 接口在背后做的。这个区分直接关系到你后面会不会写错代码第 5 章我单独说。删除方向常见的有五个deleteById(Serializable id)按主键删单条delete(T entity)按实体里的主键值删deleteByMap(MapString, Object)按等值条件删Map 的 key 是列名delete(WrapperT)按条件删条件可以很复杂deleteBatchIds(Collection?)按主键集合批量删。更新方向有两个updateById(T entity)根据实体主键更新实体里为 null 的字段默认不更新update(T entity, WrapperT updateWrapper)先构造更新条件再传入要设置的字段。比如写userMapper.update(user, new LambdaUpdateWrapperUserEntity().eq(UserEntity::getStatus, 1))语义是“把 status 为 1 的记录更新为 user 里非 null 的字段值”。这种写法条件灵活但有个隐患如果 user 对象大部分字段是 null很容易把一行数据更新成只剩几个字段。项目里见过一次事故是从前端传了一个残缺对象直接调用 update结果整行被覆盖。后来我养成一个习惯批量更新字段少时直接用 UpdateWrapper 的 set 方法明确设置而不是依赖实体对象。2.2 查询方法单查、列表、计数、分页BaseMapper 的查询方法覆盖了日常 90% 的查询需求。按主键查有selectById、selectBatchIds按 Map 等值条件查有selectByMap按 Wrapper 条件查的核心方法更多我用一张表列出来方法参数返回说明selectByIdSerializable idT按主键查单条selectBatchIdsCollection idsList按主键集合批量查selectOneWrapperT查单条发现多条会抛异常selectCountWrapperLong查数量selectListWrapperList查列表最常用selectMapsWrapperList查列表返回键值对selectObjsWrapperList只取第一列常用于取 id 列表selectPagePage, WrapperP分页查询分页方法有个前置条件必须配置MybatisPlusInterceptor分页插件否则分页参数不会生效。我见过不少人刚接触 MyBatis-Plus直接调 selectPage发现总数不对、数据全量查出最后发现就是没注册分页拦截器。这个问题很经典而且不会报错只会让你看到错误的数据量和性能问题。selectOne也要小心如果查询结果多于一条它会直接抛TooManyResultsException。这不是 bug是 MyBatis 的既有行为。如果你预期结果可能有多条请用 selectList 再自行处理。2.3 Wrapper 参数和实体参数两条方法线要分清BaseMapper 的方法参数可以分成两类实体参数和 Wrapper 参数。实体参数的方法insert、updateById、delete(T)关注的是实体里的字段值。比如 updateById 只更新非 null 字段。而 Wrapper 参数的方法delete(Wrapper)、update(T, Wrapper)、selectList(Wrapper)关注的是查询条件。有一个经常被混淆的点selectOne(Wrapper) 不等于按 id 查它是按自定义 where 条件查比如selectOne(new LambdaQueryWrapperUserEntity().eq(UserEntity::getUsername, zhangsan))。如果你要按 id 查直接用 selectById 更清晰。用 Wrapper 时我的建议是优先用 Lambda 系列也就是 LambdaQueryWrapper 和 LambdaUpdateWrapper。它们用方法引用UserEntity::getStatus代替字符串列名status编译期就能查出字段名写没写错重构实体字段时也不会默默生成错误 SQL。老项目里可能有大量字符串列名写法至少新代码从今天开始统一用 Lambda 吧。这个习惯一旦养成能少踩好多低级错误。3. 泛型 UserEntity 不是摆设MyBatis-Plus 的元数据驱动机制3.1 框架是怎么把 T 变成 TableInfo 的现在到本文最提神的部分也是“到底是什么意思”真正的答案。先看宏观过程项目启动MyBatis 扫描到 UserMapper 接口MyBatis-Plus 判断这个接口继承了 BaseMapper然后读取它的泛型参数根据泛型参数得到 UserEntity 类型根据 UserEntity 类型创建 TableInfo 并缓存基于 TableInfo 为 BaseMapper 内置方法生成 SQL注册 MappedStatement。关键是第二步和第四步。获取接口的泛型参数简化模型是这样的Type type mapperClass.getGenericInterfaces()[0]; ParameterizedType pType (ParameterizedType) type; Class? entityClass (Class?) pType.getActualTypeArguments()[0];因为 UserMapper 继承的是BaseMapperUserEntity所以getActualTypeArguments()[0]拿到的是UserEntity.class。如果这里拿不到具体类型或者拿到的是 Object后面的步骤就进行不下去。这就是为什么继承 BaseMapper 时必须把具体实体类写清楚的底层原因——不是你习惯了要写是框架运行机制要求你必须写。3.2 从 UserEntity 到 SQL注解和命名映射的完整链路拿到 UserEntity.class 以后MyBatis-Plus 会解析这个类的元数据生成 TableInfo。TableInfo 里保存的信息包括表名、主键字段、普通字段列表、逻辑删除字段、乐观锁字段等。解析规则大致是这样表名优先取TableName的值没有注解就取类名转下划线。UserEntity 默认表名就是 user_entity。主键优先取TableId标记的字段没有则查找名为 id 的字段如果还是没有依赖主键的方法就会失效。普通字段优先取TableField的值没有注解就属性名转下划线。不参与 SQL 的字段用TableField(exist false)标记常用于实体里冗余的非表字段。之后内置方法的 SQL 生成器会基于 TableInfo 拼接 SQL。拿 selectById 举例最终生成的 SQL 大致是SELECT id,user_name,status FROM user WHERE id?如果启用了逻辑删除SQL 会自动追加AND deleted0。这看起来像是在写 SQL其实所有表名和字段都来自 TableInfo而不是你手写的 XML。这就是 MyBatis-Plus 与传统 MyBatis 最大的不同它把“实体到表”的映射做成了可复用的元数据SQL 变成了元数据的产物。这里再补充一个容易忽略的功能在实体类字段上写TableField(select false)可以让这个字段不出现在默认查询的 SELECT 列表里比如大字段 content。selectById、selectList 默认都不会查它只有你自己写 SQL 需要时手动查询。这种字段级控制在内容类业务系统里非常好用。3.3 为什么泛型写错了项目启动就报错假如你把 UserMapper 写成extends BaseMapperObject或者写成自定义泛型extends BaseMapperT项目启动时大概率会看到类似Can not find table cache in this region for class java.lang.Object的异常。原因正如上面所说MyBatis-Plus 注册 BaseMapper 内置方法时必须拿到具体实体类型而这个推导过程发生在启动阶段不是调用阶段。所以配置错误会在启动时立刻暴露而不是等到第一个请求进来才报错。这其实是设计优点错误越早暴露越好。理解了这一点你再看到“Mapper 接口必须写具体泛型”的规范就不是死记硬背了而是框架机制的自然结论。如果你想让一个 Mapper 可以给多个实体复用那等于跟这个机制对着干会处处碰壁。接受它反而简单。4. 继承 BaseMapper 之后日常 CRUD 可以怎么写含自定义方法4.1 Service 层如何配合 BaseMapper实际项目里我们一般不直接在主业务代码里注入 UserMapper 调用而是再多加一层 Service。MyBatis-Plus 给这一层提供了IServiceT接口和ServiceImplM, T实现类。你的 Service 接口继承 IService 实现类继承 ServiceImplUserMapper, UserEntity就能获得批量操作、链式查询等额外能力Service public class UserServiceImpl extends ServiceImplUserMapper, UserEntity implements UserService { // 通用 CRUD 方法直接从父类继承 // 自定义业务方法在这里补 }ServiceImpl 内部持有 baseMapper 对象也就是你的 UserMapper。所以你在 Service 里依然可以直接用baseMapper.selectList(...)访问 BaseMapper 的全部方法。而 IService 提供的 save、saveBatch、getById、list、page 等方法底层最终也是调 baseMapper 的单表 CRUD。不要把它们看成两套体系它们共享同一个 Mapper 和同一份元数据。4.2 自定义方法如何与 BaseMapper 共存BaseMapper 覆盖了单表 CRUD但真实需求总会超出这个范围。比如查“最近七天活跃用户”这种复杂 SQL或者跨表 join就得在 UserMapper 接口里追加自己的方法。两种常见写法XML 和注解。复杂 SQL 我推荐放在 XML 里维护Mapper public interface UserMapper extends BaseMapperUserEntity { ListUserEntity selectActiveUser(Param(days) Integer days); }XML 文件mapper namespacecom.example.mapper.UserMapper select idselectActiveUser resultTypecom.example.entity.UserEntity SELECT * FROM user WHERE last_login_time DATE_SUB(NOW(), INTERVAL #{days} DAY) /select /mapper注意 XML 的 namespace 必须精确指向 UserMapper 接口的全限定名否则 MyBatis 无法把方法实现绑定到接口方法上。这一点和传统 MyBatis 没有任何区别。另一种方式是用注解 Select 直接写在接口方法上适合几条简单 SQL。但一旦 SQL 超过两三行、需要动态条件判断时注解的可读性远不如 XML。我的习惯是简单查询用注解凡是涉及动态条件、多表关联、复杂统计的统统进 XML。这不是规定是长期维护体验得出来的结论。4.3 一个完整的 Controller-Service-Mapper 示例把前面所有东西串起来日常开发的调用链路大概长这样。Controller 接收参数Service 做业务逻辑Mapper 只管数据读写。RestController RequestMapping(/user) public class UserController { Resource private UserService userService; GetMapping(/page) public IPageUserEntity page(RequestParam Integer current, RequestParam Integer size) { return userService.page(new Page(current, size), new LambdaQueryWrapperUserEntity() .eq(UserEntity::getStatus, 1) .orderByDesc(UserEntity::getId)); } }这段代码里userService.page(...)最终会走到 ServiceImpl 里的 baseMapper.selectPageBaseMapper 的分页能力在这里起了决定性作用。查询条件全部由 LambdaQueryWrapper 承载字段引用能在编译期检查。这个结构在中小团队里足够清晰也是 MyBatis-Plus 官方文档推荐的姿势。如果你是刚接触 MyBatis-Plus建议先把 BaseMapper 里最常用的十个方法记住insert、deleteById、updateById、selectById、selectOne、selectCount、selectList、selectPage、delete(Wrapper)、update(T, Wrapper)。剩下的方法等确实需要时再查记忆负担小日常开发也够用了。5. 踩坑清单关于 BaseMapper 你看不到的边界5.1 泛型边界一个 Mapper 只能管一个实体这是设计约束一个 Mapper 接口继承 BaseMapper 时只能指定一个泛型实体。有些同学想省事在一个 Mapper 里既想 selectById(UserEntity)又想 selectById(OrderEntity)这行不通。BaseMapper 内置方法的泛型 T 只会指向一个实体。如果确实需要操作多张表正确做法是定义多个 Mapper比如 UserMapper、OrderMapper各自继承 BaseMapper对应实体。跨表查询放到聚合层自定义方法里用 DTO 作为结果类型。强行让一个 Mapper 碰多张表TableInfo 机制会失效最后只能全写 XML等于丢掉 BaseMapper 带来的所有便利。5.2 表名和主键的坑TableName 与 TableId 的重要性我在第 1 章说过不写 TableName 时默认按类名转下划线推导表名。这个默认行为会引发几类问题类名 UserEntity默认表名 user_entity但实际表叫 t_user所有通用方法查错表类名 UserInfo默认表名 user_info如果表恰好也叫 user_info没问题但你不能保证每个类都这么巧实体里没有一个字段叫 id也没有 TableIdselectById、updateById 这类依赖主键的方法直接失效。规范做法很直接每个实体类都写明 TableName每个实体主键字段都写 TableId。即使表和类名刚好一致也建议写。多人协作的项目里显式配置永远比隐式约定可靠。主键的 IdType 也要关注。数据库主键是自增IdType 配置成 INPUT 而不传 idinsert 时就可能触发出错配成 AUTO 又是另一种行为。这个注解直接影响插入后主键能不能自动回填到实体对象里。别小看这一点很多“插入后拿不到 id”的疑问最后都能查到是 IdType 配置问题。5.3 逻辑删除对 deleteById 的影响逻辑删除是我最推荐尽早了解的功能之一很多老项目一上来就物理删数据想恢复时追悔莫及。在实体里加一个TableLogic字段比如 deleted之后调userMapper.deleteById(1L)实际 SQL 会变成UPDATE user SET deleted 1 WHERE id 1 AND deleted 0而不是DELETE FROM user WHERE id 1这带来两个感知点你以为在删数据其实在改数据所有 BaseMapper 默认查询会自动追加deleted 0条件所以被逻辑删过的数据不会出现在 selectList 结果里。如果你某天发现一条数据明明“删了”但数据库里还在先去看实体上有没有 TableLogic而不是怀疑框架出了 bug。另外逻辑删除字段建议全项目统一命名比如 deleted并在全局配置里让所有没有逻辑删除字段的表不受影响避免各表行为不一致。5.4 批量插入的真相BaseMapper 并没有自带 saveBatch这是最容易被误解的点。搜“MyBatis-Plus 批量插入”时十篇帖子有八篇会告诉你用 saveBatch但 saveBatch 根本不在 BaseMapper 里它是IServiceT的默认方法。ServiceImpl 实现 saveBatch 时本质是循环调用baseMapper.insert(entity)只不过会借助 MyBatis 的批量执行机制来减少数据库往返。所以如果你只写了UserMapper extends BaseMapperUserEntity直接调userMapper.saveBatch(...)是编译不过的。正确选择有三种在 Service 实现类里继承 ServiceImplUserMapper, UserEntity用this.saveBatch(list)自己写 XML foreach 批量插入一条 SQL 插入多条数据库连接串加上rewriteBatchedStatementstrue让 MySQL 批量执行真正合并。我做过一个 2 万行的导入场景用 ServiceImpl.saveBatch 配批量执行机制性能已经比逐条插入好很多。如果数据量达到几十万行建议 XML foreach 分批提交每批 500 到 1000 条效果通常更好。千万别在 for 循环里单条 insert那会让数据库反复握手性能差到想哭。5.5 驼峰映射、字段命名与其他边界MyBatis-Plus 默认开启驼峰映射实体属性 userName 映射到列名 user_name。这个默认值虽然省事但有几个容易忽略的例外属性名如果本身带下划线比如 authorId映射到 author_id 没问题但如果你的 Java 属性叫 author_id想映射到 authorId 列那就对不上了布尔类型或带 is 前缀的字段比如 isDeleted映射到 is_deleted 时容易出歧义数据库用了大写列名时最好通过 TableField 显式映射。遇到这些边界我的做法是查不到数据先不猜直接把 SQL 日志打开看一眼生成的 select 列表和 where 条件问题基本立刻清楚。MyBatis-Plus 的日志配置就是标准 SQL 日志开启后不用猜。还有两件小事。第一泛型参数不要写全角符号Java 编译器只认英文半角写错了代码直接编译失败。这种问题在论坛复制代码时很常见值得留意。第二分页插件一定要配置否则 selectPage 只是把全表数据查回来在内存里“翻页”数据量大时性能会非常难看。在这些坑里我踩得最痛的是批量插入那一次。线上导入几十万条数据我用的是自己封装的一个批量工具一直以为是数据量问题后来才意识到逐条 insert 等于让 MySQL 反复握手。切到 XML foreach 分批插入后耗时降了一个量级。从那以后我对 BaseMapper 的边界认识就深了它做好的是单表 CRUD 的基础约定超出边界的部分老老实实回到 SQL 的世界里。这行extends BaseMapperUserEntity也在我心里从“固定模板”变成了一张地图的起点。