MyBatis源码深度剖析:动态代理、设计模式与SQL执行链路
2026/9/13 3:06:41 网站建设 项目流程

1. 项目概述:为什么每个 Java 工程师都值得啃一次 MyBatis

MyBatis 是我见过的最适合拿来当“源码第一课”的开源框架。它的源码体量不像 Spring 那样庞大到让人望而生畏,但接口与抽象类的边界、设计模式的运用、SQL 解析和参数绑定背后的算法思路,一个都不少。更难得的是,这个框架几乎所有 Java 项目都在用,你完全可以白天写业务、晚上断点跟踪,把学习和工作打通,不用专门搭一套环境。

这篇博文我想带读者走一条主线:从一个你自己写的 Mapper 接口开始,看它如何被 MapperProxy 动态代理,如何在 SqlSession 里走缓存、建 StatementHandler,最终落到 JDBC 执行,再把 ResultSet 映射回 POJO。这条链路一旦在脑子里跑通,MyBatis 面试题基本就没有死角了,因为你已经不是在背源码类名,而是在讲一个真实发生的调用故事。适合读这篇文章的人有三类:刚准备看源码但找不到切入点的同学、MyBatis 面试前想系统梳理知识点的候选人、以及写了好几年业务代码却对框架内部机制“一脸懵”的工程师。

1.1 我要拆解的核心关键词

先给一张导读表,把标题里的关键词和 MyBatis 源码里的落点对齐,后面每一章都会回扣这张表:

