MyBatis-Plus Mapper层实战:从BaseMapper到SQL注入器的完整指南

发布时间:2026/10/13 11:29:49
MyBatis-Plus Mapper层实战:从BaseMapper到SQL注入器的完整指南 1. 从一套写了三年的Mapper样板代码说起如果你维护过一段时间手写MyBatis的Mapper层大概率见过这样一个画面每个接口里整整齐齐排着一排selectById、updateById、deleteById每个XML开头都是一段机械复制的resultMap多一张表就多一摞这类代码。后来我把一个运营中台项目的Mapper层整体迁到了MyBatis-Plus最大的感受不是MP多省事而是它把“通用”和“定制”真正分开了跑不掉的CRUD交给框架复杂的SQL留给自己。我接手这个项目时Mapper层已经写了三年。会员表加一个mobile字段要动实体类、改XML的resultMap、核对select的列名、再检查insert和update语句漏掉一处大概率就是线上报错。部门里几个同事都吃过这个亏所以提到重构大家的诉求高度一致把重复劳动降下来但不要失去MyBatis对SQL的掌控力。MyBatis-Plus正好踩在这个点上它不替代MyBatis而是往MyBatis里预置了一批通用方法同时把SQL注入器、拦截器这些扩展口子全留了出来。这篇文章按从基础到扩展的顺序梳理Mapper相关的完整用法。前两章适合刚接触MP或者正在选型的读者后面关于自定义SQL、SQL注入器和生产环境踩坑的部分适合想玩明白MP扩展机制的Java后端。这里先给个结论性判断MP的定位从来不是把人变懒而是把“总是要写”的代码收编掉让你把精力花在真正需要人肉维护的动态SQL上。1.1 这个项目原本的Mapper层长什么样老项目的Mapper层是典型的手写MyBatis风格每个实体对应一个接口和一套XML文件。UserMapper里躺着selectById、selectByUserName、countAll、deleteById这些方法OrderMapper翻开来几乎是一模一样的结构换掉表名和字段名就是另一个文件。改字段的时候最痛苦因为同一个字段分散在XML的列名、resultMap、insert语句和update语句里编辑器只能帮你改同一个文件里的引用跨文件的漏网之鱼全靠心细。这种模式还带来一个隐性问题代码评审时没人认真看这些CRUD模板但每个人都复制粘贴过。复制最容易出错的不是SQL本身而是parameterType和resultType写错、多了个尾逗号、condition参数没带全。这类问题通常到测试阶段才暴露排查起来又特别费时间。1.2 MyBatis-Plus在Mapper层的思路通用和定制分离MP解决重复劳动的方式很直接把CRUD方法直接放到BaseMapper接口里你的Mapper继承它以后等于白拿了一套可用的SQL实现。这套实现不是运行时反射拼出来的而是在MyBatis初始化阶段由SQL注入器把这些方法生成成一个个MappedStatement注册进去。也就是说MP最终做的事情和手写XML没有本质区别只是把机械化流程交给了代码。MP还定了一堆默认约定。类名User默认映射表user字段名mobile默认映射列mobile主键默认叫id。靠这些约定一个没加任何注解的POJO继承BaseMapper后就能做基本CRUD。当然约定是用来打破的表名和字段名不一致时可以加TableName、TableField覆盖。这种“有默认值但允许覆盖”的设计就是MP在Mapper层最核心的哲学。2. BaseMapper的默认方法会用和用对是两回事如果你第一次打开BaseMapper接口的源码会被里面十几个方法晃一下。但别急着每个都背日常真正高频的其实只有少数几个。下面这张表把常用方法按用途分了一下后面再逐个补充容易踩的细节。方法用途需要注意的点insert插入一条记录null字段默认不参与insertdeleteById按主键删除受逻辑删除注解影响不一定是物理删除delete按Wrapper条件删除条件为空时是全表删除updateById按主键更新null字段默认不参与updateupdate按Wrapper条件更新SET和WHERE是分离的selectById按主键查询查不到返回nullselectOne按条件查询单条查到多条会抛异常selectCount按条件统计数量返回LongselectList按条件查列表平时用得最多selectMaps查列表返回Mapkey不是驼峰是数据库列名selectBatchIds按主键集合批量查询集合为空返回空List2.1 默认方法清单与适用场景insert、updateById、selectList、selectCount这四兄弟基本能覆盖业务系统的普通操作。deleteById和delete建议谨慎使用尤其是逻辑删除场景MP会自动把删除操作改写为更新逻辑删除字段但前提是实体里配置正确下文会专门讲这个坑。selectBatchIds适合用一大串主键拼查询的场景注意如果传入的集合为空MP返回的是空List而不是查询报错业务代码可以放心处理。selectMaps是不少人忽略的方法。当你需要连表查询或者只查某些字段但又不想为此新建一个VO类时selectMaps直接返回Map列表。字段名默认是数据库列名比如nick_name而不是实体里的nickName。如果你希望它自动转成驼峰可以在配置里打开mapUnderscoreToCamelCaseMP默认就开着这个选项所以这个场景下返回的Map key也会是驼峰。2.2 实体映射注解表名、主键、字段的约定与覆盖MP对实体映射有一套默认规则但实际项目中表名和类名经常对不上这时候注解就派上用场了。TableName用来指定表名比如UserInfo类对应t_user_info表直接在类上写TableName(t_user_info)。TableId标记主键字段并且可以指定主键策略。用的是数据库自增就写IdType.AUTO不是自增主键而是由应用传入的字符串ID用IdType.INPUT如果希望框架帮你生成分布式ID用IdType.ASSIGN_ID默认会落到雪花算法。TableField的用途更广改名、指定字段是否存在、控制自动填充和逻辑删除。比如实体里有个字段在表里叫create_time类里叫createTime不加注解也能靠驼峰映射自动对应但如果表字段名比较特殊直接写TableField(create_time)最稳妥。另一个容易漏的点是实体里有些字段只是业务计算用的表里根本没这列必须加TableField(exist false)否则MP生成SQL时会把这个字段当成表的字段报错。2.3 insert和update的null字段策略以及selectOne的隐藏规则BaseMapper里的insert和updateById默认字段策略是NOT_NULL实体里为null的字段不会出现在INSERT语句或SET语句里。这个设计很聪明插入时不会把null覆盖掉数据库列的默认值更新时也不会把已有数据改成null。想彻底把某个字段置空反而不能用updateById得用UpdateWrapper显式set这个后文展开。selectOne值得单独说如果你用selectOne查数据而匹配结果不止一条MP会直接抛异常。原因是selectOne底层调用selectList后对返回列表做了size判断多条数据时宁可报错也不猜。如果你只是顺手想取一条比如按某个非唯一字段查最近的一条记录建议用selectList后取第一条或者wrapper里orderByDesc某个时间字段再用last(limit 1)限制行数。3. 条件构造器Wrapper我为什么最终只留了Lambda版Wrapper是MP最让人上头的部分。有了它很多查询连SQL都不用写直接用Java代码把条件链出来。但Wrapper家族里那些眼花缭乱的类用多了以后我反而只留一个LambdaQueryWrapper以及跟它配套的LambdaUpdateWrapper。不是说QueryWrapper不能用而是既然有更好的选择就没有理由回到字符串列名的时代。3.1 QueryWrapper和LambdaWrapper的差距不只是少打几个引号先看QueryWrapper的典型写法QueryWrapperUser wrapper new QueryWrapper(); wrapper.eq(mobile, mobile).like(nick_name, keyword);问题在于“mobile”和“nick_name”是字符串。今天代码能跑不代表下次重构安全。实体字段类型可以从String改成Integer时IDE的重构工具能改Java代码里的引用但改不了字符串字面量。一旦列名写错不是编译报错而是运行时SQL异常。LambdaQueryWrapper用实体方法引用替代字符串LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getMobile, mobile).like(User::getNickName, keyword);列名拼写错误在编译期就现原形IDE也能帮你跳转到对应实体字段。因为Lambda表达式引用的是getter方法字段改名时IDE重构会同步更新所有引用。唯一的例外是列名无法通过实体方法引用表达比如动态拼接表名、字段名或者确实需要写原生SQL片段这种情况下再用apply和last兜底。3.2 条件组合的完整套路and/or嵌套、exists、applyWrapper提供的条件方法不少日常主要够用这几类eq、ne、gt、lt、ge、le做比较like、notLike、likeRight、likeLeft做模糊匹配between、notBetween处理区间in、notIn处理集合isNull、isNotNull判空。每个方法都有个关键细节连续调用多个条件方法是AND关系。OR条件容易踩坑的是嵌套组合。假设你要查“状态为1且创建时间在某个区间内或者用户名等于某个值”直接链式写可能变成“状态为1且创建时间在区间内或者用户名等于某个值”逻辑完全跑偏。正确姿势是用and方法包一组子条件LambdaQueryWrapperOrder wrapper new LambdaQueryWrapper(); wrapper.eq(Order::getStatus, 1) .and(w - w.ge(Order::getCreateTime, start) .le(Order::getCreateTime, end) .or() .eq(Order::getUserName, name));需要写原生SQL片段时apply是比last安全的选择。apply支持参数占位MP会帮你处理参数转义wrapper.apply(date_format(create_time, %Y-%m-%d) {0}, dateStr);不要把用户输入直接拼进apply或last哪怕只是条件片段。虽然MP在这块没有完全禁用但SQL注入风险是实打实的。last的语义是“把这段内容直接拼到SQL最末尾”比如写last(limit 1)。它和分页插件一起用时容易冲突因为分页插件本身要改写limit重复出现分页语句会导致SQL语法错误。3.3 UpdateWrapper一种把SET和WHERE拆开的更新方式updateById只能按主键更新而且null字段不参与。但业务里经常出现“把某个字段清空”或者“按条件批量更新某些列”的需求这时候用UpdateWrapper最合适。LambdaUpdateWrapperUser wrapper new LambdaUpdateWrapper(); wrapper.set(User::getMobile, null) .set(User::getStatus, 0) .eq(User::getDepartmentId, deptId); userMapper.update(null, wrapper);update的第一个参数传null表示实体本身不参与SQL构建完全由UpdateWrapper里的set和条件决定。这个写法能精确控制更新哪些列不受实体字段策略的限制。另外注意LambdaUpdateWrapper也支持直接在条件里eq、in、like和QueryWrapper的条件能力是重叠的更新条件的表达力和查询一样。4. 分页查询与自定义SQLMP不会关掉原生MyBatis的手艺很多人误以为用了MP就只能用BaseMapper碰到多表联查就一筹莫展。实际上MP的分页插件建立在MyBatis的ExecutorInterceptor机制上自定义SQL照样能被拦截并加上分页逻辑。分页和自定义SQL从来不是非此即彼而是配合使用。4.1 PaginationInnerInterceptor分页插件的配置和原理分页功能不是MP自带的需要先注册插件。新版MP推荐使用MybatisPlusInterceptor统一管理内部拦截器Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); interceptor.addInnerInterceptor(pagination); return interceptor; }分页插件做的事情不复杂拦截到查询SQL后先执行一条count语句拿到总数再把原SQL改写成分页形式。内部是靠拦截Mybatis的Executor接口对BoundSql里的SQL做解析和拼装。理解这个对排查问题很有用后面踩坑章节会提到日志里看到一条SELECT和一条COUNT同时出现说明分页插件正常工作了不是SQL执行了两次。maxLimit这个参数建议设置防止调用方传一个特别大的页码把数据库打挂。插件在执行前会校验pageSize是否超过maxLimit超限就替换成maxLimit不报错也不放行算是一种兜底保护。4.2 自定义方法的三种姿势以及怎么选自定义SQL的三种主流姿势分别是注解SQL、XML文件和InnerInterceptor拦截器。注解SQL适合简单场景直接在Mapper方法上加Select、UpdateSelect(select * from t_user where mobile #{mobile}) User selectByMobile(String mobile);缺点是注解里写动态SQL很痛苦if标签在注解里可读性极差一旦判断条件变多那就是给自己埋雷。XML文件是手写MyBatis的老传统复杂查询和多表联查时最推荐。Mapper接口和XML文件放在同一包路径下MP的MapperScannerConfigurer会自动扫描。自定义方法、多表JOIN、动态if判断XML都能精准表达还能复用SQL片段。InnerInterceptor适合做横切逻辑比如公共数据权限过滤、全局逻辑删除强制追加条件等。这类需求如果每个SQL里都手写条件极易漏用拦截器统一处理更可控。但它对SQL解析技术要求高不建议一上来就写容易误伤自带SQL。4.3 多表联查场景下Mapper接口和VO的设计样板多表联查时返回结果往往不是单个实体而是几个表字段的组合。规范做法是建一个VO类字段按页面需要组装Mapper方法返回IPage public interface OrderMapper extends BaseMapperOrder { IPageOrderDetailVO selectOrderDetailPage(Page? page, Param(userId) Long userId, Param(status) Integer status); }对应XML:select idselectOrderDetailPage resultTypecom.example.vo.OrderDetailVO SELECT o.id, o.order_no, u.nick_name, o.amount FROM t_order o LEFT JOIN t_user u ON o.user_id u.id where if testuserId ! nullAND o.user_id #{userId}/if if teststatus ! nullAND o.status #{status}/if /where ORDER BY o.id DESC /select调用时第一个参数必须是IPage的子类分页插件看到这个参数才会去改写SQLPageOrderDetailVO page new Page(1, 10); IPageOrderDetailVO result orderMapper.selectOrderDetailPage(page, 1001L, 2); result.getTotal(); result.getRecords();这里有个新手常犯的错误方法参数里没有IPage只在方法返回类型里写了IPage分页插件是不会工作的。分页插件判断的依据是Mapper方法的入参里有没有IPage类型而不是返回值。5. 高级扩展用SQL注入器做一个通用批量插入方法BaseMapper的默认能力满足普通CRUD但遇到批量插入就会尴尬。一张几千行的Excel导入如果用BaseMapper的insert一条条循环光网络往返和SQL解析就够喝一壶。MP官方没有把批量插入直接放进BaseMapper但它留了SQL注入器这个后门我们完全可以自己造一个通用insertBatch方法让所有Mapper都继承。5.1 SQL注入器的工作机制从AbstractMethod到MappedStatementMyBatis启动时Mapper接口里的每个方法都要对应生成一个MappedStatement方法调用时根据MappedStatement里的SqlSource执行SQL。手写XML时MyBatis从XML里解析这个映射关系。MP的SQL注入器则是在代码里动态构造MappedStatement把BaseMapper接口里的方法批量注入进去。SQL注入器的核心是DefaultSqlInjector。它负责遍历Mapper接口为每个方法生成MappedStatement。而单个方法的具体实现是AbstractMethod的子类来定义的比如Insert、SelectList这些都对应一个独立的Method类。所以自定义注入器的套路很清晰继承DefaultSqlInjector在getMethodList里追加自己的AbstractMethod就能在Mapper初始化时把它变成可用的SQL方法。5.2 手写一个insertBatch方法并注入到所有Mapper先定义一个AbstractMethod子类核心是拼出批量插入的SQL脚本public class InsertBatchMethod extends AbstractMethod { Override public MappedStatement injectMappedStatement(Class? mapperClass, Class? modelClass, TableInfo tableInfo) { String columnSql tableInfo.getAllInsertSqlColumnMaybeIf(null); String valuesSql foreach collectionlist itemitem indexindex separator, ( tableInfo.getAllInsertSqlPropertyMaybeIf(item) ) /foreach; String sql scriptINSERT INTO tableInfo.getTableName() columnSql VALUES valuesSql /script; SqlSource sqlSource languageDriver.createSqlSource(configuration, sql, modelClass); return addInsertMappedStatement(mapperClass, modelClass, insertBatch, sqlSource, null, null); } }然后自定义SqlInjector把方法追加进去public class CustomSqlInjector extends DefaultSqlInjector { Override public ListAbstractMethod getMethodList(Class? mapperClass) { ListAbstractMethod methods super.getMethodList(mapperClass); methods.add(new InsertBatchMethod()); return methods; } }再加一个自定义BaseMapper接口所有Mapper改继承它public interface CustomBaseMapperT extends BaseMapperT { int insertBatch(Param(list) ListT entityList); }最后把注入器注册成Spring BeanBean public SqlInjector sqlInjector() { return new CustomSqlInjector(); }这样项目里所有继承CustomBaseMapper的Mapper直接用insertBatch方法。某个具体Mapper如果想单独覆盖或增加方法继承CustomBaseMapper再扩展即可。需要注意不同MP版本的AbstractMethod方法名和参数可能有细微差异实际项目里以你依赖的版本API为准但这个扩展思路是通用的。5.3 批量插入的实测效果和边界情况我在一次初始化数据时做过对比List里大概1万条记录原来循环调用insert大概跑了一分多钟改成insertBatch之后几秒就结束了。这个数字跟网络、数据库配置、机器性能都有关但数量级差距是实打实的。批量插入有三个边界情况要提前处理。第一list为空时拼出的SQL是INSERT INTO xxx VALUES直接语法错误调用方必须在方法入口判空。第二超大list建议分批插入比如每2000条提交一次否则单条SQL过长数据库解析压力大。第三批量插入时MP默认不会把数据库自增主键回写到对象里如果你插入后需要用到主键要么换循环插入要么插完再查一次。这些细节在实际落地时会非常影响体验。6. 生产环境里踩过的几个坑和排查思路正常CRUD用熟以后项目上线才是真正见真章的时候。这里记录几个我亲自踩过的坑每一个都花了不少时间在排查上。6.1 逻辑删除与唯一索引冲突一个隐蔽的数据完整性隐患逻辑删除是MP很受欢迎的功能实体里加个TableLogic注解删除时MP自动把删除操作改写成UPDATE del_flag 1。但在有唯一索引的表上这个设计会出现尴尬局面。某用户表有个user_name唯一索引用户A注册后逻辑删除再注册同名用户B时数据库发现旧记录还占着user_name的唯一索引插入直接报重复。这个问题不是MP的bug而是逻辑删除和唯一索引天然冲突。解决思路一般有几个方向唯一索引里加上逻辑删除字段比如(user_name, del_flag)但删除时del_flag必须每次取不同的值否则第二条被删记录还是会冲突或者用户名带上删除时间戳再入库再或者做匿名化物理删除。没有银弹得看业务怎么权衡。还有一个更隐蔽的坑BaseMapper的默认方法会自动追加逻辑删除条件但自己写的XML SQL不会自动加。比如你手写了一个统计用户数的SQL忘了加del_flag 0结果把逻辑删除的用户全统计进去了。排查这类问题没有什么捷径只能从SQL日志里看是否带了逻辑删除条件。6.2 乐观锁只对updateById生效wrapper更新的语义差异乐观锁用Version注解配合OptimisticLockerInnerInterceptor插件。它在UPDATE时会自动带上版本号条件比如UPDATE t_user SET name ?, version version 1 WHERE id ? AND version 旧值。看起来很方便但它的触发范围非常严苛只对updateById生效。用UpdateWrapper自己拼条件更新时MP不会自动加version条件。我踩过的场景是页面批量审核功能直接new UpdateWrapper然后set审核状态忘了带version条件导致两个管理员同时审核同一条数据时后提交的覆盖了先提交的。原因是Wrapper方式更新绕过了乐观锁逻辑。解决方法是把版本号也体现在条件里LambdaUpdateWrapperRecord wrapper new LambdaUpdateWrapper(); wrapper.set(Record::getAuditStatus, 1) .eq(Record::getId, id) .eq(Record::getVersion, oldVersion);另外注意version字段本身不能为null否则MP不生成乐观锁条件。6.3 自定义SQL不要自助松绑开启SQL日志逐条验证MP的自定义注入器、自定义XML本质都是在往MappedStatement里塞SQL。但SQL生成链路长最容易在运行时才暴露问题。最好的排查工具就是把SQL日志打出来mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl日志会逐条打印MP真正执行的SQL和参数比如分页时会同时看到COUNT查询和分页查询两条语句。网上很多人问“MP的selectList查出来少了数据”打开日志一看SQL里多了一个逻辑删除条件或者分页count条件写错一切都清楚了。我在一次排查中发现自定义的insertBatch生成的SQL没有表名前缀报语法错误。当时第一反应是怀疑SQL注入器有问题后来打印出SQL日志一看是TableInfo拿错了表名查了下实体类的TableName没写全路径。这类问题没有日志引导光凭代码走读很难快速定位。说到底MP只是把MyBatis的自动化程度提高了一层但SQL仍然是最终执行者。遇到任何诡异现象先看日志里的最终SQL比反复翻文档靠谱得多。现在我的一个习惯是每写一个自定义方法、每改一次实体注解都先跑一个测试用例把SQL日志截下来看一眼确认生成的SQL和预期一致再往上走这个习惯帮我挡住了不少线上事故。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询