MyBatis 核心全过程原理
MyBatis 不是“帮你自动生成所有 SQL”的框架,而是一个“让 SQL 可控,同时把 JDBC 重复工作封装起来”的半自动 ORM 框架。
学 MyBatis 不能只会写 Mapper 和 XML。真正要理解的是:接口为什么没有实现类也能执行、XML 是什么时候解析的、#{} 为什么安全、动态 SQL 最后怎样变成一条 SQL、缓存为什么可能读到旧数据、插件为什么能改分页和审计、事务为什么应该放在 Service 层。
学习目标
学完这一章要能说清楚这些问题:
- MyBatis 相比 JDBC 到底省掉了哪些重复代码。
- Mapper 接口没有实现类,为什么可以直接调用。
- 一次
assetMapper.selectByCode(code)从 Java 方法到数据库返回对象,中间经过哪些组件。 SqlSessionFactory、SqlSession、Executor、MappedStatement、BoundSql、StatementHandler、ParameterHandler、ResultSetHandler分别负责什么。#{}和${}的本质区别,以及为什么${}会有 SQL 注入风险。- 一级缓存、二级缓存的作用范围和一致性风险。
- MyBatis 插件为什么能做分页、慢 SQL、审计、租户和数据权限。
- MyBatis 单独使用事务和 Spring 管理事务有什么区别。
- 商业项目中如何设计 Mapper、动态 SQL、批量写入、分页查询和排查慢 SQL。
MyBatis 解决什么问题
先看不用 MyBatis 的 JDBC 查询。
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 中谁负责 |
|---|---|
| 创建连接和执行 SQL | Executor、StatementHandler、JDBC |
| 设置 SQL 参数 | ParameterHandler、TypeHandler |
读取 ResultSet | ResultSetHandler |
| 字段映射成对象 | ResultMap、自动映射、TypeHandler |
| 管理 SQL 配置 | MappedStatement、Configuration |
| 让接口方法能调用 SQL | MapperProxy、MapperMethod |
所以 MyBatis 的核心不是“让你不用 SQL”,而是“你写清楚 SQL,框架帮你稳定、安全、可扩展地执行 SQL”。
总体执行链路
下面这张图是一次 Mapper 查询的完整主链路。记住这条链路,MyBatis 大部分面试题都能从这里展开。
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 对象或集合"]每一步解决的问题不同:
MapperProxy解决“接口没有实现类怎么调用”。MappedStatement解决“一个方法对应哪条 SQL、参数是什么、返回值是什么”。BoundSql解决“动态 SQL 最终拼出来是什么、参数顺序是什么”。Executor解决“缓存、批处理、真正执行查询/更新”。StatementHandler解决“创建 JDBC Statement 并处理分页等语句层能力”。ParameterHandler解决“把 Java 参数放到 SQL 占位符里”。ResultSetHandler解决“把数据库行变成 Java 对象”。
启动时:XML 如何变成运行时配置
很多人以为调用 Mapper 时才读取 XML。实际项目启动时,MyBatis 会先解析配置文件和 Mapper XML,把每条 SQL 注册到 Configuration 中。
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 的说明书”,里面包含:
| 信息 | 说明 |
|---|---|
id | SQL 唯一标识,通常是 namespace + 方法名 |
sqlSource | SQL 来源,可能是静态 SQL,也可能是动态 SQL |
parameterMap | 参数映射信息 |
resultMaps | 结果映射规则 |
statementType | 使用 Statement、PreparedStatement 还是存储过程 |
sqlCommandType | SELECT、INSERT、UPDATE、DELETE |
flushCache | 执行后是否清缓存 |
useCache | 查询结果是否使用二级缓存 |
如果 XML 的 namespace 写错,或者 SQL 的 id 和 Mapper 方法名对不上,运行时就会报类似 Invalid bound statement。这不是数据库问题,而是 statementId 没有在 Configuration 中找到对应的 MappedStatement。
Mapper 接口为什么能执行
Mapper 接口本身没有实现类,Spring 注入进来的通常是 MyBatis 创建的代理对象。
public interface AssetMapper {
AssetDO selectByCode(String assetCode);
}调用时看起来是普通方法:
AssetDO asset = assetMapper.selectByCode("A-1001");实际进入的是代理对象:
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 里一般这样写:
<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 对应方法名,两者拼起来就是唯一键。
如果不这样设计会怎样?
namespace不匹配:Mapper 方法找不到 SQL。id重复或错写:可能启动失败,或者运行时报找不到语句。- 参数名不明确:动态 SQL 或多参数方法可能取不到正确参数。
- 返回映射错误:SQL 执行成功,但对象字段为空或类型转换失败。
参数解析:单参数、多参数和对象参数
MyBatis 需要把 Java 方法参数转换成 SQL 参数。这个过程并不是简单地按变量名拿值。
单个简单参数
AssetDO selectById(Long id);where id = #{id}单参数时,MyBatis 通常能直接取到这个值。
多个参数
List<AssetDO> selectByStatusAndDept(Integer status, Long deptId);如果没有 @Param,MyBatis 默认可能按 param1、param2 或 arg0、arg1 取值,可读性差,也容易踩坑。
推荐写法:
List<AssetDO> selectByStatusAndDept(@Param("status") Integer status,
@Param("deptId") Long deptId);where status = #{status}
and dept_id = #{deptId}对象参数
List<AssetDO> selectPage(AssetQuery query);<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_time | 否 | SQL 注入 |
#{} 会生成 ?,再由 ParameterHandler 调用 JDBC 设置参数。
flowchart TD
A["XML 中写 #{assetCode}"] --> B["生成 SQL: asset_code = ?"]
B --> C["ParameterMapping 记录参数名和类型"]
C --> D["ParameterHandler 取 Java 参数"]
D --> E["TypeHandler 设置 PreparedStatement 参数"]${} 会把内容直接拼到 SQL 字符串里。
order by ${sortField} ${sortType}如果用户传入:
create_time desc; delete from assetSQL 结构就可能被改变。商业项目中只有动态表名、动态字段名、动态排序方向这类“无法用 ? 表示 SQL 结构”的地方才考虑 ${},而且必须白名单。
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。
flowchart TD
A["Mapper 方法参数"] --> B["动态 SQL 标签判断"]
B --> C["拼接符合条件的 SQL 片段"]
C --> D["生成 BoundSql"]
D --> E["SQL 文本 + 参数映射列表"]
E --> F["交给 JDBC 执行"]示例:资产列表查询。
<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 >= #{startTime}
</if>
<if test="endTime != null">
and create_time < #{endTime}
</if>
</where>
order by create_time desc
limit #{offset}, #{pageSize}
</select><where> 的价值是自动处理第一个 and。如果不用 <where>,手写拼接很容易出现 where and status = ? 这种错误 SQL。
<foreach> 常用于批量查询:
<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:
where asset_code = #{assetCode}
and status = #{status}生成的 BoundSql 类似:
sql = "where asset_code = ? and status = ?"
parameterMappings = ["assetCode", "status"]
parameterObject = AssetQuery(...)所以排查问题时要看两件事:
- 最终 SQL 是什么。
- 参数按什么顺序绑定、每个参数值是什么。
很多“我写的 SQL 明明没问题”的线上问题,本质是动态 SQL 条件没有进入、参数为空、字段名拼错、类型处理器不匹配。
Executor:真正执行 SQL 的入口
Executor 是 MyBatis 执行 SQL 的核心入口,常见类型有:
| Executor 类型 | 说明 | 适用场景 |
|---|---|---|
SimpleExecutor | 每次执行都创建新的 PreparedStatement | 默认常见场景 |
ReuseExecutor | 复用相同 SQL 的 PreparedStatement | 同一会话重复执行相同 SQL |
BatchExecutor | 批量执行更新语句 | 批量插入、批量更新 |
查询的大致流程:
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["写入一级缓存"]更新的大致流程:
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 的 Statement 或 PreparedStatement。分页插件经常拦截它,因为这里能拿到最终 SQL,可以把 SQL 改写成带分页的语句。
例如:
select id, asset_code from asset order by create_time desc分页插件可能改写成:
select id, asset_code from asset order by create_time desc limit ?, ?ParameterHandler
ParameterHandler 负责把 Java 参数绑定到 PreparedStatement。
preparedStatement.setString(1, "A-1001");
preparedStatement.setInt(2, 1);它会结合 ParameterMapping 和 TypeHandler 判断应该调用 setString、setLong、setTimestamp 还是自定义转换。
ResultSetHandler
ResultSetHandler 负责把 ResultSet 映射成 Java 对象。
flowchart TD
A["ResultSet 一行数据"] --> B["读取列名和列值"]
B --> C["根据 resultMap 找到属性"]
C --> D["TypeHandler 转换类型"]
D --> E["反射或对象工厂设置属性"]
E --> F["返回实体对象"]如果字段映射不正确,常见现象是 SQL 查到了数据,但 Java 对象字段是 null。这时要检查列别名、驼峰映射、resultMap、类型处理器。
ResultMap:为什么复杂映射不要只靠 resultType
resultType 适合字段名和属性名比较一致的简单查询。
<select id="selectById" resultType="com.demo.AssetDO">
select id, asset_code assetCode, asset_name assetName
from asset
where id = #{id}
</select>复杂查询建议用 resultMap:
<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>为什么?因为商业项目里字段经常涉及:
- 数据库下划线和 Java 驼峰不一致。
- 多表 join 后列名重复。
- 枚举、JSON、加密字段需要类型转换。
- 一对一、一对多关系需要明确映射。
TypeHandler:Java 类型和 JDBC 类型的桥
数据库类型和 Java 类型不是一回事。varchar、bigint、timestamp、json、tinyint 都需要转换成 Java 中的 String、Long、LocalDateTime、对象或枚举。
自定义枚举转换示例:
public enum AssetStatus {
ENABLED(1), DISABLED(0);
private final int code;
AssetStatus(int code) {
this.code = code;
}
public int getCode() {
return code;
}
}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。
try (SqlSession session = sqlSessionFactory.openSession()) {
AssetMapper mapper = session.getMapper(AssetMapper.class);
AssetDO a1 = mapper.selectById(1L);
AssetDO a2 = mapper.selectById(1L);
}第二次查询可能不会访问数据库,而是从一级缓存返回。
flowchart TD
A["第一次 selectById(1)"] --> B["生成 CacheKey"]
B --> C["一级缓存未命中"]
C --> D["查询数据库"]
D --> E["结果放入 SqlSession 本地缓存"]
E --> F["第二次 selectById(1)"]
F --> G["CacheKey 相同"]
G --> H["直接返回缓存"]缓存 Key 通常和这些因素有关:
MappedStatement的 id。- SQL 分页信息。
- 最终 SQL 文本。
- 参数值。
- 环境信息。
一级缓存的风险是同一个 SqlSession 里可能读到旧值。例如你在同一个会话里查询了资产,又通过其他路径更新了数据库,再查询同一个资产,可能仍然拿到本地缓存。写操作、提交、回滚会清空一级缓存,就是为了降低这种风险。
在 Spring 项目里,每次 Mapper 调用通常通过 SqlSessionTemplate 管理会话,不建议为了缓存效果手动扩大 SqlSession 生命周期。
二级缓存:为什么商业项目要谨慎
二级缓存作用域是 Mapper namespace,多个 SqlSession 可以共享。
flowchart TD
A["SqlSession 1 查询 UserMapper"] --> B["结果提交后进入二级缓存"]
C["SqlSession 2 查询同一 namespace"] --> D{"二级缓存命中?"}
D -->|"是"| E["不查数据库,直接返回缓存"]
D -->|"否"| F["查询数据库"]二级缓存最大问题不是“能不能提升性能”,而是“一致性边界太难控制”。
例如:
AssetMapper查询了资产和科室名称的 join 结果。DeptMapper更新了科室名称。AssetMapper的二级缓存未必知道DeptMapper改了数据。- 用户继续查询资产列表,可能看到旧科室名称。
分布式部署时,如果二级缓存是 JVM 本地缓存,A 节点更新后,B 节点缓存也不会自动失效。
商业项目建议:
- 一级缓存了解机制即可,不要依赖它做业务缓存。
- 二级缓存默认谨慎关闭,强一致业务不建议开。
- 需要缓存时优先使用 Redis,并明确缓存 Key、过期时间、删除策略和一致性方案。
- 查询性能问题优先看 SQL、索引、分页、表结构,而不是先开 MyBatis 二级缓存。
插件机制:为什么能实现分页和审计
MyBatis 插件本质是对核心对象做动态代理。它能拦截四类对象:
| 可拦截对象 | 典型用途 |
|---|---|
Executor | 慢 SQL、审计、缓存、数据权限入口 |
StatementHandler | 分页 SQL 改写、SQL 打印 |
ParameterHandler | 参数加密、脱敏、审计 |
ResultSetHandler | 结果脱敏、字段转换 |
插件链路:
flowchart TD
A["创建 Executor 或 StatementHandler"] --> B["Plugin.wrap 包装代理对象"]
B --> C["调用目标方法"]
C --> D["进入 Interceptor.intercept"]
D --> E["插件前置逻辑"]
E --> F["invocation.proceed 执行原方法"]
F --> G["插件后置逻辑"]一个慢 SQL 插件示例:
@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 项目里我们很少手动写:
SqlSession session = sqlSessionFactory.openSession();通常是直接注入 Mapper。
@Service
public class AssetService {
private final AssetMapper assetMapper;
public AssetService(AssetMapper assetMapper) {
this.assetMapper = assetMapper;
}
}这是因为 MyBatis-Spring 做了几件事:
- 扫描 Mapper 接口。
- 为 Mapper 创建代理对象并注册为 Spring Bean。
- 使用
SqlSessionTemplate作为线程安全入口。 - 从 Spring 事务上下文中获取数据库连接。
- 让 Mapper 方法参与
@Transactional管理的事务。
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 单独使用时,你需要自己提交或回滚。
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 项目中通常这样:
@Transactional(rollbackFor = Exception.class)
public void createAsset(AssetCreateCommand command) {
assetMapper.insert(command.toAsset());
assetLogMapper.insert(command.toLog());
}Spring 事务的核心是:
- 方法进入前开启事务,绑定数据库连接到当前线程。
- Mapper 执行时通过
SqlSessionTemplate拿到同一个连接。 - 方法正常结束提交。
- 抛出符合规则的异常时回滚。
不这样做会怎样?
- 每个 Mapper 自己提交:订单主表成功、明细失败,数据不一致。
- 事务放在 Mapper:无法覆盖一个完整业务动作。
- 事务包住远程调用:数据库锁持有时间变长,容易阻塞。
- 自调用导致事务不生效:同类方法内部调用绕过 Spring 代理。
商业 Demo:医疗资产查询和批量入库
表结构
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 接口
public interface AssetMapper {
AssetDO selectByCode(@Param("assetCode") String assetCode);
List<AssetDO> selectPage(AssetQuery query);
int batchInsert(@Param("list") List<AssetDO> list);
}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
@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 体现了商业项目的几个原则:
- 查询条件用动态 SQL,但参数仍使用
#{}。 - 分页字段要配合索引,例如
dept_id, status, create_time。 - 批量插入要分批,不能一次拼十几万条。
- 事务放在 Service 层,覆盖一次导入动作。
- 上线前要用
EXPLAIN检查selectPage是否走索引。
N+1 查询问题
N+1 指先查一批主数据,再循环查询每条数据的关联信息。
List<AssetDO> assets = assetMapper.selectPage(query);
for (AssetDO asset : assets) {
DeptDO dept = deptMapper.selectById(asset.getDeptId());
asset.setDeptName(dept.getDeptName());
}如果列表有 100 条,这段代码就是 1 次资产查询 + 100 次科室查询。
解决方式:
- SQL join 一次查出需要字段。
- 先查资产列表,再批量查所有
deptId,最后在内存中组装。 - 对低频不强一致数据使用 Redis 缓存。
- 分页时只查当前页,避免先全量查再内存分页。
不要把 N+1 误认为“数据库慢”。它本质是数据访问模式不对,SQL 次数被循环放大。
批量操作的正确姿势
批量写入常见三种方式:
| 方式 | 特点 | 风险 |
|---|---|---|
| 循环单条 insert | 简单但慢 | SQL 次数多、事务耗时长 |
foreach 拼多 values | SQL 次数少 | 单条 SQL 太大、参数过多 |
ExecutorType.BATCH | JDBC 批处理 | 需要控制 flush、内存和错误处理 |
批量不是越大越好。批量过大会带来:
- SQL 包过大。
- 数据库解析压力变高。
- 事务持锁时间变长。
- 回滚成本变高。
- JVM 中待提交对象占用内存。
一般建议从 300、500、1000 这种批大小压测,不要凭感觉设置。
线上排查流程
Mapper 找不到 SQL
Invalid bound statement (not found)排查顺序:
- Mapper XML 是否被扫描到。
- XML
namespace是否等于接口全限定名。 - SQL 标签
id是否等于方法名。 - 多模块项目打包后 XML 是否在 classpath。
- Spring Boot
mybatis.mapper-locations是否配置正确。
SQL 执行慢
flowchart TD
A["发现接口慢"] --> B["打开 SQL 日志拿到最终 SQL 和参数"]
B --> C["去数据库执行 EXPLAIN"]
C --> D{"是否走合适索引?"}
D -->|"否"| E["补索引或改 SQL 条件顺序"]
D -->|"是"| F{"扫描行数是否过大?"}
F -->|"是"| G["优化分页、过滤条件、表设计"]
F -->|"否"| H{"是否有锁等待或资源瓶颈?"}
H -->|"是"| I["排查事务、慢更新、连接池、数据库资源"]
H -->|"否"| J["检查网络、应用线程池、结果集过大"]查询有数据但对象字段为空
排查顺序:
- SQL 是否真的返回该列。
- 列名和属性名是否匹配。
- 是否需要列别名,例如
asset_code assetCode。 mapUnderscoreToCamelCase是否开启。resultMap中column、property是否写错。- 类型处理器是否正确。
事务不生效
排查顺序:
- 方法是否是
public。 - 是否通过 Spring Bean 代理对象调用。
- 是否发生同类自调用。
- 异常是否被捕获吞掉。
- 默认只回滚运行时异常,检查
rollbackFor。 - 数据源和事务管理器是否一致。
- Mapper 是否使用 Spring 管理的
SqlSessionTemplate。
常见坑
| 问题 | 原因 | 正确做法 |
|---|---|---|
使用 ${} 接收用户输入 | 字符串拼接导致 SQL 注入 | 业务值使用 #{},字段名白名单 |
| XML namespace 写错 | statementId 对不上 | namespace 写接口全限定名 |
多参数不用 @Param | 参数名不稳定 | 多参数显式写 @Param |
大 foreach in | SQL 太长、优化器估算差 | 分批、临时表、关联表 |
| 二级缓存读旧数据 | namespace 无法感知其他表更新 | 强一致业务不用二级缓存 |
| 批量过大 | SQL 包、锁、内存压力 | 控制批大小并压测 |
| Mapper 写业务逻辑 | 数据访问层职责混乱 | 业务编排放 Service |
| 只看 Mapper 不看执行计划 | 慢 SQL 根因在数据库 | 用 SQL 日志 + EXPLAIN 排查 |
面试标准回答
MyBatis 的执行流程是什么
标准回答:
业务代码调用 Mapper 接口时,实际进入 MyBatis 创建的 MapperProxy。代理会根据接口全限定名和方法名找到 MappedStatement,动态 SQL 会生成 BoundSql,Executor 负责执行查询或更新,StatementHandler 创建 PreparedStatement,ParameterHandler 设置参数,数据库返回 ResultSet 后由 ResultSetHandler 根据 ResultMap 映射成 Java 对象。#{} 和 ${} 区别是什么
标准回答:
#{ } 是预编译参数绑定,会变成 JDBC 的 ? 占位符,再由 ParameterHandler 设置参数,能防 SQL 注入;${ } 是字符串直接拼接,会改变 SQL 结构,适合动态表名、字段名、排序字段,但必须做白名单校验,不能直接接收用户输入。为什么不建议随便开启二级缓存
标准回答:
二级缓存是 Mapper namespace 级别,范围比一级缓存大,但它很难感知其他 namespace、其他服务节点、其他写入路径对数据的修改。多表关联、分布式部署和强一致业务中容易读到旧数据,所以生产中通常谨慎使用,性能问题优先通过索引、SQL、分页和 Redis 缓存方案解决。MyBatis 插件原理是什么
标准回答:
MyBatis 插件通过动态代理包装 Executor、StatementHandler、ParameterHandler、ResultSetHandler 四类核心对象,在目标方法执行前后进入 Interceptor。分页插件一般拦截 StatementHandler 改写 SQL,慢 SQL 和审计可以拦截 Executor 或 StatementHandler,但插件不要写复杂业务逻辑,否则链路隐蔽难排查。MyBatis 和 Hibernate 怎么选
标准回答:
MyBatis 是半自动 ORM,SQL 由开发者控制,适合复杂查询、报表、批量处理和性能可控场景;Hibernate/JPA 更强调对象状态管理和自动持久化,CRUD 开发效率高,但复杂 SQL 要关注最终生成语句。业务复杂、SQL 调优要求高时更偏 MyBatis,领域模型清晰且 CRUD 多时可以考虑 JPA。关联知识点
- MyBatis 动态 SQL
- MyBatis 缓存机制
- MyBatis 拦截器
- MyBatis 批量操作
- ORM 事务与一致性
- MyBatis-Plus 学习路线
- MySQL EXPLAIN 执行计划
- Spring 事务
本章小结
MyBatis 的主线可以浓缩成一句话:启动时把 XML 和接口解析成 MappedStatement,运行时通过 Mapper 代理找到 SQL,动态 SQL 生成 BoundSql,Executor 协调缓存和执行,StatementHandler 创建语句,ParameterHandler 绑定参数,ResultSetHandler 映射结果。
真正写好 MyBatis,不是多背几个标签,而是能从这条链路反推问题:SQL 为什么没找到、参数为什么为空、字段为什么没映射、缓存为什么旧、分页为什么慢、事务为什么没生效。
