Skip to content

MyBatis 核心全过程原理

MyBatis 不是“帮你自动生成所有 SQL”的框架,而是一个“让 SQL 可控,同时把 JDBC 重复工作封装起来”的半自动 ORM 框架。

学 MyBatis 不能只会写 Mapper 和 XML。真正要理解的是:接口为什么没有实现类也能执行、XML 是什么时候解析的、#{} 为什么安全、动态 SQL 最后怎样变成一条 SQL、缓存为什么可能读到旧数据、插件为什么能改分页和审计、事务为什么应该放在 Service 层。

学习目标

学完这一章要能说清楚这些问题:

  1. MyBatis 相比 JDBC 到底省掉了哪些重复代码。
  2. Mapper 接口没有实现类,为什么可以直接调用。
  3. 一次 assetMapper.selectByCode(code) 从 Java 方法到数据库返回对象,中间经过哪些组件。
  4. SqlSessionFactorySqlSessionExecutorMappedStatementBoundSqlStatementHandlerParameterHandlerResultSetHandler 分别负责什么。
  5. #{}${} 的本质区别,以及为什么 ${} 会有 SQL 注入风险。
  6. 一级缓存、二级缓存的作用范围和一致性风险。
  7. MyBatis 插件为什么能做分页、慢 SQL、审计、租户和数据权限。
  8. MyBatis 单独使用事务和 Spring 管理事务有什么区别。
  9. 商业项目中如何设计 Mapper、动态 SQL、批量写入、分页查询和排查慢 SQL。

MyBatis 解决什么问题

先看不用 MyBatis 的 JDBC 查询。

java
String sql = "select id, asset_code, asset_name, status from asset where asset_code = ?";
try (Connection connection = dataSource.getConnection();
     PreparedStatement ps = connection.prepareStatement(sql)) {
    ps.setString(1, assetCode);
    try (ResultSet rs = ps.executeQuery()) {
        if (rs.next()) {
            Asset asset = new Asset();
            asset.setId(rs.getLong("id"));
            asset.setAssetCode(rs.getString("asset_code"));
            asset.setAssetName(rs.getString("asset_name"));
            asset.setStatus(rs.getInt("status"));
            return asset;
        }
    }
}

这段代码真正的业务意图只有一件事:按资产编码查询资产。但是开发者还要反复处理连接、预编译、参数设置、结果集读取、字段映射、异常和资源关闭。

MyBatis 把这些重复工作拆成框架组件:

JDBC 重复工作MyBatis 中谁负责
创建连接和执行 SQLExecutorStatementHandler、JDBC
设置 SQL 参数ParameterHandlerTypeHandler
读取 ResultSetResultSetHandler
字段映射成对象ResultMap、自动映射、TypeHandler
管理 SQL 配置MappedStatementConfiguration
让接口方法能调用 SQLMapperProxyMapperMethod

所以 MyBatis 的核心不是“让你不用 SQL”,而是“你写清楚 SQL,框架帮你稳定、安全、可扩展地执行 SQL”。

总体执行链路

下面这张图是一次 Mapper 查询的完整主链路。记住这条链路,MyBatis 大部分面试题都能从这里展开。

mermaid
flowchart TD
    A["Service 调用 Mapper 接口方法"] --> B["MapperProxy 动态代理接管调用"]
    B --> C["MapperMethod 解析方法签名和参数"]
    C --> D["SqlSession 根据 statementId 执行"]
    D --> E["Configuration 找到 MappedStatement"]
    E --> F["SqlSource 生成 BoundSql"]
    F --> G["Executor 选择查询或更新流程"]
    G --> H["StatementHandler 创建 PreparedStatement"]
    H --> I["ParameterHandler 设置参数"]
    I --> J["JDBC 发送 SQL 到数据库"]
    J --> K["数据库返回 ResultSet"]
    K --> L["ResultSetHandler 映射结果"]
    L --> M["返回 Java 对象或集合"]

每一步解决的问题不同:

  1. MapperProxy 解决“接口没有实现类怎么调用”。
  2. MappedStatement 解决“一个方法对应哪条 SQL、参数是什么、返回值是什么”。
  3. BoundSql 解决“动态 SQL 最终拼出来是什么、参数顺序是什么”。
  4. Executor 解决“缓存、批处理、真正执行查询/更新”。
  5. StatementHandler 解决“创建 JDBC Statement 并处理分页等语句层能力”。
  6. ParameterHandler 解决“把 Java 参数放到 SQL 占位符里”。
  7. ResultSetHandler 解决“把数据库行变成 Java 对象”。

