ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MyBatis核心原理与实战:从初始化、TypeHandler到缓存与批量处理

MyBatis核心原理与实战:从初始化、TypeHandler到缓存与批量处理 我用MyBatis做了五年多的开发从最开始只会写mapper.xml的“工具人”到后来啃源码、改Configuration、调二级缓存中间踩过的坑能写满一个笔记本。这个东西看起来就是一个简单的ORM框架背几个注解、会写动态SQL就能干活但一旦你开始面对性能优化、复杂查询复用到极致、批量写入这类场景不了解它的底层结构你会发现自己一直在隔靴搔痒。这篇东西我打算用一个比较完整的主线来讲从MyBatis的整体设计出发拆它的初始化流程、SQL执行链路、TypeHandler、缓存机制再落到Spring Boot整合和一批高频面试题上。你如果是刚学MyBatis可以按顺序读如果你已经写了不少业务代码直接跳到你最困惑的那一节。我尽量不写教科书式的废话所有内容都是基于真实项目里遇到过的场景来展开。1. 核心架构拆解为什么MyBatis能成为持久层的常青树很多新人在选持久层框架时会有个疑问MyBatis和JPA、Hibernate到底差在哪要回答这个问题先得搞清楚MyBatis的核心定位——它是“半自动映射”的ORM框架。所谓半自动是指它会帮你处理JDBC里的连接管理、参数绑定、结果集映射但它不会帮你自动生成SQLSQL永远由程序员自己控制。这既是它的短板也是它这么多年没被淘汰的根本原因。1.1 半自动映射的定位决定了它的应用场景JPA那类全自动框架追求的是“对象模型驱动”你只管操作实体框架去生成SQL。听起来很省事但一旦碰到报表、复杂统计、多表关联的深度优化全自动框架生成的SQL往往会让人很被动要么优化不动要么需要写一堆被框架绑定的注解和回调。MyBatis相反它把SQL的编写权完全还给开发者甭管你是单表CRUD还是三层嵌套子查询只要你会写SQL它就能给你跑并且对数据库特性的利用是直接的不走中间层。这也是为什么在互联网业务、传统企业系统里MyBatis的占有率一直很稳。业务越复杂、SQL越定制化MyBatis越有优势。代价就是开发人员需要自己维护SQL和参数映射团队里必须有人能hold住SQL质量。你可以把MyBatis理解成一个“极度听话的执行者”你写什么SQL它就执行什么参数怎么绑定、结果怎么包装都由配置决定它不替你做业务决策。1.2 五层核心组件一条调用链读懂MyBatisMyBatis的体系可以拆成五个核心组件别看源码里类很多真正的主干就这几个组件职责类比SqlSessionFactory全局唯一的工厂负责创建SqlSession生产车间的总调度中心SqlSession每次会话的入口执行SQL、管理事务车间里的工单Executor真正执行SQL的执行器负责缓存、JDBC操作一线操作工StatementHandler处理JDBC Statement参数和结果都过它手调度员的指挥台MappedStatement一条SQL及其配置的封装参数类型、结果映射、SQL文本作业指导书一次最简单的查询调用链是这样的Mapper接口方法调用 → SqlSession.getMapper返回代理对象 → 代理通过MapperProxy找到MappedStatement → Executor执行 → StatementHandler做JDBC交互 → ResultSetHandler映射结果。这条链路上任何一个环节都可能是你优化或排查问题的切入点。我在项目里排查“某个查询查出来慢”时从来不会上来就改SQL而是先确认是SQL本身慢还是参数没走索引还是结果集映射过多导致内存压力。如果你不理解这条链路你连定位问题的起点都找不到。2. 初始化原理XMLConfigBuilder与自定义ConfigurationMyBatis启动时第一件事就是构建Configuration它是整个框架的全局配置中心所有MappedStatement、类型别名、类型处理器、插件、环境信息全都挂在它身上。网上问得最多的一个问题就是“mybatis中xmlConfigBuilder的工作流程是什么”这个地方值得好好讲。2.1 基于XML配置的初始化管线从parse到mappedStatements当你用new SqlSessionFactoryBuilder().build(inputStream)启动MyBatis时真正的幕后主角是XMLConfigBuilder。别被builder这个名字骗了它不是简单地读文件它的工作可以分成三大阶段解析全局配置创建XMLConfigBuilder时它首先用XPathParser把XML解析成可操作的XNode节点树然后调用parseConfiguration方法按mybatis-config.xml里的节点顺序逐个处理。settings、typeAliases、plugins、environments、mappers这些节点都有专门的解析逻辑。加载Mapper映射配置里的mappers标签指向mapper.xml文件路径或Mapper接口。XMLMapperBuilder登场它负责解析mapper.xml里的cache、resultMap、sql、select|insert|update|delete等节点每条SQL配置都会被包装成一个MappedStatement注册到Configuration.mappedStatements这个Map里。绑定Mapper接口MapperAnnotationBuilder会把Mapper接口的方法与MappedStatement建立一一映射关系这样你在调用userMapper.selectById(1)时代理对象才能依据方法找到对应的MappedStatement。有一类问题是“为什么我改了mapper.xml重启后才生效”其实根因就在第一阶段初始化是一次性的XMLConfigBuilder只在构建工厂时执行后面的运行期不会再去扫描XML。如果你真的需要热加载得自己写代码定时重新构建SqlSessionFactory但这在大多数场景下不推荐容易引入连接池管理混乱的问题。2.2 自定义Configuration的落地方式解决多环境定制问题有些团队的MyBatis配置比较特殊需要细粒度控制某个行为。最常见的是自定义Configuration的子类然后在构建工厂时把人家的默认实现替换掉。步骤很简单写一个配置类继承Configuration注册上你自己的TypeHandler或Interceptor然后通过XMLConfigBuilder的configuration方法手动指定public class CustomConfiguration extends Configuration { public CustomConfiguration(Environment environment) { super(environment); } } String resource mybatis-config.xml; InputStream inputStream Resources.getResourceAsStream(resource); XMLConfigBuilder builder new XMLConfigBuilder(inputStream, null, new CustomConfiguration(new Environment(dev, new JdbcTransactionFactory(), dataSource))); SqlSessionFactory factory builder.build();这种做法的典型使用场景是你要给所有SQL统一加一个自定义的日志装饰、给所有结果映射加一层脱敏逻辑或者要替换默认的缓存实现。直接改源码不现实继承Configuration是侵入性最小的扩展点。但注意自定义Configuration一旦写错可能导致MyBatis启动时一堆隐性故障比如找不到Environment、TypeHandler注册失败。我的建议是先做最小验证再逐步扩展。2.3 打印SQL配置的实用技巧别再用猜的关于“mybatis配置打印”我说两个层面。第一个是控制台打印SQL做法是在mybatis-config.xml里配置settingssettings setting namelogImpl valueSTDOUT_LOGGING/ setting namelogPrefix valuemybatis./ /settingslogImpl可选值有SLF4J、LOG4J2、STDOUT_LOGGING等生产上我用得最顺手的是SLF4J配合logback。有些团队会漏掉logImpl配置导致打印了一堆日志但看不到SQL就是因为MyBatis没有发现可用的日志实现。第二个层面是打印MyBatis启动时加载了哪些MappedStatement、缓存的明细。把日志级别调到DEBUG后你会看到类似Parsed: selectById - {select ...}的输出。如果发现某个Mapper方法执行时报“Invalid bound statement (not found)”十有八九是MappedStatement没注册上这时看启动日志比看堆栈有效得多。配置打印不是可选项它是排查初始化问题的一把钥匙。3. 执行链路从Mapper方法到TypeHandler再到缓存框架初始化完成后真正的高频路径是执行阶段。很多面试官喜欢问“Mapper接口没有实现类为什么能直接调用”其实就是代理加执行器那套机制。我能告诉你的是这一环掰开揉碎后它的复杂度集中在TypeHandler和缓存上。3.1 Mapper方法调用到SQL执行的完整链路调用userMapper.selectById(1)时JDK动态代理拦截了调用MapperProxy在invoke方法中根据方法签名找到对应的MappedStatement然后创建一个MapperMethod。MapperMethod里维护两个关键对象SqlCommand记录SQL类型和id和MethodSignature记录返回类型、参数名等。接着进入SqlSession执行具体执行逻辑委托给Executor。Executor先检查一级缓存没命中就创建StatementHandler。StatementHandler又分成RoutingStatementHandler和BaseStatementHandler前者做路由真实执行的是PreparedStatementHandler、SimpleStatementHandler或CallableStatementHandler中的一种。参数由ParameterHandler处理结果由ResultSetHandler处理。整条链路结束缓存再把结果放回去。我在做性能分析时重点盯的就是PreparedStatementHandler这一层。MyBatis默认用的就是预编译StatementSQL模板和参数分离这既是为了防SQL注入也是为了让数据库能复用执行计划。如果你看到自己打印的SQL里参数没有被替换成实际值而是?那是正常的预编译就是这个表现。3.2 TypeHandler完整工作流程从JdbcType到JavaType的双向转换TypeHandler可能是MyBatis里最容易被忽视但又最出问题的组件。它的核心职责是处理Java类型和JDBC类型之间的双向映射。Java侧往SQL里写参数时走setParameter查询结果从ResultSet取出来时走getResult。工作流程可以这样描述每个TypeHandler都会注册到TypeHandlerRegistry里当MyBatis遇到一个Java类型或者注册了JdbcType的列就会从这个注册表里找对应的处理器。内置处理器覆盖了String、Integer、Date、Boolean这些常用类型但如果你遇到LocalDateTime、枚举、JSON字符串这类特殊类型就得自定义了。举个例子我在一个老项目里遇到PostgreSQL的jsonb类型默认的StringTypeHandler拿不到jsonb字段的完整值就自己写了一个JsonTypeHandlerMappedTypes(JsonNode.class) MappedJdbcTypes(JdbcType.VARCHAR) public class JsonNodeTypeHandler extends BaseTypeHandlerJsonNode { private static final ObjectMapper MAPPER new ObjectMapper(); Override public void setNonNullParameter(PreparedStatement ps, int i, JsonNode parameter, JdbcType jdbcType) throws SQLException { ps.setString(i, MAPPER.writeValueAsString(parameter)); } Override public JsonNode getNullableResult(ResultSet rs, String columnName) throws SQLException { String value rs.getString(columnName); return value null ? null : MAPPER.readTree(value); } }注意两点MappedTypes和MappedJdbcTypes决定了这个TypeHandler何时被选中。如果你的实体字段类型是String而你想让它走自定义的处理器那必须在配置里显式指定typeHandlerJsonNodeTypeHandler或者注册时用typeHandlerRegistry.register(JsonNodeTypeHandler.class)否则MyBatis会优先走内置的StringTypeHandler你的自定义处理器根本不会触发。这个问题我踩过两次每次都是“我明明写了自定义TypeHandler为什么没生效”。3.3 一级缓存与二级缓存实现细节二级缓存别乱开缓存是MyBatis里被人问得最密集的一块也是误用率最高的地方。先说一级缓存它在SqlSession会话级别默认开启。同一个SqlSession内同一MappedStatement和相同参数的两条查询第二次命中的是一级缓存不再走数据库。一级缓存的存储结构是Executor里的PerpetualCache就是一个简单的HashMap。二级缓存是namespace级别每个Mapper映射文件一个namespace的共享缓存默认不开启。它需要你手动在mapper.xml里加cache/标签并且实体类需要实现Serializable接口。二级缓存的读写会经过一个事务提交阶段来判断——查询结果先放到TransactionalCache中只有事务提交后才会真正写入二级缓存这是为了避免脏读。二级缓存最坑的地方是多个表关联查询。一个selectUserWithOrders关联查了用户表和订单表结果被缓存到userMapper的namespace里结果你的orderMapper更新了订单表userMapper的缓存不会自动失效读到旧数据。解决方式要么是只缓存那些数据更新不频繁的简单查询要么在cache/里配置flushInterval做定时刷新。我的实际建议是系统初期尽量不要开二级缓存先优化SQL和索引实在需要再开而且只开给那些访问量极高、数据变动极少的读接口。4. Spring Boot MyBatis项目整合实战与批量处理看那些热门搜索词里有一串“第1关项目整合 - springboot mybatis”“第2关使用springboot mybatis实现一个最简单的注册功能”说明大量新手卡在从零整合这一步。这块我放到一起讲从工程结构到批量处理一次过完。4.1 项目整合的核心步骤结合MVC分层的设计Spring Boot 3.x MyBatis的整合核心依赖就两个mybatis-spring-boot-starter和数据库驱动。配置上我推荐用yaml一个最小可运行的配置长这样spring: datasource: url: jdbc:mysql://localhost:3306/demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.demo.entity configuration: map-underscore-to-camel-case: true这里最重要的一行是map-underscore-to-camel-case: true。它解决的是数据库字段user_name和Java属性userName之间的自动映射问题。没有这个配置你要么在SQL里写别名要么写一堆resultMap相当痛苦。MVC分层上Spring Boot项目的MyBatis代码一般这样组织Controller层接收请求 → Service层处理业务 → Mapper接口做数据访问 → mapper.xml写SQL。注意Mapper接口上要加Mapper注解或者直接在启动类上扫包MapperScan(com.example.demo.mapper)二者选其一即可别重复。4.2 注册功能的最简实现完整走一遍CRUD注册功能是学习MyBatis最典型的练手场景因为它涵盖了插入、查询、判断用户名重复、返回主键这几个高频操作。先建User实体字段对应id、username、password、create_time。再写UserMapper接口Mapper public interface UserMapper { User findByUsername(Param(username) String username); int insertUser(User user); }mapper.xml里对应两个操作select idfindByUsername resultTypecom.example.demo.entity.User select id, username, password, create_time from user where username #{username} /select insert idinsertUser parameterTypecom.example.demo.entity.User useGeneratedKeystrue keyPropertyid insert into user(username, password, create_time) values(#{username}, #{password}, #{createTime}) /insert两个细节值得说。一是useGeneratedKeystrue配合keyPropertyid插入成功后数据库自增主键会回填进user.getId()这样后续逻辑可以拿到新用户的id无需再去查一次。二是参数占位符要用#{username}而不是${username}。#{}走预编译能防SQL注入${}是字符串拼接虽然有时动态SQL必须用但绝不能直接拼用户输入。我在代码评审里见过好几次把${}用在用户名查询上的情况这类代码上线就是事故。4.3 MyBatis与MyBatis Plus批量写入的取舍网上常搜“java mybatis mybatis-plus 批量”大多数人是想解决批量插入的性能问题。先说原生MyBatis的批量写法最推荐的方式是使用ExecutorType.BATCH因为它会把多条SQL放到同一个PreparedStatement里攒批执行减少网络往返和SQL解析开销SqlSession session sqlSessionFactory.openSession(ExecutorType.BATCH); try { UserMapper mapper session.getMapper(UserMapper.class); for (User user : userList) { mapper.insertUser(user); } session.commit(); } finally { session.close(); }需要注意的是BATCH模式下SQL不会立即执行要等commit或者手动flushStatements才会真正提交所以你中途想看变更影响行数会得到-2这个占位值别慌。MyBatis Plus则提供了更简单的saveBatch方法底层也是攒批执行。但它的性能天花板取决于数据库连接配置尤其MySQL需要在jdbc url上加rewriteBatchedStatementstrue否则攒批是不生效的每条SQL还是各自执行。这个参数我不知道见过多少人漏掉加了之后批量插入性能直接翻倍都不是夸张。5. 常见问题排查与高频面试考点梳理说实话开发中大部分MyBatis的坑集中在三块条件不生效、缓存数据不一致、参数绑定错误。我结合近几年遇到过的问题和面试题里反复出现的考点一起整理。5.1 条件不生效与动态SQL的参数绑定问题搜“mybatis条件不生效”的人绝大多数是动态SQL里if标签的判断出了问题。最经典的写法是这样的select idfindUsers resultTypeUser select * from user where if testusername ! null and username ! and username like concat(%, #{username}, %) /if /where /selectwhere标签会自动处理掉第一个多余的和and或or所以正常情况你只需要关注test里的表达式。常见的不生效场景有三个一是参数是多个基本类型时没有加Param注解。MyBatis对多参数处理时默认参数名是param1、param2如果你在test里写username它会提示找不到属性。解决办法就是每个参数都加上Param(username)。二是test里用了实体类的属性但属性名拼错。MyBatis对属性名是大小写敏感的userId和userid是两码事一旦拼错条件直接跳过。三是数字类型的判断。id ! 这种写法对Integer类型的id是有问题的空字符串和数字比较会导致ognl表达式结果异常。我建议数字类型只判断! null字符串类型才判断空串。5.2 缓存穿透式的“查不到”和缓存刷新策略缓存导致的问题有个很典型的特征同样的参数第一次查有数据第二次查没数据或者查到脏数据。一级缓存还好生命周期短随SqlSession关闭而结束。二级缓存的问题在关联查询上正如前面说的跨namespace失效问题。另外还有一种场景是缓存导致“明明数据库有数据接口查不到”。如果开启二级缓存并且某个Mapper的缓存一直没有被flush而另一个进程直接改了数据库那这个Mapper下所有查询都会命中旧缓存。排查方式很简单看日志里有没有“Cache Hit Ratio”字样或者临时把cache/注释掉复现一次。生产上我都是通过开关控制二级缓存线上动态开关能力是很有用的。5.3 高频面试题从配置到原理的十连问把热门搜索里的面试题做了个梳理覆盖我面试候选人时最常用的几道第一个“MyBatis中XMLConfigBuilder的工作流程是什么”。面试官要听到的是解析全局配置、加载mapper映射、绑定Mapper接口、构建Configuration。能答出这三个阶段就算过关。第二个“MyBatis的一二级缓存有什么区别”。重点讲一级缓存是SqlSession级别二级缓存是namespace级别以及二级缓存默认关闭、要实体序列化、关联查询会遇到脏数据问题。第三个“TypeHandler的工作流程是怎样的”。要讲到setParameter和getResult两个方向、类型注册表、自定义处理器怎么注册最好拿一个JSON字段的例子说明。第四个“#{}和${}的区别”。#{}是预编译参数占位符防SQL注入${}是字符串直接拼接一般用于动态表名、排序字段。能用#{}绝不优先用${}。第五个“为什么Mapper接口没有实现类却能调用”。答案是JDK动态代理代理对象在MapperProxy中解析方法、找到MappedStatement、执行SQL。第六个“MyBatis源码中SqlSessionFactory的构建过程”。从Resources加载资源 → XMLConfigBuilder解析 → 得到Configuration → 构建DefaultSqlSessionFactory。第七个“MyBatis缓存命中率低怎么办”。排查SQL参数是否固定、Mapper是否是单例、是否每次都在新建SqlSession。二级缓存命中率低通常是因为SQL太动态缓存的key差异太大。第八个“自定义Configuration有什么用处”。扩展全局行为比如注册自定义TypeHandler、注入自定义Executor拦截器、替换缓存实现。第九个“迭代光标、批量操作时如何处理流式查询”。可以提ResultHandler或Cursor但要说明流式查询需要保持SqlSession开启并且事务要控制好。第十个“MyBatis插件原理”。它是基于JDK动态代理对Executor、StatementHandler、ParameterHandler、ResultSetHandler四个接口的拦截注意不要拦截自己的TypeHandler。这类问题没有一个标准答案模板核心是看能不能把原理和场景串起来。回到我自己经验MyBatis最值得投入时间去理解的恰恰是初始化那一段。因为看似很长的一条链路都和Configuration脱不开关系。你会发现在开发中遇到的90%诡异问题只要你能在脑子里画出Configuration对象里挂载了什么、mappedStatements里注册了什么、Executor的缓存里装了什么时候定位就能快很多。最后再分享一个小技巧当你排查一个“看起来和MyBatis无关”的问题时先去看看mybatis的日志级别把它调到DEBUG很多问题会直接暴露在日志里。用DEBUG日志临时定位定位完调回INFO这个习惯能帮你省出大把的查问题时间。
返回列表