关键词在 MyBatis 里的落点核心类
接口与抽象类接口定义契约,抽象类抽取公共流程SqlSession、Executor、Cache;BaseExecutor、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 MapperProxy<T> implements InvocationHandler { private final SqlSession sqlSession; private final Class<T> mapperInterface; private final Map<Method, 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,查一级缓存;缓存没命中,再调 queryFromDatabase;queryFromDatabase 内部会调用子类实现的 doQuery。这样一来,SimpleExecutor、ReuseExecutor、BatchExecutor 这些子类只需要关注“如何拿到 Statement 并执行”,不需要关心缓存和事务同步。

第二层是装饰器模式。CachingExecutor 是一个装饰器,它包在真实 Executor 外面:

public <E> List<E> 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 处理 Integer,StringTypeHandler 处理 String,DateTypeHandler 处理日期,这就把“类型转换”的方式做成了一组可替换的策略。如果你遇到一个自定义类型无法映射,不要硬编码,写一个 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 注解,或者有多个参数,就构建一个 ParamMap;
  • ParamMap 里的 key 优先取 @Param 注解的值,没有注解的参数会自动命名为 param1、param2 等。

所以你在 XML 里写#{param1}#{param2}也能跑通,只是可读性差。更推荐的方式是给每个参数都加 @Param,语义清晰,也避免背后改名引起的问题。

当 SQL 里出现#{userId}时,ParameterMappingTokenHandler 会把 userId 加入 parameterMappings,SQL 里替换成?。执行阶段,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_order:id、user_id、order_no、amount、status、create_time;
  • 订单明细表 t_order_detail:id、order_id、goods_name、price、count;
  • 查询条件:用户 ID、时间范围、订单状态,支持分页;
  • 写入场景:一次性批量插入订单及明细;
  • 监控要求:SQL 执行时间超过 500ms 的慢 SQL 要打印日志。

那我的 Mapper 接口可以这样写:

public interface OrderMapper { List<Order> listOrders(@Param("userId") Long userId, @Param("beginTime") Date beginTime, @Param("endTime") Date endTime, @Param("status") String status); int batchInsertDetail(@Param("list") List<OrderDetail> details); }

对应的 XML 里最关键的是动态 SQL 的拼接。这里要注意一个原则:能用#{}绝不用${},因为#{}走 PreparedStatement 参数绑定,既防 SQL 注入,又让数据库有缓存执行计划的机会;${}只适合动态表名、排序字段这种结构变化场景,而且必须白名单校验。

<select id="listOrders" resultType="Order"> SELECT id, user_id, order_no, amount, status, create_time FROM t_order <where> <if test="userId != null"> AND user_id = #{userId} </if> <if test="beginTime != null"> AND create_time &gt;= #{beginTime} </if> <if test="endTime != null"> AND create_time &lt;= #{endTime} </if> <if test="status != null and status != ''"> AND status = #{status} </if> </where> ORDER BY create_time DESC </select>

5.2 接口、XML、缓存与批量插入的完整实现

这个查询接口我通常会配上二级缓存,因为订单列表对实时性要求不那么极端,而且同一用户重复查列表的概率很高。配置方式是在 Mapper XML 里加一行:

<cache eviction="LRU" flushInterval="60000" size="512" readOnly="false"/>

参数含义分别在mybatis-config.xml里会有对应解析。二级缓存默认使用装饰器链:PerpetualCache 外面包 LruCache、SynchronizedCache、SerializedCache、LoggingCache,所以返回的 POJO 必须实现 Serializable,否则会报序列化错误。这个坑我在项目里踩过一次,当时查了很久才发现是对象没实现序列化接口。

批量插入订单明细,我会根据数据量选择两种方式。数据量小,比如几百条,直接一条 SQL 通过 foreach 拼接 values 列表,这样只需要一次网络往返,性能最好:

<insert id="batchInsertDetail"> INSERT INTO t_order_detail(order_id, goods_name, price, count) VALUES <foreach collection="list" item="item" 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 驱动要加上rewriteBatchedStatements=true,否则批量提交不会真正合并执行,性能提升非常有限;二是循环过程中要定期 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 test="status == '1'"> AND status = #{status} </if>

你可能会发现三种诡异现象:条件不生效,SQL 里没有这个条件;直接抛 NumberFormatException;明明有数据却查不出来。原因出在 OGNL 表达式对单引号字符常量的解析上,单字符'1'容易按 Character 处理,而 Java 里 String 和 Character 不是一个类型,比较行为和你预期完全不一样。

最稳的写法是:

<if test='status == "1"'> AND status = #{status} </if>

或者使用 toString() 消除歧义:

<if test="status == '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 内存分页改成物理分页拦截器或 PageHelper
SQL 执行很慢但没日志没配置 LogImpl 或拦截器未生效检查 mybatis 配置和插件注册

排查 MyBatis 问题时,我有一套固定的三步法:第一步看日志,确认最终生成的 BoundSql 是不是预期 SQL;第二步看缓存,确认是否命中了二级缓存或一级缓存;第三步看参数,确认 parameterMappings 里的参数值有没有绑对。这套方法能覆盖 80% 的“诡异问题”。

6.2 三个让我印象深刻的“翻车”现场

第一个是数字字符比较那个坑。当时同事写了一个按状态查询订单的接口,线上环境有的订单能查到有的查不到,排查了半天发现是 test 条件根本没生效,SQL 里直接丢掉状态条件。最后定位到 OGNL 对'1'的解析问题,改成双引号后立刻恢复。从那次以后,我对配置文件里的引号习惯就特别敏感,评审代码一定会扫一遍 test 表达式。

第二个是二级缓存造成的脏数据。我给订单主表加了二级缓存,却忘了订单明细表更新时会改订单状态。结果运营改完明细,用户端还能查到旧状态,整整一个多小时数据不对。那次之后我定了一条规矩:多表关联查询默认 useCache=false;只有低频变更、单表访问的场景才开二级缓存;更新关联表时,相关缓存必须手工清理,或者干脆把热点数据放到 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 的模板方法思想则直接指导了我写导出功能的抽象类。读源码的最大红利,从来不是面试时能背出几个类名,而是你看问题的视角会慢慢从“这个功能怎么用”变成“这个功能为什么这么设计”。希望这篇东西,能让你也尝到这种甜头。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询