启动时:XML 如何变成运行时配置

很多人以为调用 Mapper 时才读取 XML。实际项目启动时,MyBatis 会先解析配置文件和 Mapper XML,把每条 SQL 注册到 Configuration 中。

mermaid
flowchart TD
    A["加载 mybatis-config.xml 或 Spring Boot 配置"] --> B["创建 SqlSessionFactoryBuilder"]
    B --> C["解析全局配置、别名、插件、类型处理器"]
    C --> D["扫描 Mapper XML 和 Mapper 接口"]
    D --> E["解析 select、insert、update、delete 标签"]
    E --> F["生成 MappedStatement"]
    F --> G["放入 Configuration.mappedStatements"]
    G --> H["创建 SqlSessionFactory"]

MappedStatement 可以理解为“一条 SQL 的说明书”,里面包含:

信息说明
idSQL 唯一标识,通常是 namespace + 方法名
sqlSourceSQL 来源,可能是静态 SQL,也可能是动态 SQL
parameterMap参数映射信息
resultMaps结果映射规则
statementType使用 StatementPreparedStatement 还是存储过程
sqlCommandTypeSELECTINSERTUPDATEDELETE
flushCache执行后是否清缓存
useCache查询结果是否使用二级缓存

如果 XML 的 namespace 写错,或者 SQL 的 id 和 Mapper 方法名对不上,运行时就会报类似 Invalid bound statement。这不是数据库问题,而是 statementId 没有在 Configuration 中找到对应的 MappedStatement

Mapper 接口为什么能执行

Mapper 接口本身没有实现类,Spring 注入进来的通常是 MyBatis 创建的代理对象。

java
public interface AssetMapper {
    AssetDO selectByCode(String assetCode);
}

调用时看起来是普通方法:

java
AssetDO asset = assetMapper.selectByCode("A-1001");

实际进入的是代理对象:

mermaid
flowchart TD
    A["assetMapper.selectByCode"] --> B["MapperProxy.invoke"]
    B --> C["生成 statementId"]
    C --> D["statementId = namespace + 方法名"]
    D --> E["com.demo.AssetMapper.selectByCode"]
    E --> F["Configuration 查找 MappedStatement"]
    F --> G["执行 SQL"]

为什么能准确找到 SQL?因为 XML 里一般这样写:

xml
<mapper namespace="com.demo.AssetMapper">
    <select id="selectByCode" resultMap="AssetMap">
        select id, asset_code, asset_name, status
        from asset
        where asset_code = #{assetCode}
    </select>
</mapper>

namespace 对应接口全限定名,id 对应方法名,两者拼起来就是唯一键。

如果不这样设计会怎样?

  1. namespace 不匹配:Mapper 方法找不到 SQL。
  2. id 重复或错写:可能启动失败,或者运行时报找不到语句。
  3. 参数名不明确:动态 SQL 或多参数方法可能取不到正确参数。
  4. 返回映射错误:SQL 执行成功,但对象字段为空或类型转换失败。

参数解析:单参数、多参数和对象参数

MyBatis 需要把 Java 方法参数转换成 SQL 参数。这个过程并不是简单地按变量名拿值。

单个简单参数

java
AssetDO selectById(Long id);
xml
where id = #{id}

单参数时,MyBatis 通常能直接取到这个值。

多个参数

java
List<AssetDO> selectByStatusAndDept(Integer status, Long deptId);

如果没有 @Param,MyBatis 默认可能按 param1param2arg0arg1 取值,可读性差,也容易踩坑。

推荐写法:

java
List<AssetDO> selectByStatusAndDept(@Param("status") Integer status,
                                    @Param("deptId") Long deptId);
xml
where status = #{status}
  and dept_id = #{deptId}

对象参数

java
List<AssetDO> selectPage(AssetQuery query);
xml
<where>
    <if test="assetCode != null and assetCode != ''">
        and asset_code = #{assetCode}
    </if>
    <if test="status != null">
        and status = #{status}
    </if>
</where>

对象参数会通过属性名取值,本质是反射或元对象读取属性。

#{}${} 的底层区别

这是 MyBatis 必会知识点。

