引言:你写的是接口,MyBatis 怎么让它跑起来
UserMapper userMapper = sqlSession.getMapper(UserMapper.class); 这一行代码背后,MyBatis 做了三件事:用 JDK 动态代理创建了一个 UserMapper 接口的代理对象;代理对象拦截所有方法调用,根据方法名找到对应的 SQL 语句;通过 SqlSession 执行 SQL 并将结果映射为 User 对象。这篇文章从整体架构到 SqlSession 的执行链路、Mapper 代理的生成原理、插件拦截链的实现方式,再到一级缓存和二级缓存的区别,把 MyBatis 的核心机制彻底拆开。
一、MyBatis 整体架构分层

MyBatis 的架构分为四层:
| 层次 | 核心组件 | 职责 |
|---|---|---|
| 接口层 | SqlSession |
提供增删改查 API,是用户操作数据库的门面 |
| 数据处理层 | Executor、StatementHandler、ParameterHandler、ResultSetHandler |
参数映射、SQL 执行、结果映射 |
| 框架支撑层 | Configuration、MappedStatement、SqlSource |
配置管理、SQL 解析、插件拦截 |
| 引导层 | SqlSessionFactoryBuilder、SqlSessionFactory |
加载配置、创建 SqlSessionFactory |
一次 SQL 调用的完整链路:
SqlSession.selectOne(statementId, param)
→ Executor.query(ms, param)
→ 一级缓存查询 / 二级缓存查询
→ StatementHandler 创建 Statement
→ ParameterHandler 设置参数
→ JDBC Statement.execute()
→ ResultSetHandler 映射结果
→ 插件链拦截(可在 Executor、StatementHandler、ParameterHandler、ResultSetHandler 四个位置插入)
理解这条链路的关键是:每一层都有明确的职责边界,且每一层都可以被插件拦截。MyBatis 的插件机制不是"AOP 式"的任意拦截,而是精确地在四个组件的特定方法上插入自定义逻辑。
二、SqlSession:数据库操作的门面

从 SqlSessionFactory 到 SqlSession
SqlSession 不是直接创建的,而是通过 SqlSessionFactory 创建:
SqlSessionFactory factory = new SqlSessionFactoryBuilder().build(inputStream);
SqlSession session = factory.openSession();
SqlSessionFactoryBuilder.build() 做了三件事:解析 XML 配置文件(mybatis-config.xml),创建 Configuration 对象,用 Configuration 创建 DefaultSqlSessionFactory。Configuration 是 MyBatis 的"配置中心"——它持有所有 MappedStatement(一个 MappedStatement 对应一条 SQL 语句)、所有 Mapper 接口的注册信息、插件链、类型处理器注册表等。
SqlSessionFactory.openSession() 创建 DefaultSqlSession,内部持有 Executor 和 Configuration。每次 openSession() 创建一个新的 SqlSession 实例,这意味着 SqlSession 不是线程安全的——每个线程应该使用自己的 SqlSession。
SqlSession 的核心方法
public interface SqlSession {
<T> T selectOne(String statement);
<E> List<E> selectList(String statement);
int insert(String statement, Object parameter);
int update(String statement, Object parameter);
int delete(String statement, Object parameter);
<T> T getMapper(Class<T> type);
void commit();
void rollback();
void close();
}
所有增删改查方法最终都委派给 Executor 执行。getMapper 方法返回 Mapper 接口的代理对象。
Executor 的类型
MyBatis 提供了三种 Executor 实现:
| 类型 | 行为 | 适用场景 |
|---|---|---|
SimpleExecutor |
每次执行 SQL 都创建新的 Statement |
默认执行器 |
ReuseExecutor |
复用 Statement,同一个 SQL 重复使用 |
批量操作 |
BatchExecutor |
批量执行 SQL,Statement.addBatch() |
批量插入/更新 |
CachingExecutor |
装饰器模式,在以上三种执行器外包装二级缓存 | 开启二级缓存时自动使用 |
SqlSession 与事务
SqlSession 的 commit() 和 rollback() 最终调用的是 JDBC Connection 的 commit() 和 rollback()。在 Spring 集成中,SqlSession 的事务由 Spring 的 DataSourceTransactionManager 统一管理——Spring 从 SqlSessionFactory 获取 SqlSession,绑定到当前线程的 TransactionSynchronizationManager,事务提交时调用 SqlSession.commit()。
三、Mapper 代理:接口怎么变成实现类

