ARTICLE DETAIL

资讯详情

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

MyBatis核心原理与实战:深入解析XMLConfigBuilder、缓存与动态SQL

MyBatis核心原理与实战:深入解析XMLConfigBuilder、缓存与动态SQL 最早接触 MyBatis 的时候我还是个只会用JDBC写PreparedStatement的实习生。那时候每写一次查询都要手动getConnection、try-catch-finally关资源、再一个字段一个字段地setString、getString累倒是不怕怕的是稍不留神就漏了资源释放线上一压测直接连接池爆掉。后来切到 MyBatis第一感受是“原来SQL还能这么写”再后来深入源码、缓存、动态代理之后才意识到这东西真正厉害的不是帮我们省了样板代码而是把 SQL 的可控性和 Java 的类型系统缝合在了一起既不给开发者设限又把脏活扛了下来。这篇教程我不打算搞那种“照本宣科”的条文罗列。围绕 MyBatis 从最初的一行配置到最后的二级缓存我会把每步背后“为什么要这么做”讲清楚再穿插一些我实际开发里踩过的坑。不管你是刚接触 Java 持久层的在校生还是用 MyBatis 写了好几年但一直没琢磨过底层原理的 CURD 老手这篇文章应该都能让你有一些收获——尤其是后半部分关于缓存、参数绑定和 XMLConfigBuilder 初始化流程的分析都是面试里真正会问的东西。1. 项目整体设计与学习路径拆解1.1 MyBatis 到底是什么它解决了什么问题MyBatis 是一个半自动化的 ORM 框架。注意“半自动”这三个字这是它和 Hibernate、JPA 这类全自动 ORM 最本质的区别。全自动框架的核心卖点是“对象关系映射”你定义一个User实体框架自动帮你建表、自动生成 CRUD、自动维护外键关系。听起来很爽但一旦你面对的 SQL 有三四个表的 Join、有子查询、有 case when 的复杂分支或者公司 DBA 要求所有查询必须走索引、必须用指定的 SQL 方言全自动框架就会变得很难伺候——你得学它的 HQL/JPQL得理解它的缓存机制还得想尽办法绕过它的自动行为去写原生 SQL。MyBatis 的思路完全反过来它不替你决定 SQL它只是把 SQL 和 Java 方法签名绑起来。SQL 长什么样由你说了算参数怎么传、结果怎么映射也由你配置。它解决的核心问题有两个一是消灭 JDBC 样板代码二是把 SQL 从 Java 代码里拆出来集中放在 XML 或者注解里。换言之MyBatis 把“自由”还给了 SQL把“自动化”留给了参数映射、结果集封装和连接管理。用一句话概括MyBatis 你的 SQL 半自动的映射管道。1.2 为什么 SpringBoot 时代还要学 MyBatis 底层现在 SpringBoot 生态极其成熟mybatis-spring-boot-starter引入之后打个Mapper注解就能跑。很多同学会觉得既然一键整合为什么还要去啃 XMLConfigBuilder、Configuration、MapperProxy 这些底层东西我的回答很直接因为面试问也因为排查线上问题绕不开。举个例子线上一条查询突然变慢你的第一反应是看 MyBatis 打印的 SQL 日志。但如果你不清楚 MyBatis 的sqlSession和事务边界是绑在一起的你就不知道为什么有时候明明执行了两次查询日志里却只有一次数据库查询——那是一级缓存生效了。再比如说你配置了二级缓存结果数据更新后页面还是老数据如果你不了解二级缓存的失效条件是“同一个 namespace 下的增删改会清空缓存”你可能会去重启应用而不是改缓存策略。更实际的场景是动态 SQL。if标签判断条件不生效、foreach批量插入性能上不去、${}注入风险——这些问题不读原理压根定位不了。所以这篇教程里我会把“能跑”和“跑得明白”这两层都覆盖到。1.3 一条走得更顺的学习路线结合我带过几个新人的经验MyBatis 的学习路线建议分成四段第一段先把 JDBC 手写一遍。不用写多复杂的业务写一个单表增删改查就行。这一步是为了让你感知到“MyBatis 到底帮我省了什么”。第二段在不依赖 Spring 的前提下用原生SqlSessionFactoryBuilder跑通一个 XML 配置的 Demo。很多教程直接上 SpringBoot 整合版但你其实没看清它的本质——SpringBoot 只是帮你自动创建了SqlSessionFactory而已。第三段研究核心机制。包括 Mapper 接口的动态代理、参数映射、结果集映射、TypeHandler、一级缓存、二级缓存。这阶段建议配合源码看不用全读抓住几个关键类和方法的时序即可。第四段回到 SpringBoot 整合理解MapperScan做了什么、事务和 SqlSession 的关系、分页插件等主流扩展的实现思路。有了这条主线后下面我们直接进入核心概念把第一段和第二段的内容一次性讲透。2. 核心概念与运行原理拆解2.1 SqlSessionFactoryMyBatis 的一切地基MyBatis 的使用过程无论怎么包装底层都必须经过两个核心对象SqlSessionFactory和SqlSession。SqlSessionFactory顾名思义是“生产 SqlSession 的工厂”。它的构建方式如下String resource mybatis-config.xml; InputStream inputStream Resources.getResourceAsStream(resource); SqlSessionFactory sqlSessionFactory new SqlSessionFactoryBuilder().build(inputStream);这段代码虽然简单但它是理解 MyBatis 初始化流程最直接的入口。SqlSessionFactoryBuilder读取配置文件后做了一件很关键的事——把 XML 配置解析成Configuration对象然后基于这个对象创建DefaultSqlSessionFactory。这里你会看到两个容易混淆的概念SqlSessionFactoryBuilder是“一次性”的它的生命周期极短读完配置构建完工厂就可以扔了而SqlSessionFactory是“长命”的整个应用一般只有一个单例。后续所有的数据库操作都从它身上获取SqlSession因此它是线程安全的也是性能优化的重点对象。至于SqlSession你可以把它理解成“一次数据库会话”。它不是一个长期驻留的连接而是封装了 Connection、事务、执行器的一层门面。由于它本身不是线程安全的所以每次请求都应该新建用完后关闭。我们在 Spring 整合场景下往往感受不到这个过程是SqlSessionTemplate替我们做了动态代理保证每次操作都自动获取和释放。提示很多人初学时习惯用mybatis-config.xml这个默认文件名这个只是约定不是强制。build()方法传入什么路径就解析什么路径。同理XML 里的environments可以有多个通过default属性切换这在多环境打包时很有用。2.2 XMLConfigBuilder 的初始化工作流程热词里有“xmlconfigbuilser 的工作流程图”这是个高频面试点值得在这里彻底说透。注意拼写正规类名是XMLConfigBuilder继承自BaseBuilder。整个初始化流程大概分这样几步第一XMLConfigBuilder构造时接收一个XPathParser这个解析器负责把 XML 文件转成 DOM 节点树。MyBatis 用 XPath 表达式从节点树里读取配置项而不是像早期框架那样一行行读文本。第二调用parse()方法进入真正的构建过程。parse()内部先判断parsed标志位防止重复解析然后调用parseConfiguration(XNode root)。这个方法就是整个初始化的核心它按顺序处理配置文件中的各个节点properties、settings、typeAliases、typeHandlers、objectFactory、objectWrapperFactory、reflectorFactory、plugins、environments、databaseIdProvider、mappers。第三以最关键的mappers节点为例。每遇到一个mapper resource.../或mapper class.../XMLConfigBuilder 就会调用mapperElement()方法然后通过configuration.addMapper()把对应的 Mapper 接口注册进去。如果是 XML 映射文件则通过XMLMapperBuilder进一步解析包括命名空间、SQL 语句、参数映射、结果映射等。第四XMLMapperBuilder解析完每个 statement 后会构建MappedStatement对象塞进Configuration的mappedStatements这个 Map 中。这个 Map 的 key 是namespace . id也就是我们常说的“statementId”。这之后每一个 SQL 在内存里就有了唯一坐标执行时只要按 id 来找即可。下面用一份简化的步骤序列来概括面试时可以按这个顺序回答读取 XML 输入流 → 创建 XPathParserDOM 树 → 构建 XMLConfigBuilder → parse() 解析根节点 → parseConfiguration() 逐节点处理 properties/settings/typeAliases/mappers... → mapperElement() 处理 mapper 资源 → XMLMapperBuilder 解析 mapper XML 的 namespace 和 statement → 构建 MappedStatement 注册进 Configuration.mappedStatements → 最终 loader 完成后SqlSessionFactory 持有一份完整的 Configuration了解到这个层面后面看SpringBoot自动装配时就会非常轻松。mybatis-spring-boot-starter的MybatisAutoConfiguration做的核心事情无非是用SqlSessionFactoryBean包装了上面这一整段流程只是配置来源多了一个 Spring 的Environment而已。2.3 Mapper 接口和 XML 是怎么绑定的刚学 MyBatis 时我有个很大的疑惑我只定义了一个UserMapper接口没有写实现类MyBatis 是怎么在运行时把接口方法调用转换成 SQL 执行的答案藏在两个机制里命名空间绑定和动态代理。先说命名空间绑定。一个经典规则是Mapper 接口的全限定名必须等于 XML 的namespace值接口方法名必须等于 statement 的id。例如public interface UserMapper { User selectById(Integer id); }对应的 XMLmapper namespacecom.example.mapper.UserMapper select idselectById resultTypecom.example.entity.User select * from user where id #{id} /select /mapper只有保证这两条MyBatis 才能把一次userMapper.selectById(1)的调用定位到com.example.mapper.UserMapper.selectById这个 statement 上。再说动态代理。关键类有三个MapperProxyFactory、MapperProxy和MapperMethod。当我们从Configuration中获取一个 Mapper 时MyBatis 会调用MapperRegistry.getMapper()它内部从已知的knownMappers里找到对应的MapperProxyFactory然后用 JDK 动态代理生成接口的代理类。代理逻辑集中在MapperProxy.invoke()中。每次接口方法被调用都会进入这个方法然后判断是不是Object里的方法如toString、hashCode如果是就直接放行否则就创建一个MapperMethod通过反射拿到方法签名和对应的MappedStatement再根据 SQL 类型执行增删改查操作。这个设计最妙的地方在于接口本身只是一个“契约”没有写一行实现代码真正干活的是SqlSession的selectOne/selectList/insert/update/delete方法。所以面试时问到“Mapper 接口能重载吗”答案也清楚了——不能。因为重载会导致方法名相同MyBatis 无法通过方法名唯一确定 statementId。2.4 TypeHandler类型转换这件事比想象中重要TypeHandler是 MyBatis 里最容易被忽略却又极其关键的组件。它负责 Java 类型和 JDBC 类型之间的双向转换工作流程可以分成两个方向写方向Java → JDBCPreparedStatement.setXxx()。比如一个 Java 的LocalDateTime需要转成 JDBC 的TIMESTAMP。执行 SQL 前MyBatis 会找到对应的 TypeHandler调用它的setParameter()方法。读方向JDBC → JavaResultSet.getXxx()。查询返回后MyBatis 遍历结果集列根据检测到的 JDBC 类型和目标 Java 属性类型用对应的 TypeHandler 调用getResult()把数据库里的值转成对象字段。MyBatis 内置了大量 TypeHandler覆盖基本类型、String、Date、BigDecimal、byte[]、枚举等。但还是有三个高频场景需要自定义第一个是枚举。默认枚举映射对于很多业务场景不够用比如你想把数据库存的String转成某个带描述字段的枚举对象或者想用枚举的code字段作为持久化值。这时自己写一个BaseTypeHandlerE子类重写setNonNullParameter、getNullableResult三个重载方法即可。第二个是复杂 JSON 结构。比如数据库字段存了一段 JSONJava 侧想要ListString或某个自定义对象。可以自定义 TypeHandler 里调用 Jackson 或 Gson 做序列化和反序列化开发效率非常高。第三个是加密字段。有些项目要求敏感字段在数据库里密文存储但业务代码里希望直接操作明文。在 TypeHandler 里做加密和解密可以做到对业务层完全透明。自定义 TypeHandler 的注册方式也简单XML 配置里typeHandlers typeHandler handlercom.example.handler.JsonTypeHandler javaTypejava.util.List/ /typeHandlers或者直接在映射文件的参数/结果列上指定。注意TypeHandler 的匹配优先级可能让你踩坑。MyBatis 匹配时先看javaType再看jdbcType最后看类型签名。如果同一个 Java 类型注册了多个 handler且没有明确指定 jdbcType可能会出现“预估之外”的匹配结果。所以复杂场景下建议显式在 XML 里指定typeHandler属性不要只依赖全局注册。3. 从零搭建SpringBoot 整合 MyBatis 的完整实操3.1 工程结构与依赖准备现在进入实战环节。我用一个最简单也最常见的注册功能来做演示用户提交手机号、密码、昵称服务端校验后写入数据库。这个场景虽然基础但能完整覆盖 Mapper 接口、XML 映射、Service 层事务、日志打印这几大核心用法。首先创建 SpringBoot 工程JDK 建议 8 以上。核心依赖只需要两个dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency注意mybatis-spring-boot-starter的版本选择。2.x 系列对应 SpringBoot 2.x如果你是 SpringBoot 3.x需要换用 3.x 版本的 starter因为底层 MyBatis 也升到了 3.5对应关系不能乱。工程结构建议按功能分包而不是按技术分层分包com.example.demo ├── DemoApplication.java ├── controller │ └── UserController.java ├── service │ ├── UserService.java │ └── impl │ └── UserServiceImpl.java ├── mapper │ ├── UserMapper.java │ └── UserMapper.xml ├── entity │ └── User.java └── config └── MybatisConfig.javaUserMapper.xml放在resources/mapper目录下然后在application.yml里指定 mapper 文件位置。3.2 核心配置与参数说明application.yml里最关键的配置是这几项mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.demo.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这里每行都值得解释一下。mapper-locations告诉 SpringBoot 去哪里找 XML 映射文件。如果不配置MyBatis 默认会去加载类路径下和 Mapper 接口同包同名的 XML但实际项目里我们通常把 XML 放在 resources 下所以需要显式指定。type-aliases-package是类型别名扫描包。配置它之后XML 里写resultType时就不用写全限定类名直接写User即可。很多老项目不配这个导致 XML 里全是超长类名改起来非常难受。建议从一开始就配上。map-underscore-to-camel-case开启驼峰映射。数据库字段create_time会自动映射到 Java 属性的createTime不用写一堆resultMap去手动指定。但这只对单层字段生效嵌套对象还是需要resultMap处理。log-impl配置为StdOutImpl后MyBatis 会把执行的 SQL、参数、返回行数直接打印到控制台排查问题时极其有用。生产环境建议换更规范的日志实现但开发阶段这个最简单直观。另外还有configuration-properties可以注入自定义属性比如在 XML 里用${tablePrefix}这样的占位符。用途灵活但注意它和 Spring 的${...}不是同一个体系容易混淆。3.3 用注册功能走通第一个完整 CRUD下面用注册功能把整个链路串起来。先建实体类public class User { private Long id; private String phone; private String password; private String nickName; private LocalDateTime createTime; // getter/setter 省略 }然后是 Mapper 接口public interface UserMapper { int insert(User user); User selectByPhone(String phone); }对应的 XMLmapper namespacecom.example.demo.mapper.UserMapper insert idinsert parameterTypeUser useGeneratedKeystrue keyPropertyid insert into user (phone, password, nick_name, create_time) values (#{phone}, #{password}, #{nickName}, #{createTime}) /insert select idselectByPhone resultTypeUser select * from user where phone #{phone} /select /mapper这里有两个细节新手特别容易忽略。第一个是useGeneratedKeys和keyProperty。如果不配置它们数据库自增主键不会回填到user对象里。很多业务场景需要在插入成功后立刻拿到主键去关联子表数据不配置回填就只能再查一次纯属多余。第二个是createTime这个字段的赋值问题。有人习惯在 Java 代码里setCreateTime(LocalDateTime.now())有人习惯用数据库的CURRENT_TIMESTAMP。我倾向于 Java 侧用类似 MyBatis-Plus 的字段填充或自己赋值——因为一旦 SQL 里写死了CURRENT_TIMESTAMP写单元测试时数据时间就很难控制排查问题时也不直观。另外如果用了多数据源不同数据库的时间函数写法也不一样尽量在应用层处理。Service 层代码如下Service public class UserServiceImpl implements UserService { Autowired private UserMapper userMapper; Transactional Override public void register(String phone, String password, String nickName) { User existing userMapper.selectByPhone(phone); if (existing ! null) { throw new BusinessException(手机号已注册); } User user new User(); user.setPhone(phone); user.setPassword(DigestUtils.md5DigestAsHex(password.getBytes())); user.setNickName(nickName); user.setCreateTime(LocalDateTime.now()); userMapper.insert(user); } }注意这里加了Transactional。注册操作其实只有一次插入不涉及多表操作加事务更多的是一种习惯——后续业务扩展时加积分、初始化配置等都会在同一事务里不提前加的话容易漏。事务的本质是保证一组 SQL 要么全成功要么全失败MyBatis 的 SqlSession 和 Spring 事务绑定之后会自动参与事务管理提交或回滚都会同步到缓存状态。3.4 动态 SQL让 SQL 自己学会“看情况办事”注册功能跑通之后接下来要面对的就是查询条件不确定的场景。比如后台用户列表手机号可能传也可能不传昵称可能传也可能不传注册时间区间可能传也可能不传。如果每种组合都写一个 SQL那代码会爆炸。动态 SQL 就是解决这个问题的。最基础和常用的是if加whereselect idselectByCondition resultTypeUser select * from user where if testphone ! null and phone ! and phone #{phone} /if if testnickName ! null and nickName ! and nick_name like concat(%, #{nickName}, %) /if if teststartTime ! null and create_time gt; #{startTime} /if if testendTime ! null and create_time lt; #{endTime} /if /where order by id desc /selectwhere标签有两个神奇的功能一是如果内部所有条件都不成立它会自动去掉整个 where 关键字让 SQL 退化成简单的全表查询二是它会自动处理条件开头的and/or删掉第一个多余的前缀。if标签的正确写法也值得强调判断条件里用test属性里面写的是 OGNL 表达式。常见写法是! null and ! 这样组合但数字类型要注意——比如一个Integer类型的字段值等于 0 时teststatus ! null and status ! 这个表达式其实会执行成功因为 0 会被 OGNL 当成一个合法的非 false 值处理。但如果你用字符串类型比较就会出现前面提到的“条件不生效”问题后面章节再展开说。复杂一点的还有set用在更新语句里update idupdateById parameterTypeUser update user set if testphone ! nullphone #{phone},/if if testnickName ! nullnick_name #{nickName},/if if testpassword ! nullpassword #{password},/if /set where id #{id} /updateset会自动去掉最后一个逗号不需要手工处理。这个需求很常见——前端可能只传部分字段update 时如果直接写死所有字段会把没传的字段覆盖成 null危害极大。所以凡是“可局部更新”的接口都应该用set加上字段级别的if判断。批量插入时用foreachinsert idbatchInsert insert into user (phone, password, nick_name, create_time) values foreach collectionlist itemuser separator, (#{user.phone}, #{user.password}, #{user.nickName}, #{user.createTime}) /foreach /insertcollection属性名就一个坑。如果 Mapper 方法参数是ListUser且没有加Param(list)那默认名称可能取不到如果加了Param(users)这里要写成collectionusers。这里建议统一加Param无论什么集合类型都写清楚名字不要依赖默认规则。4. 缓存机制一级缓存、二级缓存与自定义扩展4.1 一级缓存SqlSession 级别的本地缓存MyBatis 缓存在热词里出现频率极高面试基本必问。先记住一句话一级缓存是SqlSession级别的缓存也叫本地缓存默认开启无法直接关闭只能通过配置修改其生命周期。一级缓存的存储结构是一个PerpetualCache实例内部就是一个简单的HashMap。当同一个SqlSession中执行两次完全相同的查询时第二次查询不会真正走数据库而是直接返回缓存里的对象。这里要重点理解“相同查询”的条件statementId 相同、参数相同、分页条件相同、SQL 字符串相同。比如try (SqlSession session sqlSessionFactory.openSession()) { UserMapper mapper session.getMapper(UserMapper.class); User u1 mapper.selectByPhone(13800138000); User u2 mapper.selectByPhone(13800138000); System.out.println(u1 u2); // true第二次命中一级缓存 }但如果是 Spring 整合场景事情就没这么简单了。Spring 里我们拿到的 Mapper 是经过SqlSessionTemplate动态代理的每次 Mapper 方法调用都会从SqlSessionTemplate获取一个新的SqlSession吗并不完全是。关键在于事务SqlSessionTemplate会检测当前是否存在 Spring 管理的事务如果有就复用当前事务绑定的 SqlSession如果没有则每次调用都新建并关闭 SqlSession。也就是说一级缓存只有在“无事务的同一个请求内多次查询”或“同一个事务内多次查询”时才可能生效。一旦每次调用都新建 SqlSession一级缓存就形同虚设了。所以面试被问到“Spring 整合后一级缓存为什么失效”时答案不是“MyBatis 关了它”而是因为我们每次操作都用的新 SqlSession缓存随 SqlSession 关闭而销毁根本没机会命中。失效条件也很关键如果 SqlSession 里执行了任何 insert、update、delete 操作即使执行的是另一张表的写操作一级缓存也会被清空。因为 MyBatis 默认认为任何写操作都可能改动缓存数据宁可清空也不冒险。这一点在BaseExecutor.update()方法的源码里可以看到它最后会调用clearLocalCache()。4.2 二级缓存namespace 级别的共享缓存二级缓存是namespace级别的同一个 namespace 下的所有 SqlSession 可以共享。它比一级缓存的作用范围更大解决的是“多个 SqlSession 之间查询结果共享”的问题。开启方式很简单。在 Mapper XML 里加一行cache evictionLRU flushInterval600000 size1024 readOnlyfalse/各个参数含义eviction是回收策略默认LRU最近最少使用也可以选FIFO、SOFT、WEAKflushInterval是刷新间隔单位毫秒size是缓存最多存放的对象数量readOnly为 true 时返回缓存对象的同一引用性能好但可能被修改false 时返回序列化拷贝的对象安全但性能低。readOnly手法值得解释一下MyBatis 的序列化缓存要求对象实现Serializable接口它会先把对象序列化成字节数组缓存取的时候再反序列化这样每个 SqlSession 拿到的都是独立对象互不影响。但对象没有实现序列化接口一取缓存就报错这个也是高频踩坑点。二级缓存有一系列生效和失效的细节生效前提是当前会话必须提交事务。因为 MyBatis 认为只有事务提交后才算“稳定”的数据未提交的事务可能随时回滚不能缓存。所以在 Spring 整合里如果一个查询方法没有开启事务调用完返回前 SqlSession 直接关闭了二级缓存也没写入多少意义反之事务方法提交时会触发commit()把缓存数据写入二级缓存区域。失效机制是同一个 namespace 下任意一个 insert、update、delete 执行后二级缓存会整体清空。MyBatis 的设计理念是“更新操作让整个 namespace 的缓存作废”这样才能避免复杂的部分失效判断。所以如果你的业务是“同一 namespace 里频繁插入但很少查询”二级缓存反而会让缓存频繁清空命中率极低不如不开。还要特别注意跨 namespace 查询的问题。比如UserMapper和OrderMapper联查OrderMapper的 SQL 里 join 了user表。此时如果UserMapper启用了二级缓存订单 Mapper 改了用户表数据并不会清空 UserMapper 的缓存这就产生了脏数据。解决思路是关联查询涉及的表尽量在同一个 namespace 里操作或者干脆不用二级缓存单表高频查询才值得开。4.3 自定义缓存缓存抽象接口的实际应用如果你觉得内置的PerpetualCache不够用比如想接 RedisMyBatis 也留了口子。核心思路是实现org.apache.ibatis.cache.Cache接口再用cache type...替换默认实现。接口要实现的几个方法很简单getId、putObject、getObject、removeObject、clear、getSize。但这里真正要关心的是——MyBatis 的Cache接口根本没有定义“过期时间”这个语义所以如果你的自定义缓存要实现 TTL得从flushInterval参数入手或者自己包一层带过期时间的对象。虽然自定义 Cache 可以接 Redis但实际项目中很少有人这么做。原因很简单MyBatis 的二级缓存完全是应用层自己的对象存储它不知道其他应用是否也改了数据库所以在分布式环境下很容易产生脏数据。真要跨服务共享缓存不如用 Redis 做业务数据缓存而不是依赖 MyBatis 的二级缓存机制。我个人的建议是单机应用、读多写少、数据几乎不变的场景可以开二级缓存微服务架构、缓存要精确控制 TTL、跨服务共享数据的场景别用 MyBatis 二级缓存把缓存层上移到 Service 层可维护性和可控性都会提高不少。5. 高频问题排查与实战避坑5.1 参数绑定问题Param 和 param1 的恩怨热词里有个“mybatis param index”这个新手的坑我见得太多了。说一个经典场景接口方法有两个参数ListUser selectByPhoneAndStatus(String phone, Integer status);XML 里想用#{phone}和#{status}绑定。听起来没问题但实际运行大概率报错Parameter phone not found. Available parameters are [arg1, arg0, param1, param2]。原因在于 Java 8 之前编译后的字节码里不会保留方法参数的名称。MyBatis 拿不到形参名只能按照位置给参数取默认名字arg0、arg1以及param1、param2。所以你会看到报错信息里提示可用的是arg0、param1这类名字而不是你以为的phone。解决办法有两个。第一个在编译插件里加-parameters参数让 javac 保留参数名。Maven 的maven-compiler-plugin里对应配置compilerArgs arg-parameters/arg /compilerArgs加了之后#{phone}就能直接用了。但这个方案依赖编译参数换个项目或别人拉代码没配好又失效不推荐作为首选。第二个也是我强烈推荐的多个参数时给每个参数加Param注解ListUser selectByPhoneAndStatus(Param(phone) String phone, Param(status) Integer status);加注解后MyBatis 会把这些参数包成一个ParamMapkey就是注解指定的名称。XML 里用#{phone}和#{status}就稳定生效了不受编译参数影响。顺便说一句即使只有一个参数如果参数是List或数组也建议加Param因为foreach里依赖集合名。另一个相关问题是#{}和${}的区别。#{}会生成PreparedStatement的占位符?由 JDBC 预编译处理能有效防止 SQL 注入${}是字符串拼接直接把值拼进 SQL。${}典型场景是动态表名、动态排序字段比如select idselectByTable select * from ${tableName} where id #{id} /select但使用${}拼接的任何内容都不能来自用户直接输入必须做白名单校验。比如表名只允许枚举集合里的值排序字段只允许白名单里的列名否则就是给 SQL 注入开口子。5.2 动态条件不生效OGNL 表达式的隐藏规则“mybatis条件不生效”这个热词背后最常见的就是if test表达式里的判断问题。最坑的一个是把数字和字符串比较if teststatus ! and status 1当status是Integer类型且值为 1 时这个条件居然不成立。问题出现在status ! 这个判断上Integer 类型拿 1 去和空字符串比较时OGNL 内部的处理逻辑和你想象的不一样。具体而言OGNL 对数字和空串的比较会走数值转换解析这个表达式可能被解析成 false导致整个条件短路。正确的写法是if teststatus ! null and status 1另外字符串判断里还要注意空串和 null 的区别。如果数据库字段允许 null前端可能传空串也可能传null所以条件要写全field ! null and field ! 。但这里又要小心如果字段是 Integer只写field ! 又会掉进数字比较的坑所以最佳实践是按照 Java 类型区别对待String 类型! null and ! Integer/Long 类型! nullList 类型! null and size() 0foreach里还有一个隐蔽的坑如果传入的集合是 null但循环体里没有正确判断生成的 SQL 里可能多出一个残缺的in ()语法直接报 SQL 编译错误。建议在foreach外部先包一层if testids ! null and ids.size() 0。5.3 日志打印与 SQL 排查开发时一定把 SQL 日志打开。配置方式上面提过StdOutImpl是最简单的。如果你用 logback 或 log4j2一般会配置成logging: level: com.example.demo.mapper: debug这样设置后MyBatis 会打印每个 statement 的执行 SQL、绑定参数和返回行数。参数打印出来是 Parameters: 13800138000(String)这种格式排查“SQL 明明看着对但查不到数据”的问题时重点看两个地方一是参数值对不对二是参数类型是不是你以为的类型。除了日志外还有一个好用的排查思路把 MyBatis 的执行过程拆成“生成 SQL → 绑定参数 → 执行 → 映射结果”四个阶段。遇到问题先问自己卡在哪一段。如果是 SQL 本身错误日志里能看到完整 SQL复制到数据库客户端里执行一遍就能定位如果是结果映射问题比如字段没对应上返回 null先检查是否配置了驼峰映射再去确认resultType是不是声明的窄类型。5.4 批量操作的 N1 问题与性能优化批量插入和批量更新是性能优化中的常见难点。热词里有“java mybatis mybatis-plus 批量”这里专门说说纯 MyBatis 下的批量操作的几个坑。第一个坑一条foreach拼几千条记录插入虽然只发了一次 SQL但 MySQL 对单条 SQL 的 SQL 语句长度有限制受max_allowed_packet影响一次性拼太多会直接报错。常见的经验值是分批每批 500 到 1000 条按实际数据大小调整。第二个坑批量更新如果用foreach拼多条 update 语句MySQL 默认驱动下 JDBC URL 里必须要加allowMultiQueriestrue否则直接报语法错误。而且这种方式在数据库层面并没有减少网络往返只是从 N 次连接变成了 N 条语句一次发出去性能提升有限。更推荐的做法是使用ExecutorType.BATCHSqlSession session sqlSessionFactory.openSession(ExecutorType.BATCH); try { UserMapper mapper session.getMapper(UserMapper.class); for (User user : userList) { mapper.insert(user); } session.commit(); } finally { session.close(); }在 Spring 环境下要拿到 BATCH 执行器得配置SqlSessionTemplate的构造参数Bean public SqlSessionTemplate sqlSessionTemplate(SqlSessionFactory sqlSessionFactory) { return new SqlSessionTemplate(sqlSessionFactory, ExecutorType.BATCH); }BATCH 模式的原理是把多次的PreparedStatement执行攒在一起等提交时再统一发给数据库减少网络往返和编译次数。但它有个副作用批量模式下的查询操作不会立即返回最新的插入数据因为数据还在缓冲区里。所以在同一个事务里先批量插入再查询可能查不到刚插入的记录需要先用flushStatements()把批处理强制执行掉。这个坑让我以前排查了很久写出来给大家提个醒。6. 面试高频考点与源码阅读路线复盘到这一步基础用法和常见问题基本都过了一遍。最后来对照热词里的面试题把核心考点串一串并给出源码阅读的具体路径。高频面试题一MyBatis 中 XMLConfigBuilder 的工作流程是什么样的这道题在前面 2.2 节已经详细展开过面试时按那个时序回答即可。如果能加上“它继承了 BaseBuilder持有 Configuration 引用通过 XPathParser 解析 XML”这样的底层细节会很加分。高频面试题二MyBatis 一级缓存和二级缓存的区别是什么答题要点是一级缓存是 SqlSession 级别默认开启生命周期短二级缓存是 namespace 级别需要显式开启跨 SqlSession 共享要求对象可序列化并且同一 namespace 下的增删改会清空缓存。如果能补充“Spring 整合后一级缓存由于 SqlSession 生命周期变化导致命中率不高”这一层说明你真的理解实际运行场景。高频面试题三Mapper 接口没有实现类它是怎么执行的答案围绕 JDK 动态代理展开MapperProxyFactory创建MapperProxy代理invoke()方法内通过MapperMethod解析方法签名和MappedStatement最终委托给SqlSession执行。能提到MapperMethod.execute()里根据SqlCommandType分发到 insert/update/delete/select 这四个方法就说明你读过源码细节。高频面试题四TypeHandler 的工作流程是什么答题分两步写方向是 Java 类型通过PreparedStatement.setXxx()写入数据库读方向是从ResultSet.getXxx()读出来映射到 Java 属性。扩展场景可以提自定义 TypeHandler 处理枚举、JSON 字段、加密字段等实际业务。阅读源码的路径也简单梳理一下按顺序读这几个类就够了第一步从入口读。XMLConfigBuilder.parse()是第一个要看的方法它把整个配置解析串起来。源码里这一段逻辑非常清晰方法名就是配置节点名。第二步读XMLMapperBuilder。重点看它如何解析select、insert这些节点以及如何把 SQL 文本通过SqlSourceBuilder转成SqlSource。这里就涉及#{}和${}的处理区别了。第三步读MapperRegistry和MapperProxyFactory。这里能看到 Mapper 接口注册和代理对象创建的过程。第四步读BaseExecutor。一级缓存的存取和清空逻辑全在这里query()方法里createCacheKey、queryFromDatabase这两个方法是核心。想了解二级缓存还要看CachingExecutor它是一级缓存上再套一层装饰器执行顺序是CachingExecutor→BaseExecutor。第五步有兴趣再读DefaultResultSetHandler。它是结果集映射最复杂的类handleRowValues、applyAutomaticMappings、createResultObject这些方法都值得啃一啃。读完它你对resultMap和自动映射的底层逻辑会彻底通透。源码阅读不要追求逐行读完抓主干、看时序、理解关键决策点即可。每读一个类就问自己三个问题这个类的职责是什么、它和上下游类怎么协作、它解决问题的方式有什么优缺点。带着问题读源码收获会大得多。最后再分享一个我自己带项目时的小习惯遇到 MyBatis 的诡异问题先别急着搜答案先把当前场景对应到源码里的执行链路上猜测可能是哪一环出了问题再打日志或加断点验证。很多表面上的“玄学问题”比如缓存不刷新、参数绑定不上、条件判断失效本质上都是执行链路中某个环节和你的预期不一致。把这个思路练熟了MyBatis 对你来说就不会再有什么黑盒。
返回列表