写法本质最终 SQL 示例是否预编译主要风险
#{assetCode}占位符参数绑定where asset_code = ?类型不匹配
${column}字符串直接拼接order by create_timeSQL 注入

#{} 会生成 ?,再由 ParameterHandler 调用 JDBC 设置参数。

mermaid
flowchart TD
    A["XML 中写 #{assetCode}"] --> B["生成 SQL: asset_code = ?"]
    B --> C["ParameterMapping 记录参数名和类型"]
    C --> D["ParameterHandler 取 Java 参数"]
    D --> E["TypeHandler 设置 PreparedStatement 参数"]

${} 会把内容直接拼到 SQL 字符串里。

xml
order by ${sortField} ${sortType}

如果用户传入:

text
create_time desc; delete from asset

SQL 结构就可能被改变。商业项目中只有动态表名、动态字段名、动态排序方向这类“无法用 ? 表示 SQL 结构”的地方才考虑 ${},而且必须白名单。

java
private static final Set<String> SORT_FIELDS = Set.of("create_time", "asset_code", "status");

public String safeSortField(String field) {
    if (!SORT_FIELDS.contains(field)) {
        return "create_time";
    }
    return field;
}

结论:业务值用 #{},SQL 结构片段用 ${} 时必须白名单。

动态 SQL 怎么工作

动态 SQL 不是在数据库中动态执行逻辑,而是在 MyBatis 端根据 Java 参数生成最终 SQL。

mermaid
flowchart TD
    A["Mapper 方法参数"] --> B["动态 SQL 标签判断"]
    B --> C["拼接符合条件的 SQL 片段"]
    C --> D["生成 BoundSql"]
    D --> E["SQL 文本 + 参数映射列表"]
    E --> F["交给 JDBC 执行"]

示例:资产列表查询。

xml
<select id="selectAssetPage" resultMap="AssetMap">
    select id, asset_code, asset_name, dept_id, status, create_time
    from asset
    <where>
        <if test="assetCode != null and assetCode != ''">
            and asset_code = #{assetCode}
        </if>
        <if test="deptId != null">
            and dept_id = #{deptId}
        </if>
        <if test="status != null">
            and status = #{status}
        </if>
        <if test="startTime != null">
            and create_time &gt;= #{startTime}
        </if>
        <if test="endTime != null">
            and create_time &lt; #{endTime}
        </if>
    </where>
    order by create_time desc
    limit #{offset}, #{pageSize}
</select>

<where> 的价值是自动处理第一个 and。如果不用 <where>,手写拼接很容易出现 where and status = ? 这种错误 SQL。

<foreach> 常用于批量查询:

xml
<select id="selectByIds" resultMap="AssetMap">
    select id, asset_code, asset_name, status
    from asset
    where id in
    <foreach collection="ids" item="id" open="(" separator="," close=")">
        #{id}
    </foreach>
</select>

但大 IN 会导致 SQL 很长、优化器估算不准、网络包变大。生产中通常控制数量,例如每批 500 或 1000,或者改成临时表、关联表、批量任务。

BoundSql:真正要执行的 SQL

BoundSql 是理解 MyBatis 的关键。动态 SQL 处理完之后,MyBatis 得到的不是“XML 标签”,而是一个包含 SQL 文本和参数映射的对象。

例如 XML:

xml
where asset_code = #{assetCode}
  and status = #{status}

生成的 BoundSql 类似:

text
sql = "where asset_code = ? and status = ?"
parameterMappings = ["assetCode", "status"]
parameterObject = AssetQuery(...)

所以排查问题时要看两件事:

  1. 最终 SQL 是什么。
  2. 参数按什么顺序绑定、每个参数值是什么。

很多“我写的 SQL 明明没问题”的线上问题,本质是动态 SQL 条件没有进入、参数为空、字段名拼错、类型处理器不匹配。

Executor:真正执行 SQL 的入口

Executor 是 MyBatis 执行 SQL 的核心入口,常见类型有:

Executor 类型说明适用场景
SimpleExecutor每次执行都创建新的 PreparedStatement默认常见场景
ReuseExecutor复用相同 SQL 的 PreparedStatement同一会话重复执行相同 SQL
BatchExecutor批量执行更新语句批量插入、批量更新

查询的大致流程:

