
1. 项目概述为什么每个 Java 工程师都值得啃一次 MyBatisMyBatis 是我见过的最适合拿来当“源码第一课”的开源框架。它的源码体量不像 Spring 那样庞大到让人望而生畏但接口与抽象类的边界、设计模式的运用、SQL 解析和参数绑定背后的算法思路一个都不少。更难得的是这个框架几乎所有 Java 项目都在用你完全可以白天写业务、晚上断点跟踪把学习和工作打通不用专门搭一套环境。这篇博文我想带读者走一条主线从一个你自己写的 Mapper 接口开始看它如何被 MapperProxy 动态代理如何在 SqlSession 里走缓存、建 StatementHandler最终落到 JDBC 执行再把 ResultSet 映射回 POJO。这条链路一旦在脑子里跑通MyBatis 面试题基本就没有死角了因为你已经不是在背源码类名而是在讲一个真实发生的调用故事。适合读这篇文章的人有三类刚准备看源码但找不到切入点的同学、MyBatis 面试前想系统梳理知识点的候选人、以及写了好几年业务代码却对框架内部机制“一脸懵”的工程师。1.1 我要拆解的核心关键词先给一张导读表把标题里的关键词和 MyBatis 源码里的落点对齐后面每一章都会回扣这张表关键词在 MyBatis 里的落点核心类接口与抽象类接口定义契约抽象类抽取公共流程SqlSession、Executor、CacheBaseExecutor、BaseTypeHandler设计模式一套全家桶级别的经典实践SqlSessionFactoryBuilder、MapperProxy、CachingExecutor算法字符串解析、参数绑定、缓存淘汰、结果集映射GenericTokenParser、ParamNameResolver、LruCache技巧与案例项目落地时的高频操作与避坑#{}与${}、ExecutorType.BATCH、自定义拦截器读的时候建议抓一条主线Mapper 接口方法调用 → MapperProxy.invoke → MapperMethod.execute → SqlSession → Executor → StatementHandler → JDBC → ResultSetHandler。只要这条链路上的每个类都知道“它在哪一步、负责什么”你就算正式进入 MyBatis 源码的世界了。1.2 认真读一遍源码你会得到什么很多人觉得读源码是面试前突击的事考完就忘。我的体感完全相反MyBatis 源码里那些设计取舍对日常开发的启发是长期的。比如我后来自己写公共服务时开口闭口就是“面向接口编程”但这个习惯真正立起来是在看了 Mapper 接口和 Executor 接口之后——接口不是给 Spring 用的是给调用方一个稳定契约抽象类也不是用来“省事”的是用来沉淀公共流程、暴露扩展点的。这些认知靠面经是背不出来的。2. 接口与抽象类MyBatis 的地基设计MyBatis 源码里接口和抽象类的分工非常明确。接口负责“定义能力”比如 SqlSession 是门面、Executor 是执行器、Cache 是缓存容器抽象类负责“沉淀流程”比如 BaseExecutor 把一级缓存、延迟加载、事务同步这些通用逻辑全包了子类只需要实现几个 doXxx 方法。搞清楚这个分工你就不再会把接口和抽象类混为一谈。2.1 Mapper 接口为什么偏偏是接口而不是抽象类这是 MyBatis 源码里最神奇的一个设计你只写了一个接口没写任何实现类但它就能在运行期帮你查出数据来。靠的是 JDK 动态代理。核心类是 MapperProxy它实现了 InvocationHandler代码逻辑可以简化成下面这样public class MapperProxyT implements InvocationHandler { private final SqlSession sqlSession; private final ClassT mapperInterface; private final MapMethod, MapperMethod methodCache; Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { // Object 类自带的方法不需要代理直接走原逻辑 if (Object.class.equals(method.getDeclaringClass())) { return method.invoke(this, args); } MapperMethod mapperMethod methodCache.computeIfAbsent(method, this::cachedMapperMethod); return mapperMethod.execute(sqlSession, args); } }为什么必须用接口因为 JDK 动态代理只认接口。Proxy.newProxyInstance 的时候传入的 interfaces 数组决定了代理对象能被强转成什么类型你不可能拿一个没有实现的类去做 JDK 代理。如果 MyBatis 想支持“类”作为 Mapper就得引入 CGLIB 那样的字节码子类代理复杂度立刻翻几倍。接口在这里既是技术约束也是设计上的理性选择Mapper 接口只关心“能不能查”不关心“怎么查”代理层把路由和参数解析全部接管了。我在实际项目里体会很深的一点是接口让 Mapper 和具体实现彻底解耦。业务代码里注入的是 OrderMapper至于背后是走 MyBatis 还是走内存 Mock调用方完全无感。这也是写框架的人和写业务的人都应该养成的习惯入口处给接口内部流程再考虑抽象类。2.2 抽象类存在的意义BaseBuilder、BaseExecutor 与 BaseTypeHandler接口解决“能不能用”抽象类解决“怎么做省事”。MyBatis 里最典型的三个抽象类是 BaseBuilder、BaseExecutor 和 BaseTypeHandler。先说 BaseBuilder。它有 Configuration、TypeAliasRegistry、TypeHandlerRegistry 这些公共依赖子类 XMLConfigBuilder、XMLMapperBuilder、XMLStatementBuilder 全部继承它这样注册别名、注册类型处理器这些操作就不用每个子类重写一遍。 Builder 场景下的抽象类本质是给你发一个“半成品工具箱”子类只关心自己负责的那一段解析逻辑。再看 BaseExecutor。这是模板方法模式和抽象类结合的典范。一级缓存、清缓存、延迟加载、事务同步这些逻辑全部写在非抽象方法里子类只需要实现 doQuery、doUpdate、doQueryCursor、doFlushStatements 这四个抽象方法。为什么要这么设计因为真正访问数据库的动作只有那几步但执行前后的缓存管理和资源清理是通用的如果每个 Executor 子类都自己写缓存逻辑代码会重复到爆炸也容易漏掉某些边界情况。BaseTypeHandler 也是一样的思路。TypeHandler 接口定义了 setParameter 和 getResult 等方法但 BaseTypeHandler 提前处理了 null 值、jdbcType 为 null 等通用情况子类只要实现 setNonNullParameter 和 getNullableResult。你去看 IntegerTypeHandler、StringTypeHandler它们的代码都很短就是因为通用逻辑都被父类吃掉了。所以自定义 TypeHandler 时正确姿势永远是继承 BaseTypeHandler而不是直接实现 TypeHandler 接口。2.3 接口与抽象类的取舍MyBatis 给我上的第一课我经常跟团队里的同学说把这两个概念放到业务代码里理解接口是“需求文档”定义清楚要提供什么能力不关心实现。比如我定义一个 PaymentService里面有 pay() 和 refund()调用方只依赖它。抽象类是“带模板的半成品”大部分逻辑已经写好了只留几个变化点让你填。比如 Excel 导出流程是查数据、做样式、输出文件这个流程对所有导出场景都一样只是数据来源和样式细节不同适合用抽象类固定流程。MyBatis 把这两者的边界划得很清晰要给外部用、要被代理、要支持多实现的东西就做成接口比如 Executor、Cache要复用公共逻辑、要有内部状态流转的东西就做成抽象类比如 BaseExecutor。这种取舍比背“接口和抽象类有什么区别”这种面试题有价值得多。3. 设计模式MyBatis 里的“全家桶”有人说 MyBatis 是一本“活的设计模式教材”这话不算夸张。从一个 XML 配置文件到真正执行一条 SQL中间至少串起了构建者模式、工厂模式、模板方法模式、装饰器模式、动态代理模式、策略模式、责任链模式。下面挑几个核心链路里的展开讲。3.1 工厂模式与构建者模式从 XML 到 SqlSession 的诞生我们平时启动 MyBatis 的第一行代码通常是SqlSessionFactory sqlSessionFactory new SqlSessionFactoryBuilder().build(inputStream);这个 SqlSessionFactoryBuilder 就是典型的构建者模式入口。它内部创建 XMLConfigBuilder由 XMLConfigBuilder 一步一步解析 mybatis-config.xml 里的各个节点最终构建出一个 Configuration 对象。这个过程大家可以理解成“按照图纸组装一台复杂的机器”图纸就是 XML产品就是 Configuration。Configuration 组装完成后会被传给 DefaultSqlSessionFactory后面每次调用 openSession()都从一个现成的工厂里生产新的 DefaultSqlSession。这里其实是工厂模式SqlSessionFactory 是工厂接口DefaultSqlSessionFactory 是具体工厂SqlSession 是产品。有一个工程经验想特别提醒Configuration 的构建成本很高里面包含了 MappedStatement、缓存、类型注册器等一大堆元数据所以 SqlSessionFactory 在应用里应该全局只有一个不要每次请求都重新 build。我见过有同事在工具方法里图省事每次都 new SqlSessionFactoryBuilder().build(...)结果就是启动慢、内存浪费还容易踩到并发问题。3.2 模板方法模式与装饰器模式执行器的分层艺术Executor 是 MyBatis 真正干活的执行器接口它有两个层次的设计很值得学习。第一层是模板方法模式。BaseExecutor 实现了 Executor 接口把查询、更新、提交、回滚这些方法都实现了但核心动作留给了子类。比如 query 方法内部会有这样的逻辑先创建 CacheKey查一级缓存缓存没命中再调 queryFromDatabasequeryFromDatabase 内部会调用子类实现的 doQuery。这样一来SimpleExecutor、ReuseExecutor、BatchExecutor 这些子类只需要关注“如何拿到 Statement 并执行”不需要关心缓存和事务同步。第二层是装饰器模式。CachingExecutor 是一个装饰器它包在真实 Executor 外面public E ListE query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException { BoundSql boundSql ms.getBoundSql(parameterObject); CacheKey key createCacheKey(ms, parameterObject, rowBounds, boundSql); return query(ms, parameterObject, rowBounds, resultHandler, key, boundSql); }它先查二级缓存缓存有就直接返回没有才委托给内部 delegate 去查数据库查完再把结果放进二级缓存里。这个模式的妙处在于二级缓存逻辑和 SQL 执行逻辑被彻底拆开了。想加缓存就包一层想换缓存策略就换装饰器完全不需要改底层执行逻辑。同样地Cache 接口的实现也是装饰器链PerpetualCache 是最底层的 HashMap 缓存外面可以包 LruCache、SynchronizedCache、SerializedCache、LoggingCache层层叠加最终得到一个“支持淘汰策略、线程安全、可序列化、带日志”的缓存对象。这种组合能力用继承是做不到的因为特性排列组合太多子类数量会爆炸。3.3 动态代理模式Mapper 接口的“无实现”魔法回到第一章提到的 MapperProxy它之所以能“凭空”实现 Mapper 接口靠的就是动态代理。每次调用 Mapper 接口的方法实际上都会进到 MapperProxy.invoke然后交给 MapperMethod.execute 处理。MapperMethod 内部会根据 SQL 命令类型分流INSERT、UPDATE、DELETE 直接调用 sqlSession 对应方法SELECT 则要看方法的返回类型返回单个对象就调 selectOne返回 List 就调 selectList返回 Cursor 就调 selectCursor返回 Map 就调 selectMap返回 Optional 还会做包装。这些判断逻辑平时你感知不到但它解释了为什么 Mapper 接口的方法签名不能乱写返回值类型直接决定走哪条执行路径。为什么 MyBatis 不直接给每个 Mapper 接口生成一个实现类而非要用代理因为接口方法背后的 SQL 是写在 XML 或注解里的运行时才需要结合方法参数动态解析。代理模式在这里提供了极大的灵活性接口定义可以随时加方法只要 XML 里有对应的 statement id它就能工作如果用代码生成器每次调整接口都得重新生成一遍负担太大。3.4 策略模式与责任链模式TypeHandler、Plugin 与拦截器TypeHandler 是典型的策略模式。MyBatis 在执行 SQL 时需要把 Java 参数值转成 JDBC 类型把 ResultSet 里的值转回 Java 类型它根据参数的类型和 jdbcType 去 TypeHandlerRegistry 里找对应的处理器。IntegerTypeHandler 处理 IntegerStringTypeHandler 处理 StringDateTypeHandler 处理日期这就把“类型转换”的方式做成了一组可替换的策略。如果你遇到一个自定义类型无法映射不要硬编码写一个 TypeHandler 注册进去就行这是源码设计好的扩展点。Plugin 和 InterceptorChain 实现的是责任链模式。MyBatis 允许你通过拦截器干预 Executor、StatementHandler、ParameterHandler、ResultSetHandler 四类核心对象。多个拦截器会对同一个目标对象层层代理执行时像过滤链一样依次通过最终才到达真实对象。这个机制在第五章我会给一个完整例子。4. 源码中的算法看似不起眼其实很精髓很多介绍 MyBatis 的文章都会忽略算法这一节但如果把“算法”定义成“用有限步骤解决问题的策略”MyBatis 里值得讲的点非常多。这一章挑四个最典型的展开。4.1 GenericTokenParser动态 SQL 解析的最小实现MyBatis 里所有#{}、${}占位符的识别都靠一个类GenericTokenParser。它的 parse 方法用一个循环加两个 indexOf把字符串里被 openToken 和 closeToken 包住的片段全部扣出来交给 TokenHandler 处理。核心逻辑可以简化成public String parse(String text) { StringBuilder builder new StringBuilder(); int offset 0; int start text.indexOf(openToken, offset); while (start -1) { int end text.indexOf(closeToken, start openToken.length()); builder.append(text, offset, start); builder.append(handler.handleToken(text.substring(start openToken.length(), end))); offset end closeToken.length(); start text.indexOf(openToken, offset); } builder.append(text.substring(offset)); return builder.toString(); }这个算法时间复杂度是 O(n)思路极其朴素但把“找到占位符、提取内容、回调替换”三个步骤拆得非常干净。ParameterMappingTokenHandler 拿到{id}后会把参数名记录到 parameterMappings 里同时返回一个?给 PreparedStatement 用。而#{}和${}的区别本质上就是同一个解析器配了不同逻辑的 TokenHandler#{}是参数预编译${}是字符串直接替换。以后再遇到“我要做一个模板字符串解析”的需求直接照抄这个实现就行它比正则表达式直观得多也比 substring 轮询稳得多。4.2 参数解析与映射算法从 #{age} 到 PreparedStatement 的占位符Mapper 接口方法的参数是怎么跑到 SQL 占位符里的这背后是 ParamNameResolver 在工作。它的处理逻辑是如果只有一个参数并且没有 Param 注解直接把这个参数作为参数对象如果只有一个参数但有 Param 注解或者有多个参数就构建一个 ParamMapParamMap 里的 key 优先取 Param 注解的值没有注解的参数会自动命名为 param1、param2 等。所以你在 XML 里写#{param1}、#{param2}也能跑通只是可读性差。更推荐的方式是给每个参数都加 Param语义清晰也避免背后改名引起的问题。当 SQL 里出现#{userId}时ParameterMappingTokenHandler 会把 userId 加入 parameterMappingsSQL 里替换成?。执行阶段PreparedStatementHandler.parameterize 会遍历 parameterMappings逐个调用 TypeHandler.setParameter把参数值按顺序绑定到 PreparedStatement 上。整条链路下来你会发现“从 Java 方法参数到数据库占位符”其实是一个两阶段的解析过程先解析参数名再绑定参数值。理解了这个遇到“参数绑定不上”“明明传了值但 SQL 里是 null”这类问题你就能顺着 parameterMappings 排查而不是凭感觉乱试。4.3 缓存算法LRU、FIFO 与 BlockingCache 的锁MyBatis 缓存模块是算法味最浓的地方。Cache 接口有很多实现我整理成一张表实现类核心算法实现要点适用场景PerpetualCache无淘汰一个 HashMap 保存所有缓存作为底层装饰对象FifoCache先进先出LinkedList 记录 key 顺序满了移除最老的周期性扫描类数据LruCache最近最少使用重写 LinkedHashMap.removeEldestEntry热点集中型查询SoftCache软引用配合 ReferenceQueue 清理内存不足时回收内存压力敏感场景WeakCache弱引用类似 SoftCache但回收更激进缓存非核心数据BlockingCache锁 阻塞ConcurrentHashMap ReentrantLock避免缓存穿透高并发热点查询二级缓存默认的淘汰策略是 LRU。LruCache 的代码很经典它内部维护一个 LinkedHashMap重写 removeEldestEntry 方法当元素数量超过 capacity 时就返回 true让最久没被访问的条目被移除。这个实现几乎没有额外的数据结构就是利用了 LinkedHashMap 自身的能力性价比极高。还有一个容易被忽略的 CacheKey。一级缓存和二级缓存的 key 不是简单拼字符串而是通过一个 CacheKey 类累加计算把 statementId、RowBounds、BoundSql、参数等等混入 hashcode 和 checksum。这样设计的好处是 key 生成冲突的概率低也避免了大字符串拼接带来的内存浪费。如果你在排查缓存不命中问题可以先确认每次查询生成的 CacheKey 是否一致不一致的话大概率是参数或者 SQL 有细微变化。4.4 结果集映射算法从 ResultSet 到 POJO 的“赋值流水线”一套算法还有一个容易被人忽略的环节ResultSetHandler 把数据库返回的二维表变成 POJO。DefaultResultSetHandler 的核心工作可以理解为“游标遍历加递归赋值”外层遍历 ResultSet 每一行内层根据 ResultMap 里的 column 到 property 映射通过 BeanWrapper 或 MapWrapper 给对象属性赋值。这里面有两点很显功底。第一反射元数据被缓存了。MyBatis 用 Reflector 记录一个类的属性列表、setter 方法、getter 方法再用 ReflectorFactory 缓存所有反射过的类。这样每次结果集映射不需要重新扫描方法性能提升非常明显。第二MetaObject 统一了对象访问方式。不管你是 POJO、Map 还是 List它都通过 ObjectWrapper 来读写属性嵌套映射时如果遇到 association 或 collection它会根据列名前缀继续递归处理。理解了这套映射算法你就明白为什么数据库列名和 Java 属性名不一致时会返回 null也明白为什么开启 mapUnderscoreToCamelCase 能救回一大半字段。5. 实战案例从接口定义到 SQL 执行的全链路前面讲了理论和源码这章来一个能直接上手的案例。假设我在做一个订单中心项目需要支持订单列表查询、批量插入订单明细、慢 SQL 日志监控。需求很普通但我会刻意把 MyBatis 的设计模式、参数解析、缓存、拦截器、批量执行的技巧全串起来。5.1 场景设计与需求描述先定义业务需求订单主表 t_orderid、user_id、order_no、amount、status、create_time订单明细表 t_order_detailid、order_id、goods_name、price、count查询条件用户 ID、时间范围、订单状态支持分页写入场景一次性批量插入订单及明细监控要求SQL 执行时间超过 500ms 的慢 SQL 要打印日志。那我的 Mapper 接口可以这样写public interface OrderMapper { ListOrder listOrders(Param(userId) Long userId, Param(beginTime) Date beginTime, Param(endTime) Date endTime, Param(status) String status); int batchInsertDetail(Param(list) ListOrderDetail details); }对应的 XML 里最关键的是动态 SQL 的拼接。这里要注意一个原则能用#{}绝不用${}因为#{}走 PreparedStatement 参数绑定既防 SQL 注入又让数据库有缓存执行计划的机会${}只适合动态表名、排序字段这种结构变化场景而且必须白名单校验。select idlistOrders resultTypeOrder SELECT id, user_id, order_no, amount, status, create_time FROM t_order where if testuserId ! null AND user_id #{userId} /if if testbeginTime ! null AND create_time gt; #{beginTime} /if if testendTime ! null AND create_time lt; #{endTime} /if if teststatus ! null and status ! AND status #{status} /if /where ORDER BY create_time DESC /select5.2 接口、XML、缓存与批量插入的完整实现这个查询接口我通常会配上二级缓存因为订单列表对实时性要求不那么极端而且同一用户重复查列表的概率很高。配置方式是在 Mapper XML 里加一行cache evictionLRU flushInterval60000 size512 readOnlyfalse/参数含义分别在mybatis-config.xml里会有对应解析。二级缓存默认使用装饰器链PerpetualCache 外面包 LruCache、SynchronizedCache、SerializedCache、LoggingCache所以返回的 POJO 必须实现 Serializable否则会报序列化错误。这个坑我在项目里踩过一次当时查了很久才发现是对象没实现序列化接口。批量插入订单明细我会根据数据量选择两种方式。数据量小比如几百条直接一条 SQL 通过 foreach 拼接 values 列表这样只需要一次网络往返性能最好insert idbatchInsertDetail INSERT INTO t_order_detail(order_id, goods_name, price, count) VALUES foreach collectionlist itemitem separator, (#{item.orderId}, #{item.goodsName}, #{item.price}, #{item.count}) /foreach /insert数据量大比如上万条我建议用 ExecutorType.BATCH 模式避免一条 SQL 太长超过数据库参数限制try (SqlSession session sqlSessionFactory.openSession(ExecutorType.BATCH)) { OrderMapper mapper session.getMapper(OrderMapper.class); for (OrderDetail detail : details) { mapper.insertDetail(detail); if (i % 500 0) { session.flushStatements(); session.clearCache(); } } session.commit(); }这里有两个容易被忽略的点一是 MySQL JDBC 驱动要加上rewriteBatchedStatementstrue否则批量提交不会真正合并执行性能提升非常有限二是循环过程中要定期 flushStatements 并 clearCache防止一级缓存和 Statement 堆积导致内存上涨。5.3 自定义分页与慢 SQL 拦截器的实现慢 SQL 日志不需要改业务代码用 MyBatis 的拦截器就能实现。核心思路是拦截 Executor.query在方法前后计时超过阈值就打印 MappedStatement 的 id 和 BoundSql 的 SQL 语句Intercepts({ Signature(type Executor.class, method query, args {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}) }) public class SlowSqlInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { long start System.currentTimeMillis(); try { return invocation.proceed(); } finally { long cost System.currentTimeMillis() - start; if (cost 500) { MappedStatement ms (MappedStatement) invocation.getArgs()[0]; Object parameter invocation.getArgs()[1]; BoundSql boundSql ms.getBoundSql(parameter); System.out.println(slow sql: ms.getId() , cost: cost ms, sql: boundSql.getSql()); } } } }这个拦截器就是责任链模式的一个活例子。注册后MyBatis 会通过 InterceptorChain.pluginAll 对 Executor 生成代理。我再提醒一句自定义分页或 SQL 改写一定要基于拦截器去做不要用 RowBounds 做参数分页。RowBounds 的默认实现是先把所有结果查出来、在内存里做 offset 和 limit数据量一大就 OOM 了。物理分页要么用 PageHelper 这类插件要么自己实现一个拦截器把原始 SQL 解析出 countSQL 和 limitSQL。5.4 一个容易踩的坑单个数字字符比较这个坑在热搜里反复出现我实际也遇到过。假设 status 字段是 String 类型在 XML 里写if teststatus 1 AND status #{status} /if你可能会发现三种诡异现象条件不生效SQL 里没有这个条件直接抛 NumberFormatException明明有数据却查不出来。原因出在 OGNL 表达式对单引号字符常量的解析上单字符1容易按 Character 处理而 Java 里 String 和 Character 不是一个类型比较行为和你预期完全不一样。最稳的写法是if teststatus 1 AND status #{status} /if或者使用 toString() 消除歧义if teststatus 1.toString() AND status #{status} /if从这里引申出一个通用建议MyBatis 的 test 表达式里字符串常量尽量用双引号别为了省转义就全部用单引号。尤其是单字符判断强烈建议加 toString()保证 OGNL 拿到的是 String 类型。这个细节虽然小但确实是线上故障的高发区。6. 常见问题与排查技巧实录最后一章整理我在实际项目中遇到过的高频问题做成速查表再讲三个印象最深的“翻车现场”。6.1 高频问题速查表问题现象常见原因排查思路test 单字符比较不生效或报错OGNL 将1解析成 Character类型不一致改成双引号或加 toString()Mapper 方法重载导致启动失败MyBatis statement id 就是方法全限定名不允许同名方法改名不要重载查询返回 null 或字段全为 null列名与属性名不一致开启 mapUnderscoreToCamelCase 或补充 resultMap二级缓存不生效没配cache/或 POJO 未实现 Serializable检查 Mapper XML 和实体类修改数据后查询还是旧值二级缓存未清空或事务未提交导致缓存未失效检查缓存配置与清缓存逻辑批量插入性能差JDBC 未开 rewriteBatchedStatements或循环中没 flush配置连接参数分批次 flush参数绑定成了 null多参数方法没加 Param或 XML 中参数名写错打开日志确认 BoundSql 和参数分页数据量大时 OOM用了 RowBounds 内存分页改成物理分页拦截器或 PageHelperSQL 执行很慢但没日志没配置 LogImpl 或拦截器未生效检查 mybatis 配置和插件注册排查 MyBatis 问题时我有一套固定的三步法第一步看日志确认最终生成的 BoundSql 是不是预期 SQL第二步看缓存确认是否命中了二级缓存或一级缓存第三步看参数确认 parameterMappings 里的参数值有没有绑对。这套方法能覆盖 80% 的“诡异问题”。6.2 三个让我印象深刻的“翻车”现场第一个是数字字符比较那个坑。当时同事写了一个按状态查询订单的接口线上环境有的订单能查到有的查不到排查了半天发现是 test 条件根本没生效SQL 里直接丢掉状态条件。最后定位到 OGNL 对1的解析问题改成双引号后立刻恢复。从那次以后我对配置文件里的引号习惯就特别敏感评审代码一定会扫一遍 test 表达式。第二个是二级缓存造成的脏数据。我给订单主表加了二级缓存却忘了订单明细表更新时会改订单状态。结果运营改完明细用户端还能查到旧状态整整一个多小时数据不对。那次之后我定了一条规矩多表关联查询默认 useCachefalse只有低频变更、单表访问的场景才开二级缓存更新关联表时相关缓存必须手工清理或者干脆把热点数据放到 Redis 里统一管理。第三个是 Mapper 方法重载导致的启动失败。我曾在接口里写过两个同名的查询方法一个参数少一个参数多想着重载挺正常。结果 MyBatis 启动直接报错提示 statement id 已存在。原因就是 MyBatis 用接口全限定名 方法名作为 statement id不允许同名方法。那次我意识到MyBatis 的 Mapper 接口并不是一个普通 Java 接口每个方法映射一条 SQL语法规则和普通接口不一样。7. 写在最后的一点个人建议如果你从来没有系统读过源码我真心建议从 MyBatis 开始而不是 Spring。切入点不用太高深就从你项目里一个真实的 Mapper 方法入手把断点打在 MapperProxy.invoke 上程序一跑调用栈会自动帮你把后面的类全部带出来。你不需要硬背每条代码路径只要把 Configuration、MappedStatement、Executor、StatementHandler、ResultSetHandler 这条主线走通以后再遇到任何 MyBatis 相关问题你都能自己顺着源码找到答案。我个人还有一个习惯每次读框架源码都会顺手把里面的设计模式、算法技巧抄到自己项目的公共模块里。GenericTokenParser 被我改造成了内部模板解析工具LruCache 的思路被我用来写数据字典缓存BaseExecutor 的模板方法思想则直接指导了我写导出功能的抽象类。读源码的最大红利从来不是面试时能背出几个类名而是你看问题的视角会慢慢从“这个功能怎么用”变成“这个功能为什么这么设计”。希望这篇东西能让你也尝到这种甜头。