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 >= #{beginTime} </if> <if test="endTime != null"> AND create_time <= #{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 的模板方法思想则直接指导了我写导出功能的抽象类。读源码的最大红利,从来不是面试时能背出几个类名,而是你看问题的视角会慢慢从“这个功能怎么用”变成“这个功能为什么这么设计”。希望这篇东西,能让你也尝到这种甜头。