主题
十、Mybatis(8 题)
10.1 ★★★★Mybatis 的核心执行流程是怎样的?从 SqlSession 到执行 SQL 的完整流程?各组件的职责是什么?
📖 点击查看答案
核心组件:
- SqlSessionFactory:创建 SqlSession 的工厂,重量级对象,全局单例。
- SqlSession:会话对象,封装 CRUD API,线程不安全,方法级生命周期。
- Executor:执行器,负责 SQL 执行与缓存管理,有 SIMPLE、REUSE、BATCH 三种类型。
- StatementHandler:处理 JDBC Statement 的创建与参数设置。
- ParameterHandler:设置 SQL 参数(占位符赋值)。
- ResultSetHandler:处理结果集映射为 Java 对象。
- TypeHandler:Java 类型与 JDBC 类型转换。
完整执行流程:
- 调用
SqlSession.selectOne("namespace.id", param)。 Configuration根据 statement id 获取MappedStatement(封装 SQL、参数映射、结果映射)。SqlSession委托给Executor执行。Executor先查一级缓存 → 二级缓存,未命中则继续。- 创建
StatementHandler,通过ParameterHandler设置参数。 - 执行 JDBC Statement,获取 ResultSet。
ResultSetHandler根据<resultMap>将行数据映射为 Java 对象。- 返回结果,Executor 写入缓存。
java
// 核心流程源码(简化)
public <E> List<E> selectList(String statement, Object parameter) {
MappedStatement ms = configuration.getMappedStatement(statement);
return executor.query(ms, wrapCollection(parameter), RowBounds.DEFAULT, Executor.NO_RESULT_HANDLER);
}
// Executor.query → StatementHandler.prepare → ParameterHandler.setParameters → execute → ResultSetHandler.handleResultSets动态代理机制:Mapper 接口通过 MapperProxyFactory 生成代理对象,方法调用被 MapperProxy.invoke 拦截,转为调用 SqlSession 的对应方法,statementId = 接口全限定名 + 方法名。
关键点:Configuration 全局唯一,缓存所有 XML 解析结果;Executor 是缓存与执行的核心;插件通过拦截 Executor/StatementHandler/ParameterHandler/ResultSetHandler 实现增强。
10.2 ★★★Mybatis 的延迟加载原理是什么?Mapper 接口和 XML 文件是如何绑定的?为什么 DAO 接口没有实现类也能工作?
📖 点击查看答案
延迟加载原理:
- 配置
lazyLoadingEnabled=true后,关联对象(association/collection)在真正访问时才执行 SQL。 - 底层通过 CGLIB 动态代理 生成代理对象,拦截
toString、hashCode、equals等方法及属性 getter。 - 主对象加载时不加载关联属性,调用
getDetail()时触发拦截器 → 调用loadObject()→ 执行关联 SQL。 aggressiveLazyLoading=false(3.4+ 默认)时按需加载,否则任一方法触发全部加载。
xml
<!-- 配置 -->
<settings>
<setting name="lazyLoadingEnabled" value="true"/>
<setting name="aggressiveLazyLoading" value="false"/>
</settings>
<!-- resultMap 中 association 配置 fetchType="lazy" -->
<association property="user" javaType="User" fetchType="lazy" select="selectUserById" column="user_id"/>Mapper 接口与 XML 绑定:
- XML 中
namespace= Mapper 接口全限定名。 <select id="xxx">的 id 对应接口方法名。- 解析 XML 时将
MappedStatement以namespace.id为 key 存入Configuration。
无实现类原理(JDK 动态代理):
MapperRegistry注册 Mapper 接口。- 调用
getMapper(UserMapper.class)时,MapperProxyFactory.newInstance()通过 JDK 动态代理生成代理对象。 - 代理对象实现
UserMapper接口,方法调用被MapperProxy.invoke()拦截。 invoke方法根据method全限定名拼出 statementId,委托给SqlSession执行。
java
// JDK 动态代理核心
public Object invoke(Object proxy, Method method, Object[] args) {
String statementId = interfaceName + "." + method.getName();
Class<?> returnType = method.getReturnType();
if (returnType == List.class) {
return sqlSession.selectList(statementId, args[0]);
} else {
return sqlSession.selectOne(statementId, args[0]);
}
}关键点:Mapper 接口方法不能重载(id 唯一);延迟加载需关联查询在同一 SqlSession 内(缓存可用)。
10.3 ★★★★#{} 和 ${} 的区别是什么?为什么 #{} 能防止 SQL 注入?底层是如何实现的?
📖 点击查看答案
核心区别:
| 维度 | #{} | ${} |
|---|---|---|
| 底层 | PreparedStatement 占位符 ? | 字符串拼接 |
| SQL 注入 | 防止 | 不能防止 |
| 类型处理 | 自动 TypeHandler 转换 | 原样拼接 |
| 性能 | 可预编译缓存 | 每次生成新 SQL |
| 使用场景 | 参数值 | 表名、列名、排序字段等结构 |
#{} 防 SQL 注入原理:
- 解析 SQL 时,
#{}被替换为?,参数值单独保存为ParameterMapping。 - 执行时使用
PreparedStatement.setXXX(position, value)设置参数。 - JDBC 驱动对参数值做转义处理(如单引号转义),不会改变 SQL 结构。
java
// 源码解析(SqlSourceBuilder)
// 原 SQL: SELECT * FROM user WHERE name = #{name}
// 解析后: SELECT * FROM user WHERE name = ?
// 参数映射: ParameterMapping{property='name', javaType=String}
// 执行时
PreparedStatement ps = conn.prepareStatement("SELECT * FROM user WHERE name = ?");
ps.setString(1, "张三' OR '1'='1"); // 整体作为字符串值,不会拼接进 SQL${} 字符串拼接示例:
java
// 原 SQL: SELECT * FROM user ORDER BY ${column}
// 解析后: SELECT * FROM user ORDER BY id (column=id 时)
// 风险:若 column = "id; DROP TABLE user",会执行恶意 SQL使用场景对比:
xml
<!-- #{} 用于值 -->
<select id="selectByName">
SELECT * FROM user WHERE name = #{name}
</select>
<!-- ${} 用于结构(表名/列名/排序),需做白名单校验 -->
<select id="selectByOrder">
SELECT * FROM user ORDER BY ${column} <!-- 必须业务层校验 -->
</select>关键点:#{} 本质是 PreparedStatement 机制;${} 是字符串替换,安全性需业务层保证;动态表名/排序字段用 ${},但必须校验白名单。
10.4 ★★★★Mybatis 的动态 SQL 有哪些标签?底层是如何拼接 SQL 的?使用场景有哪些?
📖 点击查看答案
动态 SQL 标签:
| 标签 | 作用 | 场景 |
|---|---|---|
<if> | 条件判断 | 可选查询条件 |
<where> | 自动处理 AND/OR 前缀 | 多条件 WHERE 拼接 |
<choose>/<when>/<otherwise> | if-else 分支 | 互斥条件 |
<trim> | 自定义前后缀与删除 | 灵活拼接 |
<set> | 自动处理 UPDATE 末尾逗号 | 动态更新 |
<foreach> | 遍历集合 | IN 查询、批量插入 |
<bind> | 定义 OGNL 变量 | 模糊查询拼接 |
底层拼接原理:
- 解析 XML 时,动态 SQL 被转换为
SqlNode树(如MixedSqlNode、IfSqlNode、ForEachSqlNode)。 - 执行时调用
SqlNode.apply(DynamicContext),根据参数动态生成 SQL 片段,写入DynamicContext。 DynamicContextSource将拼接后的 SQL 与参数传给SqlSourceBuilder,将#{}替换为?,最终生成BoundSql。
xml
<!-- 典型示例 -->
<select id="selectUsers" resultType="User">
SELECT * FROM user
<where>
<if test="name != null">
AND name = #{name}
</if>
<if test="age != null">
AND age = #{age}
</if>
<choose>
<when test="sort == 'name'">
ORDER BY name
</when>
<otherwise>
ORDER BY id
</otherwise>
</choose>
</where>
</select>
<!-- foreach 批量插入 -->
<insert id="batchInsert">
INSERT INTO user (name, age) VALUES
<foreach collection="list" item="u" separator=",">
(#{u.name}, #{u.age})
</foreach>
</insert>SqlNode 树结构示例:
java
// <where><if>...</if></where> 解析为
MixedSqlNode
├── StaticTextSqlNode("SELECT * FROM user")
├── WhereSqlNode
│ └── IfSqlNode(test="name!=null", contents=[StaticTextSqlNode("AND name = #{name}")])使用场景:
- 多条件查询:用户可选填筛选条件。
- 批量操作:
<foreach>拼接 IN/VALUES。 - 动态更新:只更新非空字段。
- 多数据库兼容:通过
<databaseId>切换。
注意:<if> 的 test 用 OGNL 表达式;<foreach> 的 index 是下标(List)或 key(Map);动态 SQL 频繁变化会导致预编译缓存失效。
10.5 ★★★★Mybatis 的缓存机制是怎样的?一级缓存和二级缓存的区别、作用域和失效场景是什么?
📖 点击查看答案
一级缓存(Local Cache):
- 作用域:
SqlSession级别,默认开启,无法关闭(可设localCacheScope=STATEMENT关闭)。 - 数据结构:
PerpetualCache(HashMap),key =statementId + params + rowBounds + sql。 - 存放位置:
BaseExecutor.localCache。
二级缓存(namespace 级别):
- 作用域:Mapper 的 namespace,跨 SqlSession 共享。
- 默认关闭,需在 XML 中
<cache/>开启,实体类需Serializable。 - 查询顺序:二级缓存 → 一级缓存 → 数据库。
对比:
| 维度 | 一级缓存 | 二级缓存 |
|---|---|---|
| 作用域 | SqlSession | namespace(跨 SqlSession) |
| 默认 | 开启 | 关闭 |
| 失效条件 | insert/update/delete、clearCache()、关闭会话 | 整个 namespace 的增删改 |
| 实现 | Executor 内 HashMap | Decorator 模式(LRU/FIFO/Soft/Weak) |
| 并发 | 单线程使用 | 跨会话,需 Serializable |
失效场景:
- 一级缓存:执行
update/insert/delete(同 SqlSession 内清空)、调用sqlSession.clearCache()、SqlSession 关闭。 - 二级缓存:namespace 内任意
insert/update/delete后清空整个 namespace 缓存。
xml
<!-- 开启二级缓存 -->
<mapper namespace="com.xxx.UserMapper">
<cache eviction="LRU" flushInterval="60000" size="1024" readOnly="false"/>
<!-- eviction: LRU/FIFO/SOFT/WEAK -->
</select>
</mapper>多表查询缓存问题:
- 二级缓存基于 namespace,若 A Mapper 查询关联 B 表,B 表更新不会清空 A 的缓存 → 脏读。
- 解决:使用
<cache-ref namespace="..."/>共享缓存,或禁用多表查询的二级缓存。
分布式缓存集成:通过实现 Cache 接口,可对接 Redis/Memcached:
java
public class RedisCache implements Cache {
public String getId() { ... }
public void putObject(Object key, Object value) { redis.set(key, value); }
public Object getObject(Object key) { return redis.get(key); }
}关键点:Spring 整合下 SqlSession 由 SqlSessionTemplate 管理,未开启事务时每次方法调用创建新 SqlSession → 一级缓存失效;开启事务时同事务内复用 SqlSession,一级缓存生效。
10.6 ★★★★Mybatis 的插件(Interceptor)机制原理是什么?可以拦截哪些对象的方法?如何实现一个自定义的分页插件?
📖 点击查看答案
插件原理:
- 基于 JDK 动态代理 + 责任链模式,在创建
Executor/StatementHandler/ParameterHandler/ResultSetHandler时通过InterceptorChain.pluginAll()包装代理。 - 每个插件层层包裹目标对象,调用时依次执行各插件的
intercept方法。
可拦截的四大对象:
| 对象 | 拦截方法 | 典型场景 |
|---|---|---|
| Executor | update, query, commit, rollback | 缓存、事务、慢 SQL |
| StatementHandler | prepare, parameterize, batch | 改写 SQL(分页) |
| ParameterHandler | setParameters | 参数加密 |
| ResultSetHandler | handleResultSets | 结果脱敏 |
实现分页插件示例:
java
@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class PageInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler handler = (StatementHandler) invocation.getTarget();
// 通过反射获取原始 SQL
MetaObject metaObject = SystemMetaObject.forObject(handler);
String originalSql = (String) metaObject.getValue("delegate.boundSql.sql");
// 从 ThreadLocal 获取分页参数
Page page = PageContext.get();
if (page != null) {
// 改写 SQL
String pageSql = originalSql + " LIMIT " + page.getOffset() + ", " + page.getPageSize();
metaObject.setValue("delegate.boundSql.sql", pageSql);
// 查询总数(可选)
}
return invocation.proceed();
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this); // 仅拦截目标类型
}
@Override
public void setProperties(Properties properties) { }
}xml
<!-- 注册插件 -->
<plugins>
<plugin interceptor="com.xxx.PageInterceptor">
<property name="dialect" value="mysql"/>
</plugin>
</plugins>关键点:
@Intercepts+@Signature指定拦截的方法签名。Plugin.wrap用动态代理包装目标对象,仅拦截匹配方法。- 通过
MetaObject反射读写目标对象私有属性(如boundSql.sql)。 - 多插件按注册顺序反向执行(洋葱模型)。
注意:插件过多影响性能(多层代理);分页插件要考虑不同数据库方言;PageHelper 是成熟的开源实现,优先使用。
10.7 ★★★Mybatis 是如何进行分页的?RowBounds 分页和插件分页的区别?分页插件 PageHelper 的原理是什么?
📖 点击查看答案
分页方式:
- SQL 分页(推荐):在 SQL 中写
LIMIT offset, size,性能最好。 - RowBounds 分页(逻辑分页):Mybatis 内置,通过 ResultSet 跳过前 N 条记录。
- 插件分页(物理分页):通过 Interceptor 改写 SQL,加 LIMIT 子句。
RowBounds vs 插件分页:
| 维度 | RowBounds | 插件分页 |
|---|---|---|
| 类型 | 逻辑分页 | 物理分页 |
| 实现 | ResultSet.absolute(offset) 跳过 | SQL 加 LIMIT |
| 数据量 | 全量查询后跳过 | 只查需要的 |
| 性能 | 差(大结果集慢) | 好 |
| 适用 | 小数据量 | 生产环境 |
java
// RowBounds(不推荐)
List<User> users = sqlSession.selectList("selectAll", null, new RowBounds(10, 20));
// 实际执行:SELECT * FROM user(全量),ResultSet 跳过前 10 条PageHelper 原理:
- 调用
PageHelper.startPage(pageNum, pageSize)→ 将 Page 对象存入 ThreadLocal。 - 紧接的下一次查询被拦截器拦截(
PageInterceptor)。 - 拦截器从 ThreadLocal 取 Page,改写 SQL:
- MySQL:
SELECT * FROM user LIMIT ?, ? - Oracle:使用 ROWNUM 子查询
- MySQL:
- 额外执行 COUNT 查询获取总数,封装为
PageInfo。 - 查询完成后清理 ThreadLocal。
java
// PageHelper 使用
PageHelper.startPage(2, 10); // 第 2 页,每页 10 条
List<User> list = userMapper.selectAll();
PageInfo<User> pageInfo = new PageInfo<>(list);
// pageInfo.getList() → 当前页数据
// pageInfo.getTotal() → 总记录数java
// PageInterceptor 核心逻辑
@Intercepts({@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})})
public class PageInterceptor implements Interceptor {
public Object intercept(Invocation invocation) throws Throwable {
Page page = PageHelper.getLocalPage();
if (page != null) {
Object[] args = invocation.getArgs();
MappedStatement ms = (MappedStatement) args[0];
BoundSql boundSql = ms.getBoundSql(args[1]);
String sql = boundSql.getSql();
// 改写 SQL
String pageSql = sql + " LIMIT " + page.getStartRow() + ", " + page.getPageSize();
// 通过反射更新 boundSql.sql
ReflectUtil.setFieldValue(boundSql, "sql", pageSql);
}
return invocation.proceed();
}
}关键点:startPage 必须紧邻查询,否则 ThreadLocal 会被下次调用污染;COUNT 查询会解析原 SQL 去除 ORDER BY 优化;多数据源时需指定 dialect。
10.8 ★★★★Mybatis-Plus 相比原生 Mybatis 有哪些核心增强功能?代码生成器和通用 CRUD 的底层原理是什么?
📖 点击查看答案
核心增强功能:
- 通用 CRUD(BaseMapper):无需 XML 即可实现
insert/updateById/deleteById/selectById/selectList等。 - 条件构造器(Wrapper):链式 API 构建查询条件,如
QueryWrapper.eq("name", "张三").ge("age", 18)。 - 代码生成器:根据数据库表自动生成 Entity/Mapper/Service/Controller。
- 分页插件:内置
PaginationInnerInterceptor,无需额外配置。 - 主键策略:
@TableId(type = IdType.AUTO/ASSIGN_ID/ASSIGN_UUID)。 - 逻辑删除:
@TableLogic字段,删除转为 update。 - 自动填充:
@TableField(fill = FieldFill.INSERT/UPDATE)+MetaObjectHandler。 - 乐观锁:
@Version字段 +OptimisticLockerInnerInterceptor。 - 多租户:
TenantLineInnerInterceptor自动拼接租户条件。
通用 CRUD 原理:
- 启动时 MP 扫描
@TableName注解的实体类,注册到MybatisMapperRegistry。 - 为每个 BaseMapper 方法注入
MappedStatement(SQL 模板预定义)。 - SQL 通过
SqlInjector动态注入到 Configuration 中,与 XML 解析的 statement 共存。 - 实体类通过反射获取表名(
@TableName或类名驼峰转下划线)、字段名(@TableField)。
java
// BaseMapper 方法注入核心
public class DefaultSqlInjector extends AbstractSqlInjector {
@Override
public List<AbstractMethod> getMethodList(Class<?> mapperClass) {
return Stream.of(
new Insert(),
new DeleteById(),
new UpdateById(),
new SelectById(),
new SelectList()
).collect(Collectors.toList());
}
}
// 每个 AbstractMethod 调用 inject() 生成 MappedStatement代码生成器原理:
- 通过 JDBC
DatabaseMetaData获取表结构与字段信息。 - 模板引擎(Velocity/Freemarker)渲染 Entity/Mapper/Service/Controller。
- 命名策略:表名 → 类名(下划线转驼峰),字段名 → 属性名。
java
// 代码生成器配置
FastAutoGenerator.create("jdbc:mysql://localhost/test", "root", "root")
.globalConfig(builder -> builder.author("author").outputDir("src/main/java"))
.packageConfig(builder -> builder.parent("com.xxx").moduleName("user"))
.strategyConfig(builder -> builder.addInclude("user").addTablePrefix("t_"))
.execute();Wrapper 条件构造器原理:
QueryWrapper内部维护List<ISqlSegment>存储条件片段(name = ?、age > ?)。getCustomSqlSegment()拼接为${ew.customSqlSegment},参数通过paramNameValuePairs传递。- XML 中使用
#{ew.paramNameValuePairs.MPGENVAL1}引用参数。
关键点:MP 不破坏 Mybatis 原生能力,XML 与 BaseMapper 可混用;BaseMapper 方法本质是预生成的 MappedStatement;自定义 SQL 仍走 XML/注解方式。