mermaid
flowchart TD
    A["Executor.query"] --> B["创建 CacheKey"]
    B --> C{"一级缓存命中?"}
    C -->|"是"| D["直接返回本地缓存结果"]
    C -->|"否"| E["查询二级缓存或数据库"]
    E --> F["StatementHandler 创建语句"]
    F --> G["ParameterHandler 设置参数"]
    G --> H["数据库执行"]
    H --> I["ResultSetHandler 映射结果"]
    I --> J["写入一级缓存"]

更新的大致流程:

mermaid
flowchart TD
    A["Executor.update"] --> B["清理本地缓存"]
    B --> C["StatementHandler 创建语句"]
    C --> D["ParameterHandler 设置参数"]
    D --> E["JDBC 执行 insert/update/delete"]
    E --> F["返回影响行数"]

为什么更新要清缓存?因为同一个 SqlSession 中之前查过的数据可能已经不准确了。

StatementHandler、ParameterHandler、ResultSetHandler

这三个组件经常一起出现。

StatementHandler

StatementHandler 负责创建和处理 JDBC 的 StatementPreparedStatement。分页插件经常拦截它,因为这里能拿到最终 SQL,可以把 SQL 改写成带分页的语句。

例如:

sql
select id, asset_code from asset order by create_time desc

分页插件可能改写成:

sql
select id, asset_code from asset order by create_time desc limit ?, ?

ParameterHandler

ParameterHandler 负责把 Java 参数绑定到 PreparedStatement

java
preparedStatement.setString(1, "A-1001");
preparedStatement.setInt(2, 1);

它会结合 ParameterMappingTypeHandler 判断应该调用 setStringsetLongsetTimestamp 还是自定义转换。

ResultSetHandler

ResultSetHandler 负责把 ResultSet 映射成 Java 对象。

mermaid
flowchart TD
    A["ResultSet 一行数据"] --> B["读取列名和列值"]
    B --> C["根据 resultMap 找到属性"]
    C --> D["TypeHandler 转换类型"]
    D --> E["反射或对象工厂设置属性"]
    E --> F["返回实体对象"]

如果字段映射不正确,常见现象是 SQL 查到了数据,但 Java 对象字段是 null。这时要检查列别名、驼峰映射、resultMap、类型处理器。

ResultMap:为什么复杂映射不要只靠 resultType

resultType 适合字段名和属性名比较一致的简单查询。

xml
<select id="selectById" resultType="com.demo.AssetDO">
    select id, asset_code assetCode, asset_name assetName
    from asset
    where id = #{id}
</select>

复杂查询建议用 resultMap

xml
<resultMap id="AssetMap" type="com.demo.AssetDO">
    <id column="id" property="id"/>
    <result column="asset_code" property="assetCode"/>
    <result column="asset_name" property="assetName"/>
    <result column="dept_id" property="deptId"/>
    <result column="status" property="status"/>
</resultMap>

为什么?因为商业项目里字段经常涉及:

  1. 数据库下划线和 Java 驼峰不一致。
  2. 多表 join 后列名重复。
  3. 枚举、JSON、加密字段需要类型转换。
  4. 一对一、一对多关系需要明确映射。

TypeHandler:Java 类型和 JDBC 类型的桥

数据库类型和 Java 类型不是一回事。varcharbiginttimestampjsontinyint 都需要转换成 Java 中的 StringLongLocalDateTime、对象或枚举。

自定义枚举转换示例:

java
public enum AssetStatus {
    ENABLED(1), DISABLED(0);

    private final int code;

    AssetStatus(int code) {
        this.code = code;
    }

    public int getCode() {
        return code;
    }
}
java
public class AssetStatusTypeHandler extends BaseTypeHandler<AssetStatus> {
    @Override
    public void setNonNullParameter(PreparedStatement ps, int i,
                                    AssetStatus parameter, JdbcType jdbcType) throws SQLException {
        ps.setInt(i, parameter.getCode());
    }

    @Override
    public AssetStatus getNullableResult(ResultSet rs, String columnName) throws SQLException {
        int code = rs.getInt(columnName);
        return code == 1 ? AssetStatus.ENABLED : AssetStatus.DISABLED;
    }

    @Override
    public AssetStatus getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
        int code = rs.getInt(columnIndex);
        return code == 1 ? AssetStatus.ENABLED : AssetStatus.DISABLED;
    }

    @Override
    public AssetStatus getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
        int code = cs.getInt(columnIndex);
        return code == 1 ? AssetStatus.ENABLED : AssetStatus.DISABLED;
    }
}

