反模式与排查宝典:MyBatis 常见陷阱与排错指南

概述

通过前面 9 篇文章的深度剖析 ,我们从 SqlSession 的核心生命周期、Executor 的执行器策略、StatementHandler 的 JDBC 封装、映射器代理 MapperProxy 的精妙设计、动态 SQL 引擎的解析与执行、一级/二级缓存的深层机制、插件拦截链的 AOP 实现,到 Spring Boot 与 MyBatis 的整合核心,最终以 MyBatis-Plus 的增强机制收尾,构建了一张完整的 MyBatis 内部知识网。然而,正向的知识构建最终必须转化为逆向的排错与避险能力。本文作为系列的收官之作,将完成这"最后一公里"的闭环。

本文将 MyBatis 开发与生产环境中反复出现、后果严重 的 22+ 个反模式,归纳为配置初始化、会话与事务、缓存、映射与 SQL、插件拦截、Spring Boot 整合、MyBatis-Plus 特定等七大领域。每个反模式都严格遵循错误示例、现象描述、排查思路、基于源码的根因分析、修正方案、最佳实践 的六步结构进行深度剖析。我们不会停留在"如何做"的表层,而是深入到 BaseExecutor.querySqlSessionTemplate.SqlSessionInterceptorPlugin.wrap 等核心源码中,揭示"为何出错"。

本文提炼了一套以 MyBatis TRACE 日志、p6spy SQL 监控、自定义诊断拦截器及 Arthas 动态追踪 为核心的标准化诊断工具箱,并提供一张工具-反模式映射表 与一张标准化排查决策树

文章组织架构图

flowchart LR subgraph "1. 反模式全景与概述" A["1. 反模式总览与分类"] end A --> B["2. 配置与初始化反模式"] A --> C["3. SqlSession与事务管理反模式"] A --> D["4. 缓存相关反模式"] A --> E["5. 映射器与SQL反模式"] A --> F["6. 插件拦截反模式"] A --> G["7. Spring Boot整合反模式"] A --> H["8. MyBatis-Plus特定反模式"] B --> B1["案例1-3: mapper-locations, type-aliases, config覆盖"] C --> C1["案例4-6: 连接泄漏, 一级缓存失效, 批处理未flush"] D --> D1["案例7-9: 一级缓存脏读, 二级缓存不一致, CacheKey不稳定"] E --> E1["案例10-13: SQL注入, 映射失败, @Param缺失, 0值误判"] F --> F1["案例14-16: 插件顺序, count未优化, 异常中断"] G --> G1["案例17-19: 多数据源, Mapper重复注册, Customizer覆盖"] H --> H1["案例20-22: MappedStatement冲突, 多租户遗漏, 逻辑删除遗漏"] B1 & C1 & D1 & E1 & F1 & G1 & H1 --> I["9. 诊断工具集与标准化排查决策树"] I --> J["10. 面试高频专题"] style A fill:#e1f5fe style I fill:#fff3e0 style J fill:#e8f5e9

架构图说明

  • 总览说明:全文共 10 大模块。模块 1 提供反模式的全景分类。模块 2 至 8 是核心,深入七大领域的 22+ 个具体案例,从错误示例一路追踪到源码根因。模块 9 将所有排查手段系统化为诊断工具箱、映射表和标准化决策树。模块 10 则以高频面试题的形式,将排错知识升华。
  • 逐模块说明
    • 模块 2-8 :每一个模块聚焦一个特定领域,每个案例都是"现象-排查-根因-修复"的完整闭环。例如,在缓存反模式中,我们会深入 BaseExecutorlocalCache 探讨脏读;在插件拦截反模式中,我们会剖析 InterceptorChain.pluginAll 的包装顺序如何导致功能失效。
    • 模块 9 :这是本文的"操作手册",将 p6spyArthas 等工具与具体反模式现象挂钩,提供搜索关键词和检查点。标准化决策树则从"SQL 未执行"、"缓存不命中"等常见异常现象出发,一步步引导至最终的根因定位。
  • 关键结论掌握 MyBatis 内部组件的生命周期与协作方式是高效排查 Mapper 加载失败、缓存数据不一致、插件失效等问题的根本基础。排错不是玄学,是对源码逻辑的逆向推演。

1. 反模式总览与分类

在生产环境中,MyBatis 的灵活性常被误用,导致各种隐蔽且影响严重的问题。我们将这些高频反模式归纳为七大领域,共计 22+ 个典型案例。

反模式名称 所属领域 风险等级 可能导致的现象
mapper-locations通配符失效 配置与初始化 Mapper XML完全不加载,所有SQL执行报BindingException
type-aliases-package配置错误 配置与初始化 resultType找不到类,ClassNotFoundException
config-locationconfiguration冲突 配置与初始化 自定义配置(如日志、拦截器)被覆盖或失效
非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 反模式全景分类图

flowchart LR subgraph A ["1.配置与初始化"] direction LR A1["案例1: 通配符失效"] A2["案例2: 类别名失效"] A3["案例3: 配置覆盖"] end subgraph B ["2.SqlSession与事务"] direction LR B1["案例4: 连接泄漏"] B2["案例5: 一级缓存失效"] B3["案例6: 批处理未flush"] end subgraph C ["3.缓存相关"] direction LR C1["案例7: 一级缓存脏读"] C2["案例8: 二级缓存不一致"] C3["案例9: CacheKey不稳定"] end subgraph D ["4.映射器与SQL"] direction LR D1["案例10: SQL注入"] D2["案例11: 映射失败"] D3["案例12: @Param缺失"] D4["案例13: 0值误判"] end subgraph E ["5.插件拦截"] direction LR E1["案例14: 顺序不当"] E2["案例15: count未优化"] E3["案例16: 异常未处理"] end subgraph F ["6.Spring Boot整合"] direction LR F1["案例17: 多数据源误连"] F2["案例18: Mapper重复注册"] F3["案例19: Customizer覆盖"] end subgraph G ["7.MyBatis-Plus"] direction LR G1["案例20: CRUD冲突"] G2["案例21: 多租户遗漏"] G3["案例22: 逻辑删除遗漏"] end A & B & C & D & E & F & G --> H("生产事故") style A fill:#ffcdd2 style B fill:#f8bbd0 style C fill:#e1bee7 style D fill:#d1c4e9 style E fill:#c5cae9 style F fill:#bbdefb style G fill:#b2dfdb style H fill:#ff8a80,color:#fff

