MyBatis(三):插件机制、Mapper 原理与执行流程
MyBatis(三):插件机制、Mapper 原理与执行流程
导语:这一篇讲 MyBatis 的"可扩展性"与"骨架"。插件部分要说清:为什么只能拦截四大接口、JDK 动态代理 + 责任链是怎么串起来的、分页插件为什么能做到物理分页、PageHelper 的 count 与深分页怎么优化;骨架部分要能一口气说出 SqlSessionFactory → SqlSession → Executor → StatementHandler → ResultSetHandler 的完整链路,以及 Mapper 接口没有实现类却能调用的原因(JDK 动态代理 +
MapperRegistry),共 13 题。
一、插件与拦截器
1. MyBatis 插件(拦截器)只能拦截哪几类接口?为什么?
答: 只能拦截四大接口,这是 MyBatis 在源码里写死的扩展点:
| 接口 | 职责 | 常被拦截的方法 |
|---|---|---|
Executor | 执行器:增删改查、事务、缓存入口 | update、query、flushStatements、commit、rollback |
StatementHandler | 语句处理:创建 Statement、设置超时/批量 | prepare、parameterize、batch、update、query |
ParameterHandler | 参数设置 | setParameters、getParameterObject |
ResultSetHandler | 结果集映射 | handleResultSets、handleOutputParameters |
为什么只有这四个? 因为在 Configuration 初始化的关键节点上,MyBatis 只在这四处调用了插件的代理包装:
// 典型调用点(示意)
executor = (Executor) interceptorChain.pluginAll(executor); // newExecutor
statementHandler = (StatementHandler) interceptorChain.pluginAll(statementHandler); // newStatementHandler
parameterHandler = (ParameterHandler) interceptorChain.pluginAll(parameterHandler); // newParameterHandler
resultSetHandler = (ResultSetHandler) interceptorChain.pluginAll(resultSetHandler); // newResultSetHandler而 SqlSession、Configuration、MapperProxy 等没有 pluginAll 调用点,因此无法通过标准插件机制拦截(要拦就得改用自定义 SqlSession 包装、Spring AOP 或字节码增强)。
注意:
StatementHandler有四个实现类(SimpleStatementHandler、PreparedStatementHandler、CallableStatementHandler、RoutingStatementHandler),插件最终代理到具体实现类,而RoutingStatementHandler只是根据StatementType做路由——拦截StatementHandler时实际代理的是具体实现。
2. MyBatis 插件的运行原理是什么?
答: 一句话——JDK 动态代理 + 责任链。
核心类:Interceptor(插件接口)、InterceptorChain(责任链)、Plugin(代理工厂 + InvocationHandler)
public interface Interceptor {
Object intercept(Invocation invocation) throws Throwable; // 真正的增强逻辑
default Object plugin(Object target) { // 生成代理对象
return Plugin.wrap(target, this);
}
default void setProperties(Properties properties) {} // 接收 <plugin><property> 配置
}Plugin.wrap(target, interceptor) 做的事:
public static Object wrap(Object target, Interceptor interceptor) {
Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor); // 解析 @Intercepts/@Signature
Class<?> type = target.getClass();
Class<?>[] interfaces = getAllInterfaces(type, signatureMap); // 只保留"需要拦截的接口"
if (interfaces.length > 0) {
return Proxy.newProxyInstance(type.getClassLoader(), interfaces,
new Plugin(target, interceptor, signatureMap)); // 生成 JDK 代理
}
return target; // 不含目标接口则原样返回
}调用链(责任链):
mapper.update(...)
└─ 代理1(插件A).invoke()
├─ 命中签名?→ 否:直接 target 调用(不进入 intercept)
└─ 是:interceptorA.intercept(invocation)
├─ 前置逻辑(改参数/改 SQL)
├─ invocation.proceed() // 交给链上的下一层
│ └─ 代理2(插件B)… → 真实 Executor
└─ 后置逻辑(改结果/统计耗时)四个必须答对的细节:
@Intercepts+@Signature决定"拦不拦":@Signature(type = Executor.class, method = "query", args = {...})中的args必须与目标方法参数完全一致(类型 + 顺序),否则签名匹配不上;MyBatis 3.5.x 启动时会校验,配置错误直接抛异常(比"静默失效"更好排查);getAllInterfaces只保留签名涉及的接口,所以一个插件如果只声明了StatementHandler,作用在Executor上时wrap会原样返回(等于没生效);- 注册顺序 = 代理包装顺序:
interceptorChain.pluginAll是按注册顺序依次包装,最终最先注册的插件在最外层(其前置逻辑最先执行、后置逻辑最后执行),与 Servlet Filter 的链式结构一致; - 只有
proceed()才会继续往下走:不调用就是"短路"(可用于直接返回缓存/拒绝执行),这也是插件能做"缓存/限流"的原因。
一句话:MyBatis 插件 = 针对四大接口的 JDK 动态代理,多个插件串成责任链,
proceed()决定是否继续向下。
3. 如何自定义一个 MyBatis 插件?多层插件如何串联?
答: 三步:实现 Interceptor → 加 @Intercepts → 注册。
(1)完整示例(打印慢 SQL)
@Intercepts({
@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
@Signature(type = Executor.class, method = "update",
args = {MappedStatement.class, Object.class})
})
public class SlowSqlPlugin implements Interceptor {
private long threshold = 500L;
@Override
public Object intercept(Invocation invocation) throws Throwable {
long start = System.currentTimeMillis();
try {
return invocation.proceed(); // 必须先放行,再统计
} finally {
long cost = System.currentTimeMillis() - start;
if (cost > threshold) {
MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
BoundSql boundSql = ms.getBoundSql(invocation.getArgs()[1]);
log.warn("慢SQL {}ms | {} | 参数={}", cost, boundSql.getSql(), boundSql.getParameterMappings());
}
}
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this); // 固定写法
}
@Override
public void setProperties(Properties properties) { // 接收 <property name="threshold" value="500"/>
if (properties != null && properties.getProperty("threshold") != null) {
this.threshold = Long.parseLong(properties.getProperty("threshold"));
}
}
}(2)注册方式
<!-- ① mybatis-config.xml -->
<plugins>
<plugin interceptor="com.x.SlowSqlPlugin">
<property name="threshold" value="500"/>
</plugin>
</plugins>// ② Spring Boot:注册为 Bean 即可(mybatis-spring-boot-starter 会自动收集所有 Interceptor)
@Bean
public Interceptor slowSqlPlugin() { return new SlowSqlPlugin(); }(3)多层插件如何串联
- 注册 A、B、C 三个插件后,
Executor实际变成Proxy(A) → Proxy(B) → Proxy(C) → SimpleExecutor; - 执行顺序:A 前置 → B 前置 → C 前置 → 真实执行 → C 后置 → B 后置 → A 后置(洋葱模型);
- 常见组合顺序建议:分页插件放最外层(先改写 SQL 再交给监控/权限插件),监控插件放最内层(统计的是真实执行耗时)。
(4)三个坑
- 忘记
proceed()→ 方法直接被"吃掉",查询返回 null; @Signature的args写错 → 插件静默不生效(老版本)或启动报错(新版本);- 在插件里再次调用同一个
Executor的方法 → 会递归进入插件链(分页插件里再executor.query就会套娃),必须用invocation.getArgs()里的原始对象或StatementHandler层来做二次查询。
4. 分页有哪几种方式?分页插件的原理是什么?
答:
三种分页方式:
| 方式 | 原理 | 评价 |
|---|---|---|
RowBounds(逻辑分页) | 查全部结果,ResultSetHandler 遍历时丢弃前 offset 条,只映射 limit 条 | 内存分页:数据量大时又慢又占内存,不推荐 |
| SQL 手写分页 | limit ?,?(MySQL)、rownum/row_number()(Oracle)、OFFSET FETCH(SQL Server 2012+) | 可控,但每种数据库写法不同,业务代码里到处是方言 |
| 分页插件(物理分页) | 拦截器改写 SQL 追加分页条件,只查需要的数据 | 推荐,业务无感(PageHelper、MyBatis-Plus 分页插件) |
物理分页插件的原理(以 PageHelper 为例):
1. 业务调用 PageHelper.startPage(pageNum, pageSize)
→ 把 Page 对象放入 ThreadLocal(PageMethod.LOCAL_PAGE)
2. PageInterceptor 拦截 Executor.query()
├─ 从 ThreadLocal 取出 Page;没有则直接放行(不影响本查询)
├─ 先查 count:把原 SQL 改写为 count SQL 执行,得到 total
├─ 再把原 SQL 改写为"带分页"的 SQL:
│ MySQL → select ... limit ?, ?
│ Oracle → select * from (select t.*, rownum rn from (...) t where rownum <= ?) where rn > ?
├─ 把分页参数(offset/limit)**追加到参数映射中**(addParameterMapping,
│ 并处理 additionalParameters),否则 #{} 会与参数错位
├─ 执行改写后的查询
└─ finally:清理 ThreadLocal(避免线程池复用导致"串页")
3. 结果封装为 PageInfo(total、pages、list、hasNextPage 等)必须答出的三个技术点:
- 拦截点是
Executor.query,不是StatementHandler:在Executor层拿得到MappedStatement+ 参数 +BoundSql,且一次改写覆盖所有语句; - 改写 SQL 的同时必须改参数映射:加
limit ?,?后多出两个?,要通过BoundSql.setAdditionalParameter()或重建ParameterMapping让#{}对齐——这是分页插件最容易出 bug 的地方; ThreadLocal用完必须清:PageHelper.startPage()后没有紧跟一次查询(例如查了两次或抛异常),ThreadLocal 会残留,导致后续查询被莫名分页——生产上建议用PageHelper.startPage(...).doSelectPage(() -> mapper.select(...))这种"用完即清"的 API。
5. PageHelper 的 count 查询与深分页怎么优化?
答:
(1)count 查询的智能改写
PageHelper 会尽量把 select a, b from t left join ... where ... 改写成 select count(0) from t ... where ...(去掉 order by、select 列表换成 count(0));如果 SQL 含 distinct、group by、having 等无法简单替换的结构,就外包一层:select count(0) from (原 SQL) tmp。
优化手段:
| 手段 | 说明 |
|---|---|
PageHelper.startPage(pageNum, pageSize, false) | 不查 count(已知总数/不需要总数时用,去掉一次昂贵查询) |
reasonable=true | 页码越界自动修正(首页/尾页),避免空结果导致的业务异常 |
pageSizeZero=true | pageSize=0 时返回全部(不做分页) |
| 给 count 查询减负 | count 只要"有没有满足条件的行",避免不必要的 join 与排序;大表 count 本身可能很慢,考虑近似总数/缓存总数 |
| 业务上少用"总页数" | 无限流式列表(只判断 hasNext)比"精确总数"便宜得多 |
(2)深分页优化(面试高频,"limit 1000000, 20"为什么慢)
limit 1000000, 20 的问题在于:MySQL 会扫描并丢弃前 100 万行(即使走二级索引,也要回表取 100 万次),因此越翻越慢。
| 优化方案 | 做法 | 适用 |
|---|---|---|
| 游标/Seek 分页(首选) | 记住上一页最后一条的 id,下一页用 where id > #{lastId} order by id limit 20 | 顺序翻页(App 列表、滚动加载) |
| 延迟关联 | 先在覆盖索引上查出主键(select id from t where ... order by ... limit 1000000, 20),再 join 回原表取整行 | 必须"跳页"且无法用游标时 |
| 覆盖索引 | 让排序 + 过滤字段都在索引里,避免回表 | 与上面配合 |
| 限制最大页数 | 业务上禁止翻到第 1000 页(搜索引擎式"只给前 N 页") | 大多数 To C 列表 |
一句话:count 能省就省(不查总数),翻页优先游标(
where id > lastId),必须跳页就用延迟关联。
6. MyBatis 插件能用来做哪些事?开发时要注意什么?
答: 插件是 MyBatis 最常用的扩展点,典型落地场景:
| 场景 | 拦截点 | 做什么 |
|---|---|---|
| 分页 | Executor.query | 改写 SQL 加分页 + 查 count(PageHelper / MP 分页插件) |
| SQL 监控/慢 SQL | Executor.query/update | 打印最终 SQL、参数、耗时、慢 SQL 告警 |
| 读写分离 | Executor.query/update | 读走从库、写走主库(或按注解/线程上下文路由) |
| 多租户 | StatementHandler.prepare | 自动给 SQL 追加 tenant_id = ? 条件 |
| 数据权限 | Executor.query | 按登录用户自动拼 dept_id in (...) 或改写条件 |
| 自动填充 / 乐观锁 | Executor.update | 自动补 create_time/update_time/version |
| 防全表更新删除 | Executor.update | 检测无 where 的 update/delete 并拦截(团队规范兜底) |
开发时的五个坑(比"会写插件"更重要):
proceed()与异常:增强逻辑要用try/finally,别把proceed()漏掉或用异常打断链路;- 改 SQL 必须同步改参数:只改
BoundSql.getSql()而不addAdditionalParameter/重建参数映射,会让#{}错位(分页、租户条件都是"加?"的典型场景); ThreadLocal必须finally清理:线程池复用下残留会导致"串页/串租户"这类极难排查的 bug;- 性能开销:插件是每条 SQL 都要过的路径,避免在其中做重活(如全量反射、拼接大字符串、同步打日志到磁盘);
- 别把业务逻辑塞进插件:插件适合"横切关注点",把业务规则放进去会让 SQL 行为隐式且不可见,排查成本极高。
打印"最终 SQL"的正确姿势:
BoundSql.getSql()拿到的才是动态 SQL 拼好之后的 SQL(<if>/<foreach>都已展开),参数要从BoundSql.getParameterMappings()+ParameterObject里取;MyBatis 内置LoggingCache/日志实现(配置logImpl或 Mapper 日志级别为 DEBUG)也能直接打印 SQL 与参数——先看框架自带能力,再决定要不要自己写插件。
二、Mapper 接口与注册
7. Mapper 接口的工作原理是什么?方法能重载吗?
答:
(1)没有实现类,为什么能调用?—— JDK 动态代理
1. Configuration.addMapper(UserMapper.class)
└─ MapperRegistry.addMapper()
└─ new MapperAnnotationBuilder(config, type).parse() // 解析注解 + 加载同名 XML
2. sqlSession.getMapper(UserMapper.class)
└─ MapperRegistry.getMapper()
└─ MapperProxyFactory.newInstance(sqlSession)
└─ Proxy.newProxyInstance(..., new MapperProxy<>(sqlSession, mapperInterface, methodCache))
3. 调用 userMapper.findById(1)
└─ MapperProxy.invoke()
├─ Object 自带方法(toString/hashCode/equals)→ 本地实现
├─ 接口 default 方法 → 反射调用
└─ 其他 → MapperMethod.execute(sqlSession, args)
├─ 用「接口全限定名.方法名」定位 MappedStatement
├─ 解析 SqlCommandType / 方法返回类型 / 参数(ParamMap、RowBounds、ResultHandler)
└─ 走 sqlSession.selectOne/insert/update/... → Executor → 数据库关键映射规则(记住这三条就不会乱):
| 接口侧 | XML 侧 |
|---|---|
| 接口全限定名 | namespace |
| 方法名 | statement 的 id |
| 方法参数 | parameterType / #{} |
| 方法返回值 | resultType / resultMap |
(2)方法能重载吗?—— 结论:不要重载
根本原因:statementId = namespace + "." + 方法名,不含参数类型。
| 情况 | 结果 |
|---|---|
两个重载方法都用注解(@Select 等)写 SQL | ❌ 启动报错:Mapped Statements collection already contains value for xxx.UserMapper.findById |
| 重载方法只在接口声明,SQL 只在 XML 里定义一份 | ⚠️ 能跑(两个方法指向同一个 statement),但参数个数不同时 param1/param2 与 @Param 命名极易出错,不推荐 |
泛型桥接方法 / default 方法 | MyBatis 会自动跳过(canHaveStatement 判定 bridge 与 default),不会重复注册 |
实践建议:一个方法名对应一条唯一 statement,需要"按不同参数查同一张表"就改方法名(
findById/findByName),或用参数对象 + 动态 SQL 分支——可读性和可维护性都远好于重载。
8. 接口绑定(Mapper 绑定)有哪几种实现方式?
答:
| 方式 | 做法 | 适用 |
|---|---|---|
| XML 绑定 | SQL 写在 XxxMapper.xml,namespace 指向接口全限定名;接口与 XML 同名同包时自动加载(mybatis.mapper-locations 指定路径) | 复杂 SQL(多表 join、动态 SQL) |
| 注解绑定 | 在接口方法上用 @Select / @Insert / @Update / @Delete,以及 @SelectProvider(拼接 SQL 类) | 简单 CRUD,少量 SQL |
| 混合 | 复杂 SQL 用 XML + 简单 CRUD 用注解 | 常见组合(但同一 statementId 不允许两处定义) |
几个容易含糊的点:
- XML 与注解可以共存,但不能重复定义同一个
id,否则报already contains value; @Results/@Result/@One/@Many可以在注解侧配置映射与关联查询(等价于<resultMap>+association/collection);- 注解写动态 SQL 很吃力:
<script>标签可以在注解里写 XML(@Select("<script>...</script>")),但可读性差,复杂逻辑仍应放 XML; @SelectProvider/@InsertProvider适合"用 Java 代码动态拼 SQL"的场景(比 XML 更灵活,但脱离了 SQL 与代码分离的初衷)。
9. Mapper 接口是怎么被扫描注册到 MyBatis 的?
答: 分"原生 MyBatis"与"Spring 集成"两条路径,面试问的多是后者。
(1)原生 MyBatis
Configuration configuration = new Configuration();
configuration.addMapper(UserMapper.class); // 或 sqlSessionFactoryBuilder 解析 config 时由 <mappers> 触发
// → MapperRegistry.addMapper → MapperAnnotationBuilder.parse():解析注解 + 加载同名 XML(2)Spring / Spring Boot(重点)
| 组件 | 作用 |
|---|---|
@MapperScan("com.x.mapper") | 注解式扫描(等价于 XML 里的 <mybatis:scan>) |
MapperScannerConfigurer | 老式配置类(basePackage + sqlSessionFactoryBeanName) |
ClassPathMapperScanner | 继承 Spring 的 ClassPathBeanDefinitionScanner,把接口注册为 MapperFactoryBean 类型的 BeanDefinition |
MapperFactoryBean | FactoryBean<Mapper>:getObject() 返回 sqlSession.getMapper(mapperInterface)——注入到业务里的其实是代理对象 |
SqlSessionTemplate | 线程安全的 SqlSession 实现,每次调用通过代理从当前事务(SqlSessionUtils)中获取/新建会话 |
SpringManagedTransaction | 让 MyBatis 的 Connection 与 Spring 事务的 ConnectionHolder 绑定,实现"MyBatis 与 Spring 共用同一个连接、同一个事务" |
扫描流程(能口述到这一层就够):
@MapperScan
→ MapperScannerRegistrar 解析注解
→ ClassPathMapperScanner.scan(basePackages)
→ 为每个接口生成 BeanDefinition(beanClass = MapperFactoryBean,构造参数 = 接口 Class)
→ 容器启动时实例化 MapperFactoryBean(自动注入 SqlSessionFactory/SqlSessionTemplate)
→ 业务注入 UserMapper 时拿到 JDK 动态代理对象两个高频追问:
- "Mapper 接口能不能被
@Autowired注入?为什么不是 MyBatis 自己创建的?" —— 是 Spring 的容器创建了MapperFactoryBean,通过FactoryBean机制返回代理对象,所以它是 Spring Bean,能做依赖注入与 AOP 代理(事务注解对 Mapper 生效也是这个原因); - "同一个 Mapper 接口能被注入到多个地方吗?是同一个对象吗?" —— 是(默认单例),其内部持有
SqlSessionTemplate,真正每次执行时才会取/建SqlSession,因此线程安全(见第 12 题)。
三、执行流程与核心组件
10. 简述 MyBatis 的工作原理(执行流程)?
答: 按"启动阶段"与"运行阶段"分开讲,条理最清楚。
(1)启动 / 构建阶段(一次性)
1. XmlConfigBuilder 解析 mybatis-config.xml(或 Configuration 手工构建)
→ 得到 Configuration(全局唯一,含 settings/environments/typeHandlers/plugins/mappers)
2. 对每个 Mapper:
XMLMapperBuilder 解析 XxxMapper.xml → 每个 <select>/<insert> 构建一个 MappedStatement
(含 id、SQL 源、参数映射、结果映射、缓存、超时、flushCache 等)
MapperAnnotationBuilder 解析接口注解
→ 注册进 Configuration.mappedStatements / MapperRegistry
3. new SqlSessionFactoryBuilder().build(configuration) → DefaultSqlSessionFactory
(建造者模式;解析成本高、线程安全 → 全局单例)(2)运行阶段(每次调用)
1. sqlSessionFactory.openSession() → 创建 SqlSession(内含 Executor + Transaction)
2. sqlSession.getMapper(UserMapper.class) → MapperProxy 代理对象
3. userMapper.findById(1)
→ MapperProxy.invoke → MapperMethod.execute
→ sqlSession.selectOne(statementId, param)
→ Executor.query(CachingExecutor → BaseExecutor)
├─ 生成 CacheKey → 查二级/一级缓存
└─ 未命中:
├─ StatementHandler.prepare() 创建 PreparedStatement(含超时、fetchSize)
├─ ParameterHandler.setParameters() 用 TypeHandler 绑定每个 #{} 参数
├─ StatementHandler.query() 执行 & 拿到 ResultSet
└─ ResultSetHandler.handleResultSets()
用 ResultMap + TypeHandler 映射为 Java 对象(嵌套映射/延迟加载也在这里)
4. 提交或回滚事务 → 关闭 SqlSession(释放资源、清理一级缓存)一句话版本(背下来即可应对"简述原理"):
读取配置构建
Configuration与MappedStatement→SqlSessionFactory创建SqlSession→MapperProxy定位MappedStatement→Executor走缓存/查库 →StatementHandler执行、ParameterHandler设参、ResultSetHandler映射结果。
11. MyBatis 的核心组件有哪些?各自职责是什么?
答: 一张表记住(也是"分层设计"的体现):
| 组件 | 职责 | 设计模式/关键点 |
|---|---|---|
Configuration | 全局配置的唯一容器:settings、typeHandlers、mappers、interceptors | 单例;几乎所有组件都持有它 |
SqlSessionFactoryBuilder | 解析配置并构建工厂 | 建造者模式,用完即可丢 |
SqlSessionFactory | 创建 SqlSession 的工厂 | 重量级、线程安全;通常全局单例 |
SqlSession | 面向用户的门面:增删改查、事务、getMapper | 非线程安全,每次操作创建、用完关闭 |
Executor | 执行器:缓存、事务、批处理的入口;调度下面三件套 | 模板方法 + 装饰器(CachingExecutor) |
StatementHandler | 创建/执行 Statement,设置超时、fetchSize、批量 | 按 StatementType 路由(RoutingStatementHandler) |
ParameterHandler | 把 Java 参数通过 TypeHandler 绑定到 PreparedStatement | 与 #{} 一一对应 |
ResultSetHandler | 把 ResultSet 映射为对象/集合,处理嵌套与延迟加载 | 最复杂的一环,resultMap 的落地处 |
MappedStatement | 一条 SQL 的完整描述(id、SQL 源、参数/结果映射、缓存、超时…) | 启动时构建、运行期只读 |
BoundSql | 动态 SQL 拼好后的最终 SQL + 参数映射列表 | 插件改 SQL 就在这上面改 |
SqlSource | SQL 的来源抽象(DynamicSqlSource/RawSqlSource/StaticSqlSource) | 不含 ${} 的静态 SQL 会提前编译,性能更好 |
MapperRegistry / MapperProxyFactory | 注册接口并生产 MapperProxy | JDK 动态代理 |
Cache | 缓存抽象(PerpetualCache + 装饰器链) | 装饰器模式 |
面试技巧:被问"核心组件"时,按"启动期(Configuration/SqlSessionFactory/MappedStatement)→ 运行期(SqlSession/Executor/三大 Handler)→ 扩展点(Interceptor/Cache/TypeHandler)"三层说,比零散罗列更有体系感。
12. SqlSessionFactory 为什么设计成单例/重量级对象?SqlSession 为什么非线程安全?
答:
(1)SqlSessionFactory 是重量级 + 线程安全 → 全局单例
- 构建成本高:需要解析
mybatis-config.xml+ 所有 Mapper XML、构建Configuration、解析每个SqlSource(含 OGNL 表达式)、注册TypeHandler/缓存/插件; - 构建后即只读:内部持有的
Configuration在运行期不会被修改(mappedStatements、resultMaps都是只读使用),因此天然线程安全; - 结论:一个应用(一个数据源)一个
SqlSessionFactory,由 Spring 容器管理并长期复用;SqlSessionFactoryBuilder用完即可丢弃(不要长期持有)。
(2)SqlSession 是非线程安全的 → 每次操作创建、用完关闭
原因在于它持有了"会话态"资源:
| 会话态资源 | 共享的后果 |
|---|---|
Executor 与一级缓存 localCache | 两个线程互相写缓存、互相清缓存 → 读脏、莫名的"少发一条 SQL" |
Transaction 与底层 Connection | 一个线程 commit/rollback 会影响另一个线程的事务 |
Statement/ResultSet 游标 | 并发使用同一 Statement → 状态错乱、游标被关闭 |
BatchExecutor 的批处理队列 | 混入别的线程的语句,提交后"多写了数据" |
(3)Spring 是怎么办的?(这才是生产答案)
SqlSessionTemplate本身线程安全(可单例),它内部持有SqlSessionInterceptor代理:- 每次调用都去
SqlSessionUtils.getSqlSession(...):有 Spring 事务则复用事务绑定的SqlSession(保证同一事务同一连接),没有则新建一个并在调用结束后自动关闭; - 与
SpringManagedTransaction配合,让 MyBatis 的事务挂在 Spring 的ConnectionHolder上,实现"@Transactional 能把 MyBatis 操作和 JdbcTemplate/其他数据源操作放在同一事务里";
- 每次调用都去
- 所以:业务里注入的 Mapper 是单例代理,但每次执行用的
SqlSession是"当前线程/当前事务"的那一个——这就是"看似单例却线程安全"的秘密。
一句话:工厂(重量级、无状态)共享;会话(轻量级、有状态)按用按建关——MyBatis 的"工厂单例 + 会话多例"就是这条原则的标准实现。
13. MyBatis 的数据源(DataSource)有哪几种?连接是何时创建的?
答:
| 类型 | 说明 | 适用 |
|---|---|---|
UNPOOLED | 不池化,每次请求新建 Connection、用完关闭 | 学习/极低并发(连池化开销都想省) |
POOLED | MyBatis 自带的简单连接池(PooledDataSource,idleConnections/activeConnections 两个 List) | 无 Spring 或不想引第三方池 |
JNDI | 从容器(Tomcat/WebLogic)的 JNDI 取 DataSource | 传统 JavaEE 容器托管 |
三个关键点:
- 生产环境几乎不会用 MyBatis 自带池:Spring Boot 默认使用 HikariCP(
spring.datasource.*),MyBatis 只是复用DataSource(SqlSessionFactoryBean注入同一个DataSource,从而与 Spring 事务共享连接); - 连接是"延迟创建"的:
DataSource.getConnection()直到真正执行 SQL(Executor.query/update时由Transaction获取)才调用,不是openSession()时——这也是"打开会话不消耗连接"的原因; - 连接归还时机:
SqlSession.close()(或 Spring 管理下的事务结束)时归还;忘记关闭会导致连接池耗尽——Spring 集成下由SqlSessionTemplate保证关闭,所以不要手写openSession()而不关闭(必须try-with-resources或finally close)。
顺带两个高频关联问题:
- "
fetchSize怎么设?":<select fetchSize="1000">或@Options(fetchSize=...);MySQL 需要useCursorFetch=true+StatementType.PREPARED才真正生效(否则驱动把结果集全部拉到内存),大批量导出场景必须注意; - "如何设置查询超时?":
<select timeout="3">(秒),或@Options(timeout=3),底层是Statement.setQueryTimeout()——它是数据库侧超时,与连接池/RPC 超时要区分开。