如果类型转换设计不好,会出现字段值错乱、枚举转换失败、JSON 字段解析失败、时间时区不一致等问题。

一级缓存:为什么同一个 SqlSession 会复用查询结果

一级缓存默认开启,作用域是 SqlSession

java
try (SqlSession session = sqlSessionFactory.openSession()) {
    AssetMapper mapper = session.getMapper(AssetMapper.class);
    AssetDO a1 = mapper.selectById(1L);
    AssetDO a2 = mapper.selectById(1L);
}

第二次查询可能不会访问数据库,而是从一级缓存返回。

mermaid
flowchart TD
    A["第一次 selectById(1)"] --> B["生成 CacheKey"]
    B --> C["一级缓存未命中"]
    C --> D["查询数据库"]
    D --> E["结果放入 SqlSession 本地缓存"]
    E --> F["第二次 selectById(1)"]
    F --> G["CacheKey 相同"]
    G --> H["直接返回缓存"]

缓存 Key 通常和这些因素有关:

  1. MappedStatement 的 id。
  2. SQL 分页信息。
  3. 最终 SQL 文本。
  4. 参数值。
  5. 环境信息。

一级缓存的风险是同一个 SqlSession 里可能读到旧值。例如你在同一个会话里查询了资产,又通过其他路径更新了数据库,再查询同一个资产,可能仍然拿到本地缓存。写操作、提交、回滚会清空一级缓存,就是为了降低这种风险。

在 Spring 项目里,每次 Mapper 调用通常通过 SqlSessionTemplate 管理会话,不建议为了缓存效果手动扩大 SqlSession 生命周期。

二级缓存:为什么商业项目要谨慎

二级缓存作用域是 Mapper namespace,多个 SqlSession 可以共享。

mermaid
flowchart TD
    A["SqlSession 1 查询 UserMapper"] --> B["结果提交后进入二级缓存"]
    C["SqlSession 2 查询同一 namespace"] --> D{"二级缓存命中?"}
    D -->|"是"| E["不查数据库,直接返回缓存"]
    D -->|"否"| F["查询数据库"]

二级缓存最大问题不是“能不能提升性能”,而是“一致性边界太难控制”。

例如:

  1. AssetMapper 查询了资产和科室名称的 join 结果。
  2. DeptMapper 更新了科室名称。
  3. AssetMapper 的二级缓存未必知道 DeptMapper 改了数据。
  4. 用户继续查询资产列表,可能看到旧科室名称。

分布式部署时,如果二级缓存是 JVM 本地缓存,A 节点更新后,B 节点缓存也不会自动失效。

商业项目建议:

  1. 一级缓存了解机制即可,不要依赖它做业务缓存。
  2. 二级缓存默认谨慎关闭,强一致业务不建议开。
  3. 需要缓存时优先使用 Redis,并明确缓存 Key、过期时间、删除策略和一致性方案。
  4. 查询性能问题优先看 SQL、索引、分页、表结构,而不是先开 MyBatis 二级缓存。

插件机制:为什么能实现分页和审计

MyBatis 插件本质是对核心对象做动态代理。它能拦截四类对象:

可拦截对象典型用途
Executor慢 SQL、审计、缓存、数据权限入口
StatementHandler分页 SQL 改写、SQL 打印
ParameterHandler参数加密、脱敏、审计
ResultSetHandler结果脱敏、字段转换

插件链路:

mermaid
flowchart TD
    A["创建 Executor 或 StatementHandler"] --> B["Plugin.wrap 包装代理对象"]
    B --> C["调用目标方法"]
    C --> D["进入 Interceptor.intercept"]
    D --> E["插件前置逻辑"]
    E --> F["invocation.proceed 执行原方法"]
    F --> G["插件后置逻辑"]

一个慢 SQL 插件示例:

java
@Intercepts({
    @Signature(type = StatementHandler.class, method = "query",
            args = {Statement.class, ResultHandler.class})
})
public class SlowSqlInterceptor implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        long start = System.currentTimeMillis();
        try {
            return invocation.proceed();
        } finally {
            long cost = System.currentTimeMillis() - start;
            if (cost > 1000) {
                System.out.println("slow sql cost=" + cost + "ms");
            }
        }
    }
}

