概述
通过前面 9 篇文章的深度剖析 ,我们从 SqlSession 的核心生命周期、Executor 的执行器策略、StatementHandler 的 JDBC 封装、映射器代理 MapperProxy 的精妙设计、动态 SQL 引擎的解析与执行、一级/二级缓存的深层机制、插件拦截链的 AOP 实现,到 Spring Boot 与 MyBatis 的整合核心,最终以 MyBatis-Plus 的增强机制收尾,构建了一张完整的 MyBatis 内部知识网。然而,正向的知识构建最终必须转化为逆向的排错与避险能力。本文作为系列的收官之作,将完成这"最后一公里"的闭环。
本文将 MyBatis 开发与生产环境中反复出现、后果严重 的 22+ 个反模式,归纳为配置初始化、会话与事务、缓存、映射与 SQL、插件拦截、Spring Boot 整合、MyBatis-Plus 特定等七大领域。每个反模式都严格遵循错误示例、现象描述、排查思路、基于源码的根因分析、修正方案、最佳实践 的六步结构进行深度剖析。我们不会停留在"如何做"的表层,而是深入到 BaseExecutor.query、SqlSessionTemplate.SqlSessionInterceptor、Plugin.wrap 等核心源码中,揭示"为何出错"。
本文提炼了一套以 MyBatis TRACE 日志、p6spy SQL 监控、自定义诊断拦截器及 Arthas 动态追踪 为核心的标准化诊断工具箱,并提供一张工具-反模式映射表 与一张标准化排查决策树。
文章组织架构图
架构图说明:
- 总览说明:全文共 10 大模块。模块 1 提供反模式的全景分类。模块 2 至 8 是核心,深入七大领域的 22+ 个具体案例,从错误示例一路追踪到源码根因。模块 9 将所有排查手段系统化为诊断工具箱、映射表和标准化决策树。模块 10 则以高频面试题的形式,将排错知识升华。
- 逐模块说明 :
- 模块 2-8 :每一个模块聚焦一个特定领域,每个案例都是"现象-排查-根因-修复"的完整闭环。例如,在缓存反模式中,我们会深入
BaseExecutor的localCache探讨脏读;在插件拦截反模式中,我们会剖析InterceptorChain.pluginAll的包装顺序如何导致功能失效。 - 模块 9 :这是本文的"操作手册",将
p6spy、Arthas等工具与具体反模式现象挂钩,提供搜索关键词和检查点。标准化决策树则从"SQL 未执行"、"缓存不命中"等常见异常现象出发,一步步引导至最终的根因定位。
- 模块 2-8 :每一个模块聚焦一个特定领域,每个案例都是"现象-排查-根因-修复"的完整闭环。例如,在缓存反模式中,我们会深入
- 关键结论 :掌握 MyBatis 内部组件的生命周期与协作方式是高效排查 Mapper 加载失败、缓存数据不一致、插件失效等问题的根本基础。排错不是玄学,是对源码逻辑的逆向推演。
1. 反模式总览与分类
在生产环境中,MyBatis 的灵活性常被误用,导致各种隐蔽且影响严重的问题。我们将这些高频反模式归纳为七大领域,共计 22+ 个典型案例。
| 反模式名称 | 所属领域 | 风险等级 | 可能导致的现象 |
|---|---|---|---|
mapper-locations通配符失效 |
配置与初始化 | 高 | Mapper XML完全不加载,所有SQL执行报BindingException |
type-aliases-package配置错误 |
配置与初始化 | 中 | resultType找不到类,ClassNotFoundException |
config-location与configuration冲突 |
配置与初始化 | 中 | 自定义配置(如日志、拦截器)被覆盖或失效 |
非Spring环境未关闭SqlSession |
会话与事务 | 高 | 数据库连接池耗尽,系统响应缓慢直至宕机 |
| Spring事务内一级缓存"失效" | 会话与事务 | 高 | 同一事务内相同查询重复执行,性能下降 |
BatchExecutor未手动flush |
会话与事务 | 高 | 部分批处理SQL未提交,数据丢失或不一致 |
| 一级缓存脏读 | 缓存相关 | 高 | 查询到已被其他事务修改的旧数据 |
| 多节点环境二级缓存不一致 | 缓存相关 | 高 | 各节点返回数据不一致,业务逻辑错误 |
动态SQL导致CacheKey不稳定 |
缓存相关 | 中 | 缓存命中率极低,性能不升反降 |
#{}与${}混用导致SQL注入 |
映射器与SQL | 严重 | 核心数据泄露、数据被篡改、权限绕过 |
resultMap属性映射缺失 |
映射器与SQL | 中 | 查询出的POJO部分字段为null |
多参数方法未使用@Param |
映射器与SQL | 中 | 运行时抛出BindingException: Parameter 'xxx' not found |
动态SQL <if> 对0值误判 |
映射器与SQL | 高 | 业务逻辑因SQL片段丢失而错误,难以察觉 |
| 插件注册顺序不当 | 插件拦截 | 高 | 分页、脱敏等插件功能绕过或相互干扰 |
| 分页插件count查询未优化 | 插件拦截 | 高 | 慢SQL、全表扫描、数据库CPU飙升 |
| 插件中抛出未处理异常 | 插件拦截 | 高 | MyBatis核心流程中断,导致PersistenceException |
多数据源@Primary误用 |
Spring Boot整合 | 高 | Mapper连接到错误数据源,数据读写错乱 |
@MapperScan与自动扫描冲突 |
Spring Boot整合 | 中 | 启动时BeanDefinitionStoreException,Mapper重复注册 |
ConfigurationCustomizer被覆盖 |
Spring Boot整合 | 中 | 自定义配置不生效,回退为默认配置 |
| 手写与通用CRUD方法同名 | MyBatis-Plus | 高 | 启动失败,MappedStatement ID冲突 |
| 多租户插件遗漏全局配置 | MyBatis-Plus | 严重 | 租户数据隔离失效,严重数据安全事故 |
| 逻辑删除查询遗漏条件 | MyBatis-Plus | 高 | 查询结果包含本应被"已删除"的数据 |
MyBatis 反模式全景分类图
图表说明:
- 图表主旨概括:本图将七大领域的反模式进行可视化分类,每个领域下列举了3-4个最具代表性的高风险案例,它们共同指向最终的生产事故,强调问题的严重性。
- 逐层/逐元素分解:图的顶层是反模式的七大领域,每个领域是一个包含具体案例的子图。例如"配置与初始化"领域,包含通配符、别名和配置覆盖三类问题。箭头方向表明这些问题若不解决,最终都会流向"生产事故"这一结果。
- 设计原理映射:这种分类方式遵循了 MyBatis 的初始化、运行、协作的生命周期。配置问题在启动时暴露,会话和缓存问题在运行时涌现,而插件和整合问题则在组件协作时触发。这体现了软件工程中"分层暴露风险"的思想。
- 工程联系与关键结论 :在实际工程中,应先根据发生时机(启动时/运行时)和错误类型(异常/结果错误)将问题归类,再在特定领域内进行排查。理解每个领域内部的组件协作关系是排错的关键。
接下来的章节,我们将逐一深入剖析这些典型反模式。
2. 配置与初始化反模式
MyBatis 的初始化过程是整合 Spring Boot 后的第一道关卡。不正确的 XML 或注解配置往往导致 Mapper 无法加载或行为异常,问题多在应用启动阶段暴露。
2.1 案例 1:mapper-locations 通配符失效导致 Mapper 未加载
- 错误示例:
java
// application.yaml
mybatis:
mapper-locations: classpath:mapper/*.xml // 错误点:通配符只扫描当前目录,不包含子目录
java
// 目录结构
src/main/resources/
└── mapper/
├── user/
│ └── UserMapper.xml
└── order/
└── OrderMapper.xml
-
现象描述 :应用启动成功,但调用
UserMapper或OrderMapper的任何方法时,抛出org.apache.ibatis.binding.BindingException: Invalid bound statement (not found)。这意味着 MyBatis 未能为 XML 中的 SQL 语句创建MappedStatement。 -
排查思路:
- 检查日志 :开启
logging.level.org.mybatis=DEBUG,观察启动日志。可以看到 MyBatis 解析了哪些 XML,或者根本没有解析日志。 - 验证配置 :仔细核对
mapper-locations的值,是否与实际的资源路径匹配。 - 使用 Arthas :若应用已部署,可附加 Arthas,通过
ognl命令查看SqlSessionFactory的configuration对象,检查其mappedStatements集合中是否包含预期的资源。
- 检查日志 :开启
-
根因分析 : 根本原因在于
mybatis-spring在解析mapperLocations时,使用了 Spring 的PathMatchingResourcePatternResolver。其方法getResources(String locationPattern)对于路径通配符的处理有如下规则:classpath*:mapper/*.xml:默认只在当前 classpath 下的mapper目录寻找,不会递归遍历子目录。- MyBatis 启动时,
SqlSessionFactoryBean的buildSqlSessionFactory()方法会调用PathMatchingResourcePatternResolver来加载所有匹配的 XML 资源。如果模式不匹配,resolver.getResources()将返回空数组,导致XmlConfigBuilder或XmlMapperBuilder没有可解析的输入。 - 相关源码引用:
org.mybatis.spring.SqlSessionFactoryBean.buildSqlSessionFactory()中,调用PathMatchingResourcePatternResolver.getResources(mapperLocations)获取Resource[]数组。
-
修正方案 : 使用 Ant 风格的路径表达式
**来递归匹配所有子目录。java// application.yaml mybatis: mapper-locations: classpath*:mapper/**/*.xml // 修正:使用 ** 递归扫描 classpath 下所有 mapper 目录 -
最佳实践:
- 标准化目录结构 :约定所有 Mapper XML 文件统一存放在
resources/mapper目录下,按模块分好子目录。 - 默认扫描路径 :使用 Spring Boot 自动配置时,
mybatis-spring-boot-starter默认会扫描与@Mapper注解同级目录下的 XML 文件。遵循此约定可省去配置。 - 启动断言 :在 CI/CD 或单元测试中,注入
SqlSessionFactory并调用configuration.getMappedStatementNames().size()断言预期的 Mapper 数量,防止因配置错误导致生产事故。
- 标准化目录结构 :约定所有 Mapper XML 文件统一存放在
2.2 案例 2:type-aliases-package 配置错误导致实体类找不到
- 错误示例:
java
// application.yaml
mybatis:
type-aliases-package: com.example.entity // 错误点:包名写错或实体类不在该包下
xml
<!-- UserMapper.xml -->
<select id="selectUser" resultType="User"> <!-- 此处使用了别名 -->
SELECT * FROM user WHERE id = #{id}
</select>
-
现象描述 :启动时抛出
org.apache.ibatis.type.TypeException: Could not resolve type alias 'User'或映射时出错。这表明TypeAliasRegistry无法在注册表中找到User对应的Class。 -
排查思路:
- 检查配置项 :确认
mybatis.type-aliases-package的值是否正确,包路径书写无误。 - 查看启动日志 :DEBUG 级别下,
TypeAliasRegistry在注册时会打印日志,可以观察哪些类被注册。如果包路径错误,将不会有任何注册日志。 - 使用 Arthas :附加 Arthas 后,执行
ognl '@org.apache.ibatis.session.Configuration@getTypeAliasRegistry().typeAliases'查看已注册的别名映射表。
- 检查配置项 :确认
-
根因分析 : MyBatis 在初始化
Configuration时,通过TypeAliasRegistry管理别名。当SqlSessionFactoryBean处理typeAliasesPackage属性时,会调用ClassPathScanner扫描该包下的所有类,并对带有@Alias注解的类或默认使用简单类名、首字母小写的类名进行注册。- 核心源码:
org.apache.ibatis.session.Configuration.getTypeAliasRegistry().registerAliases(String packageName)。 - 该方法内部使用
ResolverUtil来扫描指定包下的所有.class文件。如果包路径错误,ResolverUtil.find()将找不到任何类,因此注册不会发生。当 XML 中使用别名时,BaseBuilder.resolveAlias()将无法从TypeAliasRegistry中找到对应的Class,从而抛出异常。
- 核心源码:
-
修正方案: 修正包路径,或直接使用全限定类名。
java// application.yaml mybatis: type-aliases-package: com.example.entity // 修正:确保包名正确xml<!-- UserMapper.xml - 更安全的方式 --> <select id="selectUser" resultType="com.example.entity.User"> </select> -
最佳实践:
- 全限定类名优先 :在
resultType,parameterType等处优先使用类的全限定名,这是最稳定、重构友好的做法。 - 统一别名管理 :如需使用别名,应在一个统一的类中定义别名常量,或在所有实体类上使用
@Alias注解显式指定别名。 - 代码审查清单 :检查
application.yaml中type-aliases-package的拼写是否正确。
- 全限定类名优先 :在
2.3 案例 3:config-location 与 configuration 同时配置导致属性覆盖
- 错误示例:
java
// application.yaml
mybatis:
config-location: classpath:mybatis-config.xml
configuration:
use-generated-keys: true
map-underscore-to-camel-case: true
xml
<!-- mybatis-config.xml -->
<configuration>
<settings>
<setting name="mapUnderscoreToCamelCase" value="false"/> <!-- 与Spring Boot配置冲突 -->
</settings>
</configuration>
-
现象描述:开发人员期望驼峰映射生效,但实际未生效;或者反之。这取决于解析顺序,导致行为不确定。
-
排查思路:
- 明确配置来源 :检查
application.yaml是否同时使用了mybatis.config-location和mybatis.configuration.*。 - 观察最终行为:开启 DEBUG 日志,观察 SQL 执行后属性映射情况,反推最终哪个配置生效。
- 查看
Configuration对象 :使用 Arthas 的ognl命令查看SqlSessionFactory.configuration.mapUnderscoreToCamelCase等属性的最终值。
- 明确配置来源 :检查
-
根因分析 :
mybatis-spring-boot-starter的MybatisAutoConfiguration和SqlSessionFactoryBean共同处理这些配置。- 解析配置文件 :如果
config-location属性被设置,SqlSessionFactoryBean会使用XMLConfigBuilder解析指定的 XML 文件,构建一个Configuration对象。 - 应用 Spring Boot 属性 :随后,
MybatisAutoConfiguration使用org.springframework.boot.context.properties.bind.Binder将mybatis.configuration下的属性,通过反射调用 setter 方法直接设置到 步骤 1 生成的Configuration对象上。 - 覆盖问题 :如果
mybatis-config.xml中定义了某个settings,而application.yaml中又定义了同名属性,Spring Boot 属性将直接覆盖 XML 中的设置。这种设计使得 XML 中的配置与 YAML 中的配置的优先级关系变得模糊。核心源码见org.mybatis.spring.boot.autoconfigure.MybatisProperties.applyTo(Configuration configuration)方法。
- 解析配置文件 :如果
-
修正方案 : 只选择一种配置方式,避免混用。
java// 方案一:纯Spring Boot配置(推荐) mybatis: configuration: use-generated-keys: true map-underscore-to-camel-case: true # config-location: 不设置java// 方案二:纯XML配置 mybatis: config-location: classpath:mybatis-config.xml # configuration: 不设置任何子属性 -
最佳实践:
- 优先 Spring Boot 配置 :在 Spring Boot 项目中,优先使用
mybatis.configuration.*和mybatis.type-handlers-package等属性,利用 IDE 的自动补全和类型安全优势。 - 复杂配置用 XML :当需要配置复杂的插件、自定义类型处理器或对象工厂时,才使用
config-location指向 XML 文件,并严禁 在 Spring Boot 的配置文件中再声明mybatis.configuration属性。 - 配置审计 :通过自动化脚本检查
application.yaml中是否同时存在config-location和configuration键。
- 优先 Spring Boot 配置 :在 Spring Boot 项目中,优先使用
3. SqlSession 与事务管理反模式
SqlSession 是 MyBatis 与数据库交互的一次性会话。对其生命周期的管理不当,是导致连接泄漏、事务混乱和性能问题的根源。
3.1 案例 4:非 Spring 环境下未正确关闭 SqlSession
- 错误示例:
java
public class NonSpringApp {
public static void main(String[] args) {
SqlSessionFactory factory = ...;
for (int i = 0; i < 1000; i++) {
SqlSession session = factory.openSession();
User user = session.selectOne("org.example.mapper.UserMapper.getUser", 1);
System.out.println(user);
// 错误点:忘记调用 session.close()
}
}
}
-
现象描述 :程序运行一段时间后,数据库连接池(如 HikariCP、Druid)报错
Connection is not available, request timed out after xxxxms,应用响应变慢,最终导致无法建立新连接。 -
排查思路:
- 监控连接池 :观察
HikariPool的 JMX MBean 或 Druid 监控页,会看到活跃连接数持续升高且不释放。 - 线程 Dump :使用
jstack或Arthas thread -b分析线程堆栈。会看到大量线程阻塞在HikariPool.getConnection()等待连接。 - 代码审查 :重点检查所有调用
session.openSession()的代码路径,确保close()在finally块中被调用。
- 监控连接池 :观察
-
根因分析 : 每次调用
factory.openSession(),DefaultSqlSessionFactory都会创建一个新的DefaultSqlSession实例,其内部封装了一个从数据源获取的java.sql.Connection。当session.close()未被调用时,该Connection不会被归还给连接池,导致连接泄漏。DefaultSqlSession.close()的核心逻辑如下(源码位置org.apache.ibatis.session.defaults.DefaultSqlSession.close()):java// DefaultSqlSession.java (简化版) public void close() { try { executor.close(isCommitOrRollbackRequired(false)); closeCursors(); dirty = false; } finally { ErrorContext.instance().reset(); } } // BaseExecutor.java public void close(boolean forceRollback) { // ...清理逻辑... if (transaction != null) { transaction.close(); // 这里才真正将Connection归还给池 } // ... }只有在
close()方法的最终调用链中,事务对象Transaction的close()方法才会调用Connection.close(),将连接归还给数据源。如果不调用session.close(),就永远不会触发这个归还动作。 -
修正方案 : 使用 try-with-resources 语句自动关闭
SqlSession。java// 修正后 try (SqlSession session = factory.openSession()) { User user = session.selectOne("org.example.mapper.UserMapper.getUser", 1); System.out.println(user); } // session.close() 会被自动调用 -
最佳实践:
- Spring 托管生命周期 :在 Spring 环境中,永远不要手动创建和关闭
SqlSession。使用SqlSessionTemplate或注入Mapper接口,Spring 容器会负责管理其生命周期。 - 静态代码检查 :使用 SonarQube 或 IDE 插件,规则集应包括对
SqlSession、InputStream等需要close()的资源的检查。 - 非 Spring 场景强制约束 :如必须手动管理,所有
openSession()必须紧邻 try-with-resources 结构,严禁在多个方法间传递未经管理的SqlSession。
- Spring 托管生命周期 :在 Spring 环境中,永远不要手动创建和关闭
3.2 案例 5:Spring @Transactional 内多次相同查询仍执行 SQL
- 错误示例:
java
@Service
public class UserServiceImpl {
@Transactional
public UserDto process(Long id) {
// 第一次查询
User user = userMapper.selectById(id);
// 第二次相同查询
User sameUser = userMapper.selectById(id); // 现象:这条SQL会被再次执行
return convert(user);
}
}
-
现象描述 :在同一个
@Transactional方法中,对同一数据进行了两次相同的查询,但 MyBatis 日志或p6spy监控显示,这两次查询都向数据库发送了 SQL,似乎一级缓存没有生效。 -
排查思路:
- 检查事务边界 :确认调用方和被调用方是否都在同一个 Spring
@Transactional注解的作用域内。 - 开启 MyBatis TRACE 日志 :
logging.level.org.mybatis=TRACE。观察日志,每次查询前都会打印Cache Hit Ratio [xxx:xxx],可以确认缓存是否命中。 - 检查
SqlSession创建 :关键点在于理解 Spring 如何管理SqlSession。
- 检查事务边界 :确认调用方和被调用方是否都在同一个 Spring
-
根因分析 : 这与 Spring 整合 MyBatis 的核心类
SqlSessionTemplate及其内部类SqlSessionInterceptor的实现有关。-
核心机制 :
SqlSessionTemplate通过SqlSessionInterceptor代理了SqlSession的所有操作。每次执行 MyBatis 操作时,SqlSessionInterceptor都会先尝试从当前事务(TransactionSynchronizationManager)中获取绑定的SqlSession。如果存在则复用,否则创建一个新的SqlSession,执行完毕后立即关闭。 -
一级缓存与
SqlSession生命周期绑定 :MyBatis 的一级缓存生命周期与SqlSession严格一致。SqlSession打开时缓存存在,关闭时缓存清空。 -
冲突点 :当
selectById方法被调用时,SqlSessionInterceptor会获取或创建SqlSession。在默认的ExecutorType.SIMPLE下,MyBatis 为每个selectById调用创建了一个新的StrictMap(Spring 模式下)或ReuseShare?不,实际上,关键在于 Spring 管理下的SqlSession可能并未在两次selectById之间保持打开。更精确地说,如果 Spring 事务管理器配置不当,或者使用了注解式事务但AOP代理未完全生效,TransactionSynchronizationManager.getResource()可能返回null,导致每次操作都创建新的SqlSession,从而一级缓存失效。源码位置org.mybatis.spring.SqlSessionTemplate.SqlSessionInterceptor.invoke():java// SqlSessionInterceptor.java public Object invoke(Method method, Object[] args) throws Throwable { SqlSession sqlSession = getSqlSession( SqlSessionTemplate.this.sqlSessionFactory, SqlSessionTemplate.this.executorType, SqlSessionTemplate.this.exceptionTranslator); // ...用这个SqlSession执行方法... // 关键点:如果未在事务内,执行完SQL会立即close sqlSession }
-
-
修正方案:
- 确保事务生效 :确认
@Transactional注解的方法是通过 AOP 代理调用的(即从外部类调用,而非本类内部调用)。这是最常见的原因。 - 使用
ExecutorType:对于需要一级缓存场景,确认未使用BATCH或REUSE执行器。 - 明确设计:如果一个方法内需要多次访问同一数据,最佳实践是将其提取到方法外部,或使用更高级的缓存策略。
- 确保事务生效 :确认
-
最佳实践:
- 避免跨方法依赖一级缓存:一级缓存是 MyBatis 的内部优化机制,不应作为业务逻辑的依赖。业务逻辑需要保证数据一致性时,应在 DAO/Service 层显式控制。
- 事务确认 :在 Spring Boot 应用中,使用
spring-boot-starter-aop并确保@EnableTransactionManagement已启用(通常自动配置已处理)。 - 监控 :通过
p6spy监控相同 SQL 在同一个请求周期内的重复执行次数,作为识别事务或缓存问题指标。
3.3 案例 6:使用 BatchExecutor 但在循环结束后未调用 flushStatements
- 错误示例:
java
@Transactional
public void batchInsert(List<User> users) {
SqlSession sqlSession = sqlSessionTemplate.getSqlSessionFactory()
.openSession(ExecutorType.BATCH); // 使用批处理执行器
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
for (User user : users) {
mapper.insert(user);
}
sqlSession.commit();
sqlSession.close();
// 错误点:虽然调用了commit,但并未显式调用 flushStatements
}
-
现象描述:事务提交后,发现部分 SQL 并未实际执行,数据库中缺少数据。尤其是在批处理数量较大时,更容易出现。
-
排查思路:
- 对比数据:将插入列表数量和数据库最终数据量进行对比,发现数量对不上。
- 日志对比 :开启
p6spy,可以看到执行的 SQL 语句数量和实际预期不符。 - 断点调试 :在
BatchExecutor的flushStatements和doFlushStatements上设置断点,观察它们是否被调用。
-
根因分析 :
BatchExecutor的工作模式是"延迟执行"。调用mapper.insert(user)时,SQL 语句和参数被添加到内部的Statement的批次队列中,并不会立即发送给数据库。-
commit与flushStatements的关系 :JDBC 的Connection.commit()仅用于提交事务,它不保证 之前通过Statement.addBatch()添加的批命令被执行完。HashSet需要在commit之前,显式调用Statement.executeBatch()显式执行所有缓存的命令。 -
MyBatis 的执行链 :
BaseExecutor.flushStatements()就是用来触发doFlushStatements()的。我们在commit前调用它会强制刷新。BaseExecutor的commit方法虽然也会调用flushStatements,但在某些事务管理器和配置下此行为可能被跳过或延迟。查阅源码org.apache.ibatis.executor.BaseExecutor.flushStatements()和commit():java// BaseExecutor.java public void commit(boolean required) throws SQLException { if (closed) { throw new ExecutorException(...); } clearLocalCache(); flushStatements(); // commit会刷新, 但最安全的方式是手动调用 if (required) { transaction.commit(); } }尽管
commit内部调了flushStatements,但直接依赖commit仍然存在风险。例如,如果 commit 过程中出现异常,刷新行为可能被中断。
-
-
修正方案 : 在循环结束后、
commit之前,显式调用flushStatements()。java// 修正后 for (User user : users) { mapper.insert(user); } sqlSession.flushStatements(); // 显式刷新,确保所有批命令发送到数据库 sqlSession.commit(); -
最佳实践:
- 显式刷新 :在使用
BatchExecutor时,将sqlSession.flushStatements()作为强制规范,放在commit之前。 - 控制批次大小 :不要在一个
SqlSession中处理海量数据。应分批处理,例如每处理 500 条记录就flushStatements和clearCache一次,防止内存溢出。 - 使用 MyBatis-Plus :
MyBatis-Plus的ServiceImpl.saveBatch()方法内部已经正确处理了分批次和flushStatements,推荐直接使用。
- 显式刷新 :在使用
4. 缓存相关反模式
缓存是性能优化的双刃剑。不当的缓存策略是数据不一致的主要来源,尤其在分布式和高并发场景下。
4.1 案例 7:一级缓存脏读(同一 SqlSession 内查询后外部修改)
- 错误示例:
java
// 线程1:在Spring环境中模拟同一SqlSession内的操作
@Transactional
public UserDto getAndUpdate(Long id) {
// 1. 通过MyBatis查询用户,数据进入此SqlSession的一级缓存
User user1 = userMapper.selectById(id);
System.out.println(Thread.currentThread().getName() + ":" + user1.getAge()); // 输出:30
// 2. 模拟在同一事务内,通过其他方式(非当前SqlSession)修改了数据库
jdbcTemplate.update("UPDATE user SET age = ? WHERE id = ?", 40, id);
// 3. 再次查询同一数据
User user2 = userMapper.selectById(id); // 一级缓存命中,返回旧数据
System.out.println(Thread.currentThread().getName() + ":" + user2.getAge()); // 现象:仍输出30,脏读!
return user2;
}
-
现象描述:在一个方法中多次查询同一数据,即使中间有其他操作明确修改了数据库,后续查询返回的仍是第一次查询时的旧值,造成了逻辑错误。
-
排查思路:
- SQL 日志 :开启 MyBatis TRACE 日志,会看到日志
Cache Hit Ratio [xxx:xxx],证明第二次查询命中了缓存,没有打印 SQL。 - 代码审查:检查是否在同一个事务内通过 JDBC 模板等非 MyBatis 手段修改了数据库。
- 加断点 :在
BaseExecutor.query()中的localCache.getObject(key)处打断点,观察缓存对象何时被创建、何时被命中。
- SQL 日志 :开启 MyBatis TRACE 日志,会看到日志
-
根因分析 : MyBatis 的一级缓存是
SqlSession级别的,实现类为PerpetualCache,存储在BaseExecutor.localCache中。-
当执行增删改操作时,
BaseExecutor的update()方法会调用clearLocalCache(),强制清空一级缓存。但如果数据是通过外部程序修改 (如本例中的jdbcTemplate),MyBatis 无法感知,因此不会清空缓存。 -
核心源码
org.apache.ibatis.executor.BaseExecutor.query():java// BaseExecutor.java (简化版) public <E> List<E> query(MappedStatement ms, ...) throws SQLException { // ... CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql); // 尝试从一级缓存中获取 list = resultHandler == null ? (List<E>) localCache.getObject(key) : null; if (list != null) { // 缓存命中,直接返回,不会再查询数据库 handleLocallyCachedOutputParameters(...); return list; } // 缓存未命中,才去数据库查询 list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql); return list; }该逻辑清晰地表明,一旦
localCache中有值,将完全跳过数据库查询,这直接导致了脏读。
-
-
修正方案:
- 使用
@Transactional(propagation = Propagation.REQUIRES_NEW):将外部修改操作放在一个新事务中,使其在当前SqlSession的事务提交前完成并可见,但这不能解决一级缓存问题。 - 手动清缓存(不推荐) :在修改后、查询前调用
sqlSession.clearCache(),但侵入性强。 - 使用二级缓存控制 :如果业务允许脏读时间窗口,可配置二级缓存,并通过
flushCache属性在修改时主动清空。 - 最佳修正:不要混用 MyBatis 和 JDBC 在同一个事务边界内修改同一条数据。 将所有持久化操作统一到 MyBatis 的 Mapper 方法中。
- 使用
-
最佳实践:
- 数据访问统一:在一个事务边界内,应尽量只使用一种持久化 API(MyBatis 或 JPA 或 JDBC)。
- 警惕混合操作 :代码审查时,对于
@Transactional方法内同时存在mapper.update()和jdbcTemplate.update()操作同一张表的场景,必须提出警告。 - 缓存意识 :所有开发人员都必须清醒地认识到,MyBatis 的一级缓存是会话级别内存缓存 ,默认开启且无法关闭(只能通过设置
localCacheScope=STATEMENT来缩减其影响范围)。
4.2 案例 8:多节点环境开启二级缓存导致数据不一致
- 错误示例:
java
// application.yaml
mybatis:
configuration:
cache-enabled: true
xml
<!-- UserMapper.xml -->
<mapper namespace="com.example.mapper.UserMapper">
<cache type="org.mybatis.caches.redis.RedisCache"/> <!-- 使用Redis缓存 -->
<select id="findById" resultType="User" useCache="true">
SELECT * FROM user WHERE id = #{id}
</select>
</mapper>
-
现象描述:应用部署在多个节点上。节点 A 更新了数据并更新了节点 A 的二级缓存,但节点 B 的二级缓存仍然是旧数据。请求打到不同节点时,返回的数据不一致,出现"幽灵数据"现象。
-
排查思路:
- 观察现象:通过前端页面或 API 响应,发现数据在不同刷新请求下会反复变回旧值。
- 检查缓存后台:查看 Redis 等集中式缓存中,是否存在多个不同版本的缓存项(如果使用了集中式缓存)。
- 抓包对比:分别对节点 A 和节点 B 抓包,查看 SQL 执行情况。节点 A 执行了 UPDATE 并可能更新了缓存;节点 B 在查询请求到来时直接返回了缓存,而没执行 SQL。
-
根因分析 : MyBatis 的二级缓存是
Mapper级别的,但它本身不具备分布式同步能力 。它的设计是基于单服务器内存的,即使使用了RedisCache这样的第三方实现,其同步机制也可能是简单的set/get,并不感知跨节点的更新。- 核心流程 :
CachingExecutor.query()负责二级缓存的逻辑。 CachingExecutor内部的TransactionalCacheManager管理着待提交的缓存条目。当在一个节点(如A)执行更新时,CachingExecutor.update()最终会清空该MappedStatement关联的缓存空间。- 然而,这个清空操作是本地 的。如果使用 Redis 等集中缓存,它可能只是清除了 Redis 中该节点自己管理的那个 key(如果 key 设计为包含节点信息),或者虽然清除了,但其他节点的本地
TransactionalCacheManager并不知道缓存已失效。更本质上,这是分布式缓存与本地事务管理器之间的一致性协调问题 。源码核心在org.apache.ibatis.executor.CachingExecutor.query()和org.apache.ibatis.cache.TransactionalCacheManager。
- 核心流程 :
-
修正方案:
- 禁用二级缓存 :在分布式多节点环境下,生产级应用的首选是禁用 MyBatis 二级缓存(
cache-enabled: false)。这是最简单、最安全的实践。 - 使用统一的缓存层:如果需要缓存,在 Service 层使用 Spring Cache、JetCache 等框架,配合 Redis/Caffeine 实现。这些框架对分布式环境有更好的支持(如支持通过 MQ 或 Redis Pub/Sub 实现缓存失效广播)。
- 业务容忍最终一致性 :如果必须使用 MyBatis 二级缓存,必须接受数据最终一致性,并设置极短的
timeToLive和timeToIdle。
- 禁用二级缓存 :在分布式多节点环境下,生产级应用的首选是禁用 MyBatis 二级缓存(
-
最佳实践:
- 生产禁用 :在没有经过严格的分布式一致性测试前,多节点生产环境默认必须禁用 MyBatis 二级缓存。
- 分层缓存:使用"本地缓存"(Caffeine)+"分布式缓存"(Redis)的两级缓存架构,由成熟框架(如 JetCache)来处理缓存同步与失效。
- 业务评估:仅对极少更新的元数据、字典表等,才可谨慎考虑开启 MyBatis 二级缓存。
4.3 案例 9:动态 SQL 导致 CacheKey 频繁变化,缓存命中率低
- 错误示例:
xml
<select id="findUsers" resultType="User">
SELECT * FROM user WHERE name LIKE CONCAT('%', #{name}, '%')
<if test="idList != null">
AND id IN
<foreach collection="idList" item="item" open="(" separator="," close=")">
#{item}
</foreach>
</if>
</select>
java
// 调用方每次都生成新的 ArrayList
List<Integer> ids1 = new ArrayList<>(Arrays.asList(1, 2));
List<Integer> ids2 = new ArrayList<>(Arrays.asList(1, 2));
mapper.findUsers("test", ids1); // CacheKey A
mapper.findUsers("test", ids2); // CacheKey B,虽然内容相同,但key不同
-
现象描述 :开启了二级缓存,但监控显示
Cache Hit Ratio极低。理论上参数相同的连续查询,并未走缓存,依然执行了 SQL。 -
排查思路:
- 对比查询参数 :检查日志中相邻两个相同查询的参数对象,虽然是"内容相同",但是否是同一个对象实例或同一个
List。 - 开启 DEBUG 日志 :在
BaseExecutor.createCacheKey()或CachingExecutor.query()处设置断点,观察CacheKey的生成过程。 - 检查
CacheKey的update方法 :CacheKey的update(Object)方法会调用对象的hashCode()和toString()等方法。
- 对比查询参数 :检查日志中相邻两个相同查询的参数对象,虽然是"内容相同",但是否是同一个对象实例或同一个
-
根因分析 : MyBatis 的
CacheKey由BaseExecutor.createCacheKey()或MappedStatement.getBoundSql()等逻辑生成。对于集合类型的参数,CacheKey的生成依赖于List对象的hashCode()。-
JDK 中
AbstractList的equals和hashCode是基于内容的,所以ids1.equals(ids2)为true。 -
但是,
CacheKey的update方法在处理对象时,最终会调用obj.hashCode()。虽然ids1.hashCode()与ids2.hashCode()相同,问题常出在更复杂对象上。一个更常见的陷阱是:如果foreach的参数是某个未重写equals/hashCode的复杂对象集合,则即使业务含义相同,每次传入的新对象的hashCode也可能不同,导致CacheKey不同。 -
源码分析
org.apache.ibatis.cache.CacheKey.update(Object):javapublic void update(Object object) { int baseHashCode = object == null ? 1 : ArrayUtil.hashCode(object); count++; checksum += baseHashCode; baseHashCode *= count; hashcode = multiplier * hashcode + baseHashCode; // 将object的字符串表示添加到updatelist中 updateList.add(object); }如果传入的
idList对象虽然内容相同但实例不同,且hashCode()逻辑被错误复写,就可能导致生成不同的CacheKey。
-
-
修正方案:
- 确保参数的稳定性 :在应用层就确保作为查询参数的实体类,尤其是集合和自定义对象,正确且稳定地实现了
equals()和hashCode()方法。 - 简化参数 :尽量使用基本类型、
String、Map<String, Object>或实现了稳定hashCode的不可变对象作为查询参数。避免直接传递复杂的、行为不确定的领域对象。
- 确保参数的稳定性 :在应用层就确保作为查询参数的实体类,尤其是集合和自定义对象,正确且稳定地实现了
-
最佳实践:
- 使用基本类型和
@Param:用@Param("ids") List<Long> ids传递 ID 集合,比传递一个包含List的复合对象更稳定。 - 代码审查 :对于用作查询参数的复杂对象,强制审查其
equals()和hashCode()的实现。 - 业务缓存 :对于高并发、对缓存依赖高的查询,建议在 Service 层进行缓存,避免依赖 MyBatis 过于底层的
CacheKey生成机制。
- 使用基本类型和
5. 映射器与 SQL 反模式
这是开发者每天都要面对的直接战场。SQL 注入、映射失败、动态 SQL 误判等反模式,直接关乎系统安全和数据准确性。
5.1 案例 10:误用 ${} 拼接用户输入导致 SQL 注入
- 错误示例:
java
@Mapper
public interface UserMapper {
@Select("SELECT * FROM user WHERE name = '${name}'")
List<User> findByName(@Param("name") String name);
}
-
现象描述 :输入
' OR '1'='1' --,查询返回了所有用户信息;或输入恶意 SQL,导致数据库被拖库、篡改。 -
排查思路:
- 安全扫描 :使用 SonarQube、Fortify 等代码审计工具,此类工具会对
${}在 SQL 中的使用发出高优先级警告。 - 日志审查 :使用
p6spy或自定义诊断拦截器,记录最终执行的完整 SQL。通过观察日志中 SQL 的拼接情况,可以迅速定位 SQL 注入点。 - 手动测试:在输入框中输入典型的注入测试字符串进行验证。
- 安全扫描 :使用 SonarQube、Fortify 等代码审计工具,此类工具会对
-
根因分析 : 在 MyBatis 的 SQL 构建流程中,
#{}和${}由不同的处理路径处理。#{}:在解析 SQL 时,SqlSourceBuilder会将#{}替换为?,生成ParameterMapping列表。随后编译出的StaticSqlSource或DynamicSqlSource会将参数值安全地设置进PreparedStatement,这是预编译,从原理上杜绝了 SQL 注入。${}:SqlSourceBuilder同样处理它,但逻辑完全不同。它进行的是字符串直接替换 。它会取出${}内的属性值,通过 OGNL 从参数对象中获取值,然后直接拼接 到 SQL 字符串中。这个过程完全绕过了PreparedStatement的参数设置机制。- 源码证据:在
org.apache.ibatis.builder.SqlSourceBuilder和org.apache.ibatis.scripting.xmltags.TextSqlNode中,${}的内容会被GenericTokenParser解析,并通过BindingTokenParser直接进行值替换。最终生成的 SQL 已经是拼接了用户输入的字符串。
-
修正方案 : 坚定不移地使用
#{}。java@Select("SELECT * FROM user WHERE name = #{name}") // 修正:使用 #{} List<User> findByName(@Param("name") String name);对于 ORDER BY、动态表名等确实需要动态 SQL 的场景,必须在应用层进行严格的白名单校验。
javaprivate static final Set<String> ALLOWED_COLUMNS = Set.of("id", "username", "createtime"); public List<User> findUsersByOrder(String orderColumn) { if (orderColumn == null || !ALLOWED_COLUMNS.contains(orderColumn)) { throw new IllegalArgumentException("Invalid column: " + orderColumn); } return mapper.findUsersByOrder(orderColumn); } -
最佳实践:
- 铁律 :所有来自用户输入或不可信来源的数据,必须通过
#{}传递。${}应被视作"语义上等同于直接执行的代码",需要极高级别的安全审查。 - 静态分析 :在 CI 流水线中集成安全扫描工具,自动拦截包含
${}的 SQL 映射。 - 代码审查 :重点检查 XML Mapper 和注解
@Select/Update中是否存在${}。
- 铁律 :所有来自用户输入或不可信来源的数据,必须通过
5.2 案例 11:resultMap 中字段映射缺失或 resultType 不匹配
- 错误示例:
xml
<!-- 数据库字段: create_time, 实体属性: createTime -->
<select id="findAll" resultType="com.example.entity.User">
SELECT id, create_time AS crt_time FROM user
</select>
java
public class User {
private Long id;
private Date createTime; // 属性名与别名 crt_time 不一致
// getters and setters...
}
-
现象描述 :查询执行成功,不报错,但返回的
User对象中createTime属性为null。 -
排查思路:
- 比对名称 :逐一对比 SQL 查询返回的列名(
AS后的别名或原始列名)与resultType对应 POJO 的字段名。 - 检查驼峰转换 :确认
mapUnderscoreToCamelCase配置是否生效。本例中,别名是crt_time,即使开启驼峰也无法映射到createTime。 - 单元测试 :为映射器编写断言
assertNotNull(user.getCreateTime())的集成测试,可以第一时间发现映射问题。
- 比对名称 :逐一对比 SQL 查询返回的列名(
-
根因分析 : 当使用
resultType时,MyBatis 使用DefaultResultSetHandler的createAutomaticMappings方法进行自动映射。DefaultResultSetHandler.createAutomaticMappings()会遍历ResultSetMetaData的列信息(getColumnLabel()获取别名,再回退到getColumnName()),然后尝试在resultType的类中寻找具有相同名称的属性。- 寻找过程涉及
Reflector,它会考虑驼峰转换(如果开启)。但如果列名是crt_time,转换成驼峰是crtTime,依然无法匹配到createTime,所以该属性为null。源码位置org.apache.ibatis.executor.resultset.DefaultResultSetHandler.createAutomaticMappings()。
-
修正方案 : 确保 SQL 别名与实体类属性名一致,或使用
<resultMap>显式映射。xml<!-- 方案一:修正SQL别名(推荐) --> <select id="findAll" resultType="com.example.entity.User"> SELECT id, create_time AS createTime FROM user </select> <!-- 方案二:使用resultMap --> <resultMap id="UserMap" type="com.example.entity.User"> <result column="create_time" property="createTime"/> </resultMap> <select id="findAll" resultMap="UserMap"> SELECT * FROM user </select> -
最佳实践:
- CI 集成测试 :在持续集成环境中运行集成测试,断言所有 POJO 的关键字段不为
null,这是防止映射失败的最后一道防线。 - MyBatis Generator / Plus :使用代码生成器自动生成
resultMap和实体类,避免手写映射错误。 mybatis.configuration.log-impl:配置StdOutImpl在开发时查看执行结果,可以辅助发现映射问题。
- CI 集成测试 :在持续集成环境中运行集成测试,断言所有 POJO 的关键字段不为
5.3 案例 12:多参数方法未使用 @Param 注解
- 错误示例:
java
// 错误: 多个参数未用 @Param 注解
public interface UserMapper {
List<User> findByNameAndAge(String name, Integer age);
}
xml
<select id="findByNameAndAge" resultType="User">
SELECT * FROM user WHERE name = #{name} AND age = #{age}
</select>
-
现象描述 :运行时抛出
org.apache.ibatis.binding.BindingException: Parameter 'name' not found. Available parameters are [arg1, arg0, param1, param2]。 -
排查思路:
- 检查接口方法 :查看 Mapper 接口方法的参数个数,当参数大于 1 个时,检查是否使用了
@Param注解或在 XML 中使用了 MyBatis 默认的参数名(arg0、param1)。 - JDK 版本与编译选项 :是否使用 JDK 8+ 且编译时保留了方法参数名(
-parameters选项)。如果是,则可以使用真实参数名。但依赖编译选项是脆弱的。
- 检查接口方法 :查看 Mapper 接口方法的参数个数,当参数大于 1 个时,检查是否使用了
-
根因分析 : MyBatis 使用
ParamNameResolver来解析参数名。- 当方法没有使用
@Param注解时,ParamNameResolver.getNamedParams()方法会尝试使用 JDK 反射 APIParameter.getName()获取参数名。如果编译时未加-parameters参数,获取到的名字是arg0,arg1等。 - 同时,为了提供通用性,MyBatis 还会为每个参数生成
param1,param2这样的默认名称。因此异常信息中提示可用参数是[arg1, arg0, param1, param2]。 - 源码位置
org.apache.ibatis.reflection.ParamNameResolver.getNamedParams(Object[] args)。
- 当方法没有使用
-
修正方案 : 为所有超过一个参数的方法,强制使用
@Param注解。java// 正确:使用 @Param 注解明确指定名称 public interface UserMapper { List<User> findByNameAndAge(@Param("name") String name, @Param("age") Integer age); } -
最佳实践:
- 强制规范 :在团队编码规范中规定,只要 Mapper 方法参数超过 1 个,就必须使用
@Param注解。 - CI 静态检查 :可自定义 IDE 插件或 SonarQube 规则,检查 Mapper 接口多参数方法是否缺少
@Param。 - 单元测试:为每个 Mapper 方法编写简单的单元测试验证绑定正确性。
- 强制规范 :在团队编码规范中规定,只要 Mapper 方法参数超过 1 个,就必须使用
5.4 案例 13:动态 SQL 的 <if test="..."> 中误判 Integer 类型 0 值为假
- 错误示例:
xml
<select id="findByStatus" resultType="User">
SELECT * FROM user WHERE 1=1
<if test="status != null and status != ''"> <!-- 错误: Integer 与空字符串比较 -->
AND status = #{status}
</if>
</select>
-
现象描述 :当
status参数的值为0时(表示禁用状态),动态 SQL 的判断条件为false,AND status = 0的片段被丢弃,SQL 查询了所有用户,导致业务逻辑错误。 -
排查思路:
- 审查动态 SQL :重点审查
<if test>中判断数值的表达式。 - 开启 TRACE 日志 :查看 MyBatis 生成的最终 SQL。当
status=0时,Trace 日志会显示拼接出的 SQL 中没有status条件。 - 理解 OGNL :MyBatis 使用 OGNL 来解析
<if test>中的表达式。
- 审查动态 SQL :重点审查
-
根因分析 : 在 OGNL 表达式中,
Integer类型的0被视为false,这与 Java 的布尔逻辑不同(Java 中只有 boolean 才用于判断)。然而,更隐蔽的陷阱是status != ''这句话。一个Integer值与空字符串''比较时,Integer会被转换为字符串,而Integer.valueOf(0).toString()是"0",不等于"",所以正常情况下不会出错。但这段代码的风险本质在于:使用status != ''这种专用于字符串的判空逻辑来判断数值类型 ,这是不规范的,且在某些 OGNL 版本或复杂对象嵌套下,行为可能不一致。 更常见的错误是:<if test="status != null">如果 status 恰好是某个 POJO 的属性,这个判断是安全的。但如果是简单的int或Integer,这个表达式本身只检查非空,不检查是否为0,因此如果设计意图是status == 0也作为查询条件,则此写法正确。设计意图如果是"有值时才查询",那么当 status=0 时,确实应该作为条件。问题在于开发者经常用!= ''来同时判空和空字符串,这对数值类型是无效的。核心根因是 OGNL0的 falseness 与字符串比较的误用。 -
修正方案 : 对数值类型的参数,只进行
null判断。xml<!-- 修正:数值类型仅判断 null --> <if test="status != null"> AND status = #{status} </if> -
最佳实践:
- 类型匹配判断 :
String类型用!= null and != '';数值类型(int,Integer,Long等)用!= null;集合类型用!= null and !collection.isEmpty()。 - 代码审查 :逐条检查
<if test>中的表达式,确保判空逻辑与参数类型匹配。 - 使用
@Builder或Optional:在调用 Mapper 前,构建明确的条件对象,避免传递null或0等二义性值。
- 类型匹配判断 :
6. 插件拦截反模式
MyBatis 插件是责任链模式的典型实现,其包装顺序和异常处理机制是产生疑难问题的温床。
6.1 案例 14:分页插件与脱敏插件注册顺序不当
- 错误示例:
java
// application.yaml
mybatis:
configuration:
interceptors:
- com.example.plugin.SensitivePlugin // 脱敏插件
- com.github.pagehelper.PageInterceptor // 分页插件
-
现象描述:一个需要分页查询的接口,先执行了分页查询,但在结果返回时,分页总记录数可能不正确,或者数据内容没有被正确脱敏/加密。
-
排查思路:
- 检查插件列表 :打印
Configuration.interceptorChain中插件的注册顺序。 - 阅读插件代码 :查看每个插件的
plugin()方法和intercept()方法使用的签名,确定它包装的是Executor,StatementHandler还是ResultSetHandler。 - 断点调试 :在
Plugin.wrap()和各自的intercept()方法上打断点,观察代理对象嵌套结构和调用顺序。
- 检查插件列表 :打印
-
根因分析 : MyBatis 的插件链通过
Plugin.wrap()实现,它返回一个 JDK 动态代理对象。多个插件会形成一层层的代理嵌套。-
InterceptorChain.pluginAll(Object target)方法的源码逻辑是:循环遍历拦截器列表,依次调用每个拦截器的plugin方法,将上一次的返回值作为下一次的输入进行包装。 -
源码位置 :
org.apache.ibatis.plugin.InterceptorChain.pluginAll():javapublic Object pluginAll(Object target) { for (Interceptor interceptor : interceptors) { target = interceptor.plugin(target); } return target; } -
影响 :由于代理是嵌套的,后注册的插件在外层,先注册的插件在内层 。对于执行器
Executor,调用顺序是:外层插件(后注册)先拦截,然后逐层进入内层,最内层是Executor实现。对于结果处理器ResultSetHandler,结果数据返回的顺序则是:内层插件(先注册)先处理,然后向外层传递。 -
分页插件通常拦截
Executor.query,在 SQL 执行前进行改写。脱敏插件大多拦截ResultSetHandler.handleResultSets,在结果返回后进行数据脱敏。如果事件在分页插件改写 SQL 之前或之后发生,逻辑可能会错乱。如果脱敏插件错误地处理了分页查询count的结果,就可能导致总记录数异常。插件的注册顺序决定了它们的执行顺序和嵌套结构,错误的顺序可能导致功能互相屏蔽或逻辑出错。
-
-
修正方案: 严格根据插件的职责和协作关系定义顺序。通常:
- 功能性插件优先:如多租户、数据权限过滤插件,这些需要在数据查询最源头生效,应注册在最前面(最内层)。
- 分页插件居中:分页插件需要在功能过滤之后执行,以获得正确的分页结果。
- 数据处理插件在后:如脱敏、加密插件,在结果返回的最后阶段处理,应注册在最后面(最外层)。
java// 修正后 mybatis: configuration: interceptors: // 1. 多租户插件(最先执行,过滤数据) - com.example.plugin.TenantPlugin // 2. 分页插件(在过滤后执行) - com.github.pagehelper.PageInterceptor // 3. 脱敏插件(最后执行,处理结果) - com.example.plugin.SensitivePlugin -
最佳实践:
- 责任链可视化:在架构文档中画出插件链的嵌套结构和执行时序图。
- 单元测试 :为插件链的协作编写集成测试,模拟带有多个插件的
SqlSessionFactory,验证最终的执行结果是否符合预期。 - 统一基类:如果是自主研发的插件,可以设计一个统一的排序接口,在初始化时自动排序,避免手动配置顺序错误。
6.2 案例 15:分页插件 count 查询未优化
- 错误示例: 使用 PageHelper 分页,但对一个复杂的视图或多表 JOIN 查询进行分页。
java
PageHelper.startPage(pageNum, pageSize);
// 此查询包含 LEFT JOIN 多个大表,且 SELECT 后是 DISTINCT 或复杂子查询
List<ComplexVO> list = complexMapper.selectComplex();
PageInfo<ComplexVO> pageInfo = new PageInfo<>(list);
-
现象描述 :分页查询响应时间极长,数据库 CPU 使用率飙升。监控显示,
COUNT(*)查询耗时占整个分页请求的 90% 以上。 -
排查思路:
- 开启 PageHelper
debug日志 :logging.level.com.github.pagehelper=DEBUG,会打印出改写后的 COUNT SQL。 - 分析 SQL :检查打印出的 COUNT SQL 计划,发现它对整个复杂查询(包括 JOIN 和 DISTINCT)做了
COUNT(*),导致全表扫描。 - 数据库慢查询日志:此 SQL 必然出现在数据库的慢查询日志中。
- 开启 PageHelper
-
根因分析 : PageHelper 等分页插件通过
jsqlparser解析原始 SQL,并尝试生成一个SELECT COUNT(*)查询来获取总记录数。- 对于简单的单表查询,改写非常高效。
- 对于包含
LEFT JOIN、DISTINCT、GROUP BY、复杂子查询的 SQL,jsqlparser无法进行深度优化。它可能只是简单地将SELECT列替换为COUNT(0),而保留了不必要的 JOIN,导致数据库执行了一个极其昂贵的 COUNT 操作。 - 源码层面:
com.github.pagehelper.parser.CountSqlParser负责此转换。它虽然会尝试移除不必要的ORDER BY、GROUP BY,优化JOIN,但优化能力有限,无法处理所有复杂场景。
-
修正方案:
- 手动编写 COUNT 查询 :对于复杂的 SQL,不在 XML 中处理,而是单独提供一个手写的、高度优化的 COUNT SQL。然后通过 PageHelper 的
PageInfo构造器直接传入总数。 - 使用
MyBatis-Plus分页 :MyBatis-Plus的分页插件(PaginationInnerInterceptor)提供了更强大的count优化机制,如当LEFT JOIN未产生数据膨胀时自动移除它,但仍可能不够完美。终极方案仍是手动编写。
java// 修正后 long total = complexMapper.countComplex(params); // 手写的优化后的count查询 PageHelper.startPage(pageNum, pageSize); List<ComplexVO> list = complexMapper.selectComplex(params); PageInfo<ComplexVO> pageInfo = new PageInfo<>(list); pageInfo.setTotal(total); // 传入手动计算的总数 - 手动编写 COUNT 查询 :对于复杂的 SQL,不在 XML 中处理,而是单独提供一个手写的、高度优化的 COUNT SQL。然后通过 PageHelper 的
-
最佳实践:
- COUNT 性能评审:对所有涉及分页的复杂查询,必须对生成的 COUNT SQL 进行执行计划评审。
- 默认关闭自动 COUNT :在某些场景下,如果总记录数不是必须的(如 App 的无尽流模式),可以通过
PageHelper.startPage(pageNum, pageSize, false)关闭 COUNT 查询,能极大提升性能。 - 监控:使用 APM 或自定义拦截器监控 COUNT SQL 的响应时间。
6.3 案例 16:自定义插件在 intercept 方法中抛出未处理异常
- 错误示例:
java
@Intercepts({
@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
public class BadPlugin implements Interceptor {
public Object intercept(Invocation invocation) throws Throwable {
MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
// 模拟一个运行时异常
throw new RuntimeException("Oops! Something went wrong in plugin for: " + ms.getId());
}
}
-
现象描述 :所有经过此插件的 MyBatis 操作都会抛出
org.apache.ibatis.exceptions.PersistenceException,异常信息中包含了插件的错误,但原始的 SQL 操作无法执行。 -
排查思路:
- 查看异常堆栈 :异常堆栈会清晰地指向
BadPlugin.intercept()和Plugin.invoke()。 - 逐个排除插件:在有多个插件时,可以通过二分注释法,快速定位到出问题的插件。
- 查看异常堆栈 :异常堆栈会清晰地指向
-
根因分析 :
Plugin是 JDK 动态代理的InvocationHandler。所有对被代理对象的调用都会经过Plugin.invoke()方法。Plugin.invoke()内部会调用用户编写的interceptor.intercept(new Invocation(target, method, args))。- 如果用户的
intercept方法抛出了非InvocationTargetException的异常 ,这个异常会直接冒泡到调用方,被包装成PersistenceException,导致整个 MyBatis 操作失败。 - 源码路径
org.apache.ibatis.plugin.Plugin.invoke()->interceptor.intercept()。由于没有 try-catch 保护,用户插件的未处理异常会直接中断核心执行链。
-
修正方案 : 在
intercept方法内部使用try-catch包裹核心逻辑,避免未处理异常的泄露。对于非致命错误,应记录日志并进行监控,而不是直接中断业务。java// 修正后 public Object intercept(Invocation invocation) throws Throwable { try { // 插件核心逻辑 return invocation.proceed(); } catch (Exception e) { log.error("Plugin execution error...", e); // 监控告警 // 根据业务决定是继续执行 invocation.proceed() 还是抛出业务异常 throw new RuntimeException("Plugin error", e); } } -
最佳实践:
- 防护性编程 :将插件代码视作中间件,必须进行防护性编程,对
intercept方法进行全面的try-catch。 - 熔断与降级 :在插件中加入熔断逻辑。当插件故障率达到一定阈值时,自动跳过插件逻辑,直接执行
invocation.proceed(),保住系统可用性。 - 单元测试:必须为插件的异常场景编写单元测试,验证其不会导致核心流程中断。
- 防护性编程 :将插件代码视作中间件,必须进行防护性编程,对
7. Spring Boot 整合反模式
Spring Boot 的自动配置极大地简化了 MyBatis 的集成,但其"约定优于配置"的理念也意味着,一旦约定被打破,排查问题往往需要深入自动配置的内部。
7.1 案例 17:多数据源场景下 @Primary 误用导致 Mapper 误连
- 错误示例:
java
@Configuration
public class DataSourceConfig {
@Bean @Primary
public DataSource ds1() { return new HikariDataSource(); }
@Bean
public DataSource ds2() { return new HikariDataSource(); }
}
-
现象描述 :应用有 A、B 两个数据源,为 A 数据源配置的 Mapper,在执行时却访问了 B 数据源,或反之。产生脏数据或
Table doesn't exist等错误。 -
排查思路:
- 确认数据源绑定 :检查
SqlSessionFactoryBean的配置,确认哪个 Mapper 包被绑定到了哪个SqlSessionFactory。 - 日志验证 :开启
logging.level.org.mybatis.spring=DEBUG,logging.level.org.springframework.jdbc=DEBUG,可看到数据源获取和切换的详细过程。 - 使用 Arthas :动态注入代码,打印 Mapper 代理对象内部持有的
SqlSessionTemplate的sqlSessionFactory所使用的数据源 URL 信息。
- 确认数据源绑定 :检查
-
根因分析: 当存在多个数据源时,Spring 的自动配置需要知道默认注入哪一个。
@Primary注解指定了Spring容器级别的首选 Bean 。如果没有为每个数据源显式地创建不同的SqlSessionFactory和SqlSessionTemplate,并绑定到特定的 Mapper,那么自动配置的MybatisAutoConfiguration会无条件地使用这个@Primary的DataSource来创建唯一的SqlSessionFactory。- 源码分析:
org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration.sqlSessionFactory(DataSource dataSource)方法是自动创建工厂的地方。它会注入 ApplicationContext 中唯一的 或带有 @Primary 的DataSource。如果忘记为不同的 Mapper 扫描包配置多个SqlSessionFactoryBean,所有 Mapper 都将共用这个工厂,最终连接到@Primary标记的数据源。
-
修正方案:
- 明确分离 :为每个数据源都创建独立的
SqlSessionFactory和SqlSessionTemplateBean,并通过@MapperScan注解的sqlSessionFactoryRef或sqlSessionTemplateRef属性明确指定 Mapper 使用哪一个。 - 弃用
@Primary:在多数据源场景下,最好不要用@Primary,强制每个 Mapper 都明确指定要使用的数据源组件,消除歧义。
java@Configuration @MapperScan(basePackages = "com.example.mapper.ds1", sqlSessionFactoryRef = "ds1SqlSessionFactory") public class Ds1Config { ... } @Configuration @MapperScan(basePackages = "com.example.mapper.ds2", sqlSessionFactoryRef = "ds2SqlSessionFactory") public class Ds2Config { ... } - 明确分离 :为每个数据源都创建独立的
-
最佳实践:
- 严禁单工厂多数据源 :永远不要尝试在一个
SqlSessionFactory下管理多个不同数据源,应退化为单数据源。 - 使用动态数据源 :对于读写分离等场景,使用
AbstractRoutingDataSource作为单一数据源入口,内部根据 key 切换,这才是正确的多数据源实践。 - 集成测试:为每个 Mapper 编写集成测试,断言其操作的是预期的数据源(可通过查询上下文信息或特定表)。
- 严禁单工厂多数据源 :永远不要尝试在一个
7.2 案例 18:@MapperScan 与 Boot 自动扫描重叠
- 错误示例:
java
@SpringBootApplication
@MapperScan(basePackages = "com.example.mapper") // 手动扫描
public class MyApp { ... }
java
// 同时,pom.xml 中引入了启动器,它可能会再次自动扫描
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
</dependency>
-
现象描述 :应用启动时抛出
org.springframework.beans.factory.BeanDefinitionStoreException或NoUniqueBeanDefinitionException,提示有多个类型相同的 Mapper Bean 被发现。 -
排查思路:
- 检查
@MapperScan配置 :确认项目中有几个地方使用了@MapperScan或@MapperScans。 - 检查 MyBatis 版本 :在
mybatis-spring-boot-starter2.0+ 版本中,AutoConfiguredMapperScannerRegistrar会自动扫描带有@Mapper注解的接口。检查是否手写的@MapperScan和@Mapper注解导致了重复扫描。 - 查看 Spring 启动日志:观察 Bean 注册过程的日志,会打印出哪些包被扫描、哪些 Mapper 被注册。
- 检查
-
根因分析 :
mybatis-spring-boot-autoconfigure包中的AutoConfiguredMapperScannerRegistrar会在启动时,自动注册一个MapperScannerConfigurer来扫描所有带有@Mapper注解的接口。- 如果你又通过
@MapperScan注解显式地配置了一个MapperScannerConfigurer,并且其扫描的basePackages与自动扫描的包有重叠,就会导致同一个 Mapper 接口被两个不同的ScannerConfigurer尝试注册两次,从而引发 Bean 定义冲突。 - 源码入口在
org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration.AutoConfiguredMapperScannerRegistrar。
- 如果你又通过
-
修正方案 : 二选一 :要么完全依赖自动扫描(只为接口加上
@Mapper注解,不加@MapperScan),要么完全手动控制(在配置类上加@MapperScan,接口上不加@Mapper)。推荐使用自动扫描方式。 -
最佳实践:
- 统一注解策略 :团队内统一规定,是使用
@MapperScan显式扫描,还是使用@Mapper隐式扫描,不得混用。 - 代码审查 :检查代码库中是否同时存在
@MapperScan和分散的@Mapper注解。
- 统一注解策略 :团队内统一规定,是使用
7.3 案例 19:自定义 ConfigurationCustomizer 被自动配置覆盖
- 错误示例:
java
@Configuration
public class MyBatisConfig {
@Bean
public ConfigurationCustomizer myCustomizer() {
return configuration -> {
configuration.setMapUnderscoreToCamelCase(false);
System.out.println("Customizer applied!");
};
}
}
-
现象描述 :尽管定义了
ConfigurationCustomizerBean,但其设置似乎没有生效。例如,期望mapUnderscoreToCamelCase为false,但运行结果表明它仍然为true。"Customizer applied!"打印了,但最终配置被覆盖。 -
排查思路:
- 检查生效的配置 :通过 Arthas 或 JMX 查看
SqlSessionFactory的Configuration.mapUnderscoreToCamelCase最终值。 - 分析 Bean 初始化顺序 :理解 Spring Boot 的
MybatisAutoConfiguration在何种阶段应用ConfigurationCustomizer,又在何种阶段应用mybatis.configuration属性。
- 检查生效的配置 :通过 Arthas 或 JMX 查看
-
根因分析 :
MybatisAutoConfiguration.sqlSessionFactory()方法的创建流程如下:- 通过
XMLConfigBuilder或直接实例化创建Configuration对象。 - 调用所有
ConfigurationCustomizerBean 的customize(Configuration)方法。 - 关键一步 :最后,调用
this.mybatisProperties.applyTo(configuration)。此方法会将application.yaml中所有mybatis.configuration.*的属性,通过Binder强制设置到Configuration对象上。 - 因此,
application.yaml中的属性具有最高优先级,会覆盖通过ConfigurationCustomizer所做的任何修改。 源码位置org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration.sqlSessionFactory()。
- 通过
-
修正方案 : 要么在
ConfigurationCustomizer中设置,并确保application.yaml中没有 定义冲突的mybatis.configuration属性。要么直接在application.yaml中配置,无需使用ConfigurationCustomizer。java// 方案一:纯YAML配置(推荐) mybatis: configuration: map-underscore-to-camel-case: falsejava// 方案二:纯Customizer配置 // 1. 在 application.yaml 中,完全删除 mybatis.configuration 下的同名键。 // 2. 保留 ConfigurationCustomizer 中的设置。 -
最佳实践:
- 配置分层,权责明确 :
application.yaml用于团队标准的通用配置。ConfigurationCustomizer用于实现复杂、动态的编程化配置,如根据环境变量动态注册拦截器。严禁用两者配置同一属性。 - 统一配置入口 :团队内部协定,Properties/YAML 是唯一的静态配置来源,而编程式配置(
ConfigurationCustomizer)仅用于处理静态配置无法表达的复杂逻辑。
- 配置分层,权责明确 :
8. MyBatis-Plus 特定反模式
MyBatis-Plus 的强大之处在于其自动化和约定,而在不了解其内部机制的情况下,这些"魔法"正是反模式的重灾区。
8.1 案例 20:手写 Mapper 方法与通用 BaseMapper 方法同名导致 MappedStatement 冲突
- 错误示例:
java
public interface UserMapper extends BaseMapper<User> {
@Select("SELECT * FROM user WHERE name = #{name}")
List<User> selectList(@Param("name") String name); // 错误: 与 BaseMapper.selectList() 同名
}
-
现象描述 :应用启动时抛出
org.apache.ibatis.builder.BuilderException: Mapped Statements collection already contains value for com.example.mapper.UserMapper.selectList。 -
排查思路:
- 检查异常信息 :异常信息会明确指出冲突的
statement id。 - 审查 Mapper 接口 :检查自己定义的方法是否与父接口
BaseMapper中的 CRUD 方法重名。
- 检查异常信息 :异常信息会明确指出冲突的
-
根因分析 : MyBatis-Plus 通过
AbstractSqlInjector.inspectInject()方法,在应用启动时为BaseMapper接口中的方法动态生成MappedStatement并注册到Configuration中。- 当用户在手写的 XML 或通过注解定义了一个相同ID (
namespace + "." + methodName)的MappedStatement时,就会发生冲突。因为Configuration内部维护的mappedStatements是一个严格唯一的 Map。 - 源码位置
org.apache.ibatis.builder.MapperBuilderAssistant和 MyBatis-Plus 的com.baomidou.mybatisplus.core.injector.AbstractSqlInjector.inspectInject()。
- 当用户在手写的 XML 或通过注解定义了一个相同ID (
-
修正方案 : 重命名手写方法 ,使其与
BaseMapper中的任何方法名不同。java// 修正后 public interface UserMapper extends BaseMapper<User> { @Select("SELECT * FROM user WHERE name = #{name}") List<User> selectByName(@Param("name") String name); } -
最佳实践:
- 分前缀命名规范 :手写 Mapper 方法可以统一加
query,insert,update,delete等业务前缀,或者使用find,load,save等,避免与通用的select,insert等冲突。 - 代码生成器配置 :在使用 MyBatis-Plus 的代码生成器时,通过模板排除与
BaseMapper重名的方法。
- 分前缀命名规范 :手写 Mapper 方法可以统一加
8.2 案例 21:多租户插件未全局配置,导致部分查询遗漏 tenant_id 过滤
- 错误示例:
java
// 分页插件配置了,但忘记配置多租户插件
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
// 错误点:忘记添加 TenantLineInnerInterceptor
return interceptor;
}
-
现象描述:某个查询漏加了租户隔离条件,导致一个租户的用户看到了其他租户的数据,造成严重的数据安全事故。
-
排查思路:
- 代码审查 :重点检查
MybatisPlusInterceptorBean 的配置,看是否遗漏了TenantLineInnerInterceptor。 - SQL 监控 :通过
p6spy拦截最终执行的 SQL,检查 SQL 中是否包含tenant_id条件。如果某个查询没有,就是排查的重点。 - 单元测试:为每个查询都编写带上不同租户ID的多租户测试,断言每个租户只能看到自己的数据。
- 代码审查 :重点检查
-
根因分析 : MyBatis-Plus 的多租户是通过
TenantLineInnerInterceptor拦截器实现的。它会拦截所有Executor的执行,通过TenantLineHandler获取当前租户 ID,并使用 SQL 解析器动态地在WHERE条件后追加租户过滤条件。如果全局拦截器未配置,自然不会有租户过滤逻辑。如果配置了,但对于某些标注了@InterceptorIgnore的语句或手动构造的 SQL 片段,它也会跳过,这同样需要关注。核心组件是com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor。 -
修正方案 : 在
MybatisPlusInterceptor中全局注册TenantLineInnerInterceptor,并实现TenantLineHandler。java@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 多租户插件 interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() { @Override public Expression getTenantId() { // 从上下文获取当前租户ID,例如解析JWT Token return new StringValue("tenant_123"); } // ...其他配置... })); // 分页插件 interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } -
最佳实践:
- 全局默认开启:永远在全局配置中开启多租户拦截器,并设置需要忽略多租户过滤的白名单表(如系统字典表)。
- 集成测试:编写遍历所有 API 的集成测试,使用不同租户用户登录,确保数据隔离。
- SQL 审计 :通过中间件(如
p6spy)对所有 SQL 进行实时审计,识别缺少tenant_id的 DML/DDL 语句。
8.3 案例 22:逻辑删除开启后,手写 XML 的查询未添加 deleted = 0 条件
- 错误示例:
yaml
# application.yaml
mybatis-plus:
global-config:
db-config:
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
xml
<!-- UserMapper.xml -->
<select id="findById" resultType="User">
SELECT * FROM user WHERE id = #{id} <!-- 错误: 缺少逻辑删除条件 -->
</select>
-
现象描述 :开启逻辑删除后,当调用
userService.removeById(1)时,记录只是被标记为deleted=1。但当调用手写的findById查询时,仍然可以查到这条"已删除"的数据。 -
排查思路:
- 审查手写 SQL :检查所有手写的查询、更新 SQL,看是否缺少
deleted = 0或AND deleted = #{logic-not-delete-value}条件。 - 对比默认行为 :先调用
BaseMapper.selectById(),再调用手写的findById(),对比结果集。
- 审查手写 SQL :检查所有手写的查询、更新 SQL,看是否缺少
-
根因分析 : MyBatis-Plus 的
LogicSqlInjector在启动时,会动态修改BaseMapper的标准方法(selectById,deleteById等),在它们的 SQL 中注入逻辑删除字段的条件。例如,将所有DELETE语句改写为UPDATE,在所有SELECT语句WHERE后追加deleted=0。- 但是,这个注入过程不会处理开发者手写的 XML 或注解中的 SQL。 这些 SQL 对 MyBatis-Plus 来说是"自定义 SQL",它不会进行任何修改。因此,必须由开发者在编写 SQL 时手动处理逻辑删除逻辑。源码分析
com.baomidou.mybatisplus.core.injector.LogicSqlInjector。
- 但是,这个注入过程不会处理开发者手写的 XML 或注解中的 SQL。 这些 SQL 对 MyBatis-Plus 来说是"自定义 SQL",它不会进行任何修改。因此,必须由开发者在编写 SQL 时手动处理逻辑删除逻辑。源码分析
-
修正方案 : 在所有手写的、需要过滤逻辑删除数据的 SQL 中添加
deleted=0条件。xml<!-- 修正后 --> <select id="findById" resultType="User"> SELECT * FROM user WHERE id = #{id} AND deleted = 0 </select> -
最佳实践:
-
基础视图/公用片段 :在 XML 中,使用
<sql>片段定义通用的WHERE条件,例如:xml<sql id="notDeleted">AND deleted = 0</sql>然后在所有相关查询中
<include refid="notDeleted"/>。 -
代码审查:在引入逻辑删除功能的 Code Review 中,重点检查所有手写 SQL,确保它们都包含了逻辑删除条件的处理。
-
MyBatis-Plus 的 3.3.0+ 支持 :在 3.3.0 版本之后,MP 引入了
@SqlParser(filter = true)或全局配置来对自定义 SQL 追加逻辑删除条件,但使用需谨慎,最好还是在 SQL 层面显式处理。
-
9. 诊断工具集、映射表与标准化排查流程
面对上述纷繁复杂的反模式,我们需要一套系统化的工具和流程来快速反应。本节将总结并构建这套诊断工具箱。
9.1 核心诊断工具汇总
-
MyBatis TRACE/DEBUG 日志
- 配置 :
logging.level.com.example.mapper=TRACE - 核心输出 :SQL 执行详情、参数设置、返回结果行数、一级/二级缓存命中/未命中信息 (
Cache Hit Ratio)。 - 用途:是排查一切 SQL 执行问题、映射问题、缓存问题的第一入口。
- 配置 :
-
p6spySQL 监控- 配置 :替换 JDBC Driver 为
p6spy的虚拟驱动,并配置spy.properties。 - 核心输出:拦截所有发往数据库的完整 SQL 语句(含参数值)、执行耗时。
- 用途:用于精确监控 SQL 执行性能,发现重复执行、慢查询,以及确认 MyBatis 最终生成的 SQL。
- 配置 :替换 JDBC Driver 为
-
自定义诊断拦截器
- 实现 :实现
org.apache.ibatis.plugin.Interceptor接口,拦截Executor.update/query或StatementHandler。 - 核心能力 :记录每次 SQL 调用的完整链路,如:调用方类、方法、
MappedStatementID、参数、执行耗时、事务上下文。 - 用途:建立完整的 SQL 调用链视图,尤其在定位"谁执行了这条慢 SQL"或"插件执行顺序"时至关重要。
- 实现 :实现
-
Arthas动态追踪- 常用命令 :
watch:watch org.apache.ibatis.executor.BaseExecutor query '{params, returnObj, throwExp}' -n 5 -x 3(监控 Executor 行为)。stack:stack com.example.mapper.UserMapper findById(追踪指定方法的完整调用栈)。ognl:ognl '@org.apache.ibatis.session.Configuration@TYPE_ALIAS_REGISTRY.typeAliases'(查看别名注册)。getStatic:获取静态字段值。
- 用途:在不重启应用的情况下,动态诊断线上问题。无侵入地查看内部状态、追踪调用链和定位异常。
- 常用命令 :
-
Actuator 端点
- 端点 :
/actuator/beans,/actuator/mappings,/actuator/env,/actuator/health。 - 用途 :检查 Bean 的注册情况(特别是
SqlSessionFactory,DataSource),确认配置是否正确加载。
- 端点 :
9.2 工具 → 反模式映射表
| 典型现象 | 推荐排查工具 | 关键日志搜索词 / 检查点 |
|---|---|---|
"Mapper 未加载" / BindingException |
DEBUG 日志、Actuator /beans、Arthas ognl |
Creating a new SqlSession、(not found in any mapper)、mappedStatements 集合 |
| "SQL 执行了但无返回/字段为 null" | TRACE 日志、p6spy、断点调试 |
Total: 0、列名与属性名对比、getColumnLabel |
| "SQL 重复执行"(N+1或一级缓存失效) | p6spy、TRACE 日志、自定义拦截器 |
同一 SQL 连续出现、Cache Hit Ratio: 0/1 |
| "缓存脏读" | TRACE 日志、自定义拦截器、p6spy |
Cache Hit Ratio 在不期望时命中、同一事务内出现更新前后查询 |
| "插件不生效"(分页/脱敏等) | DEBUG 日志、Arthas watch |
插件链的 pluginAll 包装日志、分页插件的 startPage 线程变量 |
| "事务行为异常"(提交/回滚) | p6spy、自定义拦截器、TRACE 日志 |
Transaction synchronization committing, JDBC Connection 获取与释放记录 |
| "启动失败/注册冲突" | Actuator /beans、启动日志 |
BeanDefinitionStoreException、Mapped Statements collection already... |
| "连接泄漏" | Druid/Hikari 监控页、Arthas thread |
ActiveConnections 持续高位、线程堆栈阻塞在 getConnection |
9.3 标准化排查决策树
以下决策树提供了一条从异常现象出发,逐步定位到具体反模式和源码位置的通用路径。
案例2 类别名, 案例1 Mapper加载"] Q1 -- "运行时异常" --> Q3{"异常类型?"} Q3 -- "PersistenceException
/插件中断" --> F4["6.3 插件异常未处理"] Q3 -- "Parameter not found" --> F5["5.3 @Param缺失"] Q3 -- "其他异常" --> Q4{"是否连接相关?"} Q4 -- "是" --> F6["3.1 连接泄漏"] Q4 -- "否" --> F7["检查Arthas watch
Executor执行链"] Q1 -- "结果错误/无报错" --> Q5{"性能相关?"} Q5 -- "是" --> Q6{"慢SQL?"} Q6 -- "分页慢" --> F8["6.2 count查询未优化"] Q6 -- "重复SQL" --> F9["3.2 一级缓存失效
9. CacheKey不稳定"] Q5 -- "否" --> Q7{"数据正确性?"} Q7 -- "数据不一致" --> Q8{"环境?"} Q8 -- "多节点" --> F10["4.2 二级缓存不一致"] Q8 -- "单节点/事务" --> F11["4.1 一级缓存脏读
8.2 多租户遗漏"] Q7 -- "字段为null" --> F12["5.2 映射失败"] Q7 -- "插件功能失效" --> F13["6.1 插件顺序不当
8.3 逻辑删除遗漏"] F1 & F2 & F3 --> End1["定位配置与初始化代码"] F4 & F5 & F6 & F7 --> End2["定位会话/插件代码"] F8 & F9 --> End3["定位SQL与缓存策略"] F10 & F11 & F12 & F13 --> End4["定位缓存/映射/插件配置"] Start --> Q9{"是否有明确排查路径?"} Q9 -- "是" --> End5["遵循工具映射表
使用p6spy+Arthas组合拳"] style Start fill:#c8e6c9 classDef decision fill:#fff4e6,stroke:#ff9800,stroke-width:2px,color:#333; classDef process fill:#f8f9fa,stroke:#333,stroke-width:1px,color:#333; class Q1,Q2,Q3,Q4,Q5,Q6,Q7,Q8,Q9 decision; class Start,F1,F2,F3,F4,F5,F6,F7,F8,F9,F10,F11,F12,F13,End1,End2,End3,End4,End5 process;
图表说明:
- 图表主旨概括:此图是一个从顶层异常现象逐步下钻到具体反模式案例的标准化排查决策树,旨在帮助开发者建立一套有序的排错思路,避免大海捞针。
- 逐层/逐元素分解 :决策树从"遇到 MyBatis 问题"开始,首先按"启动时异常"、"运行时异常"、"结果错误"三大类别分流。然后根据具体的异常信息(如
Parameter not found)或行为特征(如慢 SQL、数据不一致)进一步分化,最终指向本文详细分析过的 20+ 个具体反模式案例(如F1代表案例 7.2)。 - 设计原理映射:这棵树本质上是 MyBatis 生命周期和组件协作图的逆向应用。启动时问题大多映射到"配置与初始化"模块;运行时异常多与"会话管理"和"插件拦截"相关;而无声无息的结果错误,则属于"缓存"、"映射"和"业务逻辑"范畴。这是一种分层诊断的思想。
- 工程联系与关键结论 :对于任何线上问题,第一时间不是去猜测,而是收集日志(TRACE/p6spy)、使用 Arthas 抓取现场(watch/stack),然后依据此决策树进行系统性排查。标准化流程是快速定位和恢复的关键。
10. 面试高频专题
本专题旨在将前文的排查知识转化为面试时的核心问答,沉淀为结构化的知识体系。
1. 如何排查 Mapper XML 文件没被加载的问题?
- 一句话回答 :从检查
mapper-locations路径通配符的准确性开始,利用启动日志和Configuration对象来最终确认。 - 详细解释 :首先检查 Spring Boot 的
mybatis.mapper-locations配置,classpath*:mapper/**/*.xml是常用模式。开启org.mybatis为 DEBUG,观察XmlMapperBuilder的解析日志。若非路径问题,则检查@MapperScan或@Mapper注解是否覆盖了对应包。最可靠的方法是:在启动后通过 Arthas 的ognl命令,调用@org.apache.ibatis.session.Configuration@mappedStatements.keys()检查清单。源码层面,SqlSessionFactoryBean会调用PathMatchingResourcePatternResolver.getResources()来收集资源,若匹配失败,则XMLConfigBuilder和XMLMapperBuilder将拿不到文件。 - 多角度追问 :
classpath:与classpath*:有何区别?→classpath*:会搜索所有 JAR 包和类路径下的资源,而classpath:仅搜索第一个。- 如果路径都正确,但 XML 中的 namespace 不对呢?→ 同样会报
BindingException。 - 如何在不重启的情况下验证修复?→ 使用 Arthas 的
retransform或热加载机制,但通常建议重发。
- 加分回答 :继承
PathMatchingResourcePatternResolver,重写getResources方法,加入详细日志,可精确诊断复杂多模块项目中的资源加载问题。
2. 为什么在 Spring 事务中,一级缓存似乎"失效"了?
- 一句话回答 :根本原因在于
SqlSessionTemplate的事务管理机制,当@Transactional未生效时,每次 Mapper 调用都使用全新的SqlSession,导致一级缓存无法共享。 - 详细解释 :一级缓存的生命周期绑定在
SqlSession上。SqlSessionTemplate通过SqlSessionInterceptor代理工作。它会在每次操作前从TransactionSynchronizationManager获取当前事务绑定的SqlSession。如果@Transactional失效,意味着每次操作都会获取到null,从而创建并立即关闭一个新的SqlSession。因此,连续的两个selectById实际是在两个独立的SqlSession中操作,一级缓存自然不生效。源码核心在SqlSessionInterceptor.invoke()和BaseExecutor.query()。 - 多角度追问 :
- 如何确认
@Transactional是否失效?→ 检查 Spring AOP 代理是否生效(通常因同类内部方法调用导致)。 ReuseExecutor下情况如何?→ 同样依赖SqlSession,缓存机制与SimpleExecutor一致。- 这与 MyBatis 本身的
localCacheScope配置有关吗?→ 无关,这是会话级缓存作用域的问题。
- 如何确认
- 加分回答 :这是一种"防御性"设计,保证了非事务环境下的
SqlSession线程安全与及时回收,但也带来了对事务完整性的强依赖。
3. #{} 与 ${} 的本质区别?为什么说 ${} 可能导致 SQL 注入?源码层面怎么证明?
- 一句话回答 :
#{}是 JDBC 预编译参数占位符,由PreparedStatement安全处理;而${}是直接的字符串替换,发生在 SQL 构建阶段,完全绕过了预编译的安全机制。 - 详细解释 :在
SqlSourceBuilder解析 SQL 时,#{}被替换为?,参数值后续由TypeHandler设置给PreparedStatement。而${}由TextSqlNode处理,GenericTokenParser解析出变量名后,通过 OGNL 从参数对象取值,然后直接进行 Java 字符串拼接 。源码证明:org.apache.ibatis.scripting.xmltags.TextSqlNode的BindingTokenParser.handleToken()方法,其返回值直接与原始 SQL 字符串拼接,生成最终的 SQL。这个 SQL 语句已经包含了用户输入,如被恶意构造,SQL 结构已被改变。 - 多角度追问 :
- 什么场景下必须用
${}?→ 动态表名、动态 ORDER BY 字段。 - 那在这些场景下如何防注入?→ 必须在应用层进行严格的白名单校验,从业务集合中取值,绝不可直接传入用户输入。
${}的性能比#{}好吗?→ 理论上 PreparedStatement 在第二次执行时效率更高,${}仅仅是字面量上的拼接,无性能优势。
- 什么场景下必须用
- 加分回答 :借助
JSqlParser等工具,可以对包含${}的 SQL 在运行时进行二次语义分析,识别并阻止可能改变SQL结构的危险片段。
4. 分页插件不生效的常见原因是什么?如何通过调试或日志定位?
- 一句话回答 :最常见的原因是插件注册顺序错误或分页的
startPage在查询方法之后调用。 - 详细解释 :排查分步:1)检查插件是否已注册,查看 MyBatis 启动日志。2)检查
PageHelper.startPage()是否紧挨着 Mapper 查询方法。PageHelper 使用ThreadLocal存储分页参数,如果在执行 SQL 前被其他线程操作或清除,则失效。3)检查插件顺序。分页插件应注册在功能性插件(如数据权限)之后,在结果处理插件(如脱敏)之前。开启com.github.pagehelper的 DEBUG 日志,会打印分页参数绑定、SQL 改写、COUNT 查询等全部信息,是定位问题的关键。 - 多角度追问 :
PageHelper如何处理ThreadLocal的内存泄漏?→ 在finally块中会调用clearPage()。- 在异步线程中分页能生效吗?→ 不能,因为
ThreadLocal无法跨线程传递。 - 多个分页插件可以共存吗?→ 可以,但各自的
startPage机制不同,极易冲突,应只保留一个。
- 加分回答 :自定义实现一个"可串行化"的
PageParam对象,结合CompletableFuture,可在异步链中手动传递分页信息,解决跨线程分页问题。
5. MyBatis 二级缓存为何在生产环境经常被禁用?多节点下有什么脏读风险?
- 一句话回答:由于 MyBatis 的二级缓存缺乏分布式环境下的同步机制,一个节点的更新无法使其他节点的缓存即时失效,导致严重的脏读和数据不一致问题。
- 详细解释 :MyBatis 的二级缓存(
CachingExecutor)是本地实现。当节点 A 更新数据并提交事务,它只会使节点 A 本地和通过Cache接口连接的远程缓存(如果有)的对应条目失效。关键过程由TransactionalCacheManager管理。然而,节点 B 的CachingExecutor并不知道这个失效事件。如果节点 B 之前缓存了该数据,后续查询将直接命中本地或远程的旧缓存,而不会查询数据库。这种"以节点自治为中心的缓存"与"以全局一致为中心的数据"之间的矛盾,是多节点环境下脏读的根本原因。因此,在未引入全局一致的缓存失效广播机制(如 Redis Pub/Sub, MQ)前,默认必须禁用。 - 多角度追问 :
- 如果使用 Redis 做二级缓存,还会有脏读吗?→ 会有,因为 Redis 也只是个存储,它不解决跨节点的主动失效通知问题。节点 B 仍会持有它认为是"最新"的缓存版本。
readOnly缓存的含义?→ 只读缓存不会被修改,因此不存在脏读,适用于不变的字典表。- 如何正确设计一个跨节点的 MyBatis 二级缓存?→ 需要修改
Cache接口的实现,在putObject和clear方法中加入全局广播通知机制。
- 加分回答:在设计模式上,这是一个典型的 Cache Invalidation 问题。业界成熟的方案如使用 Canal 监听 Binlog 来触发全局缓存失效,但这已完全脱离了 MyBatis 框架本身。
6. 使用 BatchExecutor 时,如果忘记 commit 会发生什么?MySQL 的 rewriteBatchedStatements 是什么原理?
-
一句话回答 :如果忘记
commit,所有通过addBatch()添加的语句都不会被提交到数据库,导致数据永久丢失;rewriteBatchedStatements是 MySQL JDBC 驱动的一个参数,开启后能将多个INSERT语句改写为一条INSERT INTO ... VALUES ..., ...的批量插入,大幅提升批处理性能。 -
详细解释:
- 忘记
commit的后果 :BatchExecutor在执行mapper.insert()等方法时,只是将 SQL 和参数通过Statement.addBatch()添加到批次中,并不会立即执行。只有当调用SqlSession.commit()或显式的flushStatements()时,才会真正执行Statement.executeBatch(),随后commit会将事务提交。如果连commit都不调用,就意味着没有执行批次,也没有事务提交,所有数据改变都将丢失。源码中,BatchExecutor.doFlushStatements()才会真正去调用statement.executeBatch(),而commit方法会先调用flushStatements()再提交事务。但若commit也没调用,则连接关闭时会触发自动回滚(取决于autocommit设置和连接池配置)。 rewriteBatchedStatements原理 :这是 MySQL JDBC 驱动(mysql-connector-java)5.1.38 之后提供的一个连接属性。默认情况下,即使使用 MyBatis 的BatchExecutor,发送给 MySQL 的仍然是多个独立的INSERT语句。当开启rewriteBatchedStatements=true后,驱动层会将addBatch()中的多个INSERT语句,解析并重组为一条INSERT INTO t (col1,col2) VALUES (?,?),(?,?),...的 SQL,然后传递给数据库。这极大减少了网络往返和 SQL 解析开销。这个优化在驱动层实现,对 MyBatis 是透明的。相关源码在com.mysql.cj.jdbc.StatementImpl内部。
- 忘记
-
多角度追问:
BatchExecutor和SimpleExecutor+rewriteBatchedStatements有何区别?→SimpleExecutor每次update都执行单条 SQL,即便开启该参数也无法批量组装,因为批次未积累。BatchExecutor是 MyBatis 层面的批处理,它将多个语句聚集后一次性发给驱动,此时驱动层的改写才能发挥作用。- 为什么有时候开了
rewriteBatchedStatements但没效果?→ 需要确保 JDBC 连接 URL 中正确配置了rewriteBatchedStatements=true,并且使用了BatchExecutor或 MyBatis-Plus 的saveBatch等基于批处理的方法。 BatchExecutor对SELECT语句有影响吗?→ 没有,BatchExecutor只对update(包含INSERT、UPDATE、DELETE)操作进行批处理。
-
加分回答 :在数据插入性能调优中,除了应用层批处理和驱动层改写,还可以在数据库端调整
innodb_flush_log_at_trx_commit(改为 0 或 2)和sync_binlog来减少磁盘刷新次数,但需在可靠性和性能之间权衡。对于超大批量插入,可结合LOAD DATA LOCAL INFILE获得极致性能。
7. ReuseExecutor 的 Statement 缓存可能引发什么问题?
-
一句话回答:它可能导致参数绑定错误、内存泄漏,以及在某些数据库(如 Oracle)上因游标未关闭而超出最大打开游标数。
-
详细解释:
ReuseExecutor内部使用一个Map<String, Statement>缓存,键为 SQL 语句字符串,值为PreparedStatement。当执行同一 SQL 多次时,它不会每次创建新的Statement,而是复用之前的,仅重新设置参数。这会带来几个问题:- 参数绑定错误 :如果在第二次执行时,之前的
Statement还残留有上一次查询的结果集或错误的参数状态,可能导致异常或数据错误。虽然 MyBatis 会在复用前调用clearParameters(),但在某些驱动或并发场景下仍可能存在隐患。 - 内存泄漏 :由于缓存的
Statement是强引用,它们的生命周期与SqlSession相同。如果一个SqlSession执行了大量不同的 SQL,这些Statement对象会一直占用内存和数据库游标,直到SqlSession关闭。 - 游标超限 :特别是 Oracle 等数据库会严格限制每个会话的打开游标数。
ReuseExecutor会为每条不同的 SQL 维护一个打开的游标,如果SqlSession内执行了成百上千条不同 SQL,就会迅速达到OPEN_CURSORS上限,抛出ORA-01000: maximum open cursors exceeded错误。
- 参数绑定错误 :如果在第二次执行时,之前的
- 源码实现位于
org.apache.ibatis.executor.ReuseExecutor,其doQuery/doUpdate方法会先从statementMap中获取PreparedStatement,如果没有才创建。
-
多角度追问:
- 与
SimpleExecutor对比,ReuseExecutor的性能优势在哪?→ 减少了Statement的创建和 SQL 预编译次数,适合在单次SqlSession内对少量 SQL 反复执行的情景。 - 如何规避游标超限问题?→ 降低
SqlSession的生命周期,或使用 MyBatis-Plus 的批处理封装,或干脆换用SimpleExecutor。 - 能通过配置关闭 Statement 的缓存吗?→
ReuseExecutor被设计为复用模式,若要关闭只能切换 Executor 类型。
- 与
-
加分回答 :在 Spring 集成环境下,由于
SqlSessionTemplate会根据事务动态决定SqlSession的创建和关闭,ReuseExecutor的优势被进一步弱化。对于需要高度复用 SQL 并节省预编译开销的场景,建议使用数据库连接池的PSCache特性(如 HikariCP 不支持,Druid 有类似机制),这比 MyBatis 层面的缓存更安全、更可控。
8. @MapperScan 和 @Mapper 注解在 Spring Boot 中的自动扫描机制是怎样的?冲突了怎么办?
-
一句话回答 :
@MapperScan是通过MapperScannerConfigurer显式定义扫描路径来批量注册 Mapper;@Mapper是让mybatis-spring-boot-starter的自动配置通过AutoConfiguredMapperScannerRegistrar自动发现并注册接口,两者同时使用且扫描路径重叠时会导致 Bean 重复定义而启动失败。 -
详细解释:
@MapperScan机制 :需要在某个@Configuration类上使用,底层会注册一个MapperScannerConfigurerBeanDefinition,它通过ClassPathMapperScanner在指定包下查找接口,将其定义为MapperFactoryBean的 Bean。@Mapper自动扫描机制 :mybatis-spring-boot-autoconfigure中的MybatisAutoConfiguration.AutoConfiguredMapperScannerRegistrar会解析META-INF/spring.factories,在应用启动时注册一个MapperScannerConfigurer,其扫描路径由auto-configuration自动决定,通常是启动类所在包及其子包,条件是接口上标注了@Mapper注解。- 冲突根因 :当项目既使用了
@MapperScan("com.example.mapper")定义了扫描路径,又在那些接口上添加了@Mapper注解,自动配置的MapperScannerConfigurer也会生效,导致同一个接口被两个ScannerConfigurer分别扫描并尝试注册为 Bean,Spring 容器发现同一类型有多个 BeanDefinition,抛出BeanDefinitionStoreException或NoUniqueBeanDefinitionException。
-
多角度追问:
- 如果必须共存,如何解决冲突?→ 在
@MapperScan上不需做改变,但要将自动配置中的MapperScannerConfigurer排除,可以通过spring.autoconfigure.exclude排除MybatisAutoConfiguration(不推荐),或干脆为接口移除@Mapper注解,仅用@MapperScan即可。 - 为什么默认自动扫描有时会失效?→ 可能是
@Mapper注解未正确导入包(是org.apache.ibatis.annotations.Mapper,不是 MyBatis-Plus 的@Mapper),或接口不在自动配置类的扫描包范围内。 mybatis-plus中的@MapperScan有区别吗?→ 原理一致,MybatisPlusAutoConfiguration有类似的自动扫描,同样会冲突。
- 如果必须共存,如何解决冲突?→ 在
-
加分回答 :可以在
@MapperScan的sqlSessionFactoryRef属性上做文章,将两个ScannerConfigurer关联到不同的SqlSessionFactory,实现多数据源。但即使如此,也要避免扫描路径重叠导致的同一个SqlSessionFactory下重复注册。
9. 如何通过自定义 Interceptor 实现全链路 SQL 耗时监控?
-
一句话回答 :实现
Interceptor接口,拦截StatementHandler.query/update或Executor层,记录 SQL 执行前后的纳秒时间差,并结合上下文(如 HTTP 请求 URL、用户信息)将慢 SQL 事件异步上报。 -
详细解释:
- 拦截点选择 :建议拦截
StatementHandler的query和update方法,因为此时 SQL 已经过动态 SQL 组装,我们能获取到最终的BoundSql对象,包含 SQL 文本和参数映射。@Signature配置为type = StatementHandler.class, method = "query/update", args = {Statement.class, ResultHandler.class}。 - 耗时计算 :在
intercept方法中,记录System.nanoTime()开始时间,调用invocation.proceed()执行原逻辑,结束后再次计算时间差,得到微妙或毫秒级的响应时间。 - 全链路关联 :利用
ThreadLocal或MDC将当前请求的 TraceId、用户ID、访问URL等信息传递到拦截器中,与 SQL 耗时一起记录。这要求在 Web 层 Filter 中预先设置。 - 慢查询识别与上报:设定阈值(如 1秒),超过则异步发送至监控系统(如 Prometheus, SkyWalking, 自定义 MQ)。注意拦截器内不要有太重的同步操作,以免阻塞数据库操作线程。
- 拦截点选择 :建议拦截
-
多角度追问:
- 如何防止监控本身造成性能瓶颈?→ 使用无锁队列(如
ConcurrentLinkedQueue)或Disruptor暂存事件,批量异步上报;耗时计算也可用StopWatch。 - 想知道 SQL 的参数值怎么处理?→ 通过
BoundSql.getParameterMappings()和StatementHandler.getParameterHandler()获取ParameterHandler,循环读取参数值并替换掉?占位符,生成可读的完整 SQL。注意脱敏。 - 有些 SQL 没有走 StatementHandler 拦截怎么办?→ 可以拦截
Executor的query/update,但获取的参数可能未组装,略有不同。通常StatementHandler已经足够。
- 如何防止监控本身造成性能瓶颈?→ 使用无锁队列(如
-
加分回答 :结合
Micrometer的@Timed注解或直接使用Timer.Sample,可将 SQL 耗时作为自定义指标暴露给Spring Boot Actuator,并通过Prometheus抓取,利用 Grafana 绘制 SQL 性能趋势图,实现零侵入式的监控埋点。
10. 什么是 MyBatis 的 CacheKey?为什么动态 SQL 可能会导致缓存命中率低?
-
一句话回答 :
CacheKey是 MyBatis 用于唯一标识一次查询的缓存键,由MappedStatementID、分页参数、SQL 语句和参数值共同计算得到;若动态 SQL 的参数中包含频繁变化的对象(如List、未重写equals/hashCode的对象),会导致每次生成的CacheKey都不同,缓存形同虚设。 -
详细解释:
CacheKey的构成 :在BaseExecutor.createCacheKey()方法中生成,主要包括:MappedStatement.id、RowBounds的偏移量和限制数、BoundSql.getSql()字符串本身、以及传递给 SQL 的参数值列表。对于每个参数,通过cacheKey.update(parameter)将参数的hashCode和toString等特征更新到键中。- 动态 SQL 的影响 :当使用
<foreach>传递一个List或Set时,这个集合对象作为参数参与了CacheKey的计算。如果调用方每次都创建一个新的ArrayList,虽然内容相同,但hashCode是基于内容的(AbstractList的实现),所以通常没问题。真正的问题在于:如果foreach参数是一个内容相同但实例不同且未正确重写equals/hashCode的自定义对象 ,或者集合中包含这样的对象,就会产生不同的CacheKey。更常见的情况是test条件中引用的参数对象属性为null导致 SQL 本身发生变化,这同样会使CacheKey不一致。 - 源码体现 :
org.apache.ibatis.cache.CacheKey.update(Object)会计算baseHashCode = object.hashCode(),然后乘加运算。
-
多角度追问:
- 如何排查
CacheKey是否稳定?→ 开启 DEBUG 日志,观察Cache Hit Ratio,并可以在CachingExecutor.query或BaseExecutor.query的createCacheKey处设置断点,对比连续两次请求的CacheKey字段。 - 一级缓存也用
CacheKey吗?→ 是的,localCache也是基于CacheKey命中的,缓存不稳定同样影响一级缓存。 - MyBatis-Plus 分页插件会改变
CacheKey吗?→ 分页插件的PaginationInnerInterceptor会在 SQL 外层包裹 COUNT 查询和分页查询,因此生成的MappedStatement和 SQL 都变了,缓存自然很难命中,除非插件做了特殊处理。
- 如何排查
-
加分回答 :可以通过实现一个自定义的
CacheKey生成策略来干预,比如重写update方法对集合类型统一进行排序和摘要哈希,但成本和风险较高。更务实的做法是在业务层用 Spring Cache 等抽象,以明确的业务 Key(如用户ID、状态)进行缓存,避开底层CacheKey的脆弱性。
11. 排查一个"查询结果中某个字段总是 null"的问题,你的步骤是什么?
-
一句话回答 :依序检查:SQL 查询列名/别名、实体属性名是否正确;是否开启了驼峰转换配置;
resultMap或resultType映射是否正确;TypeHandler是否匹配。 -
详细解释:
- 开启 MyBatis TRACE 日志:首先查看日志中打印出的执行结果,观察该字段对应的列是否有返回值。
- 核对列名与属性名 :检查 XML 中的 SQL 是否有
AS别名,别名是否与 Java 属性名完全一致(驼峰需关闭时)。若开启了map-underscore-to-camel-case,则检查下划线形式是否满足转换规则(如create_time->createTime)。特别注意有crt_time这种不规范命名。 - 检查
resultMap配置 :如果使用了resultMap,查看<result column="" property=""/>是否写反,jdbcType和javaType是否匹配。 - 排查
TypeHandler:若字段是自定义类型(如 JSON、枚举),检查对应的TypeHandler是否注册,是否在type-handlers-package扫描路径内,是否在resultMap中显式指定。 - 数据库驱动差异 :有时驱动返回的列名大小写或格式与预期不符,可通过
ResultSetMetaData.getColumnLabel()和getColumnName()排查。可以在DefaultResultSetHandler打断点观察。 - 构建测试用例 :编写一个返回
Map的查询,或在数据库工具中执行相同 SQL,排除数据本身为null的可能。
-
多角度追问:
- 如果列名和属性完全一致,但还是 null,可能是什么原因?→ 可能是启用了延迟加载(
fetchType=LAZY)而 Session 已关闭,或TypeHandler未生效。 - 使用
resultType="HashMap"时,key 的大小写问题怎么解决?→ MySQL 驱动默认返回的全是列名大写,可通过连接参数useColumnLabel=true并使用别名指定为小写。 - MyBatis-Plus 的
@TableField注解可以解决这个问题吗?→ 可以,设置@TableField("db_col")显式指定映射列名,优先级高于默认规则。
- 如果列名和属性完全一致,但还是 null,可能是什么原因?→ 可能是启用了延迟加载(
-
加分回答 :团队可强制要求使用 MyBatis Generator 生成
resultMap,或通过Lombok与@TableField注解联动,利用编译期检查避免字符串映射错误。在 CI 中加入针对所有 Mapper 方法的 smoke test,检查返回对象关键字段非空。
12. MyBatis-Plus 的通用 CRUD 和手写 XML 的同名方法冲突怎么办?
-
一句话回答 :重命名手写方法,使其不与
BaseMapper内的方法重名;或者在BaseMapper的基础上继承一个自定义的BaseMapper,避开默认方法名。 -
详细解释:
- 冲突原理 :MyBatis-Plus 的
AbstractSqlInjector在启动时会根据BaseMapper的接口方法动态生成对应的MappedStatement,其id为namespace.methodName。如果用户同时在手写的 XML 中定义了相同 ID 的语句,MyBatis 在加载 XML 时通过MapperBuilderAssistant注册,会检测到Map中已存在同 ID 的MappedStatement,从而抛出already contains value异常。 - 解决方案 :
- 手写方法改名 :最简单的方式,将手写的
selectList改为selectCustomList等。 - 使用自定义 BaseMapper :如果不想改名,可以自定义一个接口
MyBaseMapper<T> extends BaseMapper<T>,在其中声明与手写相同的抽象方法,但注意 MyBatis-Plus 只会为BaseMapper接口注入 SQL,自定义的子接口方法不会自动注入,因此不会冲突。但这不是 MyBatis-Plus 的常规做法。 - 排除注入 :通过
@SqlParser(filter = true)或全局配置,让特定方法不走注入逻辑,但这可能影响其他功能。
- 手写方法改名 :最简单的方式,将手写的
- 冲突原理 :MyBatis-Plus 的
-
多角度追问:
- 如果我不小心让手写的
update和BaseMapper.updateById冲突,但我是有意覆盖,怎么办?→ MyBatis 不支持MappedStatement覆盖,必须通过 XML 或注解方式重新定义一个不同 ID 的方法。 - 如何避免团队成员再次犯错?→ 代码评审时核对 Mapper 接口方法名,并在代码生成器模板中排除
BaseMapper已包含的方法名。 - MyBatis-Plus 3.x 有没有提供自动的冲突检测和跳过?→ 不会跳过,直接启动报错,这是为了安全。
- 如果我不小心让手写的
-
加分回答 :可以使用
MybatisPlusInterceptor或自定义插件检查MappedStatement冲突,并在启动阶段日志输出所有现有StatementID,辅助检查。最佳实践是为手写 SQL 统一加业务前缀,如query*、load*、save*,与select*、insert*天然隔离。
13. 逻辑删除开启后,为什么手写查询仍然能查到"已删除"的数据?
-
一句话回答 :因为 MyBatis-Plus 的
LogicSqlInjector只会修改BaseMapper内置方法的 SQL,不会处理用户手写的 SQL,所以手写 SQL 必须手动添加逻辑删除条件。 -
详细解释:
- 逻辑删除实现 :在 MyBatis-Plus 中,配置
logic-delete-field: deleted后,框架会通过LogicSqlInjector拦截BaseMapper的deleteById、selectById等方法,将DELETE改写为UPDATE,并为SELECT语句自动添加AND deleted=0条件。 - 自定义 SQL 的盲区 :用户在 XML 中手写的
select,不会被LogicSqlInjector处理,因为它只处理通过AbstractSqlInjector注入的那部分MappedStatement。自定义 SQL 直接由 MyBatis 核心解析,完全不具备逻辑删除的"感知"。 - 源码证据 :
com.baomidou.mybatisplus.core.injector.LogicSqlInjector.inspectInject()方法负责标准 CRUD 的注入,而自定义 SQL 则不会进入此逻辑。此外,LogicSqlInjector与TenantLineInnerInterceptor不同,它不是全局拦截器,而是注入时起作用。
- 逻辑删除实现 :在 MyBatis-Plus 中,配置
-
多角度追问:
- 有没有办法让自定义 SQL 也自动带上逻辑删除条件?→ 在 MyBatis-Plus 3.3.0 后提供了
@SqlParser(filter = true)和拦截器级别的处理,但配置复杂且可能影响性能;更推荐使用 SQL 片段复用(<sql>标签)或 BaseMapper 的方法。 - 手写 SQL 需要加
deleted = 0,那要不要加deleted is null?→ 根据逻辑删除字段定义,默认未删除是 0,直接加deleted = 0即可。 - 逻辑删除和唯一索引约束如何共存?→ 将
deleted字段加入唯一索引,或将已删除数据移到历史表,否则唯一索引会阻止逻辑删除后的相同数据再次插入。
- 有没有办法让自定义 SQL 也自动带上逻辑删除条件?→ 在 MyBatis-Plus 3.3.0 后提供了
-
加分回答 :可利用 MyBatis 的插件机制,拦截
StatementHandler,在手写 SQL 的BoundSql上统一追加AND ${logicField} = ${notDeleteValue},但需要考虑别名、表名等复杂情况。最稳妥的还是推荐使用 SQL 片段<include refid="notDeleted"/>显式管理。
14. 多数据源下,MyBatis 如何正确绑定事务管理器?
-
一句话回答 :每个数据源需要有自己独立的
DataSourceTransactionManager,并在@Transactional注解中通过value或transactionManager属性指定对应的事务管理器 Bean 名称。 -
详细解释:
- 多个事务管理器 :当有数据源
ds1和ds2时,需要分别创建DataSourceTransactionManagerBean,如ds1TransactionManager和ds2TransactionManager。注意不能同时指定@Primary,除非一个为主。 @Transactional指定 :在 Service 层方法上使用@Transactional("ds1TransactionManager")或@Transactional(transactionManager = "ds1TransactionManager"),明确告诉 Spring 使用哪个事务管理器。- 与 MyBatis 的绑定 :MyBatis 的
SqlSessionFactory和SqlSessionTemplate是通过@MapperScan的sqlSessionFactoryRef绑定到特定的数据源。事务管理器内部也持有相同的数据源。当事务管理器开启事务时,它会将连接绑定到当前线程,SqlSessionTemplate则会通过TransactionSynchronizationManager获取到该连接,从而保证同一事务内操作的是同一个数据源。 - 源码体现 :
DataSourceTransactionManager.doBegin()会获取连接并绑定;SqlSessionTemplate.SqlSessionInterceptor会尝试获取当前事务绑定的SqlSession,关键调用链为TransactionSynchronizationManager.getResource(sqlSessionFactory)。
- 多个事务管理器 :当有数据源
-
多角度追问:
- 可以用一个
AbstractRoutingDataSource来省去多个事务管理器吗?→ 可以,动态数据源切换时,事务管理器只需一个,因为它持有AbstractRoutingDataSource,内部路由决定了实际连接的数据库。但需注意事务内一旦使用了某个数据源,就必须始终使用该数据源(写后读主库)。 - Spring Boot 自动配置的事务管理器会冲突吗?→ 当存在多个
DataSource时,Boot 会创建多个TransactionManager,并指定一个为@Primary;使用时要显式指定非主事务管理器。 @Transactional不加参数会发生什么?→ 会使用@Primary的事务管理器,可能导致另一个数据源操作无事务。
- 可以用一个
-
加分回答 :可以利用 Spring 的
@EnableTransactionManagement配合注解,结合 AOP 切面,动态根据 Mapper 所在的包名自动选择事务管理器,避免在每个方法上硬编码名字。例如,自定义一个注解@TargetDataSource("ds1")配合切面设置TransactionSynchronizationManager的当前数据源键。
15. 流式查询(Cursor)在 Spring 事务中容易提前关闭,原因是什么?如何解决?
-
一句话回答 :因为
Cursor依赖于ResultSet保持打开,而SqlSession关闭时ResultSet会被关闭;Spring 事务中若SqlSession在方法结束前被提前关闭,Cursor就会失效;解决方法是让SqlSession的生命周期覆盖整个流式处理过程。 -
详细解释:
- 流式查询原理 :
org.apache.ibatis.cursor.Cursor<T>底层是一个懒加载的迭代器,每次iterator.next()会从 JDBCResultSet中读取下一行。这要求 JDBC 连接和ResultSet一直打开。 - Spring 事务下关闭原因 :在 Spring 托管下,
SqlSessionTemplate的SqlSessionInterceptor在非事务方法调用结束时或事务提交/回滚后会立即关闭SqlSession,进而关闭ResultSet。如果Cursor还没有被消费完,后续的next()会抛出java.sql.SQLException: Operation not allowed after ResultSet closed。 - 解决方案 :
- 让方法不事务,手动控制 :在流式查询的方法上不加
@Transactional,并确保返回Cursor后,调用方在try-with-resources中使用,但调用方不能跨事务。 - 使用
@Transactional+TransactionSynchronizationManager延迟关闭 :指定@Transactional保证连接在使用期间有效,并在 Service 方法内完成所有对Cursor的遍历,不将Cursor对象泄露到方法外部。 - 手动自定义
SqlSession:通过sqlSessionFactory.openSession()创建绑定特定线程的SqlSession,用完手动关闭,但这违背了 Spring 集成初衷。
- 让方法不事务,手动控制 :在流式查询的方法上不加
- 流式查询原理 :
-
多角度追问:
- MyBatis-Plus 的
cursor方法安全吗?→ 它返回Cursor<T>,同样需要注意生命周期,官方示例都建议在try块中遍历。 - 流式查询与分页查询如何选择?→ 流式查询适合大数据量导出、逐条处理的场景,分页查询适合需要总数和随机跳页的场景。
- 对
Cursor直接调用stream()会怎样?→ 一旦流被终止或离开 lambda,ResultSet可能仍没关闭,必须确保底层连接最终被正确释放,通常用try (Cursor<T> cursor = mapper.selectCursor(...))。
- MyBatis-Plus 的
-
加分回答 :可以自定义一个
StreamResultHandler并配合SqlSession手动管理,实现更灵活的流式处理。或者使用MyBatis-Plus的Page结合scrollResult(游标分页) 来处理,但本质上仍是流式,需注意资源释放。最佳实践:将流式 SQL 处理逻辑封装在SqlSessionDaoSupport子类中,并借助事务模板TransactionTemplate保证资源安全。
16. @TransactionalEventListener 与 MyBatis 的一级缓存有什么关系?
-
一句话回答 :
@TransactionalEventListener在事务提交后执行,而此时 MyBatis 的一级缓存所在SqlSession已经关闭,再试图访问延迟加载的属性或调用新的 MyBatis 操作,将发生LazyInitializationException类似的错误或新查询无法利用之前缓存。 -
详细解释:
- 事件执行时机 :
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)标注的方法,会在当前事务提交成功后执行。默认是AFTER_COMMIT。 - 一级缓存的边界 :一级缓存属于
SqlSession,SqlSession随着事务提交而被 Spring 关闭。因此,当事件处理方法执行时,之前那个装着缓存数据的SqlSession已经逝去。 - 影响 :如果事件处理方法中需要访问事务方法内查询出的实体,并且该实体存在未加载的延迟属性,访问会失败,因为
SqlSession已关闭。如果事件处理方法内再次通过 Mapper 查询相同数据,这不是之前那个SqlSession,一切从新开始,一级缓存不再生效。此外,若事件处理方法期望看到事务内最新提交的数据,此时数据库是可见的,但无法利用缓存。 - 源码关联 :
TransactionSynchronizationAdapter的afterCommit回调触发了事件发布,而SqlSessionTemplate的SqlSessionInterceptor在事务完成时会清理资源,关闭SqlSession。
- 事件执行时机 :
-
多角度追问:
- 如何避免在事件中延迟加载失败?→ 在事务方法内,先调用
Hibernate.initialize()或手动执行mapper.selectList完成所有需要数据的加载,或干脆关闭延迟加载。 - 事件监听器内可以开启新事务吗?→ 可以,加上
@Transactional(propagation = Propagation.REQUIRES_NEW),此时是一个全新的SqlSession,与之前一级缓存完全隔离。 @TransactionalEventListener默认是同步还是异步?→ 默认同步,在同一个线程中执行,但仍是在事务提交之后。可以通过@Async结合成为异步。
- 如何避免在事件中延迟加载失败?→ 在事务方法内,先调用
-
加分回答:设计事务事件时,应只传递业务标识(如订单ID),在监听器中通过标识重新查询最新数据,而不是传递整个实体对象。这不仅是避免缓存问题,更符合消息驱动和最终一致性的思想。
17. 如何通过 Arthas 监控某个 SqlSession 的 Executor 状态?
-
一句话回答 :使用
watch命令观察Executor的query或update方法的出入参和返回值;结合ognl查看Configuration或localCache内部状态。 -
详细解释:
watch监控执行 :比如,watch org.apache.ibatis.executor.BaseExecutor query '{params, target, returnObj}' -x 3可以观察到执行器被调用时的 MappedStatement ID、参数、返回结果。通过target可以看到当前执行者是SimpleExecutor还是BatchExecutor等。- 查看缓存状态 :
watch org.apache.ibatis.executor.BaseExecutor query 'target.localCache' -x 2可以打印出一级缓存PerpetualCache内部的cache(一个HashMap),观察其大小和键值对。 - 追踪特定 Mapper 方法的调用栈 :
stack com.example.mapper.UserMapper findById可以展示从调用方到 MyBatis 内部的完整调用链,包括SqlSessionTemplate$SqlSessionInterceptor->Plugin->Executor。 - 查看
Configuration全局状态 :ognl '@org.apache.ibatis.session.Configuration[@](你的SqlSessionFactory实例).mappedStatements.size()'。更简单的是如果知道DataSource的 Bean 名,可通过ognl调用 Spring 容器获取。通常通过先获取SqlSessionFactory实例:ognl '@org.springframework.beans.factory.BeanFactory@...'较复杂。更方便的是使用watch时附带springUtils。
-
多角度追问:
- 如何在不停止应用的情况下修改 Executor 类型?→ 无法直接修改,Executor 在
SqlSession创建时就确定了。可以热更新@Configuration类然后重启应用,但不推荐线上直接动态变实践。 - 如何判断当前是哪个
Executor在执行?→watch org.apache.ibatis.executor.BaseExecutor query 'target.getClass().getName()'。 Arthas监控会影响性能吗?→ 有一定的开销,建议限制条件(condition)和只采样(-n限制次数),并在监控完成后及时关闭。
- 如何在不停止应用的情况下修改 Executor 类型?→ 无法直接修改,Executor 在
-
加分回答 :编写 Arthas 脚本批量监控,例如结合
trace观察耗时,一旦发现 SQL 耗时超过阈值,自动调用tt记录现场。对疑难问题,用jad反编译线上的 Mapper 代理类,查看动态代理的实现细节,验证插件包装是否生效。
18. 自定义类型处理器(TypeHandler)为什么没有生效?排查思路是什么?
-
一句话回答 :检查三要素:
TypeHandler是否注册到TypeHandlerRegistry;映射中是否指定了此TypeHandler;参数或结果的 Java 类型、JDBC 类型是否匹配。 -
详细解释:
- 排查步骤 :
- 注册检查 :如果是 Spring Boot 项目,检查
mybatis.type-handlers-package是否配置了TypeHandler所在的包。如果是 XML 配置,检查<typeHandlers>配置。也可通过@MappedTypes和@MappedJdbcTypes自动注册。 - 映射配置检查 :在
resultMap中是否设置了typeHandler="com.xxx.MyHandler"属性。如果是通过resultType自动映射,确保TypeHandler上正确配置了@MappedTypes指定要处理的 Java 类型。 - 类型匹配检查 :
TypeHandler的setParameter和getResult对应方法参数类型是否正确。比如数据库返回VARCHAR,而TypeHandler的getNullableResult期望的是VARCHAR且实现了java.sql.ResultSet.getString取出,如果类不匹配则不会被调用。 - 日志验证 :DEBUG 级别下,
TypeHandlerRegistry在注册时会打印日志,可以观察启动日志确认是否注册成功。
- 注册检查 :如果是 Spring Boot 项目,检查
- 源码关联 :
org.apache.ibatis.type.TypeHandlerRegistry.register()负责注册,BaseTypeHandler是所有类型处理器的基类。DefaultResultSetHandler.getPropertyMappingValue()会为每一列查找匹配的TypeHandler。
- 排查步骤 :
-
多角度追问:
- 使用
@MappedJdbcTypes未生效的原因?→ 有时 MyBatis 版本或配置中jdbcTypeForNull的设置会影响匹配,建议同时使用@MappedTypes和@MappedJdbcTypes,并在映射中显式指定typeHandler最保险。 - 可以全局替换某个类型的默认处理器吗?→ 通过在配置中注册一个处理相同 Java 类型的
TypeHandler,可以覆盖 MyBatis 内置处理器,但注意不要破坏基础类型处理。 - 自定义的
TypeHandler在#{}和${}中都能用吗?→${}是字符串替换,不会经过TypeHandler,只有#{}会使用TypeHandler设置参数。
- 使用
-
加分回答 :编写单元测试,直接使用
SqlSession.insert插入一个包含自定义类型的对象,并查询验证。利用p6spy查看实际执行的 SQL 参数值,判断TypeHandler是否按预期序列化。若要对查询结果统一处理,可以结合 MyBatis-Plus 的TypeHandler自动映射功能。
19. 动态 SQL 中的 <if> 和 <choose> 对性能有影响吗?影响在哪里?
-
一句话回答 :动态 SQL 解析本身开销极小,几乎可忽略;性能影响主要来自动态片段导致生成的
MappedStatement无法被数据库执行计划缓存(SQL 硬解析),或者因过度复杂的动态逻辑造成Ognl求值次数增加。 -
详细解释:
- 解析开销 :MyBatis 在应用启动时就会完成动态 SQL 的解析,构建出
SqlNode树,运行时只是遍历节点和进行 OGNL 求值。这部分 CPU 时间非常短,尤其是对于简单的<if>、<where>,不是瓶颈。 - SQL 硬解析影响 :真正的性能问题在于数据库。同一个
<select>因为<if>条件不同,最终生成的 SQL 文本可能完全不同(如有的带AND status = ?,有的不带)。数据库会将每一种不同的 SQL 文本视为不同的查询,分别进行硬解析,无法共享执行计划。如果条件组合非常多,就会产生大量不同的 SQL,导致 SQL 解析时间、内存占用上升。 - OGNL 求值 :如果
test表达式里调用了方法或复杂的导航,每次查询都要重复求值,当并发高且表达式极复杂时,会有细微开销。
- 解析开销 :MyBatis 在应用启动时就会完成动态 SQL 的解析,构建出
-
多角度追问:
- 如何降低动态 SQL 的硬解析?→ 尽量保证 SQL 主体结构不变,例如使用
<if test="status != null">AND status = #{status}</if>时,数据库会做绑定变量窥视,MySQL 5.7+ 的优化器也能较好处理。或者将可能变化的条件用CASE WHEN等代替。 <choose>和多个<if>谁更好?→ 语义上<choose>互斥,生成 SQL 组合更少,有助于减少硬解析可能。性能差异可忽略。- 动态 SQL 与缓存配合时有什么坑?→ 同一个动态 SQL 生成两种 SQL 文本,缓存 Key 也会不同,导致缓存命中率下降,这不是性能影响,而是缓存策略效果减弱。
- 如何降低动态 SQL 的硬解析?→ 尽量保证 SQL 主体结构不变,例如使用
-
加分回答 :利用
p6spy或自定义拦截器统计应用中由同一个MappedStatement生成的不同 SQL 文本数量,若数量过高,可考虑重构 SQL,使用CASE WHEN或在应用层构建查询条件对象来稳定 SQL 结构。数据库端开启如MySQL performance_schema的events_statements_summary_by_digest分析 SQL 指纹,评估硬解析比例。
20. (系统设计题一)设计一个基于 MyBatis 的读写分离中间件
- 一句话回答 :核心设计是扩展
AbstractRoutingDataSource实现动态数据源路由,配合自定义 Interceptor 识别读写操作并切换数据源 Key,再利用LazyConnectionDataSourceProxy延迟连接获取以解决事务内切换问题。 - 详细解释 :
- 核心拦截器设计 :实现一个
Interceptor,拦截Executor.update和query方法。在intercept方法中,通过分析MappedStatement的 SQL 命令类型(SELECT标记为读,其他标记为写),将对应的数据源标识符(如"MASTER"或"SLAVE")设置到一个线程上下文DataSourceContextHolder中。 AbstractRoutingDataSource的扩展 :自定义DynamicDataSource继承AbstractRoutingDataSource,重写determineCurrentLookupKey()方法,从DataSourceContextHolder获取当前操作对应的数据源 Key。该 Bean 需要注入主库和从库的所有真实数据源。- 事务内写读一致性 :在
@Transactional事务内,一旦有写操作,必须保证后续的读操作在主库执行。可以通过在DataSourceContextHolder中设置一个标记,一旦发生写操作,强制将当前线程的路由锁定为"MASTER",直到事务结束。 LazyConnectionDataSourceProxy解决方案 :Spring 的DataSourceTransactionManager在事务开始时就获取数据库连接。如果此时路由还是不确定的,就无法正确切换。使用LazyConnectionDataSourceProxy包装我们的路由数据源,它会将获取真实连接的动作延迟到第一次实际 JDBC 调用时。这样,我们就可以在事务开启后、第一次 SQL 执行前,通过拦截器准确设置路由 Key,从而获取到正确的连接。
- 核心拦截器设计 :实现一个
- 多角度追问 :
- 如何保证从库负载均衡?→ 在
determineCurrentLookupKey()中返回从库 Key 时,使用加权轮询或随机算法,从多个从库数据源中选择一个。 - 从库延迟导致读到旧数据怎么办?→ 可在
DataSourceContextHolder上增加一个"强制走主库"的标记,提供给需要读己之写的核心业务方法使用。 - 这种方案的性能开销?→ 主要是 AOP 代理和一次路由判断的开销,性能损耗微乎其微,远小于其带来的架构收益。
- 如何保证从库负载均衡?→ 在
- 加分回答 :利用 Spring Cloud Alibaba 的
Sentinel对从库进行流量控制和熔断降级。当某个从库不可用时,动态将其从候选列表中移除,实现高可用。
21. (系统设计题二)设计一个 MyBatis 慢查询监控与自动熔断系统
- 一句话回答 :通过自定义 Interceptor 拦截
StatementHandler或Executor,对 SQL 进行指纹化并统计耗时;当某 SQL 指纹的统计指标超过阈值时,触发Sentinel或Resilience4j的熔断规则,抛出预定义的降级异常。 - 详细解释 :
- 核心 Interceptor 设计 :拦截
StatementHandler.query/update或Executor层。@Signature设为StatementHandler.class,"query"/"update"。在intercept方法中,invocation.proceed()前记录开始时间,之后计算cost。通过BoundSql.getSql()获取原始 SQL,利用Druid的SQLUtils或JSqlParser进行格式化,提取SQL Fingerprint,例如将SELECT * FROM user WHERE id = 1归一化为SELECT * FROM user WHERE id = ?。 - 聚合统计 :将
MappedStatement ID + SQL Fingerprint作为 Key,滑动时间窗口(如HdrHistogram或Sentinel内置的滑动窗口)内,统计总调用次数、总耗时、平均耗时、TP99 耗时、失败次数。 - 与
Sentinel/Resilience4j集成 :为每个 SQL 指纹动态创建DegradeRule。设定规则如"当 TP99 耗时 > 1000ms 且 QPS > 5 时,触发慢调用比例熔断"。当统计窗口内的数据触发 Sentinel 的熔断规则后,Sentinel 会抛出DegradeException。我们在 Interceptor 的catch块中捕获此异常,并将其转换/包装为我们自定义的、业务可感知的降级异常(如SlowSqlDegradedException),由上层 AOP 或全局异常处理器统一处理,返回降级响应(如空列表)。 - 动态配置:规则阈值应存储在配置中心(Nacos/Apollo)。Interceptor 监听配置变更,动态更新 Sentinel 的熔断规则。
- 核心 Interceptor 设计 :拦截
- 多角度追问 :
- 如何避免监控本身影响性能?→ 统计逻辑应异步化,通过
Disruptor等无锁队列发送事件,在独立线程池中聚合和判断。 - 如何识别并排除定时任务的慢查询?→ 在拦截器中,通过
Runnable或Thread的名字、MappedStatement自定义属性来过滤此类 SQL。 - 熔断后如何自动恢复?→ Sentinel 支持半开状态探测。当熔断持续一段时间后,允许少量请求通过,如果这些请求成功,则关闭熔断,这是标准的断路器模式。
- 如何避免监控本身影响性能?→ 统计逻辑应异步化,通过
- 加分回答:将 SQL 指纹数据与 APM(如 SkyWalking, Pinpoint)集成,通过其 API 将慢 SQL 作为自定义 Span 的事件上报,在监控大盘上实现 SQL 级与链路级的联动分析。
至此,我们完成了对 MyBatis 领域 22+ 个核心反模式的深度剖析,并构建了一套完整的诊断与排查体系。希望它能成为你手中那把游刃有余的"手术刀"。
MyBatis 反模式速查表
| 反模式 | 领域 | 关键现象 | 根因关键词 | 修正要点 |
|---|---|---|---|---|
| 通配符失效 | 配置与初始化 | BindingException |
PathMatchingResourcePatternResolver |
使用 **/*.xml |
| 类别名失效 | 配置与初始化 | TypeException |
TypeAliasRegistry |
使用全限定名或修正包路径 |
| 配置覆盖 | 配置与初始化 | 自定义设置不生效 | MybatisAutoConfiguration,属性覆盖 |
仅用一种配置方式 |
| 连接泄漏 | 会话与事务 | 线程阻塞,连接池耗尽 | DefaultSqlSession.close() |
try-with-resources 或 Spring 托管 |
| 一级缓存失效 | 会话与事务 | 事务内多次查询仍发SQL | SqlSessionTemplate.SqlSessionInterceptor |
确保 @Transactional 正常 |
| 批处理未flush | 会话与事务 | 部分数据丢失 | BatchExecutor.doFlushStatements() |
commit 前显式 flushStatements |
| 一级缓存脏读 | 缓存相关 | 读到被外部修改的旧数据 | BaseExecutor.query(),localCache |
同一事务内避免混用持久化方式 |
| 二级缓存不一致 | 缓存相关 | 多节点数据"幽灵"现象 | TransactionalCacheManager |
多节点环境默认禁用 |
| CacheKey不稳定 | 缓存相关 | 缓存命中率极低 | CacheKey.update() |
稳定参数,重写hashCode/equals |
| SQL注入 | 映射器与SQL | 返回全表数据 | TextSqlNode, OGNL, 字符串替换 |
使用#{},必要时白名单 |
| 映射失败 | 映射器与SQL | 结果为 null | DefaultResultSetHandler, 驼峰映射 |
别名一致或使用 <resultMap> |
@Param缺失 |
映射器与SQL | Parameter 'xxx' not found |
ParamNameResolver.getNamedParams() |
多参数强制 @Param |
| 0值误判 | 映射器与SQL | SQL片段异常丢失 | OGNL 布尔求值规则 | 数值仅判断 != null |
| 插件顺序不当 | 插件拦截 | 分页/脱敏等功能失效 | InterceptorChain.pluginAll() |
梳理责任链,明确内外层顺序 |
| count未优化 | 插件拦截 | 分页极慢 | CountSqlParser, LEFT JOIN |
手动编写并优化 COUNT SQL |
| 插件异常未处理 | 插件拦截 | PersistenceException |
Plugin.invoke() |
try-catch 并加入熔断降级 |
| 多数据源误连 | Spring Boot整合 | 访问错误数据源 | @Primary, MybatisAutoConfiguration |
sqlSessionFactoryRef 显式绑定 |
| Mapper重复注册 | Spring Boot整合 | BeanDefinitionStoreException |
AutoConfiguredMapperScannerRegistrar |
统一@MapperScan 或 @Mapper |
| Customizer被覆盖 | Spring Boot整合 | 自定义配置不生效 | MybatisProperties.applyTo() |
避免 YAML 与 Customizer 重复配置 |
| CRUD方法冲突 | MyBatis-Plus | MappedStatement ID冲突 |
AbstractSqlInjector.inspectInject |
手写方法避免与 BaseMapper 同名 |
| 多租户遗漏 | MyBatis-Plus | 租户数据泄露 | TenantLineHandler, INTERCEPTOR 缺失 |
全局注册多租户拦截器 |
| 逻辑删除遗漏 | MyBatis-Plus | 查到已删除数据 | LogicSqlInjector |
手写 SQL 显式添加 deleted=0 |