注册 Mapper 接口
MyBatis 在启动时扫描所有 Mapper 接口(通过 @MapperScan 或 XML 的 <mappers> 配置),将每个接口注册到 Configuration 的 MapperRegistry 中:
// Configuration 内部
public <T> void addMapper(Class<T> type) {
mapperRegistry.addMapper(type);
}
注册时,MyBatis 会解析 Mapper 接口的方法和对应的 XML 映射文件(或注解),为每个方法创建一个 MappedStatement 存入 Configuration。
MapperProxy:JDK 动态代理
sqlSession.getMapper(UserMapper.class) 返回的是 MapperProxy 代理对象:
public class MapperProxy<T> implements InvocationHandler {
@Override
public Object invoke(Object proxy, Method method, Object[ ] args) {
// 如果是 Object 的方法(toString、hashCode 等),直接调用
if (Object.class.equals(method.getDeclaringClass())) {
return method.invoke(this, args);
}
// 否则,转发到 MapperMethod.execute()
MapperMethod mapperMethod = cachedMapperMethod(method);
return mapperMethod.execute(sqlSession, args);
}
}
MapperMethod.execute() 根据 SQL 类型(SELECT/INSERT/UPDATE/DELETE)和返回值类型(单个对象/List/void/Map/Cursor)调用 SqlSession 的对应方法。例如,User selectById(Long id) 会被解析为 sqlSession.selectOne("com.example.UserMapper.selectById", id)。
MapperMethod 的 SQL 命令解析
MapperMethod 内部有一个 SqlCommand 对象,它从 MappedStatement 中提取 SQL 类型:
public class SqlCommand {
private final String name; // statementId,如 "com.example.UserMapper.selectById"
private final SqlCommandType type; // SELECT / INSERT / UPDATE / DELETE
public SqlCommand(Configuration configuration, Class<?> mapperInterface, Method method) {
String statementId = mapperInterface.getName() + "." + method.getName();
MappedStatement ms = configuration.getMappedStatement(statementId);
this.name = ms.getId();
this.type = ms.getSqlCommandType();
}
}
statementId 的格式是 接口全限定名.方法名,这就是为什么 Mapper XML 中的 namespace 必须和接口全限定名一致,id 必须和方法名一致。
四、插件机制:Interceptor 拦截链


插件拦截的四个位置
MyBatis 的插件可以拦截以下四个组件的特定方法:
| 组件 | 可拦截方法 | 典型用途 |
|---|---|---|
Executor |
update、query、flushStatements、commit、rollback |
SQL 执行监控、分页、读写分离 |
StatementHandler |
prepare、parameterize、batch、update、query |
SQL 改写 |
ParameterHandler |
setParameters |
参数加密 |
ResultSetHandler |
handleResultSets、handleCursorResultSets |
结果脱敏 |
插件实现原理:动态代理链
@Intercepts({
@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
public class MyPlugin implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 前置逻辑
Object result = invocation.proceed(); // 执行原方法
// 后置逻辑
return result;
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this); // 如果当前 target 是可拦截的类型,创建代理
}
}
Plugin.wrap() 内部使用 JDK 动态代理,判断 target 是否实现了 @Signature 注解中声明的接口。如果实现了,创建代理对象;否则返回原对象。多个插件形成链式代理——Plugin.wrap(wrap(target, plugin1), plugin2) 依次嵌套,调用时按反向顺序执行(最外层先执行 intercept,逐层向内)。
分页插件(PageHelper)是如何工作的
PageHelper 是最经典的 MyBatis 插件之一。它拦截 Executor.query() 方法,在 SQL 执行前做了三件事:根据 PageHelper.startPage() 设置的页码和大小计算 LIMIT 偏移量;改写原始 SQL,追加 LIMIT ? OFFSET ?;执行改写后的 SQL。同时,它还执行一条 SELECT COUNT(*) 查询获取总记录数,用于计算总页数。
PageHelper 的难点在于:改写 SQL 不是简单的字符串拼接——它需要解析 SQL 的语法结构,找到正确的插入位置。如果 SQL 中有子查询、UNION、ORDER BY 等复杂结构,LIMIT 的插入位置需要准确判断。
五、一级缓存与二级缓存