图表说明

  • 图表主旨概括:本图将七大领域的反模式进行可视化分类,每个领域下列举了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
  • 现象描述 :应用启动成功,但调用 UserMapperOrderMapper 的任何方法时,抛出 org.apache.ibatis.binding.BindingException: Invalid bound statement (not found)。这意味着 MyBatis 未能为 XML 中的 SQL 语句创建 MappedStatement

  • 排查思路

    1. 检查日志 :开启 logging.level.org.mybatis=DEBUG,观察启动日志。可以看到 MyBatis 解析了哪些 XML,或者根本没有解析日志。
    2. 验证配置 :仔细核对 mapper-locations 的值,是否与实际的资源路径匹配。
    3. 使用 Arthas :若应用已部署,可附加 Arthas,通过 ognl 命令查看 SqlSessionFactoryconfiguration 对象,检查其 mappedStatements 集合中是否包含预期的资源。
  • 根因分析 : 根本原因在于 mybatis-spring 在解析 mapperLocations 时,使用了 Spring 的 PathMatchingResourcePatternResolver。其方法 getResources(String locationPattern) 对于路径通配符的处理有如下规则:

    • classpath*:mapper/*.xml:默认只在当前 classpath 下的 mapper 目录寻找,不会递归遍历子目录
    • MyBatis 启动时,SqlSessionFactoryBeanbuildSqlSessionFactory() 方法会调用 PathMatchingResourcePatternResolver 来加载所有匹配的 XML 资源。如果模式不匹配,resolver.getResources() 将返回空数组,导致 XmlConfigBuilderXmlMapperBuilder 没有可解析的输入。
    • 相关源码引用: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 数量,防止因配置错误导致生产事故。

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

  • 排查思路

    1. 检查配置项 :确认 mybatis.type-aliases-package 的值是否正确,包路径书写无误。
    2. 查看启动日志 :DEBUG 级别下,TypeAliasRegistry 在注册时会打印日志,可以观察哪些类被注册。如果包路径错误,将不会有任何注册日志。
    3. 使用 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>
  • 最佳实践

    • 全限定类名优先 :在 resultTypeparameterType 等处优先使用类的全限定名,这是最稳定、重构友好的做法。
    • 统一别名管理 :如需使用别名,应在一个统一的类中定义别名常量,或在所有实体类上使用 @Alias 注解显式指定别名。
    • 代码审查清单 :检查 application.yamltype-aliases-package 的拼写是否正确。

2.3 案例 3:config-locationconfiguration 同时配置导致属性覆盖

  • 错误示例
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>
  • 现象描述:开发人员期望驼峰映射生效,但实际未生效;或者反之。这取决于解析顺序,导致行为不确定。

  • 排查思路

    1. 明确配置来源 :检查 application.yaml 是否同时使用了 mybatis.config-locationmybatis.configuration.*
    2. 观察最终行为:开启 DEBUG 日志,观察 SQL 执行后属性映射情况,反推最终哪个配置生效。
    3. 查看 Configuration 对象 :使用 Arthas 的 ognl 命令查看 SqlSessionFactory.configuration.mapUnderscoreToCamelCase 等属性的最终值。
  • 根因分析mybatis-spring-boot-starterMybatisAutoConfigurationSqlSessionFactoryBean 共同处理这些配置。

    1. 解析配置文件 :如果 config-location 属性被设置,SqlSessionFactoryBean 会使用 XMLConfigBuilder 解析指定的 XML 文件,构建一个 Configuration 对象。
    2. 应用 Spring Boot 属性 :随后,MybatisAutoConfiguration 使用 org.springframework.boot.context.properties.bind.Bindermybatis.configuration 下的属性,通过反射调用 setter 方法直接设置到 步骤 1 生成的 Configuration 对象上。
    3. 覆盖问题 :如果 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-locationconfiguration 键。

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,应用响应变慢,最终导致无法建立新连接。

  • 排查思路

    1. 监控连接池 :观察 HikariPool 的 JMX MBean 或 Druid 监控页,会看到活跃连接数持续升高且不释放。
    2. 线程 Dump :使用 jstackArthas thread -b 分析线程堆栈。会看到大量线程阻塞在 HikariPool.getConnection() 等待连接。
    3. 代码审查 :重点检查所有调用 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() 方法的最终调用链中,事务对象 Transactionclose() 方法才会调用 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 插件,规则集应包括对 SqlSessionInputStream 等需要 close() 的资源的检查。
    • 非 Spring 场景强制约束 :如必须手动管理,所有 openSession() 必须紧邻 try-with-resources 结构,严禁在多个方法间传递未经管理的 SqlSession

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,似乎一级缓存没有生效。

  • 排查思路

    1. 检查事务边界 :确认调用方和被调用方是否都在同一个 Spring @Transactional 注解的作用域内。
    2. 开启 MyBatis TRACE 日志logging.level.org.mybatis=TRACE。观察日志,每次查询前都会打印 Cache Hit Ratio [xxx:xxx],可以确认缓存是否命中。
    3. 检查 SqlSession 创建 :关键点在于理解 Spring 如何管理 SqlSession
  • 根因分析 : 这与 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
      }
  • 修正方案

    1. 确保事务生效 :确认 @Transactional 注解的方法是通过 AOP 代理调用的(即从外部类调用,而非本类内部调用)。这是最常见的原因。
    2. 使用 ExecutorType :对于需要一级缓存场景,确认未使用 BATCHREUSE 执行器。
    3. 明确设计:如果一个方法内需要多次访问同一数据,最佳实践是将其提取到方法外部,或使用更高级的缓存策略。
  • 最佳实践

    • 避免跨方法依赖一级缓存:一级缓存是 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 并未实际执行,数据库中缺少数据。尤其是在批处理数量较大时,更容易出现。

  • 排查思路

    1. 对比数据:将插入列表数量和数据库最终数据量进行对比,发现数量对不上。
    2. 日志对比 :开启 p6spy,可以看到执行的 SQL 语句数量和实际预期不符。
    3. 断点调试 :在 BatchExecutorflushStatementsdoFlushStatements 上设置断点,观察它们是否被调用。
  • 根因分析BatchExecutor 的工作模式是"延迟执行"。调用 mapper.insert(user) 时,SQL 语句和参数被添加到内部的 Statement 的批次队列中,并不会立即发送给数据库

    • commitflushStatements 的关系 :JDBC 的 Connection.commit() 仅用于提交事务,它不保证 之前通过 Statement.addBatch() 添加的批命令被执行完。HashSet 需要在 commit 之前,显式调用 Statement.executeBatch() 显式执行所有缓存的命令。

    • MyBatis 的执行链BaseExecutor.flushStatements() 就是用来触发 doFlushStatements() 的。我们在 commit 前调用它会强制刷新。BaseExecutorcommit 方法虽然也会调用 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 条记录就 flushStatementsclearCache 一次,防止内存溢出。
    • 使用 MyBatis-PlusMyBatis-PlusServiceImpl.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;
}
  • 现象描述:在一个方法中多次查询同一数据,即使中间有其他操作明确修改了数据库,后续查询返回的仍是第一次查询时的旧值,造成了逻辑错误。

  • 排查思路

    1. SQL 日志 :开启 MyBatis TRACE 日志,会看到日志 Cache Hit Ratio [xxx:xxx],证明第二次查询命中了缓存,没有打印 SQL。
    2. 代码审查:检查是否在同一个事务内通过 JDBC 模板等非 MyBatis 手段修改了数据库。
    3. 加断点 :在 BaseExecutor.query() 中的 localCache.getObject(key) 处打断点,观察缓存对象何时被创建、何时被命中。
  • 根因分析 : MyBatis 的一级缓存是 SqlSession 级别的,实现类为 PerpetualCache,存储在 BaseExecutor.localCache 中。

    • 当执行增删改操作时,BaseExecutorupdate() 方法会调用 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 中有值,将完全跳过数据库查询,这直接导致了脏读。

  • 修正方案

    1. 使用 @Transactional(propagation = Propagation.REQUIRES_NEW) :将外部修改操作放在一个新事务中,使其在当前 SqlSession 的事务提交前完成并可见,但这不能解决一级缓存问题。
    2. 手动清缓存(不推荐) :在修改后、查询前调用 sqlSession.clearCache(),但侵入性强。
    3. 使用二级缓存控制 :如果业务允许脏读时间窗口,可配置二级缓存,并通过 flushCache 属性在修改时主动清空。
    4. 最佳修正:不要混用 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 的二级缓存仍然是旧数据。请求打到不同节点时,返回的数据不一致,出现"幽灵数据"现象。

  • 排查思路

    1. 观察现象:通过前端页面或 API 响应,发现数据在不同刷新请求下会反复变回旧值。
    2. 检查缓存后台:查看 Redis 等集中式缓存中,是否存在多个不同版本的缓存项(如果使用了集中式缓存)。
    3. 抓包对比:分别对节点 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
  • 修正方案

    1. 禁用二级缓存 :在分布式多节点环境下,生产级应用的首选是禁用 MyBatis 二级缓存(cache-enabled: false)。这是最简单、最安全的实践。
    2. 使用统一的缓存层:如果需要缓存,在 Service 层使用 Spring Cache、JetCache 等框架,配合 Redis/Caffeine 实现。这些框架对分布式环境有更好的支持(如支持通过 MQ 或 Redis Pub/Sub 实现缓存失效广播)。
    3. 业务容忍最终一致性 :如果必须使用 MyBatis 二级缓存,必须接受数据最终一致性,并设置极短的 timeToLivetimeToIdle
  • 最佳实践

    • 生产禁用在没有经过严格的分布式一致性测试前,多节点生产环境默认必须禁用 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。

  • 排查思路

    1. 对比查询参数 :检查日志中相邻两个相同查询的参数对象,虽然是"内容相同",但是否是同一个对象实例或同一个List
    2. 开启 DEBUG 日志 :在 BaseExecutor.createCacheKey()CachingExecutor.query() 处设置断点,观察 CacheKey 的生成过程。
    3. 检查 CacheKeyupdate 方法CacheKeyupdate(Object) 方法会调用对象的 hashCode()toString() 等方法。
  • 根因分析 : MyBatis 的 CacheKeyBaseExecutor.createCacheKey()MappedStatement.getBoundSql() 等逻辑生成。对于集合类型的参数,CacheKey 的生成依赖于 List 对象的 hashCode()

    • JDK 中 AbstractListequalshashCode 是基于内容的,所以 ids1.equals(ids2)true

    • 但是,CacheKeyupdate 方法在处理对象时,最终会调用 obj.hashCode()。虽然 ids1.hashCode()ids2.hashCode() 相同,问题常出在更复杂对象上。一个更常见的陷阱是:如果 foreach 的参数是某个未重写 equals/hashCode 的复杂对象集合,则即使业务含义相同,每次传入的新对象的 hashCode 也可能不同,导致 CacheKey 不同。

    • 源码分析 org.apache.ibatis.cache.CacheKey.update(Object)

      java 复制代码
      public 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

  • 修正方案

    1. 确保参数的稳定性 :在应用层就确保作为查询参数的实体类,尤其是集合和自定义对象,正确且稳定地实现了 equals()hashCode() 方法。
    2. 简化参数 :尽量使用基本类型、StringMap<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,导致数据库被拖库、篡改。

  • 排查思路

    1. 安全扫描 :使用 SonarQube、Fortify 等代码审计工具,此类工具会对 ${} 在 SQL 中的使用发出高优先级警告。
    2. 日志审查 :使用 p6spy 或自定义诊断拦截器,记录最终执行的完整 SQL。通过观察日志中 SQL 的拼接情况,可以迅速定位 SQL 注入点。
    3. 手动测试:在输入框中输入典型的注入测试字符串进行验证。
  • 根因分析 : 在 MyBatis 的 SQL 构建流程中,#{}${} 由不同的处理路径处理。

    • #{}:在解析 SQL 时,SqlSourceBuilder 会将 #{} 替换为 ?,生成 ParameterMapping 列表。随后编译出的 StaticSqlSourceDynamicSqlSource 会将参数值安全地设置进 PreparedStatement,这是预编译,从原理上杜绝了 SQL 注入。
    • ${}SqlSourceBuilder 同样处理它,但逻辑完全不同。它进行的是字符串直接替换 。它会取出 ${} 内的属性值,通过 OGNL 从参数对象中获取值,然后直接拼接 到 SQL 字符串中。这个过程完全绕过了 PreparedStatement 的参数设置机制。
    • 源码证据:在 org.apache.ibatis.builder.SqlSourceBuilderorg.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 的场景,必须在应用层进行严格的白名单校验

    java 复制代码
    private 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

  • 排查思路

    1. 比对名称 :逐一对比 SQL 查询返回的列名(AS 后的别名或原始列名)与 resultType 对应 POJO 的字段名。
    2. 检查驼峰转换 :确认 mapUnderscoreToCamelCase 配置是否生效。本例中,别名是 crt_time,即使开启驼峰也无法映射到 createTime
    3. 单元测试 :为映射器编写断言 assertNotNull(user.getCreateTime()) 的集成测试,可以第一时间发现映射问题。
  • 根因分析 : 当使用 resultType 时,MyBatis 使用 DefaultResultSetHandlercreateAutomaticMappings 方法进行自动映射。

    • 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 在开发时查看执行结果,可以辅助发现映射问题。

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]

  • 排查思路

    1. 检查接口方法 :查看 Mapper 接口方法的参数个数,当参数大于 1 个时,检查是否使用了 @Param 注解或在 XML 中使用了 MyBatis 默认的参数名(arg0param1)。
    2. JDK 版本与编译选项 :是否使用 JDK 8+ 且编译时保留了方法参数名(-parameters 选项)。如果是,则可以使用真实参数名。但依赖编译选项是脆弱的。
  • 根因分析 : MyBatis 使用 ParamNameResolver 来解析参数名。

    • 当方法没有使用 @Param 注解时,ParamNameResolver.getNamedParams() 方法会尝试使用 JDK 反射 API Parameter.getName() 获取参数名。如果编译时未加 -parameters 参数,获取到的名字是 arg0arg1 等。
    • 同时,为了提供通用性,MyBatis 还会为每个参数生成 param1param2 这样的默认名称。因此异常信息中提示可用参数是 [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 方法编写简单的单元测试验证绑定正确性。

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 的判断条件为 falseAND status = 0 的片段被丢弃,SQL 查询了所有用户,导致业务逻辑错误。

  • 排查思路

    1. 审查动态 SQL :重点审查 <if test> 中判断数值的表达式。
    2. 开启 TRACE 日志 :查看 MyBatis 生成的最终 SQL。当 status=0 时,Trace 日志会显示拼接出的 SQL 中没有 status 条件。
    3. 理解 OGNL :MyBatis 使用 OGNL 来解析 <if test> 中的表达式。
  • 根因分析 : 在 OGNL 表达式中,Integer 类型的 0 被视为 false,这与 Java 的布尔逻辑不同(Java 中只有 boolean 才用于判断)。然而,更隐蔽的陷阱是 status != '' 这句话。一个 Integer 值与空字符串 '' 比较时,Integer 会被转换为字符串,而 Integer.valueOf(0).toString()"0",不等于 "",所以正常情况下不会出错。但这段代码的风险本质在于:使用 status != '' 这种专用于字符串的判空逻辑来判断数值类型 ,这是不规范的,且在某些 OGNL 版本或复杂对象嵌套下,行为可能不一致。 更常见的错误是:<if test="status != null"> 如果 status 恰好是某个 POJO 的属性,这个判断是安全的。但如果是简单的 intInteger,这个表达式本身只检查非空,不检查是否为 0,因此如果设计意图是 status == 0 也作为查询条件,则此写法正确。设计意图如果是"有值时才查询",那么当 status=0 时,确实应该作为条件。问题在于开发者经常用 != '' 来同时判空和空字符串,这对数值类型是无效的。核心根因是 OGNL 0 的 falseness 与字符串比较的误用。

  • 修正方案 : 对数值类型的参数,只进行 null 判断。

    xml 复制代码
    <!-- 修正:数值类型仅判断 null -->
    <if test="status != null">
        AND status = #{status}
    </if>
  • 最佳实践

    • 类型匹配判断String 类型用 != null and != '';数值类型(intIntegerLong等)用 != null;集合类型用 != null and !collection.isEmpty()
    • 代码审查 :逐条检查 <if test> 中的表达式,确保判空逻辑与参数类型匹配。
    • 使用 @BuilderOptional :在调用 Mapper 前,构建明确的条件对象,避免传递 null0 等二义性值。

6. 插件拦截反模式

MyBatis 插件是责任链模式的典型实现,其包装顺序和异常处理机制是产生疑难问题的温床。

6.1 案例 14:分页插件与脱敏插件注册顺序不当

  • 错误示例
java 复制代码
// application.yaml
mybatis:
  configuration:
    interceptors:
      - com.example.plugin.SensitivePlugin   // 脱敏插件
      - com.github.pagehelper.PageInterceptor // 分页插件
  • 现象描述:一个需要分页查询的接口,先执行了分页查询,但在结果返回时,分页总记录数可能不正确,或者数据内容没有被正确脱敏/加密。

  • 排查思路

    1. 检查插件列表 :打印 Configuration.interceptorChain 中插件的注册顺序。
    2. 阅读插件代码 :查看每个插件的 plugin() 方法和 intercept() 方法使用的签名,确定它包装的是 ExecutorStatementHandler 还是 ResultSetHandler
    3. 断点调试 :在 Plugin.wrap() 和各自的 intercept() 方法上打断点,观察代理对象嵌套结构和调用顺序。
  • 根因分析 : MyBatis 的插件链通过 Plugin.wrap() 实现,它返回一个 JDK 动态代理对象。多个插件会形成一层层的代理嵌套。

    • InterceptorChain.pluginAll(Object target) 方法的源码逻辑是:循环遍历拦截器列表,依次调用每个拦截器的 plugin 方法,将上一次的返回值作为下一次的输入进行包装。

    • 源码位置org.apache.ibatis.plugin.InterceptorChain.pluginAll():

      java 复制代码
      public Object pluginAll(Object target) {
        for (Interceptor interceptor : interceptors) {
          target = interceptor.plugin(target);
        }
        return target;
      }
    • 影响 :由于代理是嵌套的,后注册的插件在外层,先注册的插件在内层 。对于执行器 Executor,调用顺序是:外层插件(后注册)先拦截,然后逐层进入内层,最内层是 Executor 实现。对于结果处理器 ResultSetHandler,结果数据返回的顺序则是:内层插件(先注册)先处理,然后向外层传递。

    • 分页插件通常拦截 Executor.query,在 SQL 执行前进行改写。脱敏插件大多拦截 ResultSetHandler.handleResultSets,在结果返回后进行数据脱敏。如果事件在分页插件改写 SQL 之前或之后发生,逻辑可能会错乱。如果脱敏插件错误地处理了分页查询 count 的结果,就可能导致总记录数异常。插件的注册顺序决定了它们的执行顺序和嵌套结构,错误的顺序可能导致功能互相屏蔽或逻辑出错。

  • 修正方案: 严格根据插件的职责和协作关系定义顺序。通常:

    1. 功能性插件优先:如多租户、数据权限过滤插件,这些需要在数据查询最源头生效,应注册在最前面(最内层)。
    2. 分页插件居中:分页插件需要在功能过滤之后执行,以获得正确的分页结果。
    3. 数据处理插件在后:如脱敏、加密插件,在结果返回的最后阶段处理,应注册在最后面(最外层)。
    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% 以上。

  • 排查思路

    1. 开启 PageHelper debug 日志logging.level.com.github.pagehelper=DEBUG,会打印出改写后的 COUNT SQL。
    2. 分析 SQL :检查打印出的 COUNT SQL 计划,发现它对整个复杂查询(包括 JOIN 和 DISTINCT)做了 COUNT(*),导致全表扫描。
    3. 数据库慢查询日志:此 SQL 必然出现在数据库的慢查询日志中。
  • 根因分析 : PageHelper 等分页插件通过 jsqlparser 解析原始 SQL,并尝试生成一个 SELECT COUNT(*) 查询来获取总记录数。

    • 对于简单的单表查询,改写非常高效。
    • 对于包含 LEFT JOINDISTINCTGROUP BY、复杂子查询的 SQL,jsqlparser 无法进行深度优化。它可能只是简单地将 SELECT 列替换为 COUNT(0),而保留了不必要的 JOIN,导致数据库执行了一个极其昂贵的 COUNT 操作。
    • 源码层面:com.github.pagehelper.parser.CountSqlParser 负责此转换。它虽然会尝试移除不必要的 ORDER BYGROUP BY,优化 JOIN,但优化能力有限,无法处理所有复杂场景。
  • 修正方案

    1. 手动编写 COUNT 查询 :对于复杂的 SQL,不在 XML 中处理,而是单独提供一个手写的、高度优化的 COUNT SQL。然后通过 PageHelper 的 PageInfo 构造器直接传入总数。
    2. 使用 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 性能评审:对所有涉及分页的复杂查询,必须对生成的 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 操作无法执行。

  • 排查思路

    1. 查看异常堆栈 :异常堆栈会清晰地指向 BadPlugin.intercept()Plugin.invoke()
    2. 逐个排除插件:在有多个插件时,可以通过二分注释法,快速定位到出问题的插件。
  • 根因分析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 等错误。

  • 排查思路

    1. 确认数据源绑定 :检查 SqlSessionFactoryBean 的配置,确认哪个 Mapper 包被绑定到了哪个 SqlSessionFactory
    2. 日志验证 :开启 logging.level.org.mybatis.spring=DEBUGlogging.level.org.springframework.jdbc=DEBUG,可看到数据源获取和切换的详细过程。
    3. 使用 Arthas :动态注入代码,打印 Mapper 代理对象内部持有的 SqlSessionTemplatesqlSessionFactory 所使用的数据源 URL 信息。
  • 根因分析: 当存在多个数据源时,Spring 的自动配置需要知道默认注入哪一个。

    • @Primary 注解指定了Spring容器级别的首选 Bean 。如果没有为每个数据源显式地创建不同的 SqlSessionFactorySqlSessionTemplate,并绑定到特定的 Mapper,那么自动配置的 MybatisAutoConfiguration 会无条件地使用这个 @PrimaryDataSource 来创建唯一的 SqlSessionFactory
    • 源码分析:org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration.sqlSessionFactory(DataSource dataSource) 方法是自动创建工厂的地方。它会注入 ApplicationContext 中唯一的带有 @Primary 的 DataSource。如果忘记为不同的 Mapper 扫描包配置多个 SqlSessionFactoryBean,所有 Mapper 都将共用这个工厂,最终连接到 @Primary 标记的数据源。
  • 修正方案

    1. 明确分离 :为每个数据源都创建独立的 SqlSessionFactorySqlSessionTemplate Bean,并通过 @MapperScan 注解的 sqlSessionFactoryRefsqlSessionTemplateRef 属性明确指定 Mapper 使用哪一个。
    2. 弃用 @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.BeanDefinitionStoreExceptionNoUniqueBeanDefinitionException,提示有多个类型相同的 Mapper Bean 被发现。

  • 排查思路

    1. 检查 @MapperScan 配置 :确认项目中有几个地方使用了 @MapperScan@MapperScans
    2. 检查 MyBatis 版本 :在 mybatis-spring-boot-starter 2.0+ 版本中,AutoConfiguredMapperScannerRegistrar 会自动扫描带有 @Mapper 注解的接口。检查是否手写的 @MapperScan@Mapper 注解导致了重复扫描。
    3. 查看 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!");
        };
    }
}
  • 现象描述 :尽管定义了 ConfigurationCustomizer Bean,但其设置似乎没有生效。例如,期望 mapUnderscoreToCamelCasefalse,但运行结果表明它仍然为 true"Customizer applied!" 打印了,但最终配置被覆盖。

  • 排查思路

    1. 检查生效的配置 :通过 Arthas 或 JMX 查看 SqlSessionFactoryConfiguration.mapUnderscoreToCamelCase 最终值。
    2. 分析 Bean 初始化顺序 :理解 Spring Boot 的 MybatisAutoConfiguration 在何种阶段应用 ConfigurationCustomizer,又在何种阶段应用 mybatis.configuration 属性。
  • 根因分析MybatisAutoConfiguration.sqlSessionFactory() 方法的创建流程如下:

    1. 通过 XMLConfigBuilder 或直接实例化创建 Configuration 对象。
    2. 调用所有 ConfigurationCustomizer Bean 的 customize(Configuration) 方法。
    3. 关键一步 :最后,调用 this.mybatisProperties.applyTo(configuration)。此方法会将 application.yaml 中所有 mybatis.configuration.* 的属性,通过 Binder 强制设置到 Configuration 对象上。
    4. 因此,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: false
    java 复制代码
    // 方案二:纯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

  • 排查思路

    1. 检查异常信息 :异常信息会明确指出冲突的 statement id
    2. 审查 Mapper 接口 :检查自己定义的方法是否与父接口 BaseMapper 中的 CRUD 方法重名。
  • 根因分析 : MyBatis-Plus 通过 AbstractSqlInjector.inspectInject() 方法,在应用启动时为 BaseMapper 接口中的方法动态生成 MappedStatement 并注册到 Configuration 中。

    • 当用户在手写的 XML 或通过注解定义了一个相同IDnamespace + "." + methodName)的 MappedStatement 时,就会发生冲突。因为 Configuration 内部维护的 mappedStatements 是一个严格唯一的 Map。
    • 源码位置 org.apache.ibatis.builder.MapperBuilderAssistant 和 MyBatis-Plus 的 com.baomidou.mybatisplus.core.injector.AbstractSqlInjector.inspectInject()
  • 修正方案重命名手写方法 ,使其与 BaseMapper 中的任何方法名不同。

    java 复制代码
    // 修正后
    public interface UserMapper extends BaseMapper<User> {
        @Select("SELECT * FROM user WHERE name = #{name}")
        List<User> selectByName(@Param("name") String name);
    }
  • 最佳实践

    • 分前缀命名规范 :手写 Mapper 方法可以统一加 queryinsertupdatedelete 等业务前缀,或者使用 findloadsave 等,避免与通用的 selectinsert 等冲突。
    • 代码生成器配置 :在使用 MyBatis-Plus 的代码生成器时,通过模板排除与 BaseMapper 重名的方法。

8.2 案例 21:多租户插件未全局配置,导致部分查询遗漏 tenant_id 过滤

  • 错误示例
java 复制代码
// 分页插件配置了,但忘记配置多租户插件
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
    interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
    // 错误点:忘记添加 TenantLineInnerInterceptor
    return interceptor;
}
  • 现象描述:某个查询漏加了租户隔离条件,导致一个租户的用户看到了其他租户的数据,造成严重的数据安全事故。

  • 排查思路

    1. 代码审查 :重点检查 MybatisPlusInterceptor Bean 的配置,看是否遗漏了 TenantLineInnerInterceptor
    2. SQL 监控 :通过 p6spy 拦截最终执行的 SQL,检查 SQL 中是否包含 tenant_id 条件。如果某个查询没有,就是排查的重点。
    3. 单元测试:为每个查询都编写带上不同租户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 查询时,仍然可以查到这条"已删除"的数据。

  • 排查思路

    1. 审查手写 SQL :检查所有手写的查询、更新 SQL,看是否缺少 deleted = 0AND deleted = #{logic-not-delete-value} 条件。
    2. 对比默认行为 :先调用 BaseMapper.selectById(),再调用手写的 findById(),对比结果集。
  • 根因分析 : MyBatis-Plus 的 LogicSqlInjector 在启动时,会动态修改 BaseMapper 的标准方法(selectByIddeleteById 等),在它们的 SQL 中注入逻辑删除字段的条件。例如,将所有 DELETE 语句改写为 UPDATE,在所有 SELECT 语句 WHERE 后追加 deleted=0

    • 但是,这个注入过程不会处理开发者手写的 XML 或注解中的 SQL。 这些 SQL 对 MyBatis-Plus 来说是"自定义 SQL",它不会进行任何修改。因此,必须由开发者在编写 SQL 时手动处理逻辑删除逻辑。源码分析 com.baomidou.mybatisplus.core.injector.LogicSqlInjector
  • 修正方案 : 在所有手写的、需要过滤逻辑删除数据的 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 核心诊断工具汇总

  1. MyBatis TRACE/DEBUG 日志

    • 配置logging.level.com.example.mapper=TRACE
    • 核心输出 :SQL 执行详情、参数设置、返回结果行数、一级/二级缓存命中/未命中信息 (Cache Hit Ratio)。
    • 用途:是排查一切 SQL 执行问题、映射问题、缓存问题的第一入口。
  2. p6spy SQL 监控

    • 配置 :替换 JDBC Driver 为 p6spy 的虚拟驱动,并配置 spy.properties
    • 核心输出:拦截所有发往数据库的完整 SQL 语句(含参数值)、执行耗时。
    • 用途:用于精确监控 SQL 执行性能,发现重复执行、慢查询,以及确认 MyBatis 最终生成的 SQL。
  3. 自定义诊断拦截器

    • 实现 :实现 org.apache.ibatis.plugin.Interceptor 接口,拦截 Executor.update/queryStatementHandler
    • 核心能力 :记录每次 SQL 调用的完整链路,如:调用方类、方法、MappedStatement ID、参数、执行耗时、事务上下文。
    • 用途:建立完整的 SQL 调用链视图,尤其在定位"谁执行了这条慢 SQL"或"插件执行顺序"时至关重要。
  4. Arthas 动态追踪

    • 常用命令
      • watchwatch org.apache.ibatis.executor.BaseExecutor query '{params, returnObj, throwExp}' -n 5 -x 3 (监控 Executor 行为)。
      • stackstack com.example.mapper.UserMapper findById (追踪指定方法的完整调用栈)。
      • ognlognl '@org.apache.ibatis.session.Configuration@TYPE_ALIAS_REGISTRY.typeAliases' (查看别名注册)。
      • getStatic:获取静态字段值。
    • 用途:在不重启应用的情况下,动态诊断线上问题。无侵入地查看内部状态、追踪调用链和定位异常。
  5. Actuator 端点

    • 端点/actuator/beans/actuator/mappings/actuator/env/actuator/health
    • 用途 :检查 Bean 的注册情况(特别是 SqlSessionFactoryDataSource),确认配置是否正确加载。

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 committingJDBC Connection 获取与释放记录
"启动失败/注册冲突" Actuator /beans、启动日志 BeanDefinitionStoreExceptionMapped Statements collection already...
"连接泄漏" Druid/Hikari 监控页、Arthas thread ActiveConnections 持续高位、线程堆栈阻塞在 getConnection

9.3 标准化排查决策树

以下决策树提供了一条从异常现象出发,逐步定位到具体反模式和源码位置的通用路径。

flowchart TB Start["遇到MyBatis问题"] --> Q1{"现象类别?"} Q1 -- "启动时异常" --> Q2{"具体异常?"} Q2 -- "Bean定义冲突" --> F1["7.2 Mapper重复注册"] Q2 -- "映射语句冲突" --> F2["8.1 CRUD方法同名"] Q2 -- "类别名/类找不到" --> F3["2. 配置与初始化
案例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() 来收集资源,若匹配失败,则 XMLConfigBuilderXMLMapperBuilder 将拿不到文件。
  • 多角度追问
    • 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.TextSqlNodeBindingTokenParser.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 接口的实现,在 putObjectclear 方法中加入全局广播通知机制。
  • 加分回答:在设计模式上,这是一个典型的 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 内部。
  • 多角度追问

    • BatchExecutorSimpleExecutor + rewriteBatchedStatements 有何区别?→ SimpleExecutor 每次 update 都执行单条 SQL,即便开启该参数也无法批量组装,因为批次未积累。BatchExecutor 是 MyBatis 层面的批处理,它将多个语句聚集后一次性发给驱动,此时驱动层的改写才能发挥作用。
    • 为什么有时候开了 rewriteBatchedStatements 但没效果?→ 需要确保 JDBC 连接 URL 中正确配置了 rewriteBatchedStatements=true,并且使用了 BatchExecutor 或 MyBatis-Plus 的 saveBatch 等基于批处理的方法。
    • BatchExecutorSELECT 语句有影响吗?→ 没有,BatchExecutor 只对 update(包含 INSERTUPDATEDELETE)操作进行批处理。
  • 加分回答 :在数据插入性能调优中,除了应用层批处理和驱动层改写,还可以在数据库端调整 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,而是复用之前的,仅重新设置参数。这会带来几个问题:
      1. 参数绑定错误 :如果在第二次执行时,之前的 Statement 还残留有上一次查询的结果集或错误的参数状态,可能导致异常或数据错误。虽然 MyBatis 会在复用前调用 clearParameters(),但在某些驱动或并发场景下仍可能存在隐患。
      2. 内存泄漏 :由于缓存的 Statement 是强引用,它们的生命周期与 SqlSession 相同。如果一个 SqlSession 执行了大量不同的 SQL,这些 Statement 对象会一直占用内存和数据库游标,直到 SqlSession 关闭。
      3. 游标超限 :特别是 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 类上使用,底层会注册一个 MapperScannerConfigurer BeanDefinition,它通过 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,抛出 BeanDefinitionStoreExceptionNoUniqueBeanDefinitionException
  • 多角度追问

    • 如果必须共存,如何解决冲突?→ 在 @MapperScan 上不需做改变,但要将自动配置中的 MapperScannerConfigurer 排除,可以通过 spring.autoconfigure.exclude 排除 MybatisAutoConfiguration(不推荐),或干脆为接口移除 @Mapper 注解,仅用 @MapperScan 即可。
    • 为什么默认自动扫描有时会失效?→ 可能是 @Mapper 注解未正确导入包(是 org.apache.ibatis.annotations.Mapper,不是 MyBatis-Plus 的 @Mapper),或接口不在自动配置类的扫描包范围内。
    • mybatis-plus 中的 @MapperScan 有区别吗?→ 原理一致,MybatisPlusAutoConfiguration 有类似的自动扫描,同样会冲突。
  • 加分回答 :可以在 @MapperScansqlSessionFactoryRef 属性上做文章,将两个 ScannerConfigurer 关联到不同的 SqlSessionFactory,实现多数据源。但即使如此,也要避免扫描路径重叠导致的同一个 SqlSessionFactory 下重复注册。

9. 如何通过自定义 Interceptor 实现全链路 SQL 耗时监控?

  • 一句话回答 :实现 Interceptor 接口,拦截 StatementHandler.query/updateExecutor 层,记录 SQL 执行前后的纳秒时间差,并结合上下文(如 HTTP 请求 URL、用户信息)将慢 SQL 事件异步上报。

  • 详细解释

    • 拦截点选择 :建议拦截 StatementHandlerqueryupdate 方法,因为此时 SQL 已经过动态 SQL 组装,我们能获取到最终的 BoundSql 对象,包含 SQL 文本和参数映射。@Signature 配置为 type = StatementHandler.class, method = "query/update", args = {Statement.class, ResultHandler.class}
    • 耗时计算 :在 intercept 方法中,记录 System.nanoTime() 开始时间,调用 invocation.proceed() 执行原逻辑,结束后再次计算时间差,得到微妙或毫秒级的响应时间。
    • 全链路关联 :利用 ThreadLocalMDC 将当前请求的 TraceId、用户ID、访问URL等信息传递到拦截器中,与 SQL 耗时一起记录。这要求在 Web 层 Filter 中预先设置。
    • 慢查询识别与上报:设定阈值(如 1秒),超过则异步发送至监控系统(如 Prometheus, SkyWalking, 自定义 MQ)。注意拦截器内不要有太重的同步操作,以免阻塞数据库操作线程。
  • 多角度追问

    • 如何防止监控本身造成性能瓶颈?→ 使用无锁队列(如 ConcurrentLinkedQueue)或 Disruptor 暂存事件,批量异步上报;耗时计算也可用 StopWatch
    • 想知道 SQL 的参数值怎么处理?→ 通过 BoundSql.getParameterMappings()StatementHandler.getParameterHandler() 获取 ParameterHandler,循环读取参数值并替换掉 ? 占位符,生成可读的完整 SQL。注意脱敏。
    • 有些 SQL 没有走 StatementHandler 拦截怎么办?→ 可以拦截 Executorquery/update,但获取的参数可能未组装,略有不同。通常 StatementHandler 已经足够。
  • 加分回答 :结合 Micrometer@Timed 注解或直接使用 Timer.Sample,可将 SQL 耗时作为自定义指标暴露给 Spring Boot Actuator,并通过 Prometheus 抓取,利用 Grafana 绘制 SQL 性能趋势图,实现零侵入式的监控埋点。

10. 什么是 MyBatis 的 CacheKey?为什么动态 SQL 可能会导致缓存命中率低?

  • 一句话回答CacheKey 是 MyBatis 用于唯一标识一次查询的缓存键,由 MappedStatement ID、分页参数、SQL 语句和参数值共同计算得到;若动态 SQL 的参数中包含频繁变化的对象(如 List、未重写 equals/hashCode 的对象),会导致每次生成的 CacheKey 都不同,缓存形同虚设。

  • 详细解释

    • CacheKey 的构成 :在 BaseExecutor.createCacheKey() 方法中生成,主要包括:MappedStatement.idRowBounds 的偏移量和限制数、BoundSql.getSql() 字符串本身、以及传递给 SQL 的参数值列表。对于每个参数,通过 cacheKey.update(parameter) 将参数的 hashCodetoString 等特征更新到键中。
    • 动态 SQL 的影响 :当使用 <foreach> 传递一个 ListSet 时,这个集合对象作为参数参与了 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.queryBaseExecutor.querycreateCacheKey 处设置断点,对比连续两次请求的 CacheKey 字段。
    • 一级缓存也用 CacheKey 吗?→ 是的,localCache 也是基于 CacheKey 命中的,缓存不稳定同样影响一级缓存。
    • MyBatis-Plus 分页插件会改变 CacheKey 吗?→ 分页插件的 PaginationInnerInterceptor 会在 SQL 外层包裹 COUNT 查询和分页查询,因此生成的 MappedStatement 和 SQL 都变了,缓存自然很难命中,除非插件做了特殊处理。
  • 加分回答 :可以通过实现一个自定义的 CacheKey 生成策略来干预,比如重写 update 方法对集合类型统一进行排序和摘要哈希,但成本和风险较高。更务实的做法是在业务层用 Spring Cache 等抽象,以明确的业务 Key(如用户ID、状态)进行缓存,避开底层 CacheKey 的脆弱性。

11. 排查一个"查询结果中某个字段总是 null"的问题,你的步骤是什么?

  • 一句话回答 :依序检查:SQL 查询列名/别名、实体属性名是否正确;是否开启了驼峰转换配置;resultMapresultType 映射是否正确;TypeHandler 是否匹配。

  • 详细解释

    1. 开启 MyBatis TRACE 日志:首先查看日志中打印出的执行结果,观察该字段对应的列是否有返回值。
    2. 核对列名与属性名 :检查 XML 中的 SQL 是否有 AS 别名,别名是否与 Java 属性名完全一致(驼峰需关闭时)。若开启了 map-underscore-to-camel-case,则检查下划线形式是否满足转换规则(如 create_time -> createTime)。特别注意有 crt_time 这种不规范命名。
    3. 检查 resultMap 配置 :如果使用了 resultMap,查看 <result column="" property=""/> 是否写反, jdbcTypejavaType 是否匹配。
    4. 排查 TypeHandler :若字段是自定义类型(如 JSON、枚举),检查对应的 TypeHandler 是否注册,是否在 type-handlers-package 扫描路径内,是否在 resultMap 中显式指定。
    5. 数据库驱动差异 :有时驱动返回的列名大小写或格式与预期不符,可通过 ResultSetMetaData.getColumnLabel()getColumnName() 排查。可以在 DefaultResultSetHandler 打断点观察。
    6. 构建测试用例 :编写一个返回 Map 的查询,或在数据库工具中执行相同 SQL,排除数据本身为 null 的可能。
  • 多角度追问

    • 如果列名和属性完全一致,但还是 null,可能是什么原因?→ 可能是启用了延迟加载(fetchType=LAZY)而 Session 已关闭,或 TypeHandler 未生效。
    • 使用 resultType="HashMap" 时,key 的大小写问题怎么解决?→ MySQL 驱动默认返回的全是列名大写,可通过连接参数 useColumnLabel=true 并使用别名指定为小写。
    • MyBatis-Plus 的 @TableField 注解可以解决这个问题吗?→ 可以,设置 @TableField("db_col") 显式指定映射列名,优先级高于默认规则。
  • 加分回答 :团队可强制要求使用 MyBatis Generator 生成 resultMap,或通过 Lombok@TableField 注解联动,利用编译期检查避免字符串映射错误。在 CI 中加入针对所有 Mapper 方法的 smoke test,检查返回对象关键字段非空。

12. MyBatis-Plus 的通用 CRUD 和手写 XML 的同名方法冲突怎么办?

  • 一句话回答 :重命名手写方法,使其不与 BaseMapper 内的方法重名;或者在 BaseMapper 的基础上继承一个自定义的 BaseMapper,避开默认方法名。

  • 详细解释

    • 冲突原理 :MyBatis-Plus 的 AbstractSqlInjector 在启动时会根据 BaseMapper 的接口方法动态生成对应的 MappedStatement,其 idnamespace.methodName。如果用户同时在手写的 XML 中定义了相同 ID 的语句,MyBatis 在加载 XML 时通过 MapperBuilderAssistant 注册,会检测到 Map 中已存在同 ID 的 MappedStatement,从而抛出 already contains value 异常。
    • 解决方案
      1. 手写方法改名 :最简单的方式,将手写的 selectList 改为 selectCustomList 等。
      2. 使用自定义 BaseMapper :如果不想改名,可以自定义一个接口 MyBaseMapper<T> extends BaseMapper<T>,在其中声明与手写相同的抽象方法,但注意 MyBatis-Plus 只会为 BaseMapper 接口注入 SQL,自定义的子接口方法不会自动注入,因此不会冲突。但这不是 MyBatis-Plus 的常规做法。
      3. 排除注入 :通过 @SqlParser(filter = true) 或全局配置,让特定方法不走注入逻辑,但这可能影响其他功能。
  • 多角度追问

    • 如果我不小心让手写的 updateBaseMapper.updateById 冲突,但我是有意覆盖,怎么办?→ MyBatis 不支持 MappedStatement 覆盖,必须通过 XML 或注解方式重新定义一个不同 ID 的方法。
    • 如何避免团队成员再次犯错?→ 代码评审时核对 Mapper 接口方法名,并在代码生成器模板中排除 BaseMapper 已包含的方法名。
    • MyBatis-Plus 3.x 有没有提供自动的冲突检测和跳过?→ 不会跳过,直接启动报错,这是为了安全。
  • 加分回答 :可以使用 MybatisPlusInterceptor 或自定义插件检查 MappedStatement 冲突,并在启动阶段日志输出所有现有 Statement ID,辅助检查。最佳实践是为手写 SQL 统一加业务前缀,如 query*load*save*,与 select*insert* 天然隔离。

13. 逻辑删除开启后,为什么手写查询仍然能查到"已删除"的数据?

  • 一句话回答 :因为 MyBatis-Plus 的 LogicSqlInjector 只会修改 BaseMapper 内置方法的 SQL,不会处理用户手写的 SQL,所以手写 SQL 必须手动添加逻辑删除条件。

  • 详细解释

    • 逻辑删除实现 :在 MyBatis-Plus 中,配置 logic-delete-field: deleted 后,框架会通过 LogicSqlInjector 拦截 BaseMapperdeleteByIdselectById 等方法,将 DELETE 改写为 UPDATE,并为 SELECT 语句自动添加 AND deleted=0 条件。
    • 自定义 SQL 的盲区 :用户在 XML 中手写的 select,不会被 LogicSqlInjector 处理,因为它只处理通过 AbstractSqlInjector 注入的那部分 MappedStatement。自定义 SQL 直接由 MyBatis 核心解析,完全不具备逻辑删除的"感知"。
    • 源码证据com.baomidou.mybatisplus.core.injector.LogicSqlInjector.inspectInject() 方法负责标准 CRUD 的注入,而自定义 SQL 则不会进入此逻辑。此外,LogicSqlInjectorTenantLineInnerInterceptor 不同,它不是全局拦截器,而是注入时起作用。
  • 多角度追问

    • 有没有办法让自定义 SQL 也自动带上逻辑删除条件?→ 在 MyBatis-Plus 3.3.0 后提供了 @SqlParser(filter = true) 和拦截器级别的处理,但配置复杂且可能影响性能;更推荐使用 SQL 片段复用(<sql> 标签)或 BaseMapper 的方法。
    • 手写 SQL 需要加 deleted = 0,那要不要加 deleted is null?→ 根据逻辑删除字段定义,默认未删除是 0,直接加 deleted = 0 即可。
    • 逻辑删除和唯一索引约束如何共存?→ 将 deleted 字段加入唯一索引,或将已删除数据移到历史表,否则唯一索引会阻止逻辑删除后的相同数据再次插入。
  • 加分回答 :可利用 MyBatis 的插件机制,拦截 StatementHandler,在手写 SQL 的 BoundSql 上统一追加 AND ${logicField} = ${notDeleteValue},但需要考虑别名、表名等复杂情况。最稳妥的还是推荐使用 SQL 片段 <include refid="notDeleted"/> 显式管理。

14. 多数据源下,MyBatis 如何正确绑定事务管理器?

  • 一句话回答 :每个数据源需要有自己独立的 DataSourceTransactionManager,并在 @Transactional 注解中通过 valuetransactionManager 属性指定对应的事务管理器 Bean 名称。

  • 详细解释

    • 多个事务管理器 :当有数据源 ds1ds2 时,需要分别创建 DataSourceTransactionManager Bean,如 ds1TransactionManagerds2TransactionManager。注意不能同时指定 @Primary,除非一个为主。
    • @Transactional 指定 :在 Service 层方法上使用 @Transactional("ds1TransactionManager")@Transactional(transactionManager = "ds1TransactionManager"),明确告诉 Spring 使用哪个事务管理器。
    • 与 MyBatis 的绑定 :MyBatis 的 SqlSessionFactorySqlSessionTemplate 是通过 @MapperScansqlSessionFactoryRef 绑定到特定的数据源。事务管理器内部也持有相同的数据源。当事务管理器开启事务时,它会将连接绑定到当前线程,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() 会从 JDBC ResultSet 中读取下一行。这要求 JDBC 连接和 ResultSet 一直打开。
    • Spring 事务下关闭原因 :在 Spring 托管下,SqlSessionTemplateSqlSessionInterceptor 在非事务方法调用结束时或事务提交/回滚后会立即关闭 SqlSession,进而关闭 ResultSet。如果 Cursor 还没有被消费完,后续的 next() 会抛出 java.sql.SQLException: Operation not allowed after ResultSet closed
    • 解决方案
      1. 让方法不事务,手动控制 :在流式查询的方法上不加 @Transactional,并确保返回 Cursor 后,调用方在 try-with-resources 中使用,但调用方不能跨事务。
      2. 使用 @Transactional + TransactionSynchronizationManager 延迟关闭 :指定 @Transactional 保证连接在使用期间有效,并在 Service 方法内完成所有对 Cursor 的遍历,不将 Cursor 对象泄露到方法外部。
      3. 手动自定义 SqlSession :通过 sqlSessionFactory.openSession() 创建绑定特定线程的 SqlSession,用完手动关闭,但这违背了 Spring 集成初衷。
  • 多角度追问

    • MyBatis-Plus 的 cursor 方法安全吗?→ 它返回 Cursor<T>,同样需要注意生命周期,官方示例都建议在 try 块中遍历。
    • 流式查询与分页查询如何选择?→ 流式查询适合大数据量导出、逐条处理的场景,分页查询适合需要总数和随机跳页的场景。
    • Cursor 直接调用 stream() 会怎样?→ 一旦流被终止或离开 lambda,ResultSet 可能仍没关闭,必须确保底层连接最终被正确释放,通常用 try (Cursor<T> cursor = mapper.selectCursor(...))
  • 加分回答 :可以自定义一个 StreamResultHandler 并配合 SqlSession 手动管理,实现更灵活的流式处理。或者使用 MyBatis-PlusPage 结合 scrollResult (游标分页) 来处理,但本质上仍是流式,需注意资源释放。最佳实践:将流式 SQL 处理逻辑封装在 SqlSessionDaoSupport 子类中,并借助事务模板 TransactionTemplate 保证资源安全。

16. @TransactionalEventListener 与 MyBatis 的一级缓存有什么关系?

  • 一句话回答@TransactionalEventListener 在事务提交后执行,而此时 MyBatis 的一级缓存所在 SqlSession 已经关闭,再试图访问延迟加载的属性或调用新的 MyBatis 操作,将发生 LazyInitializationException 类似的错误或新查询无法利用之前缓存。

  • 详细解释

    • 事件执行时机@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 标注的方法,会在当前事务提交成功后执行。默认是 AFTER_COMMIT
    • 一级缓存的边界 :一级缓存属于 SqlSessionSqlSession 随着事务提交而被 Spring 关闭。因此,当事件处理方法执行时,之前那个装着缓存数据的 SqlSession 已经逝去。
    • 影响 :如果事件处理方法中需要访问事务方法内查询出的实体,并且该实体存在未加载的延迟属性,访问会失败,因为 SqlSession 已关闭。如果事件处理方法内再次通过 Mapper 查询相同数据,这不是之前那个 SqlSession,一切从新开始,一级缓存不再生效。此外,若事件处理方法期望看到事务内最新提交的数据,此时数据库是可见的,但无法利用缓存。
    • 源码关联TransactionSynchronizationAdapterafterCommit 回调触发了事件发布,而 SqlSessionTemplateSqlSessionInterceptor 在事务完成时会清理资源,关闭 SqlSession
  • 多角度追问

    • 如何避免在事件中延迟加载失败?→ 在事务方法内,先调用 Hibernate.initialize() 或手动执行 mapper.selectList 完成所有需要数据的加载,或干脆关闭延迟加载。
    • 事件监听器内可以开启新事务吗?→ 可以,加上 @Transactional(propagation = Propagation.REQUIRES_NEW),此时是一个全新的 SqlSession,与之前一级缓存完全隔离。
    • @TransactionalEventListener 默认是同步还是异步?→ 默认同步,在同一个线程中执行,但仍是在事务提交之后。可以通过 @Async 结合成为异步。
  • 加分回答:设计事务事件时,应只传递业务标识(如订单ID),在监听器中通过标识重新查询最新数据,而不是传递整个实体对象。这不仅是避免缓存问题,更符合消息驱动和最终一致性的思想。

17. 如何通过 Arthas 监控某个 SqlSessionExecutor 状态?

  • 一句话回答 :使用 watch 命令观察 Executorqueryupdate 方法的出入参和返回值;结合 ognl 查看 ConfigurationlocalCache 内部状态。

  • 详细解释

    • 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 限制次数),并在监控完成后及时关闭。
  • 加分回答 :编写 Arthas 脚本批量监控,例如结合 trace 观察耗时,一旦发现 SQL 耗时超过阈值,自动调用 tt 记录现场。对疑难问题,用 jad 反编译线上的 Mapper 代理类,查看动态代理的实现细节,验证插件包装是否生效。

18. 自定义类型处理器(TypeHandler)为什么没有生效?排查思路是什么?

  • 一句话回答 :检查三要素:TypeHandler 是否注册到 TypeHandlerRegistry ;映射中是否指定了此 TypeHandler;参数或结果的 Java 类型、JDBC 类型是否匹配。

  • 详细解释

    • 排查步骤
      1. 注册检查 :如果是 Spring Boot 项目,检查 mybatis.type-handlers-package 是否配置了 TypeHandler 所在的包。如果是 XML 配置,检查 <typeHandlers> 配置。也可通过 @MappedTypes@MappedJdbcTypes 自动注册。
      2. 映射配置检查 :在 resultMap 中是否设置了 typeHandler="com.xxx.MyHandler" 属性。如果是通过 resultType 自动映射,确保 TypeHandler 上正确配置了 @MappedTypes 指定要处理的 Java 类型。
      3. 类型匹配检查TypeHandlersetParametergetResult 对应方法参数类型是否正确。比如数据库返回 VARCHAR,而 TypeHandlergetNullableResult 期望的是 VARCHAR 且实现了 java.sql.ResultSet.getString 取出,如果类不匹配则不会被调用。
      4. 日志验证 :DEBUG 级别下,TypeHandlerRegistry 在注册时会打印日志,可以观察启动日志确认是否注册成功。
    • 源码关联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 表达式里调用了方法或复杂的导航,每次查询都要重复求值,当并发高且表达式极复杂时,会有细微开销。
  • 多角度追问

    • 如何降低动态 SQL 的硬解析?→ 尽量保证 SQL 主体结构不变,例如使用 <if test="status != null">AND status = #{status}</if> 时,数据库会做绑定变量窥视,MySQL 5.7+ 的优化器也能较好处理。或者将可能变化的条件用 CASE WHEN 等代替。
    • <choose> 和多个 <if> 谁更好?→ 语义上 <choose> 互斥,生成 SQL 组合更少,有助于减少硬解析可能。性能差异可忽略。
    • 动态 SQL 与缓存配合时有什么坑?→ 同一个动态 SQL 生成两种 SQL 文本,缓存 Key 也会不同,导致缓存命中率下降,这不是性能影响,而是缓存策略效果减弱。
  • 加分回答 :利用 p6spy 或自定义拦截器统计应用中由同一个 MappedStatement 生成的不同 SQL 文本数量,若数量过高,可考虑重构 SQL,使用 CASE WHEN 或在应用层构建查询条件对象来稳定 SQL 结构。数据库端开启如 MySQL performance_schemaevents_statements_summary_by_digest 分析 SQL 指纹,评估硬解析比例。

20. (系统设计题一)设计一个基于 MyBatis 的读写分离中间件

  • 一句话回答 :核心设计是扩展 AbstractRoutingDataSource 实现动态数据源路由,配合自定义 Interceptor 识别读写操作并切换数据源 Key,再利用 LazyConnectionDataSourceProxy 延迟连接获取以解决事务内切换问题。
  • 详细解释
    • 核心拦截器设计 :实现一个 Interceptor,拦截 Executor.updatequery 方法。在 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 拦截 StatementHandlerExecutor,对 SQL 进行指纹化并统计耗时;当某 SQL 指纹的统计指标超过阈值时,触发 SentinelResilience4j 的熔断规则,抛出预定义的降级异常。
  • 详细解释
    • 核心 Interceptor 设计 :拦截 StatementHandler.query/updateExecutor 层。@Signature 设为 StatementHandler.class"query"/"update"。在 intercept 方法中,invocation.proceed() 前记录开始时间,之后计算 cost。通过 BoundSql.getSql() 获取原始 SQL,利用 DruidSQLUtilsJSqlParser 进行格式化,提取 SQL Fingerprint,例如将 SELECT * FROM user WHERE id = 1 归一化为 SELECT * FROM user WHERE id = ?
    • 聚合统计 :将 MappedStatement ID + SQL Fingerprint 作为 Key,滑动时间窗口(如 HdrHistogramSentinel 内置的滑动窗口)内,统计总调用次数、总耗时、平均耗时、TP99 耗时、失败次数。
    • Sentinel/Resilience4j 集成 :为每个 SQL 指纹动态创建 DegradeRule。设定规则如"当 TP99 耗时 > 1000ms 且 QPS > 5 时,触发慢调用比例熔断"。当统计窗口内的数据触发 Sentinel 的熔断规则后,Sentinel 会抛出 DegradeException。我们在 Interceptor 的 catch 块中捕获此异常,并将其转换/包装为我们自定义的、业务可感知的降级异常(如 SlowSqlDegradedException),由上层 AOP 或全局异常处理器统一处理,返回降级响应(如空列表)。
    • 动态配置:规则阈值应存储在配置中心(Nacos/Apollo)。Interceptor 监听配置变更,动态更新 Sentinel 的熔断规则。
  • 多角度追问
    • 如何避免监控本身影响性能?→ 统计逻辑应异步化,通过 Disruptor 等无锁队列发送事件,在独立线程池中聚合和判断。
    • 如何识别并排除定时任务的慢查询?→ 在拦截器中,通过 RunnableThread 的名字、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未优化 插件拦截 分页极慢 CountSqlParserLEFT JOIN 手动编写并优化 COUNT SQL
插件异常未处理 插件拦截 PersistenceException Plugin.invoke() try-catch 并加入熔断降级
多数据源误连 Spring Boot整合 访问错误数据源 @PrimaryMybatisAutoConfiguration 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 租户数据泄露 TenantLineHandlerINTERCEPTOR 缺失 全局注册多租户拦截器
逻辑删除遗漏 MyBatis-Plus 查到已删除数据 LogicSqlInjector 手写 SQL 显式添加 deleted=0
相关推荐
_Evan_Yao4 小时前
return 的迷途:try-catch-finally 中 return 的诡异顺序与 Spring 事务暗坑
java·后端·spring·mybatis
Java成神之路-1 天前
MyBatis工作原理
mybatis
敖正炀2 天前
MyBatis 性能调优:批处理、流式查询与 SQL 优化
mybatis
敖正炀2 天前
初始化流程的完整串联:从 XML 到 SqlSessionFactory
mybatis
2301_771717212 天前
Spring Boot 自动配置核心注解
java·spring boot·mybatis
MegaDataFlowers2 天前
使用MyBatisX快速生成CRUD
mybatis
敖正炀2 天前
插件开发与拦截链——分页、脱敏、多租户实战
mybatis
敖正炀2 天前
MyBatis 架构全解:SqlSession、Executor 与 StatementHandler
mybatis
敖正炀2 天前
一级/二级缓存深度:生命周期、脏读与生产最佳实践
mybatis