本篇是 MyBatis 面试八股文深度版,25 道高频题全覆盖,每题都有代码示例,帮你彻底搞懂原理,面试不再背答案。
Q1. MyBatis 是什么?和 Hibernate 的本质区别(半自动 vs 全自动 ORM) MyBatis 是什么?
MyBatis 是一款优秀的持久层框架,它支持自定义 SQL、存储过程以及高级映射。MyBatis 免除了几乎所有的 JDBC 代码以及设置参数和获取结果集的工作,通过简单的 XML 或注解来配置和映射原始类型、接口和 Java POJO(Plain Old Java Objects,普通老式 Java 对象)为数据库中的记录。
半自动 vs 全自动 ORM:核心区别
对比维度
MyBatis(半自动)
Hibernate(全自动)
SQL 控制权
开发者自己写 SQL
框架自动生成 SQL
学习成本
低,会 SQL 就能上手
高,HQL/Criteria/缓存策略
灵活性
极高,SQL 随心所欲
较低,复杂查询需绕道
数据库移植性
差(SQL 与数据库方言绑定)
好(HQL 屏蔽方言差异)
适用场景
互联网高并发、复杂业务查询
快速开发、CRUD 简单场景
一句话记忆:
MyBatis :我告诉你 SQL,你帮我执行并把结果映射成对象。
Hibernate :你告诉我对象关系,我来决定怎么查数据库。
国内互联网公司(阿里、美团、字节)几乎清一色选 MyBatis,原因就是 SQL 可控、性能可调、DBA 可以 review。
Q2. MyBatis 整体架构(SqlSessionFactory → SqlSession → Executor → StatementHandler → ResultSetHandler) MyBatis 的架构分为三层,从上到下依次是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 用户代码(Mapper 接口调用) ↓ SqlSessionFactory(工厂,单例) ↓ SqlSession(会话,线程不安全,用完即关) ↓ Executor(执行器:SimpleExecutor/ReuseExecutor/BatchExecutor) ↓ StatementHandler(语句处理器,封装 JDBC Statement ) ↓ ParameterHandler(参数处理器,设置 SQL 参数) ↓ ResultSetHandler(结果集处理器,映射 ResultSet → Java 对象) ↓ JDBC(最终调用 java.sql .*)
各组件职责:
Configuration :全局配置对象,解析 mybatis-config.xml 和所有 Mapper XML,存储所有映射信息。
SqlSessionFactory :创建 SqlSession 的工厂,应用启动时创建一次,全局单例。
SqlSession :数据库会话,提供 CRUD 方法,内部持有 Executor。
Executor :真正执行 SQL 的地方,分三种:Simple(默认)、Reuse(复用 Statement)、Batch(批量)。
StatementHandler :对 JDBC PreparedStatement 的封装,负责 SQL 预编译和参数绑定。
ResultSetHandler :把 JDBC ResultSet 映射成 Java 对象,是 <resultMap> 发挥作用的地方。
插件的切入点 正是这四个处理器接口:Executor、StatementHandler、ParameterHandler、ResultSetHandler。
Q3. SqlSessionFactory 的创建流程(Configuration 解析,单例重要性) 创建流程分三步:
第一步:读取配置
1 2 3 String resource = "mybatis-config.xml" ;InputStream inputStream = Resources.getResourceAsStream(resource);
第二步:构建 SqlSessionFactory
1 SqlSessionFactory factory = new SqlSessionFactoryBuilder ().build(inputStream);
SqlSessionFactoryBuilder.build() 内部会:
创建 XMLConfigBuilder,解析 mybatis-config.xml
解析 <environments>、<typeAliases>、<plugins>、<mappers> 等标签
扫描每个 Mapper XML,将 <select>/<insert>/<update>/<delete> 解析为 MappedStatement 对象
所有解析结果存入 Configuration 对象
用 Configuration 创建 DefaultSqlSessionFactory
第三步:为什么必须是单例?
SqlSessionFactory 创建时解析了所有 XML 和注解,构建了完整的 Configuration,这个过程耗时且耗内存 。如果每次请求都创建一个新的工厂,系统会崩溃。
正确用法:
1 2 3 4 5 6 7 8 9 10 11 @Bean public SqlSessionFactory sqlSessionFactory (DataSource dataSource) throws Exception { SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean (); factoryBean.setDataSource(dataSource); factoryBean.setMapperLocations( new PathMatchingResourcePatternResolver () .getResources("classpath*:mapper/**/*.xml" ) ); return factoryBean.getObject(); }
Q4. SqlSession 的生命周期和线程安全问题(为什么不能是单例) SqlSession 的生命周期:
1 开启事务/会话 → 执行 SQL → 提交/ 回滚 → 关闭
标准用法(非 Spring 环境):
1 2 3 4 5 try (SqlSession session = factory.openSession()) { UserMapper mapper = session.getMapper(UserMapper.class); User user = mapper.selectById(1L ); session.commit(); }
为什么不能是单例?
SqlSession 内部持有:
数据库连接 (Connection):每个线程应该有独立的连接
一级缓存 (Local Cache):HashMap,线程 A 的缓存不能被线程 B 读到
未提交事务 :事务状态不能在线程间共享
如果把 SqlSession 做成单例,多线程并发时会出现:
数据库连接竞争
脏读(线程 A 读到线程 B 还未提交的缓存)
事务混乱
Spring 如何解决这个问题?
Spring 通过 SqlSessionTemplate 解决了线程安全问题。SqlSessionTemplate 内部使用 ThreadLocal 绑定 SqlSession,每个线程有自己独立的会话,用完自动关闭。这就是为什么在 Spring 项目里,@Autowired 注入的 Mapper 可以放心当单例使用。
Q5. #{} 和 ${} 的区别(PreparedStatement vs Statement,SQL 注入风险,使用场景) 这是 MyBatis 最高频的面试题,必须答得清晰。
本质区别:
特性
#{}
${}
底层实现
PreparedStatement(预编译)
Statement(字符串拼接)
SQL 注入
安全,参数作为占位符 ?
危险,直接字符串替换
性能
较高(数据库可缓存执行计划)
较低(每次重新编译)
使用场景
传递参数值
传递列名、表名、ORDER BY 字段
#{} 示例(安全):
1 2 3 <select id ="selectByName" resultType ="User" > SELECT * FROM user WHERE name = #{name}</select >
实际执行的 JDBC:
1 2 3 4 PreparedStatement ps = conn.prepareStatement( "SELECT * FROM user WHERE name = ?" ); ps.setString(1 , name);
即使 name = "'; DROP TABLE user; --",也只会查名字是这个字符串的用户,不会执行恶意 SQL。
${} 示例(有注入风险):
1 2 3 4 <select id ="selectFromTable" resultType ="map" > SELECT * FROM ${tableName} WHERE id = #{id}</select >
1 2 3 4 <select id ="selectWithOrder" resultType ="User" > SELECT * FROM user ORDER BY ${orderField} ${orderType}</select >
使用 ${} 的注意事项: 传入的值必须来自受信任的来源(如枚举、白名单校验),绝不能直接接受用户输入。
Q6. Mapper 接口与 XML 的绑定原理(MapperProxy 动态代理) 你写了一个接口方法:
1 2 3 public interface UserMapper { User selectById (Long id) ; }
为什么调用 userMapper.selectById(1L) 就能执行 SQL?你从来没有写过实现类啊!
答案:JDK 动态代理 + MapperProxy
绑定流程:
注册阶段 :MyBatis 启动时,扫描到 UserMapper 接口,将其注册到 MapperRegistry,与对应的 XML namespace 绑定。
获取 Mapper :调用 session.getMapper(UserMapper.class) 时,MapperRegistry 找到对应的 MapperProxyFactory,用 JDK 动态代理创建一个 UserMapper 的代理对象。
执行代理逻辑 :调用代理对象的 selectById(1L) 时,实际执行的是 MapperProxy.invoke() 方法。
1 2 3 4 5 6 7 8 9 10 11 public class MapperProxy <T> implements InvocationHandler { @Override public Object invoke (Object proxy, Method method, Object[] args) throws Throwable { MapperMethod mapperMethod = cachedMapperMethod(method); return mapperMethod.execute(sqlSession, args); } }
SQL 查找 :mapperMethod.execute() 根据 接口全限定名.方法名(如 com.example.UserMapper.selectById)从 Configuration 中找到对应的 MappedStatement(即 XML 中的 <select id="selectById">),然后执行。
Namespace 必须与接口全限定名一致 ,就是这个原因。
Q7. MyBatis 执行一条 select 语句的完整流程(从 mapper.selectById() 到 JDBC) 面试官最爱问的流程题,把这个说清楚,至少 80 分。
1 2 3 4 5 6 7 8 9 10 11 12 13 mapper.selectById (1 L) ↓ MapperProxy.invoke () ↓ MapperMethod.execute () → 判断是 SELECT ,调用 sqlSession.selectOne () ↓ DefaultSqlSession.selectOne () ↓ executor.query (ms, parameter, rowBounds, handler) ↓ [查一级缓存] → 命中则直接返回 ↓ executor.queryFromDatabase () ↓ StatementHandler.prepare () → conn.prepareStatement (sql) ↓ ParameterHandler.setParameters () → ps.setLong (1 , 1 L) ↓ StatementHandler.query () → ps.executeQuery () ↓ ResultSetHandler.handleResultSets () → 把 ResultSet 映射成 User 对象 ↓ [写入一级缓存] ↓ 返回 User 对象
关键节点说明:
一级缓存检查 :在真正查数据库之前,先查本次 SqlSession 的缓存(一个 HashMap),key 是 statementId + 参数 + 分页范围 + SQL。
SQL 预编译 :StatementHandler.prepare() 调用 connection.prepareStatement(boundSql.getSql()),得到 PreparedStatement。
参数绑定 :ParameterHandler.setParameters() 遍历参数,调用 TypeHandler 把 Java 类型转换成 JDBC 类型并设置到 PreparedStatement。
结果映射 :ResultSetHandler 遍历 ResultSet,根据 <resultMap> 或字段名映射规则,用反射把每一行转成 Java 对象。
Q8. 一级缓存(SqlSession 级别):原理、失效条件、坑点(Spring 集成后默认失效) 一级缓存的原理:
一级缓存是 SqlSession 级别的缓存,每个 SqlSession 内部有一个 PerpetualCache(本质是 HashMap)。同一个 SqlSession 执行相同的 SQL 且参数相同,第二次直接从 HashMap 读取,不再查数据库。
1 2 3 4 5 6 try (SqlSession session = factory.openSession()) { User u1 = session.selectOne("selectById" , 1L ); User u2 = session.selectOne("selectById" , 1L ); System.out.println(u1 == u2); }
一级缓存的失效条件:
SqlSession 关闭(缓存随之销毁)
调用了 session.clearCache()
执行了 INSERT / UPDATE / DELETE 操作(会清空整个 SqlSession 的缓存)
<select> 标签配置了 flushCache="true"
最大的坑:Spring 集成后一级缓存几乎失效!
Spring 的事务管理下,每次 Mapper 方法调用都可能使用不同的 SqlSession(因为 SqlSessionTemplate 在没有事务的情况下,每次调用完就关闭 SqlSession)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @Service public class UserService { @Autowired private UserMapper userMapper; public void test () { User u1 = userMapper.selectById(1L ); User u2 = userMapper.selectById(1L ); System.out.println(u1 == u2); } @Transactional public void testWithTx () { User u1 = userMapper.selectById(1L ); User u2 = userMapper.selectById(1L ); System.out.println(u1 == u2); } }
结论:不加 @Transactional 的情况下,MyBatis 一级缓存在 Spring 中基本不生效。
Q9. 二级缓存(Mapper/namespace 级别):开启方式、Cache 实现、与 Spring 集成的注意事项 二级缓存是什么?
二级缓存是跨 SqlSession 的缓存,作用域是 Mapper(namespace),同一个 Mapper 的不同 SqlSession 可以共享缓存。
开启方式:
第一步,全局配置开启(默认已开启):
1 2 3 4 <settings > <setting name ="cacheEnabled" value ="true" /> </settings >
第二步,在具体的 Mapper XML 中声明使用缓存:
1 2 3 4 5 6 7 8 9 10 11 12 13 <mapper namespace ="com.example.UserMapper" > <cache eviction ="LRU" flushInterval ="60000" size ="512" readOnly ="true" /> <select id ="selectById" resultType ="User" useCache ="true" > SELECT * FROM user WHERE id = #{id} </select > </mapper >
缓存实现(装饰器模式):
MyBatis 二级缓存通过装饰器模式层层包装:
1 2 3 4 5 SynchronizedCache(线程安全) → LoggingCache(命中率日志) → SerializedCache(序列化,要求实体实现 Serializable) → LruCache(LRU 淘汰) → PerpetualCache(实际存储,HashMap)
与 Spring 集成的注意事项:
Spring 的事务管理和 MyBatis 二级缓存有冲突:事务提交前,数据不会写入二级缓存 (防止脏读)。这意味着在同一事务内,二级缓存不生效,必须等事务提交后才能被其他 SqlSession 读到。
Q10. 为什么 MyBatis 默认关闭二级缓存?(脏读风险,多表关联场景) 虽然 cacheEnabled 默认是 true,但每个 Mapper XML 默认不声明 <cache> ,所以实际上二级缓存默认不生效。原因如下:
原因一:脏读风险
二级缓存是 namespace 级别的。如果 UserMapper 开了二级缓存,但另一个 OrderMapper 修改了 user 表(比如在 order 中记录了用户姓名),UserMapper 的缓存不会失效,导致读到过期数据。
1 2 3 4 OrderMapper.updateUserNameInOrder(userId, "新名字" ) → user 表被修改 → 但 UserMapper 的二级缓存不知道! → UserMapper.selectById(userId) → 返回旧名字(脏读)
原因二:多表关联场景复杂
实际业务中,一条 SQL 可能 JOIN 了多张表,如果这几张表各自有不同的 Mapper,任何一个表变更都可能导致缓存失效,但 MyBatis 的二级缓存无法跨 namespace 联动失效。
最佳实践:
生产环境不推荐 使用 MyBatis 二级缓存
需要缓存时,使用 Redis 等独立缓存方案,业务代码自行控制失效逻辑
只在数据几乎不变、查询频繁的场景(如字典表、配置表)中谨慎使用
Q11. 插件(Interceptor)机制:拦截点、实现方式、常见场景 拦截点:
MyBatis 插件只能拦截以下四个接口的方法:
接口
常用拦截方法
典型用途
Executor
query, update, commit
数据权限过滤、读写分离
StatementHandler
prepare, parameterize
SQL 改写(分页)
ParameterHandler
setParameters
参数加密
ResultSetHandler
handleResultSets
结果脱敏
实现一个插件(慢 SQL 监控):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 @Intercepts({ @Signature( type = StatementHandler.class, method = "query", args = {Statement.class, ResultHandler.class} ) }) public class SlowSqlPlugin implements Interceptor { private static final long SLOW_THRESHOLD_MS = 1000L ; @Override public Object intercept (Invocation invocation) throws Throwable { long start = System.currentTimeMillis(); try { return invocation.proceed(); } finally { long cost = System.currentTimeMillis() - start; if (cost > SLOW_THRESHOLD_MS) { StatementHandler handler = (StatementHandler) invocation.getTarget(); BoundSql boundSql = handler.getBoundSql(); log.warn("慢SQL [{}ms]: {}" , cost, boundSql.getSql()); } } } @Override public Object plugin (Object target) { return Plugin.wrap(target, this ); } }
注册插件:
1 2 3 4 <plugins > <plugin interceptor ="com.example.SlowSqlPlugin" /> </plugins >
插件的底层原理:
MyBatis 在创建 Executor、StatementHandler 等对象后,会依次调用所有插件的 plugin() 方法,层层用动态代理包装,形成代理链 。调用时从最外层代理依次执行,类似责任链模式。
Q12. PageHelper 分页插件的原理(ThreadLocal + SQL 改写) 使用方式(极简):
1 2 3 4 5 PageHelper.startPage(1 , 10 ); List<User> users = userMapper.selectAll(); PageInfo<User> pageInfo = new PageInfo <>(users);
原理:
第一步:ThreadLocal 存储分页参数
PageHelper.startPage(1, 10) 把分页参数存入 ThreadLocal<Page>,当前线程可以读到。
第二步:拦截 StatementHandler.prepare() 改写 SQL
PageHelper 实现了 MyBatis Interceptor 接口,拦截 StatementHandler.prepare()。在这个阶段,从 ThreadLocal 读取分页参数,对原始 SQL 进行改写:
1 2 3 4 5 6 7 8 SELECT * FROM user WHERE status = 1 SELECT * FROM user WHERE status = 1 LIMIT 0 , 10 SELECT COUNT (0 ) FROM (SELECT * FROM user WHERE status = 1 ) tmp_count
第三步:清除 ThreadLocal
分页查询完成后,清除 ThreadLocal 中的分页参数,防止内存泄漏。
一个常见的坑:
1 2 3 4 5 6 7 PageHelper.startPage(1 , 10 );if (condition) { } List<User> users = userMapper.selectAll();
正确姿势:startPage 必须紧贴查询语句。
Q13. 动态 SQL:<if>, <choose>, <foreach>, <trim>, <where>, <set> 的使用场景 动态 SQL 是 MyBatis 最强大的特性之一。
<if>:条件判断
1 2 3 4 5 6 7 8 9 10 <select id ="selectByCondition" resultType ="User" > SELECT * FROM user WHERE 1=1 <if test ="name != null and name != ''" > AND name LIKE CONCAT('%', #{name}, '%') </if > <if test ="status != null" > AND status = #{status} </if > </select >
<where>:智能去除多余的 AND/OR(推荐替代 WHERE 1=1)
1 2 3 4 5 6 7 8 <select id ="selectByCondition" resultType ="User" > SELECT * FROM user <where > <if test ="name != null" > AND name = #{name}</if > <if test ="status != null" > AND status = #{status}</if > </where > </select >
<set>:UPDATE 场景,去除多余的逗号
1 2 3 4 5 6 7 8 9 10 <update id ="updateSelective" > UPDATE user <set > <if test ="name != null" > name = #{name},</if > <if test ="email != null" > email = #{email},</if > <if test ="status != null" > status = #{status},</if > </set > WHERE id = #{id}</update >
<choose>/<when>/<otherwise>:相当于 switch-case
1 2 3 4 5 6 7 8 <select id ="selectByPriority" resultType ="User" > SELECT * FROM user WHERE <choose > <when test ="id != null" > id = #{id}</when > <when test ="email != null" > email = #{email}</when > <otherwise > status = 1</otherwise > </choose > </select >
<foreach>:IN 查询、批量插入
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <select id ="selectByIds" resultType ="User" > SELECT * FROM user WHERE id IN <foreach collection ="ids" item ="id" open ="(" separator ="," close =")" > #{id} </foreach > </select > <insert id ="batchInsert" > INSERT INTO user (name, email) VALUES <foreach collection ="users" item ="u" separator ="," > (#{u.name}, #{u.email}) </foreach > </insert >
<trim>:通用版 <where> 和 <set>
1 2 3 4 5 6 7 8 <trim prefix ="WHERE" prefixOverrides ="AND |OR " > ...</trim > <trim prefix ="SET" suffixOverrides ="," > ...</trim >
Q14. <resultMap> vs resultType:何时必须用 resultMap resultType:简单场景用它
当查询结果列名和 Java 对象字段名完全一致(或开启了驼峰映射),用 resultType 最简洁:
1 2 3 4 5 6 7 8 <settings > <setting name ="mapUnderscoreToCamelCase" value ="true" /> </settings > <select id ="selectById" resultType ="com.example.User" > SELECT id, user_name, email FROM user WHERE id = #{id}</select >
<resultMap>:以下场景必须使用
场景一:列名和字段名不一致且无法用驼峰规则解决
1 2 3 4 5 <resultMap id ="userMap" type ="User" > <id column ="user_id" property ="id" /> <result column ="user_name" property ="name" /> <result column ="create_time" property ="createAt" /> </resultMap >
场景二:嵌套对象(一对一关联)
1 2 3 4 5 6 7 8 9 <resultMap id ="orderMap" type ="Order" > <id column ="id" property ="id" /> <result column ="amount" property ="amount" /> <association property ="user" javaType ="User" > <id column ="user_id" property ="id" /> <result column ="user_name" property ="name" /> </association > </resultMap >
场景三:集合(一对多关联)
1 2 3 4 5 6 7 8 9 <resultMap id ="userWithOrdersMap" type ="User" > <id column ="id" property ="id" /> <result column ="name" property ="name" /> <collection property ="orders" ofType ="Order" > <id column ="order_id" property ="id" /> <result column ="amount" property ="amount" /> </collection > </resultMap >
Q15. <association> vs <collection>:一对一 vs 一对多映射 <association>:一对一关联(对象中嵌套对象)
场景:一个订单对应一个用户。
1 2 3 4 5 6 7 8 9 10 11 12 13 <resultMap id ="orderResultMap" type ="Order" > <id column ="order_id" property ="id" /> <result column ="amount" property ="amount" /> <association property ="user" javaType ="User" select ="selectUserById" column ="user_id" fetchType ="lazy" /> </resultMap > <select id ="selectUserById" resultType ="User" > SELECT * FROM user WHERE id = #{userId}</select >
<collection>:一对多关联(对象中嵌套集合)
场景:一个用户有多个订单。
1 2 3 4 5 6 7 8 9 10 11 12 13 <resultMap id ="userResultMap" type ="User" > <id column ="user_id" property ="id" /> <result column ="user_name" property ="name" /> <collection property ="orders" ofType ="Order" select ="selectOrdersByUserId" column ="user_id" fetchType ="lazy" /> </resultMap > <select id ="selectOrdersByUserId" resultType ="Order" > SELECT * FROM order WHERE user_id = #{userId}</select >
JOIN 查询方式(避免 N+1,推荐):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <resultMap id ="userWithOrdersMap" type ="User" > <id column ="uid" property ="id" /> <result column ="uname" property ="name" /> <collection property ="orders" ofType ="Order" > <id column ="oid" property ="id" /> <result column ="amount" property ="amount" /> </collection > </resultMap > <select id ="selectUserWithOrders" resultMap ="userWithOrdersMap" > SELECT u.id uid, u.name uname, o.id oid, o.amount FROM user u LEFT JOIN order o ON u.id = o.user_id WHERE u.id = #{userId}</select >
Q16. 懒加载(Lazy Loading)的原理(CGLIB/Javassist 代理)及配置 什么是懒加载?
懒加载指关联对象不在主查询时加载,而是在第一次访问该属性时才发起额外的 SQL 查询。目的是减少不必要的数据库查询。
配置方式:
1 2 3 4 5 6 7 <settings > <setting name ="lazyLoadingEnabled" value ="true" /> <setting name ="aggressiveLazyLoading" value ="false" /> </settings >
也可以在 <association> 或 <collection> 上单独设置:
1 2 <association property ="user" fetchType ="lazy" ... /> <collection property ="orders" fetchType ="eager" ... />
原理:CGLIB/Javassist 动态代理
MyBatis 懒加载的核心是:给返回的对象创建代理,拦截 getter 方法 。
1 2 3 4 5 6 7 8 9 selectOrder() 返回 Order 对象(user 字段为 null,但被代理) ↓ order.getUser() ← 调用 getter ↓ 代理拦截:发现 user 属性尚未加载 ↓ 执行 SELECT * FROM user WHERE id = ↓ 填充 user 字段,返回真实 User 对象
MyBatis 3.3+ 默认使用 Javassist 生成代理类(比 CGLIB 轻量,无需引入 cglib 依赖),也可以切换到 CGLIB:
1 <setting name ="proxyFactory" value ="CGLIB" />
注意: 如果在 toString()、equals() 等方法中访问懒加载属性,可能意外触发加载。aggressiveLazyLoading=false 可以避免这个问题。
Q17. N+1 查询问题:产生原因及 fetchType=eager 的权衡 N+1 问题描述:
查询 1 个用户列表(1 条 SQL),再为每个用户查询其订单(N 条 SQL),共 N+1 条 SQL。
1 2 3 4 5 6 7 List<User> users = userMapper.selectAll(); for (User user : users) { List<Order> orders = user.getOrders(); }
解决方案:
方案一:改为 fetchType="eager"
立即加载,一次 JOIN 查询搞定,但如果不需要订单数据,就做了无用的 JOIN。
方案二:使用 JOIN 查询(最推荐)
1 2 3 4 5 <select id ="selectUsersWithOrders" resultMap ="userWithOrdersMap" > SELECT u.*, o.id o_id, o.amount FROM user u LEFT JOIN order o ON u.id = o.user_id</select >
方案三:批量懒加载(aggressiveLazyLoading + 合理的批次大小)
MyBatis 支持 fetchSize 设置,减少数据库往返次数,但不能从根本上解决 N+1。
权衡原则:
数据量小、关联简单:fetchType="eager" + JOIN
关联数据很少被用到:fetchType="lazy" + 懒加载
大数据量、高并发:避免 ORM 的关联查询,改用应用层手动 JOIN(先查用户,再按 userIds 批量查订单)
Q18. 批量操作:<foreach> 批量 insert vs ExecutorType.BATCH 的区别和性能对比 方式一:<foreach> 拼接多值 INSERT(推荐)
1 2 3 4 5 6 <insert id ="batchInsert" > INSERT INTO user (name, email, status) VALUES <foreach collection ="users" item ="u" separator ="," > (#{u.name}, #{u.email}, #{u.status}) </foreach > </insert >
生成 SQL:INSERT INTO user (name, email, status) VALUES (?,?,?),(?,?,?),(?,?,?)
优点: 一次网络往返,极快。缺点: 数据量太大时 SQL 过长(建议每批 500-1000 条),MySQL 需确认 max_allowed_packet。
方式二:ExecutorType.BATCH
1 2 3 4 5 6 7 try (SqlSession session = factory.openSession(ExecutorType.BATCH)) { UserMapper mapper = session.getMapper(UserMapper.class); for (User user : users) { mapper.insert(user); } session.commit(); }
底层原理: JDBC PreparedStatement.addBatch() + executeBatch(),每条 SQL 预编译一次,参数多次批量提交。
性能对比:
方式
1000 条数据(参考)
特点
逐条 INSERT
~5000ms
最慢,不推荐
ExecutorType.BATCH
~500ms
减少网络往返,但多次预编译
<foreach> 拼接
~100ms
最快,一条 SQL
Spring 中使用 ExecutorType.BATCH:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @Autowired private SqlSessionTemplate sqlSessionTemplate;public void batchInsert (List<User> users) { SqlSession batchSession = sqlSessionTemplate .getSqlSessionFactory() .openSession(ExecutorType.BATCH); try { UserMapper mapper = batchSession.getMapper(UserMapper.class); for (int i = 0 ; i < users.size(); i++) { mapper.insert(users.get(i)); if (i % 500 == 0 ) { batchSession.flushStatements(); } } batchSession.commit(); } catch (Exception e) { batchSession.rollback(); throw e; } finally { batchSession.close(); } }
Q19. MyBatis-Plus:常用注解(@TableName, @TableId, @TableField),条件构造器(LambdaQueryWrapper) MyBatis-Plus 是 MyBatis 的增强工具,在不改变 MyBatis 基础上,提供了大量实用功能。
常用注解:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @TableName("t_user") public class User { @TableId(type = IdType.AUTO) private Long id; @TableField("user_name") private String name; @TableField(exist = false) private List<Order> orders; @TableLogic private Integer deleted; }
条件构造器(LambdaQueryWrapper,强烈推荐):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 QueryWrapper<User> wrapper = new QueryWrapper <>(); wrapper.eq("user_name" , "张三" ).ge("age" , 18 ).orderByDesc("create_time" ); LambdaQueryWrapper<User> lambda = new LambdaQueryWrapper <>(); lambda.eq(User::getName, "张三" ) .ge(User::getAge, 18 ) .orderByDesc(User::getCreateTime) .last("LIMIT 10" ); List<User> users = userMapper.selectList(lambda); List<User> users2 = userMapper.selectList( Wrappers.<User>lambdaQuery() .eq(StringUtils.isNotBlank(name), User::getName, name) .between(User::getAge, 18 , 60 ) .select(User::getId, User::getName) );
常用 BaseMapper 方法:
1 2 3 4 5 6 7 8 9 10 11 userMapper.selectById(1L ); userMapper.selectBatchIds(Arrays.asList(1L , 2L , 3L )); userMapper.insert(user); userMapper.updateById(user); userMapper.deleteById(1L ); Page<User> page = new Page <>(1 , 10 ); IPage<User> result = userMapper.selectPage(page, lambda); result.getRecords(); result.getTotal();
Q20. MyBatis-Plus 的逻辑删除实现原理(@TableLogic) 什么是逻辑删除?
不真正执行 DELETE 语句,而是更新一个标志位字段(如 is_deleted=1),查询时自动过滤已删除数据,保留数据可追溯。
配置方式:
1 2 3 4 5 6 7 mybatis-plus: global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0
实体类:
1 2 3 4 5 6 7 public class User { private Long id; private String name; @TableLogic private Integer deleted; }
原理:SQL 自动改写
MyBatis-Plus 通过插件拦截,在执行时自动改写 SQL:
1 2 3 4 5 6 7 UPDATE user SET deleted = 1 WHERE id = 1 AND deleted = 0 SELECT * FROM user WHERE id = 1 AND deleted = 0
注意事项:
自定义 SQL 不会自动加逻辑删除条件,需要手动加 AND deleted = 0
如果字段名不是全局配置的名称,必须在字段上明确声明 @TableLogic
逻辑删除后,相关唯一索引可能冲突(已删除的记录仍占用唯一值),通常需要把删除时间戳或 ID 加入联合唯一索引
Q21. MyBatis 多数据源配置方案(AOP + AbstractRoutingDataSource 或 Sharding-JDBC) 方案一:AbstractRoutingDataSource + AOP(适合读写分离、分库)
第一步:定义数据源路由
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public class DynamicDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey () { return DataSourceContextHolder.getDataSourceType(); } }public class DataSourceContextHolder { private static final ThreadLocal<String> CONTEXT = new ThreadLocal <>(); public static void setDataSourceType (String type) { CONTEXT.set(type); } public static String getDataSourceType () { return CONTEXT.get(); } public static void clear () { CONTEXT.remove(); } }
第二步:注册多数据源
1 2 3 4 5 6 7 8 9 10 11 12 @Bean public DataSource dynamicDataSource ( @Qualifier("masterDs") DataSource master, @Qualifier("slaveDs") DataSource slave) { DynamicDataSource ds = new DynamicDataSource (); Map<Object, Object> targetDataSources = new HashMap <>(); targetDataSources.put("master" , master); targetDataSources.put("slave" , slave); ds.setTargetDataSources(targetDataSources); ds.setDefaultTargetDataSource(master); return ds; }
第三步:AOP 自动切换
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 @Aspect @Component public class DataSourceAspect { @Before("@annotation(ds)") public void switchDataSource (JoinPoint point, DS ds) { DataSourceContextHolder.setDataSourceType(ds.value()); } @After("@annotation(ds)") public void restoreDataSource (JoinPoint point, DS ds) { DataSourceContextHolder.clear(); } }@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface DS { String value () default "master" ; }@DS("slave") public List<User> selectAll () { ... }
方案二:ShardingSphere / Sharding-JDBC
适合更复杂的分库分表场景,配置声明式,侵入性更低,支持水平分片、读写分离、数据脱敏等,推荐大型项目使用。
Q22. TypeHandler:自定义类型转换(枚举存储、JSON 字段、加解密) TypeHandler 的作用:
TypeHandler 负责 Java 类型和 JDBC 类型之间的转换。MyBatis 内置了常用类型的 Handler,当遇到特殊类型(枚举、JSON、加密字段),需要自定义。
场景一:枚举存储 code 值
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 public enum UserStatus { ACTIVE(1 ), INACTIVE(0 ); private final int code; }@MappedTypes(UserStatus.class) @MappedJdbcTypes(JdbcType.INTEGER) public class UserStatusTypeHandler extends BaseTypeHandler <UserStatus> { @Override public void setNonNullParameter (PreparedStatement ps, int i, UserStatus parameter, JdbcType jdbcType) throws SQLException { ps.setInt(i, parameter.getCode()); } @Override public UserStatus getNullableResult (ResultSet rs, String columnName) throws SQLException { int code = rs.getInt(columnName); return Arrays.stream(UserStatus.values()) .filter(s -> s.getCode() == code) .findFirst().orElse(null ); } }
场景二:JSON 字段(List 存为 JSON 字符串)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @MappedTypes(List.class) public class JsonListTypeHandler <T> extends BaseTypeHandler <List<T>> { private static final ObjectMapper MAPPER = new ObjectMapper (); @Override public void setNonNullParameter (PreparedStatement ps, int i, List<T> parameter, JdbcType jdbcType) throws SQLException { ps.setString(i, MAPPER.writeValueAsString(parameter)); } @Override public List<T> getNullableResult (ResultSet rs, String columnName) throws SQLException { String json = rs.getString(columnName); return json == null ? null : MAPPER.readValue(json, new TypeReference <List<T>>(){}); } }
注册 TypeHandler:
1 2 3 4 <typeHandlers > <typeHandler handler ="com.example.UserStatusTypeHandler" /> <package name ="com.example.typehandler" /> </typeHandlers >
关键组件一:SqlSessionTemplate
SqlSessionTemplate 是 Spring 提供的线程安全的 SqlSession 实现。它通过 Spring 的事务管理和 TransactionSynchronizationManager 来保证:
有事务时:从当前事务绑定的 SqlSession 中获取(共享同一个会话)
无事务时:每次调用创建新 SqlSession,用完即关
1 2 3 4 @Bean public SqlSessionTemplate sqlSessionTemplate (SqlSessionFactory factory) { return new SqlSessionTemplate (factory); }
关键组件二:MapperScannerConfigurer
自动扫描 Mapper 接口,为每个接口创建 MapperFactoryBean,注册到 Spring 容器中。
1 2 3 4 5 6 7 @Bean public MapperScannerConfigurer mapperScannerConfigurer () { MapperScannerConfigurer configurer = new MapperScannerConfigurer (); configurer.setBasePackage("com.example.mapper" ); configurer.setSqlSessionFactoryBeanName("sqlSessionFactory" ); return configurer; }
等效的注解方式(更常用):
1 2 3 @SpringBootApplication @MapperScan("com.example.mapper") public class Application { ... }
MyBatis-Spring-Boot-Starter 自动配置:
引入 mybatis-spring-boot-starter 后,SqlSessionFactory、SqlSessionTemplate、MapperScannerConfigurer 都由 MybatisAutoConfiguration 自动创建,只需在 application.yml 中配置基本属性即可。
1 2 3 4 5 mybatis: mapper-locations: classpath*:mapper/**/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
Q24. 面试高频:MyBatis 如何防止 SQL 注入?(底层 PreparedStatement 原理) SQL 注入的本质是:用户输入的数据被当作 SQL 结构的一部分来解析执行 。
MyBatis 防注入的核心:#{} 使用 PreparedStatement
当你写 WHERE name = #{name} 时,MyBatis 生成的 JDBC 代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 String sql = "SELECT * FROM user WHERE name = ?" ;PreparedStatement ps = connection.prepareStatement(sql); ps.setString(1 , userInput); ResultSet rs = ps.executeQuery();
对比 ${} 的危险:
1 2 3 4 5 6 7 String sql = "SELECT * FROM user WHERE name = '" + name + "'" ;Statement stmt = connection.createStatement();ResultSet rs = stmt.executeQuery(sql);
MyBatis 防注入总结:
永远用 #{} 传递用户输入的参数值
${} 只用于传递列名、表名等 SQL 结构元素,且这些值必须来自可信的枚举或白名单
MyBatis-Plus 的 LambdaQueryWrapper 底层也是 PreparedStatement,同样安全
额外防线:在 Web 层对输入参数做校验和过滤(配合 @Valid、@Pattern 等)
Q25. 实战:MyBatis 慢 SQL 排查步骤(日志配置 → Explain 分析 → 索引优化) 第一步:开启 MyBatis SQL 日志,定位慢 SQL
1 2 3 4 5 6 7 8 mybatis: configuration: log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl logging: level: com.example.mapper: DEBUG
或使用 P6Spy 打印完整 SQL(含实际参数,非 ? 占位符):
1 2 3 4 5 6 <dependency > <groupId > p6spy</groupId > <artifactId > p6spy</artifactId > <version > 3.9.1</version > </dependency >
1 2 3 4 5 driverlist =com.mysql.cj.jdbc.Driver outagedetection =true outagedetectioninterval =2 # 超过 2 秒的 SQL 打印告警 logMessageFormat =com.p6spy.engine.spy.appender.MultiLineFormat
第二步:用 EXPLAIN 分析执行计划
把慢 SQL 复制到数据库客户端,前面加 EXPLAIN:
1 2 3 4 EXPLAIN SELECT u.* , o.amountFROM user uLEFT JOIN order o ON u.id = o.user_idWHERE u.status = 1 AND o.create_time > '2026-01-01' ;
重点关注以下字段:
字段
危险信号
含义
type
ALL
全表扫描,最危险
key
NULL
没有使用索引
rows
很大的数字
扫描行数过多
Extra
Using filesort
需要额外排序
Extra
Using temporary
使用了临时表
第三步:针对性索引优化
1 2 3 4 5 6 7 8 9 ALTER TABLE user ADD INDEX idx_status (status);ALTER TABLE order ADD INDEX idx_user_create (user_id, create_time);
第四步:MyBatis 层面优化
1 2 3 4 5 6 7 <sql id ="baseColumns" > id, name, email, status</sql > <select id ="selectByStatus" resultType ="User" > SELECT <include refid ="baseColumns" /> FROM user WHERE status = #{status}</select >
第五步:监控告警(生产环境必备)
结合 Q11 中的慢 SQL 插件,在插件中集成 Prometheus 指标上报或发送钉钉/飞书告警,做到慢 SQL 实时预警,不等问题扩大。
总结
分类
核心考点
架构原理
SqlSessionFactory 单例、SqlSession 非线程安全、Executor 三种类型
SQL 安全
#{} vs ${}、PreparedStatement 防注入
缓存
一级缓存 Spring 集成后失效、二级缓存脏读风险
高级特性
动态代理绑定 Mapper、插件拦截链、懒加载代理
动态 SQL
<where>/<set>/<foreach> 的使用场景
MyBatis-Plus
LambdaQueryWrapper、逻辑删除 SQL 改写
实战
批量操作性能选型、多数据源路由、慢 SQL 排查
面试建议:把 Q2(整体架构)、Q5(#{} vs ${})、Q8(一级缓存坑点)、Q11(插件原理)这四道题答得滚瓜烂熟,MyBatis 相关面试基本稳了。