我的MyBatis-Plus项目实战,核心原理、踩坑记录和优化方案都在这里了。老规矩,先说下背景:前后跟过外包、自研和KA项目,从单体到微服务都摸过,ORM这块换了不少,最后稳定在MyBatis-Plus上,原因很朴素:单表CRUD不用写SQL,复杂的又能兜底。
不少人对MyBatis-Plus的认知还停留在“自动生成增删改查”这个层面,遇到性能问题或者复杂业务就手足无措。这篇文章我会从底层原理讲到生产级配置,再讲到实际项目里那些文档里找不着、只能靠踩坑换来的经验。整个过程涉及分页插件、乐观锁、逻辑删除、自动填充、批量写入优化、防全表更新、SQL注入风险这些企业级必须面对的硬骨头,希望给正在选型或者已经在用但总觉得差点意思的同学一些参考。
1. MyBatis-Plus核心原理剖析
1.1 BaseMapper的魔法从哪里来
很多新手第一次用的时候都会好奇:为什么我定义一个接口继承BaseMapper,什么都没写就有selectById、insert、updateById这些方法?SQL到底是从哪冒出来的?
MyBatis-Plus没有修改MyBatis的任何核心流程,它玩的是一套“启动时注入”的手段。MyBatis在启动时会对每个Mapper接口做解析,为每个方法生成MappedStatement(你可以理解为“一条SQL的完整描述符”),放入MapperRegistry供后续执行使用。MyBatis-Plus做的就是在这一步把常见方法对应的SQL提前定义好,并通过SQL注入器(ISqlInjector)把这些方法的MappedStatement注入到MyBatis的注册表里。
具体路径是:应用启动时,MyBatis-Plus会扫描到继承BaseMapper的自定义Mapper接口,然后由默认的DefaultSqlInjector去遍历BaseMapper中定义的一系列方法。每个方法对应一个AbstractMethod实现,比如selectById对应SelectById,insert对应Insert,这些实现类里面写好了SQL脚本模板。注入完成后,调用方的MapperProxy代理对象通过invoke找到对应的MappedStatement,走正常的MyBatis执行链路。这个过程类比起来就是:餐厅提前把预制菜放到后厨冰柜里,客人点餐时厨师直接加热上桌,不需要从买菜洗菜开始。MyBatis-Plus负责把“预制菜”准备好,你只管点菜。
这套机制有一个重要特性:自定义SQL和增强SQL共存时不会冲突。自带的注入器在启动时已经完成SQL注册,你写的XML或者注解SQL用的是另一套命名和加载逻辑。但注意一个细节:如果你的某个自定义Mapper方法名和BaseMapper里已有的方法名相同,会发生冲突,因为MyBatis-Plus注入时发现同一个Id已经存在MappedStatement,会直接报错。生产上遇到过几次,排查到最后都是自己没注意覆盖了内置方法名。
1.2 动态SQL生成的关键路径:TableInfo解析
BaseMapper里的方法能自动适配实体类和表,关键在于MyBatis-Plus启动时对实体类做了一轮“元数据解析”,解析结果缓存在TableInfoHelper中,每个实体类对应一个TableInfo实例。这个TableInfo包含什么东西?表名、主键字段(含自增策略)、普通字段列表、逻辑删除字段、乐观锁字段、自动填充字段、还有每个字段对应的列名和属性名映射关系。
解析过程依赖TableName、TableId、TableField等注解。以selectById为例,MyBatis-Plus会根据TableInfo拿到主键列名,然后拼出SQL:SELECT 字段列表 FROM 表名 WHERE 主键列名 = ?。而selectList则会把TableField里标记为逻辑删除的列自动追加到查询条件中。
这里有个值得展开的点:@TableField注解的exists属性默认是true,如果实体类里某个字段在数据库中没有对应列,必须在注解上显式写明exist=false,否则生成的SQL会引用一个不存在的列,启动时不报错,跑起来才发现。另一个是select属性,如果你希望某个字段默认不参与select查询(比如大文本字段、敏感字段),可以通过select = false控制,查询时不自动带上这个字段,需要用的时候再单独查。
TableInfoHelper缓存是全局性的,ApplicationContext启动后首次解析完成,后续复用。如果你用了多数据源或者分库分表,TableInfo依然是按实体类维度缓存的,这点上不会有问题。但要注意实体类和表之间的映射最好保持稳定,不要一个实体类来回映射多张表,否则需要手工清理TableInfo缓存,调起来很麻烦。
2. Wrapper条件构造器实战
2.1 QueryWrapper和LambdaQueryWrapper怎么选
条件构造器在MyBatis-Plus里属于高频使用组件。刚接触的同学经常在QueryWrapper和LambdaQueryWrapper之间犹豫。我给你的结论非常直接:新代码一律用LambdaQueryWrapper,不要再用QueryWrapper写死字符串列名。
为什么这么强调?QueryWrapper写起来是这种风格:
QueryWrapper<User> wrapper = new QueryWrapper<>(); wrapper.eq("user_name", "zhangsan").like("email", "@gmail.com");列名全靠字符串硬编码,一旦数据库列名发生调整、实体类字段名重构,编译期完全感知不到,只有跑到那一步才会抛异常。更麻烦的是,列名拼进去以后IDE的全局搜索根本搜不到,项目大了以后想排查某个列在哪里被用,只能靠翻历史记录。
LambdaQueryWrapper是这么写:
LambdaQueryWrapper<User> wrapper = Wrappers.lambdaQuery(); wrapper.eq(User::getUserName, "zhangsan").like(User::getEmail, "@gmail.com");用方法引用代替字符串列名,编译期类型安全,IDE重构能感知,语法上也更符合现代Java习惯。LambdaQueryWrapper的效率不比QueryWrapper差,因为通过SerializedLambda可以拿到实际的方法名,同时能通过方法名反推出属性名和列名,整个过程在一次解析后缓存在缓存里了。
说一个实际踩过的坑:使用LambdaQueryWrapper时,实体类中如果有参数名被混淆过的场景,比如编译器开启过混淆、或者字段名包含特殊字符,会偶尔出现方法引用解析异常。这种场景虽然少见,但一旦出现排查成本不低。稳妥做法是:对核心实体类加上@TableField显式列名,减少对默认列名推断的依赖。
2.2 条件拼接的技巧与防坑
Wrapper的条件方法很多,eq、ne、gt、ge、lt、le、between、like、in、isNull、isNotNull、nested、apply、last等等。大部分方法都有重载:重点是condition这个参数,每个条件方法都可以加上一个boolean参数控制是否拼接该条件。
这个设计配合Controller层的查询参数非常好用。比如前端传一个mayBeNull的参数,我们不需要手写if判断,直接:
lambdaQueryWrapper.eq(StringUtils.hasText(name), User::getUserName, name);当name没传时,这个eq条件自动不拼接。看起来很简单,但很多团队还在用if + 手动拼接的方式,代码又长又容易漏判。
下面几个是容易出问题的点:
in条件超过1000条:MySQL的in列表超过1000会报错,真实项目里如果id列表来自另一个服务,几千条是常事。处理方式是对列表分批,每500条拼一次,用or连接。这个逻辑建议抽成公共工具,不要每次手写。
like和索引的关系:like "xxx%"能走索引,like "%xxx"和like "%xxx%"通常走不了。业务需求如果必须做模糊查询,优先考虑前缀匹配,后缀匹配建议换搜索引擎或者做冗余列。
or的正确姿势:wrapper.or()这个方法的语义很容易误解。直接在两个条件中间加.or(),表示两个条件是OR关系。但一旦前面还有条件,很容易拼成(A AND B) OR (C AND D)这种你想要的,却拼成了(A AND B OR C) AND D这种诡异的组合。正确做法是用nested包裹:
lambdaQueryWrapper.eq(User::getStatus, 1) .nested(w -> w.eq(User::getType, "vip").or().eq(User::getType, "normal")) .eq(User::getDeleted, 0);nested会生成括号,把内部条件组包起来,语义清晰多了。
- last("limit 1")这类操作是直接拼接在SQL尾部的,能解决很多小众需求。但注意两点:第一,last里的内容是原样拼接进SQL的,如果拼接外部输入,就是SQL注入口子,必须严格校验参数;第二,用了last以后,如果再配合分页插件,生成的SQL可能不符合预期,这个组合要格外小心。建议对于“取第一条”这种需求,直接用selectOne或者limit 1的wrapper方式,不要通过last硬拼。
3. 生产级核心功能详解
3.1 分页插件原理与配置要点
分页是几乎每个后台系统的刚需。MyBatis-Plus的分页插件PaginationInnerInterceptor底层实现是MyBatis的Interceptor机制,通过拦截Executor.query方法,在真正执行SQL之前动态改写SQL。
以MySQL为例,执行流程大致这样:拦截器拿到原始SQL后,先判断是否包含limit,如果没带limit,则根据设置的DbType做方言改造。它会复制出一个COUNT查询(用于计算总数),同时把原SQL改写成分页SQL。改写SQL不是简单在末尾拼接limit offset, size,而是对SQL做语法树解析,处理掉select列、order by、distinct这些位置歧义。
配置分页插件时有几个要点必须注意:
- 必须明确指定Dialect(方言)。不指定数据库类型,插件不知道limit关键字怎么写。我见过不少项目只new了PaginationInnerInterceptor()没设dbType,一旦连上Oracle就崩。
- maxLimit参数控制单页最大条数,超过直接抛异常。生产环境建议设置,防止前端传一个天文数字把数据库拖死。
- 复杂SQL的count优化做不了。join了多张表、带union、带子查询的SQL,count算出来的总值往往不是业务意义上的“记录数”。比如一个left join一对多的场景,count会把右边多出来的行也算进去。这个问题我会在第五章里细说。
分页插件在配置上还有一个容易遗漏的点:拦截器的顺序问题。如果项目里还配置了其他自定义MyBatis拦截器(比如数据权限拦截、SQL日志拦截),要小心多个Interceptor之间的执行顺序。MyBatis的InterceptorChain是顺序执行,但分页插件内部有自己的一套逻辑,如果数据权限拦截器往SQL里拼了额外条件,分页插件在改SQL时可能会漏掉或者重复处理。我的经验是分页插件尽可能放在拦截器链最外层。
3.2 乐观锁:不只是加个@Version
并发更新场景下,悲观锁太重,用乐观锁是常规方案。MyBatis-Plus对乐观锁的支持很友好:实体类里加一个字段,注解上@Version,然后配置OptimisticLockerInnerInterceptor,启动后update语句自动带version条件。
原理是:执行update时,MyBatis-Plus会在SQL末尾追加WHERE version = 旧值,同时update语句里设置 version = 旧值 + 1。如果更新成功,说明在这之前没有人改过这条记录;如果影响行数为0,说明version已经不是旧值,别人改了,需要业务方自行判断重试或者提示冲突。
实际使用中有几个关键点:
- 乐观锁字段必须有默认值。新插入记录时,如果version字段在数据库里是null,后续更新时version=null会直接导致条件永假,更新永远影响0行。建议建表时默认值设为0或1。
- 乐观锁语义是对“整条记录”加锁的,如果你更新的时候只set了某几个字段,version照样会自增。这意味着频繁更新某一行时,version会变得很大,这本身没问题,但要注意配合业务做版本回收或者定期清理。
- 重试策略:检测到update返回0后,通常做法是重新查一次记录,看看是否是业务上的重复操作,还是并发冲突。如果是并发冲突,可选的方案是:让用户重试、记录冲突日志异步处理、或者用分布式锁替代乐观锁。不要盲目循环重试,容易拖垮数据库。
3.3 逻辑删除的正确打开方式
逻辑删除是MyBatis-Plus的另一个高频功能。加上@TableLogic注解后,deleteById不会真正删除记录,而是变为UPDATE语句,把逻辑删除字段置为已删除标记。查询时,MyBatis-Plus会自动在SQL后追加条件:逻辑删除字段 = 未删除标记。
这套默认行为很省心,但生产环境有几个坑你必须提前知道:
唯一索引冲突。这是最经典的坑。表里有个唯一索引(比如用户手机号),逻辑删除一条记录后,再插入同样的手机号,会报唯一索引冲突,因为逻辑删除并没有把物理记录挪走,索引里的值还在。解决方案要么放弃唯一索引改成普通索引+业务层校验,要么用“删除标记位参与唯一索引”的方式(比如删除时把deleted字段和id拼接后更新),要么引入回收站表。MyBatis-Plus自身没有处理这个问题的方案,只能靠设计层规避。
关联查询时不会自动带上逻辑删除条件。MyBatis-Plus只对基于BaseMapper自动生成的方法做逻辑删除处理,自己写的SQL(XML或注解SQL)不会自动带。比如你有条SQL:SELECT * FROM order WHERE user_id = ?,如果order表有逻辑删除字段,这条SQL会把已删除订单也查出来。解决方案也很简单:每张表的逻辑删除字段约定好命名,XML里手动加上条件。
逻辑删除影响统计和审计。报表系统和数据导出场景,逻辑删除的记录往往不在业务统计范围内,但审计团队要求能看到历史数据。这种需求要提前设计好“白名单”查询,比如单独写一套不受逻辑删除控制的查询逻辑。
3.4 字段自动填充:审计字段的自动化
create_time和update_time这种字段,每个表都有。MyBatis-Plus提供了一个MetaObjectHandler钩子接口,实现后,insert和update时可以自动填充字段值,不用在业务代码里手动set。
配置方式:实体类字段上加@TableField(fill = FieldFill.INSERT)或@TableField(fill = FieldFill.INSERT_UPDATE),然后自定义一个类实现MetaObjectHandler接口,重写insertFill和updateFill方法。
@Component public class MyMetaObjectHandler implements MetaObjectHandler { private LocalDateTime now() { return LocalDateTime.now(); } @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, now()); this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, now()); this.strictInsertFill(metaObject, "creator", String.class, UserContext.getUserId()); this.strictInsertFill(metaObject, "updater", String.class, UserContext.getUserId()); } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, now()); this.strictUpdateFill(metaObject, "updater", String.class, UserContext.getUserId()); } }这里面有几个细节:
- strictInsertFill和strictInsertFill方法名带strict,表示字段没有值时才填充。如果业务代码已经手动set了值,填充器不会覆盖。
- updateById时,所有字段参与更新Strtegy的字段都会作为SET子句。如果你希望updateTime字段只在updateFill里填充,但表结构里该字段不允许为空,那么插入时也没问题,因为insertFill会带上。
- 自动填充在Executor层面拿到的metaObject是参数对象本身。用saveBatch批量插入时,自动填充同样生效,因为最终底层还是逐条走insert逻辑。
4. 企业级实践与性能优化
4.1 批量插入的性能陷阱
很多团队直接用MyBatis-Plus的saveBatch做批量插入,但性能差异可以很悬殊。saveBatch默认分批提交,内部通过SqlSessionTemplate的批量模式操作,每次flush到数据库的数量默认1000批。如果一次插入几万条,默认batchSize可能不太理想。
这里有个关键参数,是MySQL的JDBC驱动层面的:rewriteBatchedStatements。当这个参数为true时,JDBC会把一批INSERT语句合并成一条多VALUES的INSERT语句发送给MySQL服务端,网络round-trip次数大幅减少,性能提升可能超过一个数量级。很多项目数据量大但性能上不去,查到最后就是没加这个参数。
连接池配置示例:
spring: datasource: url: jdbc:mysql://localhost:3306/db?useUnicode=true&characterEncoding=utf8&rewriteBatchedStatements=true&useSSL=false但rewriteBatchedStatements对MyBatis-Plus的saveBatch有没有效果,取决于它生成SQL的形态。实测下来,当batchSize合适、且JDBC URL配置这个参数后,插入几万行数据的耗时能明显下降。不过要注意,saveBatch是把多条单行INSERT分组合并发送,本质上还是逐条解析,和一条INSERT多条VALUES的SQL相比仍有差距。真正对性能苛刻的场景,直接手写XML的foreach批量插入更稳妥。
<insert id="insertBatch" parameterType="list"> INSERT INTO user (name, age, email) VALUES <foreach collection="list" item="item" separator=","> (#{item.name}, #{item.age}, #{item.email}) </foreach> </insert>这种方式能最大程度减少SQL解析和网络开销,让批量插入达到最优。实际项目里如果导入Excel、初始化数据这种场景,建议优先用这个方案,而不是saveBatch。
另一个容易被忽略的点:批量操作时自动填充会生效,但主键回填需要配置。saveBatch返回后,实体对象的id字段是有值的,这一点很多项目没利用好。如果批量插入后需要批量关联子表,手动foreach取id就行。
4.2 防止全表更新和全表删除
MyBatis-Plus的updateById是按主键更新的,一般不会有问题。但用update方法加上Wrapper条件时,如果Wrapper没带where条件,或者条件拼错了,就会变成全表更新。delete同理,不带条件的deleteById没有影响,但delete(wrapper)如果wrapper没加条件,全表数据直接没了。
生产环境有一个刚需防护:配置BlockAttackInnerInterceptor。这个插件会拦截不带where条件的全表更新和删除语句,直接抛异常,保护数据不被误删误改。
@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor()); return interceptor; }但这里有个坑:配置BlockAttackInnerInterceptor之后,如果业务上真有一些“不带条件更新所有记录”的合理场景(比如批量置状态、定时任务全量置位),会被一并拦截。处理方案是:把这些特定操作改成自定义SQL或者在XML中允许执行,绕开插件拦截;或者把允许通过的操作加上一个永远为真的条件(比如WHERE 1=1),这种写法能绕过插件但也能保证语义清晰。注意,这种做法不能让外部传参控制,否则条件被动态拼成空,防护等于白做。
还有一个项目里常见的坑:用update时只传入部分字段,MyBatis-Plus默认的策略是null字段不更新。这通常是我们想要的语义,但有时会带来问题。比如通过updateById传了一个DTO对象,这个对象里有字段本来就有值(非null),但因为业务操作上你不希望更新它,结果却被更新了。反过来,如果希望某些null字段也能更新,可以加updateStrategy = FieldStrategy.ALWAYS,或者在Wrapper的set方法里显式set null值。这个配置在实体类字段上细粒度控制,不要全局放开,全局放开会让所有更新都带上null字段,风险很大。
4.3 SQL注入风险与代码规范
MyBatis-Plus的Wrapper拼接是预编译SQL,字段值是参数化的,所以参数本身不会有注入风险。但关键是列名和排序字段的处理。排序字段如果是从外部接收的字符串,直接拼到orderBy里,就会产生列名注入。
举个容易出问题的例子:
wrapper.last("order by " + request.getOrderBy());如果orderBy是外部传入的字符串,恶意用户可以传user_id; DROP TABLE user; --,整个SQL就被注入了。这类问题在动态排序需求里特别常见。解决方案是维护一个白名单映射,外部传入的排序字段先做映射,命中才允许拼接,未命中给默认排序。这个逻辑建议做成通用组件,所有需要排序的接口统一使用。
preparedStatement的参数占位符#{}是安全的,但${}是直接字符串替换,MyBatis-Plus的wrapper方法里其实没有直接暴露${}的入口,但last、apply等方法本质上有这个风险。一般来说,apply里可以写数据库函数、加减表达式,比如累计数量:
wrapper.apply("stock_count = stock_count - {0}", stockCount);这样写法是预编译的,没有问题。但如果把外部输入直接拼进apply,那就是自己给自己挖坑。规则永远是:外部输入必须走参数占位符,列名、表名这类结构不能来自不可信源。
4.4 代码生成器:用而且要改
MyBatis-Plus的AutoGenerator能一键生成Entity、Mapper、Service、Controller、XML文件,确实能减少很多重复工作。但我的实际体验是:直接生成的代码不能无脑照搬,要做裁剪和定制。
我喜欢用AutoGenerator生成Entity和Mapper这两层,Controller和Service通常只生成基础结构,然后根据业务需求裁剪。生成前在策略配置里把这两个字段逻辑删除和乐观锁的字段名配置上:
StrategyConfig strategy = new StrategyConfig(); strategy.setLogicDeleteFieldName("deleted"); strategy.setVersionFieldName("version");这样可以保证生成的代码里自动带上@TableLogic和@Version注解,省去手工补。
生成后的Entity类建议做以下处理:
- 删除不必要的字段(比如不需要持久化的计算字段加@TableField(exist = false))
- 加上字段注释,生成器默认会读取数据库列注释,但注释可能不太规范,需要业务口径统一。
- 把updateTime、createTime这些字段的fill策略统一配置。
- 如果项目用了多数据源,每个数据源独立的配置生成,不要指望一个全局配置走天下。
4.5 XML和Wrapper的边界划分
我见过两种极端团队:一种极度依赖Wrapper,连三表join都用Wrapper硬拼;另一种极度抵制Wrapper,所有SQL都手写XML。这两种都不好。一个合理的划分规则是这样的:
- 单表简单操作、条件动态变化多的场景,用LambdaQueryWrapper。
- 优点是代码可控、可读性好、类型安全,条件拼接灵活。
- 多表join、复杂子查询、报表统计类SQL,用XML。
- 这类SQL逻辑复杂,性能调优需要手写SQL控制,XML的维护性比Wrapper拼出来的SQL强太多,EXPLAIN和分析起来也更直观。
我从某个项目里看过一段用Wrapper拼三表join的代码,足足一百多行,层层嵌套,Debug的时候精神状态受到很大考验。同样的逻辑如果写在XML里,一眼就能看懂SQL长什么样。反过来,一个简单queryWrapper只查单表,非要写XML和resultMap映射,也是过度设计。
有一条判断标准:SQL写完以后,你能不能立即说出它的执行计划。如果做不到,说明SQL复杂度已经超出Wrapper的表达能力,赶紧挪到XML里。
5. 常见问题与排查技巧实录
5.1 字段映射不生效问题
最典型的现象是:数据库表字段user_name,实体类属性userName,查询结果却是null。排查思路从下往上走:
- 检查MyBatis全局配置mapUnderscoreToCamelCase是否为true。MyBatis-Plus默认开启驼峰映射,如果你自己覆盖了mybatis配置,不小心关掉就会出现这个现象。
- 如果没有开启,或者你的列名不符合驼峰规则(比如user__name这种双下划线),建议直接给字段加@TableField注解显式指定列名。
- 检查实体类字段上是否有@TableField(exist = false),这个注解会让字段被排除在映射之外。
还有一类是字段名是数据库保留字,比如status、order、group、desc。实体类字段本身没事,但生成的SQL会把列名直接带上,MySQL会报语法错误。解决方案:
@TableField("`order`") private String order;或者全局配置里开启列名自动加反引号(不同数据库语法不同),但最稳的还是注解显式指定。
5.2 启动失败,提示Invalid bound statement (not found)
这个报错在MyBatis-Plus项目里非常高频,团队里几乎每个新人都踩过。可能的原因如下:
- Mapper接口没加@Mapper注解,或者没被@MapperScan扫描到。MyBatis-Plus正常会扫描所有包,但如果你用了多模块项目,某个模块的Mapper接口所在的包没有被扫描到,启动时候不会报错,运行期调用时才报。
- XML文件路径没配置。如果你是手写XML派的,需要在application.yml里配:
mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml漏掉这个配置,XML里的方法名永远绑定不上Mapper接口方法。 3. Mapper接口的方法名和XML里的id不一致。这个在重构的时候经常出现,改方法名容易漏掉XML里的id同步。
排查技巧:启动时加logging.level.your.mapper.package=debug,MyBatis-Plus会打印出实际加载的SQL语句,一目了然。
5.3 分页count不准的深层原因
分页插件自动生成的count SQL,是对原SQL做包裹处理,理论上SELECT COUNT(*) FROM (原SQL) t这样执行。但实际中count结果不准的案例我遇到好几类:
- 一对多join导致重复计数。比如订单表join订单明细表,一个订单有N条明细,count统计出来成了订单数×N。这种情况没法依赖自动count,只能手动传入一个重写的count。MyBatis-Plus支持Page对象构造时指定是否count,或者自定义CountMapper覆盖自动count逻辑。
- 带union的SQL,自动count会把union包裹后统计,结果和预期完全不同。同样需要手工指定count。
- group by的SQL,count结果会变成分组后的总行数,业务上可能就不是你要的“记录数”。
遇到这类复杂SQL,我的建议是:页面里展示总量用另一条更轻量的统计SQL,不要硬啃自动count的错。这也是报表系最常见的处理方式。
5.4 多数据源场景的兼容问题
MyBatis-Plus支持多数据源是通过扩展DynamicDataSource实现的,但分页插件对不同数据库方言的处理要提前配置。比如主库MySQL、从库PostgreSQL这种混合玩法,需要给PaginationInnerInterceptor设置多种dialect,或者自定义数据源路由时对dialect做适配。
实际项目中遇到过一个生产事故:切换数据源后,分页插件还拿着MySQL的limit语法去查Oracle,SQL直接报错。排查后发现是PaginationInnerInterceptor的DbType写死成了MySQL,没走动态判断。解决方案是给每个数据源对应的SqlSessionFactory配置独立的分页插件。多数据源和分页插件的搭配,建议在启动时打印日志确认每个DataSource实例绑定的拦截器是否正确。
5.5 性能优化排查清单
有人在生产环境里遇到接口越来越慢,查询语句明明走了索引,却还是慢。用MyBatis-Plus排查时,我一般按这个顺序查:
- 是不是查询发现是分页插件自动count拖慢的。可以看日志里两条SQL的执行时间对比,自动count往往比主查询还要重。
- 是不是逻辑删除字段导致索引失效。逻辑删除字段如果在where条件里加了,但字段本身没索引,表大以后这个条件会过滤掉大量行,直接影响查询性能。逻辑删除字段建议加索引。
- 是不是Wrapper里用了函数包裹字段,比如DATE_FORMAT(create_time, '%Y-%m-%d') = 某个值,这种写法必然让索引失效。改成范围查询create_time BETWEEN x AND y。
- 是不是批量操作把数据库连接池占满了。saveBatch大批量插入时,连接占用时间长,容易把默认连接池线程吃光,其他接口全部卡住。调batchSize到合理值,或者错峰执行。
写在最后的一些思考
跟MyBatis-Plus打交道这几年,一个很深的感受是:工具确实能把我们从70%以上的重复CRUD里解放出来,但剩下的30%恰恰是决定系统质量的环节。用好框架的关键不是背API,而是理解框架在背后做了什么事情、边界在哪里、哪些地方需要我们自己兜底。分页、乐观锁、逻辑删除、自动填充这些能力,每个都像是一把双刃剑,用好了效率很高,用错了埋坑很深。
我个人建议每个团队在引入MyBatis-Plus之前,先定一份内部规范,至少包含:实体类字段和数据库列的映射规则、Wrapper的使用边界(哪些操作必须走XML)、分页插件和防全表更新插件的配置标准、以及逻辑删除字段的统一命名和索引策略。这份规范不要定得太死,保持可执行和可迭代的灵活性,但它能让新同学少踩坑,让老同学在review代码时更有底气。后续如果你们在分页插件、批量写入或者多租户接入这些场景里有更具体的疑问,我们可以再展开聊。