1. 从MyBatis到MyBatis-Plus为什么我们需要它如果你用过MyBatis肯定对写XML映射文件、手写CRUD的SQL、处理分页参数这些事不陌生。这些工作重复、繁琐但又必不可少。MyBatis-Plus简称MP的出现就是为了解决这些痛点。它不是要替代MyBatis而是在MyBatis的基础上做了一层功能强大的增强核心目标就一个简化开发提升效率。你可以把它理解为一个“MyBatis的瑞士军刀”。它内置了通用的Mapper和Service只需要通过简单的继承就能获得单表几乎所有的CRUD操作能力连一行XML都不用写。分页、逻辑删除、字段自动填充、多租户、性能分析……这些在业务开发中高频出现的需求MP都提供了开箱即用的解决方案。最近社区里讨论的动态取消租户隔离、字段级加密等也都是MP在应对复杂业务场景时能力的体现。这篇文章我会结合自己多年的使用和踩坑经验带你彻底搞懂MP从核心原理到高阶玩法再到生产环境的避坑指南让你不仅能“用”更能“用好”。2. MP的核心架构与自动CRUD原理拆解很多初学者觉得MP很神奇继承个BaseMapper就能直接调用selectById、insert方法。这背后其实是MP核心的SQL自动注入机制在起作用。理解这个是灵活使用和排查问题的基础。2.1BaseMapperT与DefaultSqlInjector当你定义一个Mapper接口并继承BaseMapperUser时MP在应用启动阶段会对其进行处理。核心类是DefaultSqlInjector。它的工作流程是这样的扫描MP扫描所有继承了BaseMapper的接口。分析实体类通过泛型T这里是User获取对应的实体类Class对象。注入方法SqlInjector会根据实体类的元信息字段、主键、表名将一系列预定义好的AbstractMethod对象注入到你的Mapper中。这些AbstractMethod就对应着insert,deleteById,updateById,selectById,selectList等方法。SQL模板化每个AbstractMethod都关联着一个SqlSource。例如SelectById方法对应的SQL模板大致是SELECT * FROM {tableName} WHERE {keyColumn} #{id}。启动时MP会用真实的表名来自TableName注解或全局配置、主键列名来自TableId来填充这个模板生成最终的、可执行的MappedStatement并注册到MyBatis里。所以你调用的userMapper.selectById(1L)实际上执行的是MP在启动时就为你生成好的、一条完整的SQL语句。这一切对开发者都是透明的。2.2 条件构造器QueryWrapper与UpdateWrapper自动CRUD解决了单表无条件的操作但复杂的查询和更新怎么办这就是Wrapper的舞台。QueryWrapper用于构建查询条件UpdateWrapper用于构建更新条件。它们的设计精髓在于链式调用和Lambda表达式。传统方式容易因为字段名拼写错误导致运行时异常QueryWrapperUser wrapper new QueryWrapper(); wrapper.eq(user_name, 张三).gt(age, 18).orderByDesc(create_time);而Lambda方式则利用了Java的编译期检查安全得多LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getName, 张三).gt(User::getAge, 18).orderByDesc(User::getCreateTime);UpdateWrapper更强大它可以直接设置更新字段和更新条件UpdateWrapperUser wrapper new UpdateWrapper(); wrapper.set(email, newemail.com).setSql(balance balance 100) // 支持SQL片段 .eq(status, 1); userMapper.update(null, wrapper); // 第一个参数为null表示更新字段由wrapper指定注意使用UpdateWrapper且第一个实体参数为null时必须确保在wrapper中通过.set()指定要更新的字段否则SQL会变成UPDATE table SET WHERE ...导致语法错误或更新全部数据极其危险。3. 分页查询的深度配置与内存分页陷阱分页是MP的招牌功能之一但用不好就容易掉坑里。3.1 正确配置分页插件首先必须在配置类中声明PaginationInnerInterceptor插件否则分页方法不生效。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 添加分页插件 PaginationInnerInterceptor paginationInnerInterceptor new PaginationInnerInterceptor(DbType.MYSQL); // 设置请求的页面大于最大页后操作 true调回到首页false 继续请求 默认false paginationInnerInterceptor.setOverflow(false); // 设置最大单页限制数量默认 500 条-1 不受限制 paginationInnerInterceptor.setMaxLimit(500L); interceptor.addInnerInterceptor(paginationInnerInterceptor); return interceptor; } }3.2 使用分页对象Page使用起来非常简单// 参数1当前页参数2每页大小 PageUser page new Page(1, 10); // 执行查询page对象会被填充结果 PageUser resultPage userMapper.selectPage(page, queryWrapper); ListUser records resultPage.getRecords(); // 当前页数据 long total resultPage.getTotal(); // 总记录数3.3 警惕“内存分页”陷阱这是MP分页最容易出问题的地方。分页的本质是在数据库层面用LIMIT和OFFSET实现的。如果你的QueryWrapper中包含了联表查询例如wrapper.inSql(“dept_id”, “SELECT id FROM dept WHERE ...”)或者使用了自定义的xmlSQLMP的插件可能无法正确地将分页参数LIMIT追加到整个复杂SQL的外层从而导致所谓的“内存分页”。内存分页的表现MP会先执行你的SQL不带LIMIT把所有数据从数据库查到JVM内存中然后在内存中进行分页截取。当数据量很大时这会直接导致OOM内存溢出。如何避免对于单表操作MP的分页是安全的。对于多表关联查询强烈建议将关联查询写在XML文件中并确保SQL是一个简单的SELECT ... FROM ... WHERE ...形式MP插件可以正常处理。复杂的、带有子查询的语句风险较高。可以在日志中打开SQL打印mybatis-plus.configuration.log-implorg.apache.ibatis.logging.stdout.StdOutImpl检查最终执行的SQL是否包含LIMIT关键字。如果没有就是内存分页。4. 高级特性实战逻辑删除、字段填充与多租户4.1 逻辑删除优雅地“软删除”逻辑删除意味着数据不从表中物理移除而是通过一个标志位如deleted来标记已删除。MP对此有完美支持。全局配置mybatis-plus: global-config: db-config: logic-delete-field: deleted # 全局逻辑删除的实体字段名 logic-delete-value: 1 # 逻辑已删除值默认为 1 logic-not-delete-value: 0 # 逻辑未删除值默认为 0实体类字段TableLogic private Integer deleted;配置后当你调用mapper.deleteById(1)MP实际执行的是UPDATE user SET deleted 1 WHERE id 1。而调用mapper.selectList(wrapper)时MP会自动在查询条件后追加AND deleted 0。这极大地简化了业务代码。4.2 字段自动填充告别手动set创建时间像create_time,update_time这种字段我们希望在插入或更新时自动设置。MP提供了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()); this.strictInsertFill(metaObject, “createBy”, String.class, getCurrentUserId()); // 示例填充创建人 } Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, “updateTime”, LocalDateTime.class, LocalDateTime.now()); this.strictUpdateFill(metaObject, “updateBy”, String.class, getCurrentUserId()); } }实体类字段注解TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime;这样在执行insert或update方法时这些字段会自动被处理器填充无需在业务代码中手动赋值。4.3 多租户与“动态取消租户隔离”多租户Tenant是SaaS系统的常见需求即一套系统为多个客户租户服务数据在逻辑上隔离。MP通过TenantLineInnerInterceptor插件实现。配置租户插件Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); TenantLineInnerInterceptor tenantInterceptor new TenantLineInnerInterceptor(); tenantInterceptor.setTenantLineHandler(new TenantLineHandler() { Override public Expression getTenantId() { // 从当前请求上下文中获取租户ID例如从ThreadLocal中 String tenantId TenantContext.getCurrentTenant(); return new StringValue(tenantId); } Override public String getTenantIdColumn() { return “tenant_id”; // 数据库中的租户ID列名 } Override public boolean ignoreTable(String tableName) { // 忽略不需要租户隔离的表如全局配置表 return “sys_config”.equalsIgnoreCase(tableName); } }); interceptor.addInnerInterceptor(tenantInterceptor); return interceptor; }配置后所有增删改查操作MP都会自动在SQL中加上AND tenant_id ‘当前租户’。那么如何“动态取消租户隔离”呢这是最近的热门话题。场景是超级管理员需要跨租户查询数据。MP官方并没有直接提供一个开关API。常见的实践方案有以下几种使用InterceptorIgnore注解需谨慎在Mapper方法上使用InterceptorIgnore(tenantLine “true”)可以跳过租户插件。但这需要修改Mapper接口且是静态的。自定义ThreadLocal上下文这是更灵活的动态方案。在TenantLineHandler.getTenantId()方法中不直接从固定的上下文获取而是先检查一个自定义的ThreadLocal变量比如IgnoreTenantContext。public class TenantContext { private static final ThreadLocalString CURRENT_TENANT new ThreadLocal(); private static final ThreadLocalBoolean IGNORE_TENANT ThreadLocal.withInitial(() - false); public static void setIgnoreTenant(boolean ignore) { IGNORE_TENANT.set(ignore); } public static String getCurrentTenant() { if (Boolean.TRUE.equals(IGNORE_TENANT.get())) { return null; // 返回null租户插件可能不会追加条件取决于实现 } return CURRENT_TENANT.get(); } }然后在需要跨租户查询的代码块前后手动设置和清理try { TenantContext.setIgnoreTenant(true); // 执行需要跨租户的查询 ListData allData dataMapper.selectList(wrapper); } finally { TenantContext.setIgnoreTenant(false); // 务必清理避免污染后续操作 }你需要在自定义的TenantLineHandler中判断如果getTenantId()返回null或特定值则构造一个“11”之类的永真条件或者直接让插件不做任何追加。这种方案需要对MP租户插件的内部逻辑有较深理解并进行定制化改造风险较高。使用多个数据源/Mapper为超级管理员单独配置一个不启用租户插件的SqlSessionTemplate或Mapper专门用于执行跨租户查询。这种方式隔离性最好但架构复杂。5. 字段级加密透明化处理敏感数据“数据库字段级加密”是数据安全的重要一环。目标是让敏感数据如手机号、身份证号在数据库中以密文存储但在业务代码中读写时却是明文对开发者透明。结合Spring Boot和MP有几种实现思路。5.1 基于TypeHandler的加解密这是最贴合MP和MyBatis原生机制的方式。核心是为需要加密的字段自定义一个TypeHandler。实现加解密TypeHandlerMappedTypes(String.class) MappedJdbcTypes(JdbcType.VARCHAR) public class CryptoTypeHandler extends BaseTypeHandlerString { private final CryptoService cryptoService; // 你的加解密服务 Override public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException { // 在写入数据库前加密 ps.setString(i, cryptoService.encrypt(parameter)); } Override public String getNullableResult(ResultSet rs, String columnName) throws SQLException { // 从数据库读出后解密 String cipherText rs.getString(columnName); return cipherText ! null ? cryptoService.decrypt(cipherText) : null; } // 重载其他getNullableResult方法... }在实体字段上指定TypeHandlerTableField(typeHandler CryptoTypeHandler.class) private String phoneNumber;优点对业务代码完全透明insert、select、update时自动加解密。缺点模糊查询LIKE和等值查询会失效因为数据库里存的是密文。WHERE phone_number ‘明文’将无法匹配。5.2 基于自定义注解与AOP/插件的方案为了支持查询更复杂的方案是结合AOP或MP的插件如InnerInterceptor在SQL执行前后进行干预。自定义注解在实体类字段上标注EncryptedField。实现一个InnerInterceptor在beforeQuery或beforeUpdate等方法中解析MappedStatement和参数对象找到带有EncryptedField注解的字段。改写SQL或参数对于等值查询将查询条件中的明文参数加密再交给数据库执行。例如将WHERE phone_number ‘13800138000’中的值替换为密文。对于模糊查询这是一个巨大挑战。通常需要放弃数据库层的LIKE改为在内存中过滤数据量小的话或者使用专门的密文检索技术如盲索引、同态加密的一部分特性但这非常复杂且性能开销大。生产建议对于需要等值查询的字段如身份证号采用TypeHandler 数据库函数索引如果数据库支持对函数结果建索引或额外的“哈希索引列”方案。即新增一个phone_hash列存储手机号的哈希值如SHA256查询时先对输入参数计算哈希再用哈希列去等值查询。这既能保护原始数据又能支持高效检索但无法支持模糊查询。模糊查询的需求通常需要从产品层面重新考虑或者使用专门的加密数据库产品。6. 生产环境性能调优与常见“坑点”6.1 批量操作的正确姿势循环调用insert(entity)性能极差。务必使用MP提供的批量方法。// 1. 使用Service的saveBatch方法 (推荐) ListUser userList ...; userService.saveBatch(userList); // 2. 使用Mapper的批量插入方法需要开启SQL注入 // 在配置中开启 injector // mybatis-plus.global-config.enable-sql-injector: true // 然后可以使用 userMapper.insertBatchSomeColumn(userList); (需要自定义注入方法)saveBatch方法默认是将多条INSERT语句打包在一个事务里提交并非真正的批量INSERT ... VALUES (...), (...), ...。如需后者需要配置rewriteBatchedStatementstrueMySQL驱动参数并可能需自定义SQL。6.2 避免N1查询问题这个问题在使用MP的TableField(exist false)关联对象时容易发生。例如查询用户列表每个用户要显示部门名称。错误示范ListUser users userMapper.selectList(null); for (User user : users) { Dept dept deptMapper.selectById(user.getDeptId()); // 循环查询数据库 user.setDeptName(dept.getName()); }正确做法使用一次性的IN查询或者直接使用MyBatis的关联查询在XML中写collection或associationMP本身不解决关联查询的N1问题它专注于单表操作。6.3 大字段查询与延迟加载实体类中如果有String类型的content字段存储大文本或JSON但列表查询时并不需要每次都查询它会浪费网络和内存。解决方案使用resultMap和association的fetchType“lazy”如果用了XML关联。拆分子查询列表查询用一个不含大字段的UserVO详情查询再用完整的User实体。MP的TableField注解的select属性可以设置为false这样默认的查询方法如selectById就不会包含这个字段。需要时再通过自定义SQL或wrapper.select(“content”)来指定查询。TableField(select false) private String content; // 大字段6.4 版本字段Version的乐观锁用于解决高并发下的数据更新冲突。在实体类字段上加Version并在更新时带上版本号。Version private Integer version;更新时MP会自动在SQL中加上WHERE id ? AND version ?。如果版本号不匹配更新行数为0你可以在业务逻辑中据此判断更新失败进行重试或其他处理。务必注意乐观锁只适用于冲突频率不高的场景高并发争抢下重试开销大。6.5 SQL注入风险与wrapper的使用安全使用QueryWrapper时如果参数来自用户输入要警惕SQL注入。MP的eq、like等方法默认使用预编译参数是安全的。危险在于使用apply或last方法拼接原生SQL片段时。// 危险用户输入直接拼接 String userInput “1; DROP TABLE user;”; wrapper.apply(“id “ userInput); // 安全使用预编译占位符 wrapper.apply(“id {0}”, userInput); // MP会将{0}替换为预编译参数同样last方法用于拼接ORDER BY等子句也要确保其内容不可被用户篡改。7. 自定义全局方法与插件开发扩展MP能力当MP的内置方法不满足需求时你可以进行扩展。7.1 自定义全局Mapper方法假设你想在所有Mapper里添加一个selectByExample方法。创建自定义SQL注入器继承DefaultSqlInjectorpublic class MySqlInjector extends DefaultSqlInjector { Override public ListAbstractMethod getMethodList(Class? mapperClass, TableInfo tableInfo) { ListAbstractMethod methodList super.getMethodList(mapperClass, tableInfo); // 添加你自己的方法 methodList.add(new SelectByExample()); return methodList; } }创建自定义方法继承AbstractMethodpublic class SelectByExample extends AbstractMethod { Override public MappedStatement injectMappedStatement(Class? mapperClass, Class? modelClass, TableInfo tableInfo) { String sql “SELECT %s FROM %s WHERE %s”; // 构建你的SQL模板 SqlSource sqlSource languageDriver.createSqlSource(configuration, String.format(sql, ...), modelClass); return this.addSelectMappedStatementForTable(mapperClass, “selectByExample”, sqlSource, tableInfo); } }在配置中替换默认的注入器Bean public MySqlInjector mySqlInjector() { return new MySqlInjector(); }7.2 开发自定义插件InnerInterceptorMP的插件体系基于InnerInterceptor接口。你可以实现它在SQL执行的各个生命周期进行拦截。比如你想在所有查询语句执行前打印一个日志。Intercepts({Signature(type Executor.class, method “query”, args {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})}) public class MyQueryInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { MappedStatement ms (MappedStatement) invocation.getArgs()[0]; Object parameter invocation.getArgs()[1]; System.out.println(“即将执行查询: “ ms.getId()); // 这里可以修改parameter或做其他处理 return invocation.proceed(); } Override public Object plugin(Object target) { return Plugin.wrap(target, this); } Override public void setProperties(Properties properties) { } }然后将其添加到MybatisPlusInterceptor的拦截器链中。通过插件你可以实现非常强大的功能比如数据权限过滤、SQL执行时间监控、统一租户处理等。从我自己的项目经验来看MP极大地提升了开发效率但把它用“稳”需要对它的原理和边界有清晰的认知。尤其是在涉及复杂SQL、多租户、数据安全等场景时不能完全依赖其“自动化”而要理解其背后的机制必要时进行定制或结合原生MyBatis的能力。把MP当作一个强大的单表ORM工具和功能增强包在合适的场景使用它才能最大程度发挥其价值避免掉入潜在的陷阱。