插件不要滥用。分页、审计、租户、数据权限可以做;复杂业务判断不要写进插件,否则执行链路会变得隐蔽,排查困难。

Spring 集成:SqlSessionTemplate 做了什么

Spring 项目里我们很少手动写:

java
SqlSession session = sqlSessionFactory.openSession();

通常是直接注入 Mapper。

java
@Service
public class AssetService {
    private final AssetMapper assetMapper;

    public AssetService(AssetMapper assetMapper) {
        this.assetMapper = assetMapper;
    }
}

这是因为 MyBatis-Spring 做了几件事:

  1. 扫描 Mapper 接口。
  2. 为 Mapper 创建代理对象并注册为 Spring Bean。
  3. 使用 SqlSessionTemplate 作为线程安全入口。
  4. 从 Spring 事务上下文中获取数据库连接。
  5. 让 Mapper 方法参与 @Transactional 管理的事务。
mermaid
flowchart TD
    A["Spring 扫描 Mapper 接口"] --> B["MapperFactoryBean 创建 Mapper 代理"]
    B --> C["业务代码注入 Mapper"]
    C --> D["Mapper 调用进入 SqlSessionTemplate"]
    D --> E{"当前线程是否有 Spring 事务?"}
    E -->|"有"| F["复用事务绑定的 Connection"]
    E -->|"无"| G["获取新 Connection 执行后释放"]

这也是为什么事务通常放在 Service 层,而不是 Mapper 层:Service 代表一个完整业务动作,可能包含多个 Mapper 调用。

MyBatis 事务:单独使用和 Spring 管理的区别

MyBatis 单独使用时,你需要自己提交或回滚。

java
try (SqlSession session = sqlSessionFactory.openSession(false)) {
    AssetMapper mapper = session.getMapper(AssetMapper.class);
    mapper.insert(asset);
    mapper.insertAssetLog(log);
    session.commit();
} catch (Exception e) {
    session.rollback();
    throw e;
}

Spring 项目中通常这样:

java
@Transactional(rollbackFor = Exception.class)
public void createAsset(AssetCreateCommand command) {
    assetMapper.insert(command.toAsset());
    assetLogMapper.insert(command.toLog());
}

Spring 事务的核心是:

  1. 方法进入前开启事务,绑定数据库连接到当前线程。
  2. Mapper 执行时通过 SqlSessionTemplate 拿到同一个连接。
  3. 方法正常结束提交。
  4. 抛出符合规则的异常时回滚。

不这样做会怎样?

  1. 每个 Mapper 自己提交:订单主表成功、明细失败,数据不一致。
  2. 事务放在 Mapper:无法覆盖一个完整业务动作。
  3. 事务包住远程调用:数据库锁持有时间变长,容易阻塞。
  4. 自调用导致事务不生效:同类方法内部调用绕过 Spring 代理。

商业 Demo:医疗资产查询和批量入库

表结构

sql
create table asset (
    id bigint primary key auto_increment,
    asset_code varchar(64) not null,
    asset_name varchar(128) not null,
    dept_id bigint not null,
    status tinyint not null,
    create_time datetime not null,
    update_time datetime not null,
    unique key uk_asset_code (asset_code),
    key idx_dept_status_time (dept_id, status, create_time)
);

Mapper 接口

java
public interface AssetMapper {
    AssetDO selectByCode(@Param("assetCode") String assetCode);

    List<AssetDO> selectPage(AssetQuery query);

    int batchInsert(@Param("list") List<AssetDO> list);
}

XML

