MyBatis 核心原理:SqlSession、Mapper 代理、插件机制与缓存(101)

MyBatis、JPA与ORM约 14 分钟阅读7,784 字符
知识库文档 102 / 251

引言:你写的是接口,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 调用的完整链路:

code
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 创建:

java
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 的核心方法

java
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 中:

java
// Configuration 内部
public <T> void addMapper(Class<T> type) {
    mapperRegistry.addMapper(type);
}

注册时,MyBatis 会解析 Mapper 接口的方法和对应的 XML 映射文件(或注解),为每个方法创建一个 MappedStatement 存入 Configuration。

MapperProxy:JDK 动态代理

sqlSession.getMapper(UserMapper.class) 返回的是 MapperProxy 代理对象:

java
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 类型:

java
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 结果脱敏

插件实现原理:动态代理链

java
@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 查询只执行一次数据库查询,后续查询从缓存中返回。

java
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/> 标签。

xml
<cache
  eviction="LRU"           <!-- 淘汰策略 -->
  flushInterval="60000"    <!-- 刷新间隔(毫秒) -->
  size="1024"              <!-- 缓存大小 -->
  readOnly="true"          <!-- 只读缓存,性能更高 -->
/>

二级缓存的执行流程:查询先查二级缓存 → 如果未命中,查一级缓存 → 如果仍未命中,查数据库。写入时:先写入一级缓存,在 SqlSession.commit() 时将一级缓存的数据刷新到二级缓存。

二级缓存的陷阱

  1. 脏数据:如果多个应用共享同一个数据库,一个应用修改了数据,另一个应用的二级缓存不会自动感知。需要配合分布式缓存(如 Redis)或缓存失效机制。
  2. 序列化:如果 readOnly="false"(默认),缓存的对象必须实现 Serializable——因为 MyBatis 通过序列化/反序列化来创建缓存对象的副本,防止缓存对象被修改。
  3. 关联查询:<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+ 才完整支持了默认方法的代理。