一级缓存(SqlSession 级别)
一级缓存默认开启,无法关闭。它的作用域是 SqlSession:同一个 SqlSession 中,相同的 SQL 查询只执行一次数据库查询,后续查询从缓存中返回。
SqlSession session = factory.openSession();
User user1 = session.selectOne("selectById", 1L); // 查询数据库
User user2 = session.selectOne("selectById", 1L); // 命中一级缓存,不查数据库
session.close();
一级缓存的 key 由 MappedStatement 的 ID、SQL 语句、参数、RowBounds 分页参数等组成。以下操作会清空一级缓存:执行 INSERT/UPDATE/DELETE 操作、调用 session.clearCache()、调用 session.commit() 或 session.close()。
一级缓存的陷阱:Spring 集成时的缓存失效
在 Spring 集成中,SqlSessionTemplate 会在每次数据库操作后自动关闭 SqlSession(因为 SqlSession 是线程不安全的,Spring 为每个操作创建一个新的 SqlSession)。这意味着同一个事务中的两次相同查询,如果不在同一个 SqlSession 中,一级缓存不会生效。解决方法:开启事务——Spring 在同一事务中使用同一个 SqlSession,一级缓存才有效。
二级缓存(Mapper 级别)
二级缓存默认关闭,需要在配置中显式开启。它的作用域是 SqlSessionFactory(跨 SqlSession)。
开启方式:在 mybatis-config.xml 中设置 cacheEnabled=true,并在 Mapper XML 中添加 <cache/> 标签。
<cache
eviction="LRU" <!-- 淘汰策略 -->
flushInterval="60000" <!-- 刷新间隔(毫秒) -->
size="1024" <!-- 缓存大小 -->
readOnly="true" <!-- 只读缓存,性能更高 -->
/>
二级缓存的执行流程:查询先查二级缓存 → 如果未命中,查一级缓存 → 如果仍未命中,查数据库。写入时:先写入一级缓存,在 SqlSession.commit() 时将一级缓存的数据刷新到二级缓存。
二级缓存的陷阱
- 脏数据:如果多个应用共享同一个数据库,一个应用修改了数据,另一个应用的二级缓存不会自动感知。需要配合分布式缓存(如 Redis)或缓存失效机制。
- 序列化:如果
readOnly="false"(默认),缓存的对象必须实现Serializable——因为 MyBatis 通过序列化/反序列化来创建缓存对象的副本,防止缓存对象被修改。 - 关联查询:
<cache/>只缓存当前 Mapper 的查询结果。如果 A 表的查询结果关联了 B 表的数据,A 被修改后二级缓存失效,但 B 被修改时 A 的缓存不会失效——导致关联查询返回脏数据。
六、常见陷阱

