MyBatis面试题
MyBatis 工作原理:SqlSessionFactory → SqlSession → Executor → StatementHandler
这是一个非常经典的 MyBatis 底层原理题,能看出你对框架的理解深度,而不只是会用 CRUD。
这就像去餐馆点菜 🍜。
SqlSessionFactory 是“常驻大厨”,SqlSession 是“一次点单”,Executor 是“执行跑腿”,StatementHandler 就是最后“挥锅铲炒菜”的人。
我用一张流图把核心链路画出来,再逐个击破。
整体流程一句话总结 📝
MyBatis 的执行流程本质上是 "工厂生产会话,会话委派执行器,执行器调度语句处理器" 的链式调用过程,最终完成 SQL 的解析、参数设置、执行和结果映射。
核心链路流程图 🚀
逐个组件核心职责详解 🔍
1. SqlSessionFactory 🏭
- 核心作用:SqlSession 的工厂类,负责创建 SqlSession 对象
- 生命周期:应用启动时创建一次,全局唯一,贯穿整个应用生命周期
- 关键点:
- 由 SqlSessionFactoryBuilder 根据 mybatis-config.xml 和 Mapper.xml 构建
- 内部持有所有 MyBatis 的配置信息(环境、映射、插件等)
- 线程安全,多个线程可以同时访问获取 SqlSession
2. SqlSession 📞
- 核心作用:MyBatis 与数据库交互的顶层 API,提供 CRUD、事务管理等方法
- 生命周期:每次数据库请求创建一个,使用完毕必须关闭
- 关键点:
- 线程不安全,绝对不能作为类的成员变量共享
- 本身不直接执行 SQL,而是将请求委派给 Executor 执行器
- 提供了 getMapper () 方法,获取 Mapper 接口的代理对象
3. Executor ⚙️
核心作用:SQL 执行的调度器,负责缓存管理和事务管理
生命周期:与 SqlSession 同生共死
关键点:
- 有三种实现:SimpleExecutor(默认,每次执行创建新 Statement)、ReuseExecutor(复用 Statement)、BatchExecutor(批量执行)
- 维护了一级缓存(SqlSession 级)和二级缓存(Mapper 级)
- 所有 SQL 执行都要经过 Executor 的 update () 或 query () 方法
核心动作:
- 接到 SQL 请求,先查 一级缓存(localCache),命中直接返回结果,不往下走。
- 未命中 → 委托
StatementHandler干活。 - 事务提交前,负责清理缓存,触发刷新。
💬 “Executor 拿着工单,先翻翻旁边小黑板(一级缓存),没有再叫执行器动手。”
4. StatementHandler 🎛️
创建 PreparedStatement → 设参 → 执行 SQL → 处理结果集- 核心作用:JDBC Statement 的封装,真正负责与 JDBC 交互
- 生命周期:每次 SQL 执行创建一个
- 关键点:
- 是 MyBatis 插件拦截的核心对象(分页插件就是拦截这里)
- 内部会调用 ParameterHandler 设置参数
- 执行 SQL 后,调用 ResultSetHandler 处理结果集并映射成 Java 对象
** “它组装好 JDBC 对象,灌参数,一颠勺出锅,最后摆盘成 Java 对象。”**
🔄 整条链路的口语化串联
想象一次查询 userMapper.selectById(1):
SqlSession拿到命令,根据MappedStatement找到 SQL。- 转交
Executor,它先摸缓存,没有就创建StatementHandler。 StatementHandler拿过Connection,搞出PreparedStatement,塞入 1,执行。- 返回结果交给
ResultSetHandler映射成 User 对象。 SqlSession提交事务(或回滚),关闭,生命周期结束。
面试加分项 ✨
- 插件机制:MyBatis 的插件本质上是基于动态代理,只能拦截四大对象(Executor、StatementHandler、ParameterHandler、ResultSetHandler)
- 一级缓存:默认开启,同一个 SqlSession 内有效,执行增删改操作会清空缓存
- Mapper 代理原理:使用 JDK 动态代理,Mapper 接口没有实现类,代理对象会将方法调用转发给 SqlSession 的对应方法
#{} 与 ${} 区别(SQL 注入问题)
面试官您好,关于 MyBatis 中 #{} 和 ${} 的区别,我从底层原理、核心差异、SQL 注入风险三个方面来回答:
核心底层原理 🧠
- #{}:是预编译处理(PreparedStatement),MyBatis 会将 SQL 中的
#{}替换成?号,然后调用 JDBC 的PreparedStatement的 set 方法来赋值 ${}:是字符串替换(Statement),MyBatis会直接将${}包裹的变量值原封不动 地拼接成完整的 SQL 语句再执行
核心区别对比表 📊
| 对比维度 | #{} | ${} |
|---|---|---|
| 处理方式 | 预编译,参数占位符? | 字符串直接拼接 |
| SQL 注入风险 | ✅ 无,自动转义特殊字符 | ❌ 极高,无任何转义 |
| 适用场景 | 绝大多数参数传递(WHERE 条件、INSERT/UPDATE 值) | 动态表名、动态列名、动态排序字段 |
| 性能 | 更好,SQL 可复用,数据库缓存执行计划 | 较差,每次都是新 SQL |
| 类型处理 | 自动识别 Java 类型与 JDBC 类型转换 | 纯字符串替换,无类型转换 |
| 底层机制 | 使用 PreparedStatement 预编译 | 使用 Statement 或字符串拼接后执行 |
| 传入类型 | 自动判断并加单引号(字符串) | 原样输出,无任何处理 |
🔍 底层到底干了啥?看这个流程
一句话:#{} 是把参数当“数据”来对待;${} 是把参数当“代码”来执行。 这就是注入的根源。
SQL 注入问题详解 ⚠️
这是面试必问的核心点!我举个最经典的登录场景例子:
1. 使用 #{} 的安全写法
<select id="login" resultType="User">
SELECT * FROM user WHERE username = #{username} AND password = #{password}
</select>当传入恶意参数 username="admin' OR '1'='1" 时:
- 预编译后 SQL:
SELECT * FROM user WHERE username = ? AND password = ? - 实际执行:
SELECT * FROM user WHERE username = 'admin\' OR \'1\'=\'1' AND password = 'xxx' - ✅ 特殊字符
'被自动转义为\',SQL 注入失效
2. 使用 ${} 的危险写法
<select id="login" resultType="User">
SELECT * FROM user WHERE username = ${username} AND password = ${password}
</select>当传入同样的恶意参数时:
- 直接拼接后 SQL:
SELECT * FROM user WHERE username = 'admin' OR '1'='1' AND password = 'xxx' - ❌ 由于
'1'='1'恒成立,无论密码是什么,都能成功登录!
3. 更严重的注入攻击
如果传入 username="admin'; DROP TABLE user; --":
- 拼接后 SQL:
SELECT * FROM user WHERE username = 'admin'; DROP TABLE user; --' AND password = 'xxx' - 💥 直接删除整个
user表!后果不堪设想
4. 🤔 那 ${} 是不是完全不能用?
不是。需要动态指定表名、列名、order by字段时,${} 是唯一选择,因为这些不能作为参数占位符:
// ✅ 按排序字段动态查询 — 务必在代码里白名单过滤!
@Select("SELECT * FROM users ORDER BY ${orderColumn} ${direction}")
List<User> findAllOrdered(@Param("orderColumn") String orderColumn,
@Param("direction") String direction);最佳实践 ✅
- 99% 的场景都应该使用
#{},这是 MyBatis 推荐的安全写法 - 只有在需要动态表名、动态列名、动态排序时才使用
${} - 使用
${}时必须手动做白名单校验,例如:
// 动态排序字段白名单校验
if (!Arrays.asList("id", "create_time", "update_time").contains(sortField)) {
throw new IllegalArgumentException("非法的排序字段");
}- 绝对不要在
WHERE条件、INSERT/UPDATE的字段值中使用${}
一级缓存与二级缓存机制
📖 先来一个接地气的理解:你去图书馆借书
一级缓存 👤 → 你书包里的个人便签
你借了《Java 并发编程》,记在便签上,下次再看直接从书包掏,不用再跑书架。这个便签只有你自己看得到,毕业(会话关闭)就扔。
二级缓存 📋 → 图书馆大厅的共享白板
所有同学都把最近借过的热门书写在白板上,任何人借书前先看一眼白板,有就直接拿,没有再跑书架。白板是共享的,大家都按规矩读写(靠事务提交)。
搞清这个比喻,我们再落到代码上。
缓存整体概览 📊
MyBatis 提供了两级缓存机制来优化数据库查询性能,减少数据库访问次数:
一级缓存(SqlSession 级)🔴
核心特点:默认开启,不能关闭,作用域为单个 SqlSession
工作原理
- 同一个 SqlSession 中执行相同的 SQL 查询,第一次会查询数据库并将结果放入缓存
- 后续相同查询直接从缓存中获取,不再访问数据库
- 缓存底层是一个
HashMap,key 由statementId+sql+参数组成
缓存失效场景 ⚠️
- SqlSession 关闭时,一级缓存会被清空
- 执行了
insert、update、delete操作,会清空当前SqlSession的一级缓存 - 手动调用
sqlSession.clearCache()方法 - 执行了不同的
MapperStatement(即使 SQL 相同)
注意事项
- 多线程环境下,不同 SqlSession 的一级缓存互不影响
- 分布式系统中,一级缓存可能导致脏读问题
SqlSession session = sqlSessionFactory.openSession();
UserMapper mapper = session.getMapper(UserMapper.class);
User user1 = mapper.findById(1); // 查库,结果放入一级缓存
User user2 = mapper.findById(1); // 命中一级缓存,不会发 SQL
session.close(); // 缓存 bye bye二级缓存(Mapper 级)🟢
核心特点:默认关闭,需要手动开启,作用域为同一个 namespace 的 Mapper
- 开启方式
<!-- 全局配置文件mybatis-config.xml -->
<settings>
<setting name="cacheEnabled" value="true"/>
</settings>
<!-- 具体Mapper.xml中添加 -->
<cache/>工作原理
- 多个
SqlSession共享同一个 Mapper 的二级缓存 - 当
SqlSession关闭时,会将一级缓存中的数据刷新到二级缓存 - 不同
SqlSession查询相同namespace下的相同 SQL,可以从二级缓存获取数据
- 多个
缓存失效场景 ⚠️
- 执行了
insert、update、delete操作,会清空当前namespace的二级缓存 - 缓存过期时间到达(可配置)
- 缓存大小达到上限(可配置)
- 执行了
注意事项
- 二级缓存中的对象必须实现Serializable接口
- 多表关联查询时,容易出现脏数据问题
- 不建议在生产环境中使用默认的二级缓存实现
🔄 一张图看懂请求流转
一级缓存 vs 二级缓存 🆚
| 对比维度 | 一级缓存 | 二级缓存 |
|---|---|---|
| 作用域 | SqlSession 级 | Mapper (namespace) 级 |
| 默认状态 | 开启 | 关闭 |
| 可关闭性 | 不能关闭 | 可以关闭 |
| 共享范围 | 单个 SqlSession | 多个 SqlSession |
| 底层实现 | HashMap | PerpetualCache (默认) |
| 失效时机 | SqlSession 关闭 / 增删改 / 手动清空 | 增删改 / 过期 / 大小上限 |
| 脏读风险 | 低 | 高 |
| 常见坑 | 同一会话修改数据后仍可能读到旧缓存 | 脏读、序列化要求、对象被修改污染 |
| 适用场景 | 短事务、避免重复查库 | 热点读多写少数据、跨会话共享 |
面试加分点 ✨
- 一级缓存的实现细节:在
BaseExecutor的query方法中实现,先查缓存,没有再查数据库 - 二级缓存的装饰器模式:MyBatis 使用了装饰器模式对二级缓存进行增强,如
LoggingCache、SynchronizedCache、LruCache等 - MyBatis 一级缓存清空时机
- 只要执行任意增删改,哪怕没提交,一级缓存都会立即清空。这点经常被误会。
- 二级缓存的“只读”陷阱
- 若设置
readOnly="true"(只读),返回的是缓存对象的同一个引用。 - 一个会话偷偷改了对象的值,另一个会话拿到的就是脏数据,非常危险。
- 生产环境务必评估:读多写少且不可变对象才用只读,否则用
readOnly="false"返回序列化副本。
- 若设置
- 分布式场景下的进化
- 在微服务中,我们常把“二级缓存”抽象成分布式缓存(Redis),它既能跨会话,又能跨实例。
- Spring Cache(
@Cacheable)其实也是一种“业务级二级缓存”,面试时关联讲出来会让面试官眼前一亮。
- 缓存的最佳实践:
- 优先使用一级缓存,简单高效
- 二级缓存建议使用第三方缓存框架如 Redis,而不是 MyBatis 默认实现
- 对于频繁修改的数据,不要使用缓存
- 多表关联查询避免使用二级缓存
🎯 送你的面试话术模板
“一级缓存是 SqlSession 级别的本地缓存,MyBatis 默认开启,它是一个 HashMap,同一个会话里相同的查询只会执行一次 SQL。事务内发生增删改或会话关闭时,缓存会被清空。
二级缓存是 mapper 命名空间级别的跨会话缓存,需要手动开启,常用 Ehcache 或 Redis 实现。查询顺序是先二后一,再查库。事务提交后,一级缓存的内容才会刷新到二级缓存,这避免了其他会话读到未提交的数据。使用二级缓存时要注意配置只读或深拷贝,防止对象被意外修改,并尽量与分布式缓存结合来支撑集群部署。”
MyBatis 插件机制与分页插件原理
MyBatis 插件机制核心原理 🎯
1. 本质:动态代理 + 责任链模式
MyBatis 插件本质上是拦截器 (Interceptor),通过 JDK 动态代理为目标对象生成代理对象,在执行目标方法前后插入自定义逻辑。
2. 可拦截的四大核心对象
MyBatis 只允许拦截以下 4 个对象的方法,这是面试必考点:
| 拦截对象 | 作用 | 可拦截方法 |
|---|---|---|
| Executor | 执行器,负责 SQL 执行和事务管理 | update, query, commit, rollback 等 |
| StatementHandler | SQL 语句处理器,负责与 JDBC 交互 | prepare, parameterize, batch, update, query |
| ParameterHandler | 参数处理器,负责设置 SQL 参数 | getParameterObject, setParameters |
| ResultSetHandler | 结果集处理器,负责映射查询结果 | handleResultSets, handleOutputParameters |
📌 这四个对象生成时,都会被“插件代理”包裹,形成责任链。
3. 插件执行流程图
插件机制:其实就是“动态代理 + 拦截器链”;
核心接口:Interceptor,有三个方法:
public interface Interceptor {
Object intercept(Invocation invocation) throws Throwable; // 拦截逻辑
default Object plugin(Object target) {
return Plugin.wrap(target, this); // 生成代理
}
default void setProperties(Properties properties) {} // 配置参数
}工作流程(用图说话):
🔄 像个俄罗斯套娃,每个插件都能在方法前后“插一脚”。
用 @Intercepts + @Signature 指定要拦截哪个对象的哪个方法。
关键一句:Plugin.wrap 利用 JDK 动态代理,把目标对象包成代理,按配置顺序层层嵌套,形成责任链执行。
4. 自定义插件开发三步骤
- 实现 Interceptor 接口:重写
intercept()、plugin()、setProperties()方法 - 添加 @Intercepts 注解:指定要拦截的对象和方法
- 在 MyBatis 配置文件中注册插件
// 示例:自定义插件
@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class MyPlugin implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 前置增强逻辑
System.out.println("方法执行前...");
// 执行原方法
Object result = invocation.proceed();
// 后置增强逻辑
System.out.println("方法执行后...");
return result;
}
@Override
public Object plugin(Object target) {
// 生成代理对象
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
// 获取配置参数
}
}分页插件 (PageHelper) 核心原理 📄
分页插件拦截的是 Executor.query(MappedStatement, Object, RowBounds, ResultHandler)。
整体流程看下面这张图:
🔍 关键细节拆解:
- 分页参数传递:
PageHelper.startPage(1, 10)把页码、大小放入ThreadLocal,同一线程后续查询自然读到,用完即清,防止泄露。 - count 查询:
- 原始 SQL:
select * from user where age > 18 order by id - 自动优化为
select count(0) from (select * from user where age > 18) tmp_count,甚至智能去掉无用 order by,提升性能。
- 原始 SQL:
- 方言改造:
- MySQL:
limit offset, size - Oracle:嵌套
rownum,很麻烦,插件内部都封装好了。
- MySQL:
- 结果包装:将查询结果和 total 一起塞进
Page(继承ArrayList),直接返回即可用page.getTotal()🎯。
1. 核心思想:SQL 改写
PageHelper 的核心原理是在 SQL 执行前,动态改写原始 SQL,添加 LIMIT/OFFSET 子句,同时执行一条 COUNT (*) 查询获取总记录数。
2. 详细执行流程
3. 关键技术点
- ThreadLocal 存储分页参数:保证线程安全,避免多线程环境下参数混乱
- 只拦截查询方法:通过判断方法名和返回值类型,只对查询进行分页处理
- 自动识别数据库方言:根据数据库类型生成不同的分页 SQL(MySQL 用 LIMIT,Oracle 用 ROWNUM,SQL Server 用 TOP)
- 分页参数自动清除:在 finally 块中清除 ThreadLocal 中的参数,防止内存泄漏和参数污染
4. 常见坑点 ⚠️
- 分页参数与查询方法必须紧挨着:中间不能有其他查询,否则分页参数会被错误应用
- 只有紧跟在 startPage () 后的第一个查询会被分页
- 不要在循环中使用分页:会导致多次查询,性能低下
- count 查询优化:当不需要总记录数时,可以设置count=false提升性能
总结 📝
MyBatis 插件机制是基于 JDK 动态代理和责任链模式实现的 AOP 功能,允许我们在 SQL 执行的各个阶段插入自定义逻辑。而 PageHelper 分页插件正是利用了这一机制,通过拦截 StatementHandler 的 prepare 方法,动态改写 SQL 实现分页功能。
- 插件本质:责任链 + 动态代理,拦截四大核心对象。
- 分页插件:拦截
Executor.query,ThreadLocal 传参 → 拼 count → 改方言 SQL → 封装 Page。 - 接地气比喻:就像给 MyBatis 执行流程加了“切面”,分页、脱敏、监控全都能无侵入搞定 😎。
MyBatis-Plus 核心功能
面试官您好!MyBatis-Plus(简称 MP)是 MyBatis 的增强工具,在 MyBatis 的基础上只做增强不做改变,为简化开发、提高效率而生。下面我从核心特性、关键组件、高级功能三个维度为您介绍它的核心功能。
🎯 核心功能全景图
核心设计理念 🎯
MP 的设计哲学可以用一句话概括:为简化 CRUD 而生。它通过封装大量通用操作,让开发者从重复的单表 CRUD 代码中解放出来,专注于业务逻辑开发。
核心功能详解 🔧
1. 通用 CRUD 操作 ✨
这是 MP 最核心、最常用的功能,通过继承接口即可获得单表的所有 CRUD 能力,无需编写任何 XML 或 SQL。
| 接口 | 作用 | 提供方法数 | 典型方法 |
|---|---|---|---|
BaseMapper<T> | DAO 层单表操作 | 17 个 | insert()、deleteById()、updateById()、selectById()、selectList() |
IService<T> | Service 层封装 | 20 + 个 | save()、removeById()、updateById()、getById()、list()、page() |
代码示例:
// 只需继承BaseMapper,无需写任何方法
public interface UserMapper extends BaseMapper<User> {}
// 直接调用通用方法
User user = userMapper.selectById(1L);
List<User> users = userMapper.selectList(null);2. 强大的条件构造器 Wrapper 🎯
MP 提供了多种条件构造器,支持链式调用,让复杂查询变得简单优雅。
核心优势:
- ✅ 避免硬编码 SQL 字段名
- ✅ 支持 Lambda 表达式,编译期检查字段正确性
- ✅ 支持复杂条件组合(and、or、in、like、between 等)
- ✅ 支持排序、分组、分页等高级查询
代码示例:
// Lambda查询(推荐)
List<User> users = userMapper.selectList(
Wrappers.<User>lambdaQuery()
.like(User::getName, "张")
.ge(User::getAge, 18)
.orderByDesc(User::getCreateTime)
);3. 分页插件 📄
MP 内置了强大的分页插件,支持多种数据库,只需简单配置即可使用。
配置示例:
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 添加分页插件
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}使用示例:
Page<User> page = new Page<>(1, 10); // 第1页,每页10条
Page<User> userPage = userMapper.selectPage(page,
Wrappers.<User>lambdaQuery().eq(User::getStatus, 1)
);
long total = userPage.getTotal(); // 总记录数
List<User> records = userPage.getRecords(); // 当前页数据4. 主键生成策略 🔑
MP 支持多种主键生成策略,无需手动设置主键值。
| 策略 | 说明 | 适用场景 |
|---|---|---|
| ASSIGN_ID | 雪花算法(默认) | 分布式系统,生成全局唯一 ID |
| AUTO | 数据库自增 | 单库单表,数据库支持自增 |
| INPUT | 用户手动输入 | 业务主键,如订单号 |
| ASSIGN_UUID | UUID | 简单分布式场景 |
使用示例:
@Data
@TableName("user")
public class User {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String name;
private Integer age;
}5. 逻辑删除 🗑️
MP 支持逻辑删除,只需一个注解即可实现,无需手动编写删除和查询条件。
配置示例:
mybatis-plus:
global-config:
db-config:
logic-delete-field: deleted # 逻辑删除字段名
logic-delete-value: 1 # 已删除值
logic-not-delete-value: 0 # 未删除值使用示例:
@Data
@TableName("user")
public class User {
@TableId
private Long id;
private String name;
@TableLogic
private Integer deleted;
}
// 调用删除方法时,实际执行的是UPDATE语句
userMapper.deleteById(1L);
// 查询时会自动过滤已删除的数据
List<User> users = userMapper.selectList(null);6. 自动填充 ⏰
MP 支持自动填充公共字段,如创建时间、更新时间、创建人、更新人等。
步骤 1:添加注解
@Data
@TableName("user")
public class User {
@TableId
private Long id;
private String name;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}步骤 2:实现元对象处理器
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
this.strictInsertFill(metaObject, "createTime", LocalDateTime::now, LocalDateTime.class);
this.strictInsertFill(metaObject, "updateTime", LocalDateTime::now, LocalDateTime.class);
}
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime::now, LocalDateTime.class);
}
}7. 乐观锁 🔒
MP 支持乐观锁插件,通过版本号机制解决并发更新问题。
配置示例:
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}使用示例:
@Data
@TableName("user")
public class User {
@TableId
private Long id;
private String name;
private Integer money;
@Version
private Integer version;
}
// 更新时会自动带上版本号条件
// UPDATE user SET name=?, money=?, version=version+1 WHERE id=? AND version=?
userMapper.updateById(user);8. 代码生成器 💻
MP 提供了强大的代码生成器,可以一键生成 Entity、Mapper、Service、Controller 等代码,极大提高开发效率。
新版代码生成器示例(3.5.1+):
FastAutoGenerator.create("jdbc:mysql://localhost:3306/test", "root", "password")
.globalConfig(builder -> {
builder.author("作者")
.outputDir("D:/code")
.disableOpenDir();
})
.packageConfig(builder -> {
builder.parent("com.example")
.moduleName("system");
})
.strategyConfig(builder -> {
builder.addInclude("user", "role") // 需要生成的表
.entityBuilder().enableLombok();
})
.execute();其他重要功能 📌
- 多数据源支持:通过
@DS注解轻松实现多数据源切换 - 性能分析插件:开发环境下打印 SQL 执行时间,帮助优化慢 SQL
- 防全表更新与删除插件:防止误操作导致全表更新或删除
- 联表查询:通过
MP-Join插件支持复杂的联表查询 - 动态表名:支持运行时动态切换表名
面试常见追问 🤔
MyBatis-Plus 的核心是对 MyBatis “单表操作零 SQL” 的极致优化,保留了 MyBatis 原生的灵活性,同时在条件构造、分页、乐观锁、自动填充等高频场景上大幅提高生产力。它不适合替代手写复杂 SQL,但配合原生 MyBatis 混合使用,效率极高。
MP 的雪花算法是如何实现的?
- 采用 Twitter 的 Snowflake 算法,生成 64 位的 Long 类型 ID
- 由 1 位符号位、41 位时间戳、10 位机器 ID、12 位序列号组成
- 支持自定义机器 ID 生成策略
MP 的分页插件原理是什么?
- 基于 MyBatis 的拦截器机制,在 SQL 执行前进行拦截
- 自动拼接分页 SQL(如 MySQL 的
LIMIT,Oracle 的ROWNUM) - 自动执行 count 查询获取总记录数
逻辑删除和物理删除的区别?
- 物理删除:直接从数据库中删除记录,无法恢复
- 逻辑删除:通过标记字段表示记录已删除,数据仍保留在数据库中
- 逻辑删除的优点:数据可恢复、不影响索引结构、便于审计
与 Hibernate / JPA 的对比选型
核心本质区别 🔍
- MyBatis:半 ORM 框架,本质是 "SQL 映射器"。它只负责 JDBC 结果集到 Java 对象的映射,SQL 完全由开发者编写和控制。
- Hibernate/JPA:全 ORM 框架,本质是 "对象关系映射器"。它试图完全屏蔽 SQL,让开发者用面向对象的方式操作数据库。
关键维度对比表 📊
| 对比维度 | MyBatis | Hibernate/JPA |
|---|---|---|
| 核心思想 | 手写 SQL,结果映射到对象 | 对象与表映射,自动生成 SQL |
| ORM 程度 | 半 ORM ✂️ | 全 ORM 🧩 |
| SQL 控制 | 🔥完全可控,手写 SQL | 自动生成,自定义复杂 |
| 动态查询 | <if> <foreach> 直观灵活 | Criteria API / QueryDSL 学习成本高 |
| 关联映射 | resultMap + association/collection,显式加载 | @OneToMany 等注解,自动加载(易出现 N+1、LazyException) |
| 性能 | 更高,可极致优化 ⚡ | 中等,有一定性能损耗 |
| 学习曲线 | 简单,上手快 📈 | 陡峭,概念多 |
| 数据库移植性 | 差,SQL 依赖数据库 | 好,方言自动适配 🌍 |
| 缓存机制 | 基础一级 / 二级缓存 | 强大的多级缓存体系 |
| 复杂查询 | 优势明显,灵活度高 | 劣势,JPQL / 原生 SQL 麻烦 |
| 开发效率 | 简单 CRUD 慢,复杂查询快 | 简单 CRUD 快,复杂查询慢 |
| 代码量 | 多,需要写 Mapper 接口和 XML | 少,继承 JpaRepository 即可 |
| 调试难度 | 低,SQL 直接可见 🔍 | 高,生成的 SQL 难以阅读 |
🔥 面试加分点:Hibernate 的 @DynamicUpdate、FetchMode、BatchSize;MyBatis 的 TypeHandler、插件机制、二级缓存整合 Redis。
选型决策树 🌳
决策流程图(收藏级)
真实项目中的坑与经验 💡
MyBatis 的坑
- SQL 维护成本高:大量 XML 文件,复杂项目中 SQL 散落在各处
- 没有对象状态管理:需要手动处理关联关系和级联操作
- 分页插件依赖:需要引入 PageHelper 等第三方插件
Hibernate/JPA 的坑
- N+1 查询问题:关联查询时容易触发大量额外 SQL 🚨
- 性能调优困难:生成的 SQL 不可控,优化需要深入理解 Hibernate 内部机制
- 复杂查询噩梦:多表关联、子查询、聚合函数等用 JPQL 写起来非常痛苦
- 缓存一致性问题:二级缓存使用不当会导致数据不一致
总结与建议 📝
什么时候选 MyBatis?
- 互联网项目,对性能要求高,需要极致 SQL 优化
- 业务复杂,查询逻辑多变,需要灵活控制 SQL
- 团队有丰富的 SQL 经验,习惯手写 SQL
- 项目历史遗留系统,已经使用 MyBatis
什么时候选 Hibernate/JPA?
- 企业级管理系统,CRUD 为主,业务逻辑相对简单
- 需要快速开发,缩短项目周期
- 可能需要切换不同数据库
- 团队熟悉 JPA 规范,愿意投入时间学习
最佳实践 ✨
- 混合使用:简单 CRUD 用 JPA,复杂查询用 MyBatis
- Spring Data JPA + MyBatis:这是目前很多中大型项目的主流选择
- 避免极端:不要为了用 JPA 而强行把所有复杂查询都用 JPQL 写
- 性能监控:无论用哪个框架,都要做好 SQL 性能监控
分页插件 PageHelper 的底层原理是什么?
一句话核心总结
PageHelper 是基于MyBatis 拦截器机制实现的无侵入式分页插件,核心逻辑是:拦截 Executor 的 query 方法 → 从 ThreadLocal 获取分页参数 → 根据数据库方言动态拼接分页 SQL → 先执行 count 查询获取总条数 → 再执行分页查询 → 封装成 Page 对象返回。
🧠 核心工作链:存、拦、拼、清
PageHelper的底层并不神秘,它本质上是MyBatis拦截器机制与ThreadLocal线程隔离的完美配合,主要遵循四个步骤。
为了方便理解,我们可以看下面这张核心流程图:
光看流程图可能还不够具体,我们直接进入源码逻辑,拆解它的 “四板斧”。
1️⃣ 第一板斧:startPage只是写入了“线程便签”
当你在Service层写下 PageHelper.startPage(1, 10) 时,这个方法并没有执行任何数据库查询。
它底层只是做了两件很简单的事:
- New了一个Page对象(把页码、每页大小存进去)。
- 将这个Page对象塞到了当前线程独享的
ThreadLocal局部变量中。
这么做的好处是:同一个线程内的后续业务代码,能无侵入地共享这个参数,且多线程并发时互不干扰。
2️⃣ 第二板斧:拦截器精准“劫胡”Executor
这是整个插件的灵魂。PageHelper 实现了 MyBatis 的 Interceptor 接口,成为一个“官方拦截器”。它通过 @Intercepts 注解,精准配置了拦截目标,即Executor 执行器的 query 方法。
当 MyBatis 应用启动并加载插件时,会使用 JDK 动态代理,把 PageHelper 这个拦截器织入到 Executor 的调用链中。一旦业务代码调起 Mapper 查询,代理对象便会先触发拦截逻辑。
3️⃣ 第三板斧:见人说人话,见库用方言 🗣️
拦截到 SQL 后,它不会直接硬塞 LIMIT,而是通过 Dialect(方言)机制,根据项目连接的不同数据库类型,自动匹配合适的语法。
- MySQL:会主动拼接
LIMIT ?, ?。 - Oracle:会构造多层嵌套的
ROWNUM查询。 - SQL Server:则会处理
TOP或OFFSET FETCH等语法。
具体到拦截方法 intercept 中,它执行了约6个关键动作:
- 取值:从
ThreadLocal里尝试取出分页参数。 - 判空:如果没取到,说明当前查询不需要分页,直接放行,执行原始逻辑。
- 生成Count SQL:如果取到了参数,先对原始 SQL 进行解析和包装,生成一个
SELECT count(0) FROM (你的原始SQL) tmp_count,去数据库查一下总共有多少条数据。 - 拼装 LIMIT 子句:获取原始的 BoundSql,根据当前页码和大小,计算出
LIMIT offset, size,拼接到原 SQL 末尾,生成最终的分页 SQL。 - 执行最终查询:用新 SQL 去数据库执行,拿到本页的数据集合。
- 封装对象:将查到的总条数和数据集合,一起封装到一个继承自
ArrayList的Page对象中,返回给调用方。
4️⃣ 第四板斧:清理与注意事项 ⚠️
(1)一定要记得“撕便签”
通过 ThreadLocal 传参虽然优雅,但如果不清理,会造成参数污染。比如,先在某个方法里 startPage(1, 5),紧接着如果不清理 ThreadLocal,很可能后续的下拉框查询或者更新操作也会 “意外” 带上 LIMIT 5,导致 SQL 报错或数据丢失。
因此,官方默认在 finally 代码块中会自动清除了 ThreadLocal 存储的对象,我们也建议手动在关键路径使用 PageHelper.clearPage() 清理或配合 try-finally 使用。
(2)只对“紧跟着”的第一次查询生效
拦截器在完成一次分页查询后,会智能地将 ThreadLocal 中的参数清掉。所以,如果你写 startPage() 后,接着调了两个 Mapper 方法,只有第一个查询会被自动拼上 LIMIT,第二个会回归正常全量查询。
三大核心机制 💡
1. MyBatis 拦截器(Interceptor)
MyBatis允许在 SQL 执行的关键节点插入自定义逻辑,四大核心拦截对象:Executor、StatementHandler、ParameterHandler、ResultSetHandlerPageHelper拦截的是Executor 接口的两个 query 方法,拦截时机是:SQL 生成完成后,数据库执行前- 底层通过 JDK 动态代理实现,为目标
Executor生成代理类,在方法执行前后插入分页逻辑
2. 数据库方言(Dialect)适配
PageHelper 通过 Dialect 接口抽象不同数据库的分页语法差异,内置了几乎所有主流数据库的实现:
| 数据库 | 分页语法示例 | 核心特点 |
|---|---|---|
| MySQL | SELECT * FROM user LIMIT 0,10 | 最简单,直接使用 LIMIT 子句 |
| Oracle | SELECT * FROM (SELECT t.*, ROWNUM rn FROM (原始SQL) t WHERE ROWNUM <= 10) WHERE rn > 0 | 使用 ROWNUM 伪列,需要嵌套两层 |
| SQLServer 2012+ | SELECT * FROM user ORDER BY id OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY | 使用 OFFSET/FETCH 标准语法 |
| PostgreSQL | SELECT * FROM user LIMIT 10 OFFSET 0 | 和 MySQL 类似,LIMIT 和 OFFSET 顺序相反 |
3. ThreadLocal 参数传递
PageHelper.startPage(pageNum, pageSize)方法会将分页参数(页码、页大小、是否 count、排序等)存入当前线程的 ThreadLocal中- 拦截器执行时,从
ThreadLocal中取出分页参数,判断是否需要分页 - 分页逻辑执行完成后,自动调用 remove () 方法清除 ThreadLocal 中的参数,避免内存泄漏和参数错乱
完整执行流程 📊
面试必问的关键细节 & 常见坑 ⚠️
1.只有紧跟在 startPage () 后的第一个查询方法会被分页
- 原因:分页参数执行一次后就会被自动清除
- 错误示例:先查 A 表,再查 B 表,只有 A 表会被分页
2.复杂查询 count 不准确问题
- 原因:
PageHelper默认会将原始 SQL 的select部分替换为count(0),如果原始 SQL 有distinct、group by、多表联查等,count结果会出错 - 解决方法:自定义
count查询方法,在 Mapper 中通过countMethod指定
3.不能嵌套分页查询
- 原因:ThreadLocal 中只能保存一组分页参数,嵌套查询会覆盖外层参数
- 解决方法:手动分页,或者使用 PageInfo 的 list 属性进行二次处理
4.排序问题
- 不要在 Java 代码中对分页结果进行排序,这样会导致分页错乱
- 正确做法:在 SQL 中使用 order by 子句,或者通过
PageHelper.orderBy()方法指定排序
5.ThreadLocal 内存泄漏
- 虽然 PageHelper 会自动清除参数,但如果在拦截器执行前抛出异常,可能导致参数没有被清除
- 最新版本(5.x)已经优化了这个问题,使用 try-finally 保证参数一定会被清除
6.性能瓶颈
LIMIT offset, size在数据量巨大的深分页场景下(比如翻到第10000页),性能会急剧下降。也许我们需要考虑采用“游标分页”(Cursor-based Pagination)来优化。
优缺点总结 ✅❌
| 优点 | 缺点 |
|---|---|
| 使用简单,无侵入式,只需一行代码 | 复杂查询(多表联查、group by)count 不准确 |
| 支持几乎所有主流数据库 | 不支持跨库分页 |
| 支持多种分页方式(普通分页、滚动分页) | 只能对 MyBatis 生效,不支持其他 ORM 框架 |
| 自动处理总条数计算 | 大表深度分页性能较差(offset 过大) |
面试加分项 🌟
- 可以提到 PageHelper 的分页合理化功能(
pageNum<=0时返回第一页,pageNum> 总页数时返回最后一页) - 可以分享大表分页优化经验:使用游标分页(
WHERE id > lastId LIMIT 10)代替 offset 分页 - 可以对比 PageHelper 和 MyBatis-Plus 分页插件的区别:原理类似,但 MyBatis-Plus 支持 lambda 查询和更丰富的分页配置
为什么 SqlSession 是线程不安全的?
这个问题看似八股,其实背后考察的是你对 MyBatis 一级缓存、事务管理和状态机设计 的理解深度。咱们直接撕开 DefaultSqlSession(MyBatis 默认实现)看看它在多线程下是怎么“翻车”的。
核心结论 🎯
SqlSession 的线程不安全本质上是因为它内部维护了多个可变状态,且这些状态没有做任何线程同步保护。多个线程同时操作同一个 SqlSession 实例时,会出现数据脏读、事务混乱、程序崩溃等严重问题。
三大根本原因 ⚠️
1. 一级缓存(PerpetualCache)的线程安全问题
每个 SqlSession 实例都拥有自己独立的一级缓存,它的默认实现是PerpetualCache,底层就是一个普通的 HashMap:
public class PerpetualCache implements Cache {
private final String id;
private final Map<Object, Object> cache=new HashMap<>(); // 无同步机制
// ...
}- 多个线程同时读写这个 HashMap 时,会触发并发修改异常 (ConcurrentModificationException)
- 可能出现缓存脏读:一个线程修改了数据,另一个线程读到的还是旧的缓存值
- 可能出现缓存覆盖:两个线程同时写入相同 key 的缓存,导致数据丢失
- 线程 A 查询
id=1,put 进缓存 → 此时缓存 {1: userA} - 线程 B 在同一时刻查询
id=1,脏读、扩容死循环、甚至 size 计数错误都可能发生。 - 更可怕的是,线程 A 做 update/commit 会清空缓存,线程 B 读到一个半清不楚的数据,直接业务异常。
- 线程 A 查询
📌 用图说话:
核心痛点:读写并发 + 无同步 = 灾难。 💥
2. 事务上下文的状态混乱
SqlSession 内部持有一个 Executor 对象,Executor 又持有 Transaction 对象(JDBC 连接绑在里面)。
// Executor 持有 Transaction,事务里绑着一个 Connection
public abstract class BaseExecutor implements Executor {
protected Transaction transaction;
// ...
}SqlSession 维护了完整的事务上下文状态:
- 事务是否开启
- 事务是否提交 / 回滚
- 数据库连接(Connection)的持有状态
如果多个线程共享同一个 SqlSession:
- 一个线程提交事务,会导致另一个线程未完成的操作被意外提交
- 一个线程关闭连接,会导致另一个线程正在执行的 SQL 抛出连接已关闭异常
- 事务隔离级别会被多个线程互相干扰
- 线程 A 执行到一半,正准备
commit。 - 线程 B 偷偷调了
rollback()或close()。 - 结果:线程 A 的 commit 直接炸掉,因为连接已经关闭或状态被破坏。
- 线程 A 执行到一半,正准备
就像一个水龙头(连接),两个人同时拧,水花四溅。🔧💦
3. 执行器(Executor)的状态共享
SqlSession 内部维护着 closed、dirty(是否执行过写操作)等状态变量,没有 volatile,没有锁保护。
// DefaultSqlSession 中
private boolean dirty; // 非原子,无同步
public void commit() {
// ...
if (dirty) {
// 执行 flush 等操作
}
// 返回前置 dirty=false
}在线程 A 执行 dirty=true 之后、执行清缓存之前,线程 B 进来读 dirty,可能看到过期的 false,导致该刷新的数据没刷新。这种 指令重排序 + 多线程可见性 问题会让你在查问题时怀疑人生。
SqlSession 内部持有一个Executor 实例,Executor 是真正执行 SQL 的核心组件,它也维护了:
- 一级缓存的引用
- 事务状态
- 批处理语句的队列
Executor 同样没有做任何线程同步处理,多个线程同时调用 Executor 的方法会导致内部状态彻底混乱。
直观对比图 📊
🧠 设计初衷:“男人(SqlSession)就该活在一个请求里”
MyBatis 作者设计 SqlSession 时就没打算让它线程安全,就是为了 轻量 和 生命周期短。
官方文档明确:一个 SqlSession 对应一次数据库会话,用完即关,不要跨线程传递。
正确的打开方式:
- 与 Spring 集成:
SqlSessionTemplate利用ThreadLocal保证每个线程拥有自己的SqlSession。 - 手动管理:每个线程自己
openSession() → 操作 → close(),绝不多人混用一个。
📊 一图总结翻车点
| 组件/区域 | 线程安全风险 | 后果 |
|---|---|---|
一级缓存 (HashMap) | 并发读写无锁,内存结构被破坏 | 脏数据、死循环、NullPointerException |
事务 (Connection) | 多线程共享连接,随意 commit/close | 事务状态混乱、连接已关闭异常 |
状态变量 (dirty) | 非原子、无可见性保证 | 缓存未刷新、数据不一致 |
正确使用姿势 ✅
- 每个线程使用独立的 SqlSession 实例:通过
SqlSessionFactory.openSession()获取 - 在 Spring 环境中使用 SqlSessionTemplate:它内部通过
ThreadLocal为每个线程绑定一个独立的SqlSession - 永远不要将 SqlSession 作为类的静态变量或实例变量:它应该是方法级别的局部变量
面试加分点 🌟
SqlSession 不是 ThreadLocal 的,就该被 ThreadLocal 管着。
- 提到一级缓存的默认实现是
PerpetualCache,基于非线程安全的 HashMap - 说明
SqlSessionTemplate的核心原理是ThreadLocal代理 - 区分一级缓存(SqlSession 级)和二级缓存(Mapper 级,线程安全)的不同
- 指出在分布式环境下,一级缓存可能导致数据不一致问题