xml
<mapper namespace="com.demo.asset.AssetMapper">
    <resultMap id="AssetMap" type="com.demo.asset.AssetDO">
        <id column="id" property="id"/>
        <result column="asset_code" property="assetCode"/>
        <result column="asset_name" property="assetName"/>
        <result column="dept_id" property="deptId"/>
        <result column="status" property="status"/>
        <result column="create_time" property="createTime"/>
        <result column="update_time" property="updateTime"/>
    </resultMap>

    <select id="selectByCode" resultMap="AssetMap">
        select id, asset_code, asset_name, dept_id, status, create_time, update_time
        from asset
        where asset_code = #{assetCode}
    </select>

    <select id="selectPage" resultMap="AssetMap">
        select id, asset_code, asset_name, dept_id, status, create_time, update_time
        from asset
        <where>
            <if test="assetCode != null and assetCode != ''">
                and asset_code = #{assetCode}
            </if>
            <if test="deptId != null">
                and dept_id = #{deptId}
            </if>
            <if test="status != null">
                and status = #{status}
            </if>
        </where>
        order by create_time desc
        limit #{offset}, #{pageSize}
    </select>

    <insert id="batchInsert">
        insert into asset(asset_code, asset_name, dept_id, status, create_time, update_time)
        values
        <foreach collection="list" item="item" separator=",">
            (#{item.assetCode}, #{item.assetName}, #{item.deptId},
             #{item.status}, #{item.createTime}, #{item.updateTime})
        </foreach>
    </insert>
</mapper>

Service

java
@Service
public class AssetImportService {
    private final AssetMapper assetMapper;

    public AssetImportService(AssetMapper assetMapper) {
        this.assetMapper = assetMapper;
    }

    @Transactional(rollbackFor = Exception.class)
    public void importAssets(List<AssetDO> assets) {
        int batchSize = 500;
        for (int i = 0; i < assets.size(); i += batchSize) {
            int end = Math.min(i + batchSize, assets.size());
            assetMapper.batchInsert(assets.subList(i, end));
        }
    }
}

这个 Demo 体现了商业项目的几个原则:

  1. 查询条件用动态 SQL,但参数仍使用 #{}
  2. 分页字段要配合索引,例如 dept_id, status, create_time
  3. 批量插入要分批,不能一次拼十几万条。
  4. 事务放在 Service 层,覆盖一次导入动作。
  5. 上线前要用 EXPLAIN 检查 selectPage 是否走索引。

N+1 查询问题

N+1 指先查一批主数据,再循环查询每条数据的关联信息。

java
List<AssetDO> assets = assetMapper.selectPage(query);
for (AssetDO asset : assets) {
    DeptDO dept = deptMapper.selectById(asset.getDeptId());
    asset.setDeptName(dept.getDeptName());
}

如果列表有 100 条,这段代码就是 1 次资产查询 + 100 次科室查询。

解决方式:

  1. SQL join 一次查出需要字段。
  2. 先查资产列表,再批量查所有 deptId,最后在内存中组装。
  3. 对低频不强一致数据使用 Redis 缓存。
  4. 分页时只查当前页,避免先全量查再内存分页。

不要把 N+1 误认为“数据库慢”。它本质是数据访问模式不对,SQL 次数被循环放大。

批量操作的正确姿势

批量写入常见三种方式:

方式特点风险
循环单条 insert简单但慢SQL 次数多、事务耗时长
foreach 拼多 valuesSQL 次数少单条 SQL 太大、参数过多
ExecutorType.BATCHJDBC 批处理需要控制 flush、内存和错误处理

批量不是越大越好。批量过大会带来:

  1. SQL 包过大。
  2. 数据库解析压力变高。
  3. 事务持锁时间变长。
  4. 回滚成本变高。
  5. JVM 中待提交对象占用内存。

一般建议从 300、500、1000 这种批大小压测,不要凭感觉设置。

线上排查流程

Mapper 找不到 SQL

text
Invalid bound statement (not found)

排查顺序:

  1. Mapper XML 是否被扫描到。
  2. XML namespace 是否等于接口全限定名。
  3. SQL 标签 id 是否等于方法名。
  4. 多模块项目打包后 XML 是否在 classpath。
  5. Spring Boot mybatis.mapper-locations 是否配置正确。

SQL 执行慢

mermaid
flowchart TD
    A["发现接口慢"] --> B["打开 SQL 日志拿到最终 SQL 和参数"]
    B --> C["去数据库执行 EXPLAIN"]
    C --> D{"是否走合适索引?"}
    D -->|"否"| E["补索引或改 SQL 条件顺序"]
    D -->|"是"| F{"扫描行数是否过大?"}
    F -->|"是"| G["优化分页、过滤条件、表设计"]
    F -->|"否"| H{"是否有锁等待或资源瓶颈?"}
    H -->|"是"| I["排查事务、慢更新、连接池、数据库资源"]
    H -->|"否"| J["检查网络、应用线程池、结果集过大"]

查询有数据但对象字段为空

排查顺序:

  1. SQL 是否真的返回该列。
  2. 列名和属性名是否匹配。
  3. 是否需要列别名,例如 asset_code assetCode
  4. mapUnderscoreToCamelCase 是否开启。
  5. resultMapcolumnproperty 是否写错。
  6. 类型处理器是否正确。

事务不生效

排查顺序:

  1. 方法是否是 public
  2. 是否通过 Spring Bean 代理对象调用。
  3. 是否发生同类自调用。
  4. 异常是否被捕获吞掉。
  5. 默认只回滚运行时异常,检查 rollbackFor
  6. 数据源和事务管理器是否一致。
  7. Mapper 是否使用 Spring 管理的 SqlSessionTemplate

常见坑

问题原因正确做法
使用 ${} 接收用户输入字符串拼接导致 SQL 注入业务值使用 #{},字段名白名单
XML namespace 写错statementId 对不上namespace 写接口全限定名
多参数不用 @Param参数名不稳定多参数显式写 @Param
foreach inSQL 太长、优化器估算差分批、临时表、关联表
二级缓存读旧数据namespace 无法感知其他表更新强一致业务不用二级缓存
批量过大SQL 包、锁、内存压力控制批大小并压测
Mapper 写业务逻辑数据访问层职责混乱业务编排放 Service
只看 Mapper 不看执行计划慢 SQL 根因在数据库用 SQL 日志 + EXPLAIN 排查

面试标准回答

MyBatis 的执行流程是什么

标准回答:

text
业务代码调用 Mapper 接口时,实际进入 MyBatis 创建的 MapperProxy。代理会根据接口全限定名和方法名找到 MappedStatement,动态 SQL 会生成 BoundSql,Executor 负责执行查询或更新,StatementHandler 创建 PreparedStatement,ParameterHandler 设置参数,数据库返回 ResultSet 后由 ResultSetHandler 根据 ResultMap 映射成 Java 对象。

#{}${} 区别是什么

标准回答:

text
#{ } 是预编译参数绑定,会变成 JDBC 的 ? 占位符,再由 ParameterHandler 设置参数,能防 SQL 注入;${ } 是字符串直接拼接,会改变 SQL 结构,适合动态表名、字段名、排序字段,但必须做白名单校验,不能直接接收用户输入。

为什么不建议随便开启二级缓存

标准回答:

text
二级缓存是 Mapper namespace 级别,范围比一级缓存大,但它很难感知其他 namespace、其他服务节点、其他写入路径对数据的修改。多表关联、分布式部署和强一致业务中容易读到旧数据,所以生产中通常谨慎使用,性能问题优先通过索引、SQL、分页和 Redis 缓存方案解决。

MyBatis 插件原理是什么

标准回答:

text
MyBatis 插件通过动态代理包装 Executor、StatementHandler、ParameterHandler、ResultSetHandler 四类核心对象,在目标方法执行前后进入 Interceptor。分页插件一般拦截 StatementHandler 改写 SQL,慢 SQL 和审计可以拦截 Executor 或 StatementHandler,但插件不要写复杂业务逻辑,否则链路隐蔽难排查。

MyBatis 和 Hibernate 怎么选

标准回答:

text
MyBatis 是半自动 ORM,SQL 由开发者控制,适合复杂查询、报表、批量处理和性能可控场景;Hibernate/JPA 更强调对象状态管理和自动持久化,CRUD 开发效率高,但复杂 SQL 要关注最终生成语句。业务复杂、SQL 调优要求高时更偏 MyBatis,领域模型清晰且 CRUD 多时可以考虑 JPA。

关联知识点

  1. MyBatis 动态 SQL
  2. MyBatis 缓存机制
  3. MyBatis 拦截器
  4. MyBatis 批量操作
  5. ORM 事务与一致性
  6. MyBatis-Plus 学习路线
  7. MySQL EXPLAIN 执行计划
  8. Spring 事务

本章小结

MyBatis 的主线可以浓缩成一句话:启动时把 XML 和接口解析成 MappedStatement,运行时通过 Mapper 代理找到 SQL,动态 SQL 生成 BoundSqlExecutor 协调缓存和执行,StatementHandler 创建语句,ParameterHandler 绑定参数,ResultSetHandler 映射结果。

真正写好 MyBatis,不是多背几个标签,而是能从这条链路反推问题:SQL 为什么没找到、参数为什么为空、字段为什么没映射、缓存为什么旧、分页为什么慢、事务为什么没生效。