陷阱一:#{ } 和 ${ } 的区别
#{ } 是预编译占位符,MyBatis 将其替换为 ?,通过 PreparedStatement 设置参数,防止 SQL 注入。${ } 是字符串替换,直接拼接 SQL,有 SQL 注入风险。${ } 只在需要动态拼接 SQL 关键字(如 ORDER BY ${column}、GROUP BY)时使用,且参数必须来自白名单校验。
陷阱二:Mapper 接口方法重载
MyBatis 不允许 Mapper 接口方法重载。因为 statementId 是 接口名.方法名,如果方法名相同,MappedStatement 的 ID 会冲突。解决方案:用不同的方法名,或在 XML 中用不同的 id。
陷阱三:SqlSession 线程安全
SqlSession 不是线程安全的。在 Spring 集成中,SqlSessionTemplate 通过 ThreadLocal 确保每个线程使用独立的 SqlSession。如果你在非 Spring 环境中手动管理 SqlSession,每个线程必须创建自己的 SqlSession。
陷阱四:懒加载与序列化
MyBatis 的懒加载通过 CGLIB 代理实现。如果启用了懒加载,返回的对象实际上是 CGLIB 代理对象,不能直接序列化——需要关闭懒加载或使用 Serializable 的代理实现。
小结:从接口到数据库的完整链路
MyBatis 的核心链路可以总结为三个"代理":Mapper 代理(JDK 动态代理拦截接口方法调用)、Executor 代理(CachingExecutor 装饰器包装二级缓存)、插件代理(Plugin.wrap() 创建拦截链)。理解这三个代理的作用和嵌套关系,你就能解释 MyBatis 中任何"为什么这个行为不符合预期"的问题——不管是缓存失效、插件不生效,还是 Mapper 方法找不到对应的 SQL。
(以上正文阅读时长约 40-50 分钟,以下问答部分不计入正文阅读时长)
文末答疑:面试官会怎么问
问 1:MyBatis 的**#{}**和**${}**的区别,面试官几乎必问。但更深一层的问题是:MyBatis 在底层是如何处理**#{}**的?它和 JDBC 的**PreparedStatement.setString()**有什么区别?
答:MyBatis 在解析 SQL 时,将 #{} 替换为 ? 占位符,生成一个 BoundSql 对象,其中包含解析后的 SQL 模板和参数列表。执行时,ParameterHandler 遍历参数列表,根据参数类型选择合适的 TypeHandler 调用 PreparedStatement.setXxx() 设置参数。MyBatis 和直接使用 JDBC 的区别在于:MyBatis 通过 TypeHandler 机制自动选择正确的 setXxx() 方法,而 JDBC 需要手动调用。例如,如果参数是 java.util.Date,MyBatis 的 DateTypeHandler 会自动调用 setTimestamp(),而 JDBC 需要你手动判断并调用。
问 2:MyBatis 的插件链和 Servlet Filter 的过滤器链有什么区别?如果两个插件都拦截了**Executor.query()**,它们的执行顺序是怎样的?
答:MyBatis 插件链和 Servlet Filter 链的核心区别在于代理方式:MyBatis 用 JDK 动态代理嵌套(Plugin.wrap(wrap(target, p1), p2)),Servlet Filter 用责任链模式(FilterChain.doFilter() 递归调用)。执行顺序上,MyBatis 插件链按配置文件中 <plugins> 标签的声明顺序,后声明的先执行(因为后声明的在外层代理)。例如 <plugin>p1</plugin><plugin>p2</plugin>,p2 的 intercept() 先执行,p1 后执行。这和 Filter 链的行为一致——FilterChain.doFilter() 也是按注册顺序依次调用。
问 3:MyBatis 的一级缓存和 Hibernate 的一级缓存(Session 缓存)有什么本质区别?为什么 MyBatis 的一级缓存不能跨 SqlSession,而 Hibernate 的可以跨 Session?
答:MyBatis 的一级缓存是基于 HashMap 的简单实现,key 是 (statementId, params, rowBounds) 的哈希值,value 是查询结果。Hibernate 的一级缓存是基于 EntityKey 的持久化上下文,它不仅缓存查询结果,还维护了实体对象的"快照",用于脏检查(dirty checking)。Hibernate 的一级缓存也不能跨 Session——它也是 Session 级别的。你可能混淆了 Hibernate 的二级缓存,它和 MyBatis 的二级缓存一样,都是 SessionFactory 级别、跨 Session 的。MyBatis 一级缓存的独特之处在于:它默认开启且无法关闭,而 Hibernate 的一级缓存是 JPA 规范要求的,同样无法关闭。
问 4:在你的项目中,MyBatis 的二级缓存你用过吗?你是用 MyBatis 自带的 Cache 实现还是集成了 Redis?为什么不直接用 Spring Cache 而要用 MyBatis 的二级缓存?
答:MyBatis 自带的二级缓存(PerpetualCache)是 JVM 内存级别的,不适合分布式环境。集成 Redis 作为二级缓存(通过 mybatis-redis 或 mybatis-ehcache)可以实现跨实例的缓存共享。但实际项目中,我更倾向于用 Spring Cache(@Cacheable)而不是 MyBatis 的二级缓存,原因有三:第一,Spring Cache 的缓存粒度是方法级别的,可以精确控制缓存的 key 和条件,比 MyBatis 的 Mapper 级别更灵活;第二,Spring Cache 的缓存失效策略更丰富(@CacheEvict、@CachePut),可以手动控制缓存失效,而 MyBatis 二级缓存的失效完全依赖 INSERT/UPDATE/DELETE 操作;第三,使用 Spring Cache 避免了对 MyBatis 的过度耦合——如果将来换 ORM 框架,缓存逻辑不需要改动。
问 5:MyBatis 的**MapperProxy**是 JDK 动态代理,为什么不用 CGLIB?如果 Mapper 接口有默认方法(**default**方法),MyBatis 怎么处理?
答:JDK 动态代理的前提是目标对象必须实现接口,而 Mapper 接口恰好满足这个条件——所以 JDK 动态代理是更自然的选择。CGLIB 代理通过继承目标类实现,不能代理接口。对于接口的默认方法,JDK 动态代理的 InvocationHandler.invoke() 不会自动调用默认方法——需要显式处理。MyBatis 在 MapperProxy.invoke() 中做了判断:如果方法是 Object 的方法(toString、equals、hashCode 等),直接调用;如果是接口的默认方法,通过 MethodHandle 调用默认实现;如果不是默认方法,转发到 MapperMethod.execute()。Java 8 之前的版本不支持默认方法,MyBatis 3.5+ 才完整支持了默认方法的代理。