MyBatis与MyBatis-Plus:核心机制、缓存与TypeHandler实战

发布时间:2026/10/13 3:36:24
MyBatis与MyBatis-Plus:核心机制、缓存与TypeHandler实战
接手过好几个用MyBatis做的老项目也带团队从零搭过基于MyBatis-Plus的新服务。这些年经常有同事问“MyBatis和MyBatis-Plus到底怎么选用MP是不是就不用学MyBatis了”这问题看似简单真要答透得把这两兄弟的底层机制、缓存、TypeHandler、批量写入这些核心点全捋一遍。这篇文章就按我在实际项目里的踩坑经验把MyBatis和MyBatis-Plus的差异、源码链路、缓存坑、类型处理器、批量操作、日志排查这些高频话题逐个拆开讲适合刚接触MyBatis的新手也适合准备从MyBatis迁移到MP的老手参考。1. 先搞明白MyBatis和MyBatis-Plus到底差在哪1.1 定位完全不同不是简单的“增强版”很多人一上来就背结论“MyBatis-Plus是MyBatis的增强版选它就行”。这话对了一半。MyBatis的核心定位是SQL映射框架它把SQL写在XML或注解里让开发者完全掌控每一条查询动态SQL、多表Join、复杂统计想怎么写就怎么写。它的设计哲学就是“SQL由你掌控”框架只负责把参数传进去、把结果集映射出来。MyBatis-Plus下文直接叫MP的核心定位是MyBatis的增强工具它在不改变MyBatis底层执行链路的前提下补齐了单表CRUD、条件构造器、分页插件、逻辑删除、字段自动填充这些日常开发里写得最烦的重复代码。注意MP不是ORM框架它的底层走得还是MyBatis那套Executor、StatementHandler、ResultSetHandler的链路SQL照样经过MyBatis语法解析。这个定位差异直接影响选型。如果你的系统里70%以上是单表操作、简单的条件查询用MP能省掉大量重复的XML和Mapper方法但如果你天天写报表SQL、动态条件几十个、多表关联绕来绕去那MP帮不上太多忙该手写SQL还是得手写。我见过不少团队上了MP之后遇到复杂查询完全不会写XML了——这就是把“增强工具”当成“替代品”的后果。1.2 从一次接需求经历看二者差异去年接手过一个老服务整个Mapper层全是手工XML一个用户表的基本增删改查方法加起来写了快两百行XML光看resultMap字段映射就看得头晕。后来团队新开一个子服务我们换成了MP实体类加TableName、TableId、TableField三个注解Mapper接口继承BaseMapperUser单表的增删改查一行代码没写。同事当时很感慨“原来这部分工作量真能省到这个程度。”但我要强调一句恰恰是因为先有MyBatis那套扎实底子我们才能理解MP背后的SQL到底怎么生成的。BaseMapper里的selectList、selectPage这些方法本质上是MP的TableInfoHelper扫描实体注解后自动拼接成SQL再交给MyBatis的MappedStatement去执行。不明白这条链路的人遇到MP生成的SQL不符合预期时根本不知道去哪里排查。2. 核心机制初始化流程与SQL执行链路2.1 MyBatis初始化流程从配置文件到MappedStatement面试题“mybatis中初始化工作流程”是高频考点其实搞清楚一条主链就行SqlSessionFactoryBuilder读取mybatis-config.xml或直接构建Configuration对象→XMLConfigBuilder解析全局配置 → 把解析结果放进Configuration→ 注册MapperXMLMapperBuilder解析XML文件MapperAnnotationBuilder解析注解 → 构建MappedStatement→ 最终返回DefaultSqlSessionFactory。Configuration在这条链里是绝对核心。所有MappedStatement、TypeHandler、Interceptor、缓存对象全挂在它身上。你可以把Configuration理解成一个“总仓库”MyBatis运行时所有需要查询的东西都从这里取。MappedStatement则对应XML里每一个select、insert、update、delete标签里面封装了SQL语句、参数映射、结果映射、缓存策略这些元数据。MP在启动阶段做的额外事情就是往Configuration里塞东西它扫描实体类上的TableName、TableId、TableField注解生成TableInfo对象缓存在内存里后续所有BaseMapper的CRUD方法都靠TableInfo里的字段信息去拼SQL。这也是为什么MP对实体类有比较强的规范要求——比如表必须有主键、字段名最好和数据库列能对得上不然自动生成的SQL就是错的。2.2 一条查询SQL从接口到数据库的完整链路很多新手看MyBatis源码时容易懵其实就是一条线Mapper接口动态代理 →SqlSession→Executor一级缓存的逻辑就在这层 →StatementHandler预处理SQL、设置参数 →ParameterHandler参数映射TypeHandler在这里起作用 → JDBCPreparedStatement.execute()→ResultSetHandler结果集映射TypeHandler又出现一次。写到这里大家应该能发现Executor是整条链路的“项目负责人”它负责调度StatementHandler、ParameterHandler、ResultSetHandler。一级缓存在BaseExecutor里通过localCachePerThread实现同一个SqlSession会话内相同SQL和参数的查询会直接命中缓存。MP做的事情是在Mapper接口代理和SqlSession之间又插了一层MybatisPlusInterceptor。分页、乐观锁、动态表名这些功能都是通过拦截Executor的方法实现的。这也是为什么MP分页插件必须在Executor层面拦截——因为只有在真正执行SQL之前才能去改写SQL语句、拼上LIMIT或ROWNUM。2.3 顺着源码看SQL执行细节实际调试时我习惯在StatementHandler的parameterize方法上打一个断点能看到SQL的?占位符和参数列表是怎么对应的。如果参数映射出了问题比如#{}写成${}或者参数名对不上基本就在这一层暴露出来。另一个值得打断点的位置是ResultSetHandler的handleResultSets很多类型转换异常、字段映射丢失都是因为结果集映射没走对。3. 缓存机制实战一级缓存与二级缓存的坑3.1 一级缓存SqlSession级别默认开启一级缓存是MyBatis内置的默认就开着不需要任何配置。它的作用域是SqlSession同一个SqlSession内查询条件相同、参数相同、SQL相同第二次查询直接走缓存不走数据库。面试高频题“mybatis一级缓存什么时候失效”答案其实是一组条件执行了update、delete、insert操作只要写了数据库缓存立刻清空手动调用sqlSession.clearCache()提交或回滚事务两次查询之间SqlSession不是同一个实际项目里的坑是在Spring环境中SqlSession是跟着事务走的。同一个事务方法内一级缓存生效一旦跨方法、跨事务SqlSession已经不是同一个了一级缓存就没意义了。所以别指望用一级缓存扛高并发它只适合解决“同一个事务里重复查询同一行数据”这种场景。3.2 二级缓存namespace级别默认关闭二级缓存需要手动开启。在Mapper XML里加一个cache/标签或者实体类上加CacheNamespace注解同时要求所有缓存的实体类实现Serializable接口因为二级缓存的存储可能涉及序列化。二级缓存的作用域是namespace也就是一个Mapper接口对应的所有SqlSession共享。但我要泼一盆冷水二级缓存在绝大多数分布式项目里应该直接关掉。原因很简单MyBatis的二级缓存是本地内存缓存没有分布式一致性。节点1查询了数据放进本地缓存节点2查询时自己的缓存没有照样打到数据库节点1更新了一条记录它自己清了自己的缓存但节点2的缓存里还是旧数据。微服务环境下这叫脏读而且非常难排查。我接手过一个项目某接口查出来的数据偶尔是旧的查了好久才发现是某个不小心加的cache/标签导致的。所以我的建议是单机小项目、对数据一致性要求不高的场景二级缓存可以玩一旦上了微服务、多节点部署直接不用把缓存交给Redis那层去做。3.3 集群环境下缓存策略的取舍有同事问“那我把二级缓存关了热点数据怎么缓存”我的做法是热点数据要么在Service层加CacheableSpring Cache Redis要么用Caffeine做本地缓存但必须带好失效策略和分布式缓存同步机制。MyBatis的二级缓存定位解决的是“同namespace内高频重复查询”的问题它从来不是全局缓存方案。做好取舍就不会被它坑到。4. TypeHandler完整工作流程与自定义实践4.1 TypeHandler到底是怎么工作的“mybatis中typehandler的工作流程图”这个话题很多面试官喜欢让候选人现场画。核心链路其实很清晰参数进数据库时Java对象 →ParameterHandler→TypeHandler.setParameter→PreparedStatement.setXxx。 查询结果出来时ResultSet.getXxx→TypeHandler.getResult→ 转换成Java对象。TypeHandler就是连接Java类型和JDBC类型之间的那座桥。MyBatis内置了一堆默认TypeHandler覆盖基本类型、String、BigDecimal、Date、LocalDateTime这些常用类型。但现实世界总是不够用——比如枚举类型、JSON字段、自定义对象、Oracle的TIMESTAMP、PostgreSQL的数组类型默认处理不了就需要自己写。4.2 Oracle查询时间映射的那些坑热搜词里“oracle用mybatis查询时间映射”是我实际踩过的坑。Oracle的DATE和TIMESTAMP通过JDBC驱动返回的是java.sql.Timestamp如果你的实体字段是java.util.Date默认映射没问题。但如果你用了LocalDateTime就要注意版本JDBC驱动得是12c以上版本MyBatis版本最好在3.4.5以上否则运行时直接报ClassCastException。再一个坑是时区问题。Oracle数据库存储的TIMESTAMP是带时区的如果JDBC连接串里没有显式指定时区驱动会按JVM默认时区转换经常出现查询出来的时间比数据库里的时间少8小时的情况。排查方法要么在JDBC连接串里加上oracle.jdbc.timezoneAsRegionfalse要么启动参数设置-Duser.timezoneAsia/Shanghai。4.3 手写一个枚举TypeHandler实战项目里最常见的自定义场景就是枚举映射。比如订单状态在数据库里存的是Integer0、1、2Java代码里想直接用枚举类默认的TypeHandler不认识你的枚举得自己写。实现步骤不复杂继承BaseTypeHandlerOrderStatus重写setNonNullParameter参数入库时把枚举转成Integer、getNullableResult把数据库的Integer转回枚举然后在mybatis-config.xml里注册或者直接用MP的TableField(typeHandler OrderStatusHandler.class)标注在实体字段上。如果用MP其实还有更轻量的方案在枚举字段上加EnumValue注解标注这个枚举对应数据库存的什么值MP自动完成转换连TypeHandler都不用写。只有那些不太好用枚举注解解决的场景比如JSON字符串映射、敏感字段加密解密才需要老老实实自定义TypeHandler。我去年写过一个日志脱敏的TypeHandler就是在setParameter的时候加密、getResult的时候解密配合MyBatis用起来非常顺手。5. 批量写入、SQL日志与XML开发体验5.1 三种批量插入方式怎么选“java mybatis mybatis-plus 批量”这个搜索词背后是无数人被批量插入性能问题折磨过的真实经历。批量插入的方式主要有三种性能差异巨大。第一种循环单条insert性能最差。每条SQL都要走一遍完整的解析、预编译、执行1000条数据就是1000次网络往返效率极低。我只在数据量个位数的时候这么干。第二种XML里用foreach标签拼接一条大的INSERT语句比如INSERT INTO t_user (name, age) VALUES (#{item.name}, #{item.age}), (...), (...)。这种方式SQL拼接紧凑一次提交性能不错。但要注意两点一是SQL长度有上限别一把拼一万条进去会被数据库的max_allowed_packet限制打回来二是foreach的list参数名要写对用Param(list)显式传递不然容易循环报错。第三种ExecutorType.BATCH。把sqlSessionFactory的executorType设置为BATCH或者手动获取批量的SqlSession来执行。BATCH模式会把多条SQL放在同一个批次里提交配合flushStatements()统一刷出性能提升明显。它的坑是自动提交被关闭你得自己管理commit而且BATCH模式下PreparedStatement会被复用如果你在循环里改了SQL结构反而会出问题。MP的saveBatch走的是第三种方式的变体。源码里能看到它会按1000条一批内部使用批量Executor每满一批就flush一次。这里要提醒一句一次性传几万条数据给saveBatch内存会先炸掉。我实测过批量大小控制在1000~2000比较稳内存占用和提交频率是平衡的。5.2 MySQL批量插入的 rewriteBatchedStatements 参数用JDBC批量插入MySQL时还有一个容易被忽略的参数rewriteBatchedStatementstrue。JDBC驱动默认执行批量插入时其实还是逐条发送的根本没发挥批量优势。加上这个参数之后驱动会把多条insert语句重写成真正的批量insert性能提升可以说是质的飞跃。但加了之后要关注一个坑批量SQL的重写可能在解析时改变某些行为的细节比如LAST_INSERT_ID()返回的值。所以上线前一定要在测试环境压一遍数据确认主键回填和生产上的其他行为没有变化。5.3 SQL日志打印配置与XML高亮方案排查SQL问题第一步永远是看到SQL长什么样。Spring Boot项目中在application.yml里配置mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl控制台就能直接看到完整SQL语句和参数列表。注意MyBatis日志打印的SQL是带?占位符的Parameters是另一行需要自己把参数对应到占位符上。如果觉得日志太长太吵可以换成slf4j实现配合日志级别debug在需要时打开。再说XML高亮问题。很多人用IDEA写MyBatis的XML文件默认是没有SQL高亮的整段全是灰的长SQL看一会眼睛就花了。我的建议是直接装MyBatisX插件它会识别XML里的SQL语义给关键字上色还能从Mapper接口方法直接跳转到XML对应语句自动补全、参数提示都好用。装了以后写复杂动态SQL的效率提升很明显搜“mybatis xml高亮”看到网友推荐最多的也是它。6. 实战复盘从MyBatis迁移到MyBatis-Plus怎么又稳又顺6.1 先判断你的项目适不适合迁迁移不是拍脑袋的事情。我做过的判断标准很简单如果项目里Mapper文件超过一半是基本CRUD和简单单表查询迁移收益很大如果全是复杂报表SQL、动态拼接满天飞迁了反而难受。因为MP的自动CRUD帮不了复杂查询你还得保留XML那拆成两套东西反而增加认知成本。迁移的基本步骤引入mybatis-plus-boot-starter注意版本要和Spring Boot匹配实体类加TableName、TableId、TableField注解主键策略按需配置Mapper接口改成继承BaseMapperT原有的自定义XML保留不动分页插件从PageHelper换成MP的PaginationInnerInterceptor配置逻辑删除、字段自动填充全局配置开启驼峰映射和原项目保持一致6.2 迁移路上踩过的坑清单版本冲突是我见人踩得最多的坑。同时引入mybatis-plus-boot-starter又引了原生mybatis的jar类冲突、版本不一致启动直接报错。解决办法是只保留MP的starter它会自己带对应版本的MyBatis。驼峰映射是另一个容易忽略的点。MP默认开启驼峰转下划线如果老项目里关闭了这个配置迁移后SQL查询出来的列名会对不上实体属性数据全是null。原项目如果mapUnderscoreToCamelCase是falseMP里也要全局关掉保持行为一致。还有QueryWrapper的安全问题。条件值可以用参数占位符安全传参但如果把外部传入的字符串直接拼进QueryWrapper的字段名就得特别小心。字段名的位置是没有参数占位符的恶意拼接可能造成问题。我的做法是外层做白名单校验字段名只允许走固定的枚举或常量。6.3 MP分页插件与乐观锁插件的顺序MybatisPlusInterceptor里可以挂多个内置拦截器顺序有讲究。分页插件PaginationInnerInterceptor、乐观锁OptimisticLockerInnerInterceptor、防全表更新BlockAttackInnerInterceptor谁先谁后会影响执行逻辑。我一般按“分页 → 乐观锁 → 防全表更新”的顺序注册分页在最外层拦截先改SQL再走后续逻辑。这个顺序在官方文档里有说明但很多人没细看。7. 高阶面试题与典型问题排查实录7.1 面试题速查表把热搜里关于MyBatis和MP的高频问题整理成了下面这张速查表面试前临时抱佛脚也能用问题核心答案要点MyBatis和MyBatis-Plus的区别MyBatis是SQL映射框架MP是MyBatis的增强工具MP解决80%单表CRUD复杂查询仍需手写SQLMyBatis一级缓存何时失效update/delete/insert后、clearCache、commit/rollback、SqlSession不是同一个MyBatis二级缓存怎么开启XML加cache/或CacheNamespace实体实现Serializable按namespace共享#{}和${}的区别#{}是预编译占位符安全${}是直接拼接SQL有注入风险仅允许用在表名/排序等动态部分TypeHandler工作流程参数Java→TypeHandler→PreparedStatement.setXxx结果ResultSet.getXxx→TypeHandler→JavaMyBatis初始化流程SqlSessionFactoryBuilder→XMLConfigBuilder→Configuration→Mapper注册→MappedStatementMP分页原理MybatisPlusInterceptor拦截Executor改写SQL追加LIMIT或分页方言MyBatis支持Gauss吗GaussDB提供兼容PostgreSQL协议的JDBC驱动MyBatis可以对接注意分页方言、序列自增、主键策略差异MP分页插件可能需要自定义DialectSpring Data JPA和MP的区别JPA是全自动ORM抽象更高复杂查询调优成本高MP是半自动SQL可控性好两者面向不同场景7.2 param index 与参数绑定细节热搜词“mybatis param index”说的是Mapper方法参数绑定的问题。单个参数的时候可以直接在SQL里用参数名多个参数的时候一定要用Param注解显式指定名字否则MyBatis会按param1、param2的位置参数来映射。这里有个版本差异老版本默认按param1、param2新版本按arg0、arg1如果你不写Param升级MyBatis版本后SQL可能就对应不上了。我的规范做法是所有Mapper接口方法都显式加Param注解。虽然多敲几个字符但能避免将来改参数顺序、升级版本时把SQL搞错。这不是风格问题是稳定性问题。7.3 常见问题排查实录这里再补充几个实际项目里遇到的典型问题。第一个Oracle查询时间映射异常前面已经讲过TypeHandler和时区的问题核心就是检查驱动版本、连接串时区配置、MyBatis版本三者是否都对。第二个MP分页插件在非MySQL数据库上失效。MP的PaginationInnerInterceptor是按数据库方言来拼接分页SQL的你用什么数据库就配置对应的DbType。之前我接了一个使用GaussDB的项目MP的默认方言列表里没有对应类型分页SQL拼出来直接报错最后是自定义Dialect继承Dialect接口手写了分页方言才解决。第三个“mybatis缓存”的面试题很容易被问崩。二级缓存的底层实现是TransactionalCacheManager加若干个Cache对象数据结构核心就是HashMap。Cache接口的putObject、getObject方法需要理解清楚cache/标签还可以配上eviction、flushInterval、size、readOnly这些属性来决定淘汰策略和缓存行为。面试时能说出这一层基本就算过关了。第四个批量插入时SQL日志里看不到批量效果。如果你用批量Executor控制台可能只看到一条SQL带多次参数这是正常的不代表只执行了一次。别被日志误导要看数据库侧的连接数和实际执行时间。我自己这些年用下来最大的体会是MyBatis和MP不是非此即彼的关系它们解决的是同一个问题的不同层次。MyBatis是地基MP是地基上的脚手架。先花点心思把MyBatis的Executor链路、缓存机制、TypeHandler这些底层概念吃透再用MP时你才能真正做到“知其所以然”——遇到MP自动生成的SQL不对、分页失效、类型映射报错你能顺着源码往下查而不是只会百度报错信息。最后再分享一个小细节现在排查问题时我习惯在调试环境打开MyBatis的日志输出把StdOutImpl显示的SQL直接复制到数据库客户端里跑一遍先确认SQL本身没问题再回头查参数映射和类型转换。这个习惯帮我排掉了至少60%的“看似奇怪”的线上问题。希望这篇文章能让你少踩几个我当年踩过的坑。