MyBatis面试精讲:一级二级缓存、#{}和${}、懒加载的实现原理
MyBatis面试精讲:一级二级缓存、#{}和${}、懒加载的实现原理
适读人群:Java后端开发 | 难度:★★★★☆ | 出现频率:高
开篇故事
好几年前,我接手一个遗留项目,代码里有一个SQL查询用了${param}而不是#{param},被人发现了SQL注入漏洞,用户传入' OR '1'='1就能绕过登录验证。
当时排查这个问题,顺便把MyBatis的#和$的区别彻底搞清楚了。
还有一次,系统出了个很奇怪的bug:同一个Service内,先查一次数据,修改后再查,第二次返回的还是修改前的数据。查了很久,发现是MyBatis一级缓存在同一个SqlSession内缓存了旧数据,第二次查询直接返回缓存,没有重新查数据库。
MyBatis的这些特性,面试官很爱问,今天全部整理清楚。
一、高频考点拆解
MyBatis这道题,面试官考察三个点:
第一点:#{}和${}的区别,以及SQL注入风险 第二点:一级缓存和二级缓存的作用域、失效条件、适用场景 第三点:懒加载(延迟加载)的实现原理(动态代理)
二、深度原理分析
2.1 #{}和${}的本质区别
#{}:预编译参数占位符,MyBatis会将参数值以?的形式放入PreparedStatement,数据库驱动对值做安全转义,彻底防SQL注入。
// Java代码
mapper.findByName("张三' OR '1'='1");
// MyBatis生成的SQL
SELECT * FROM user WHERE name = ?
// 参数:张三' OR '1'='1(作为字符串值,不能注入)${}:直接字符串替换,将参数值原样拼接到SQL中,存在SQL注入风险。
// Java代码
mapper.orderBy("name; DROP TABLE user;--");
// MyBatis生成的SQL(直接拼接!)
SELECT * FROM user ORDER BY name; DROP TABLE user;--
// 这是灾难性的SQL注入!什么时候必须用${}?
当SQL的结构本身需要动态化,而不只是值:
- 动态表名:
SELECT * FROM ${tableName}(不同月份的分表) - 动态字段名:
ORDER BY ${orderColumn}(排序字段动态指定) - IN语句的列表(MyBatis 3以后可以用
foreach替代)
使用${}时的安全做法:
// 白名单校验,只允许指定的列名
private static final Set<String> ALLOWED_COLUMNS =
ImmutableSet.of("name", "age", "create_time");
public List<User> findSorted(String orderColumn) {
if (!ALLOWED_COLUMNS.contains(orderColumn)) {
throw new IllegalArgumentException("非法的排序列: " + orderColumn);
}
return userMapper.findSorted(orderColumn);
}2.2 一级缓存(SqlSession级别)
一级缓存的作用域是同一个SqlSession,默认开启,不可关闭。
一级缓存的失效条件:
- SqlSession关闭(close())
- SqlSession执行了commit()或rollback()
- 执行了UPDATE/DELETE/INSERT语句(同一SqlSession内,任何修改操作都会清除一级缓存)
- 手动调用
sqlSession.clearCache()
Spring环境下的一级缓存:
Spring集成MyBatis时,默认每次调用Mapper方法都会创建新的SqlSession,因此一级缓存基本没有效果(每次都是新Session)。只有在同一个事务内,Spring才会复用同一个SqlSession,此时一级缓存才会发挥作用。
2.3 二级缓存(Mapper级别)
二级缓存的作用域是同一个Mapper(namespace),跨SqlSession共享,默认关闭,需要显式开启。
开启二级缓存:
<!-- mybatis-config.xml -->
<settings>
<setting name="cacheEnabled" value="true"/>
</settings>
<!-- UserMapper.xml -->
<mapper namespace="com.example.UserMapper">
<!-- 开启二级缓存 -->
<cache eviction="LRU" flushInterval="60000" size="512" readOnly="true"/>
<!-- 这个查询的结果会被缓存 -->
<select id="findById" resultType="User" useCache="true">
SELECT * FROM user WHERE id = #{id}
</select>
</mapper>二级缓存的注意事项:
- 缓存的对象需要实现Serializable接口
- 多表关联查询时,只有修改一张表的缓存才会失效,可能导致另一张表的数据过期
- 生产中通常不推荐使用MyBatis二级缓存,一般用Redis等专业缓存
2.4 懒加载(Lazy Loading)
懒加载是指:关联对象在真正被访问时才发送SQL查询,而不是在主对象查询时就一起查。
// 不用懒加载(N+1问题)
User user = userMapper.findById(1L);
// 查询用户,同时立即查询所有订单
List<Order> orders = user.getOrders(); // 已经查出来了
// 如果查100个用户,就会有101条SQL(1条查用户 + 100条查各自的订单)// 用懒加载
User user = userMapper.findById(1L);
// 只查询了用户,orders还没有查
if (user.isVip()) { // 可能只需要判断是否是VIP,不需要订单
// 直到这里才需要orders
List<Order> orders = user.getOrders(); // 此时触发查询
}懒加载的实现原理(动态代理):
MyBatis通过CGLIB或Javassist动态代理,创建User类的子类代理对象。代理对象在调用getOrders()等关联属性的getter时,检测关联属性是否已加载,没有则发送SQL查询。
开启懒加载配置:
<!-- mybatis-config.xml -->
<settings>
<setting name="lazyLoadingEnabled" value="true"/>
<setting name="aggressiveLazyLoading" value="false"/> <!-- 关闭激进懒加载 -->
</settings>三、标准答案 + 代码验证
3.1 #{}和${}安全对比
// Mapper接口
public interface UserMapper {
// 安全:使用#{}
@Select("SELECT * FROM user WHERE username = #{username}")
User findByUsername(String username);
// 危险:使用${}(只在表名、列名等结构参数时用)
@Select("SELECT * FROM user ORDER BY ${column}")
List<User> findSortedBy(@Param("column") String column);
}
// 测试SQL注入
public void testSqlInjection() {
// 安全:#{}防注入
User user = userMapper.findByUsername("admin' OR '1'='1");
// 实际执行:WHERE username = 'admin\' OR \'1\'=\'1'(转义了特殊字符)
// 查不到任何用户
// 危险:${}注入(已被白名单校验阻止)
// userMapper.findSortedBy("name; DROP TABLE user;--");
// 这种调用要在业务层先做白名单校验
}3.2 验证一级缓存
@Service
public class CacheTestService {
@Autowired
private SqlSessionFactory sqlSessionFactory;
public void testFirstLevelCache() {
try (SqlSession session = sqlSessionFactory.openSession()) {
UserMapper mapper = session.getMapper(UserMapper.class);
// 第一次查询,执行SQL
User user1 = mapper.findById(1L);
System.out.println("第一次查询: " + user1.getName());
// 第二次相同查询,直接返回缓存,不执行SQL
User user2 = mapper.findById(1L);
System.out.println("第二次查询: " + user2.getName());
System.out.println("是同一个对象: " + (user1 == user2)); // true
// 执行更新,清除缓存
mapper.updateName(1L, "新名字");
// 第三次查询,重新执行SQL
User user3 = mapper.findById(1L);
System.out.println("更新后查询: " + user3.getName()); // 新名字
System.out.println("是同一个对象: " + (user1 == user3)); // false
}
}
}3.3 懒加载配置和使用
<!-- UserMapper.xml -->
<resultMap id="UserWithOrders" type="User">
<id property="id" column="id"/>
<result property="name" column="name"/>
<!-- fetchType="lazy": 该关联使用懒加载 -->
<collection property="orders"
column="id"
select="com.example.OrderMapper.findByUserId"
fetchType="lazy"/>
</resultMap>
<select id="findByIdWithOrders" resultMap="UserWithOrders">
SELECT id, name FROM user WHERE id = #{id}
</select>// 验证懒加载
@Test
public void testLazyLoad() {
// 开启MyBatis SQL日志:logging.level.com.example=DEBUG
User user = userMapper.findByIdWithOrders(1L);
System.out.println("查询用户后,此时还没有查询orders");
// 日志只有一条:SELECT id, name FROM user WHERE id = 1
System.out.println("用户名: " + user.getName()); // 不触发懒加载
// 访问orders时才触发查询
List<Order> orders = user.getOrders();
// 日志:SELECT * FROM orders WHERE user_id = 1
System.out.println("订单数量: " + orders.size());
}四、面试官追问
追问1:MyBatis的懒加载在什么情况下会失效?
我的回答:懒加载在几种情况下不生效。第一,序列化:如果User对象被序列化(传输到前端、存入Redis),CGLIB代理不支持跨JVM序列化,反序列化后的对象是原始User对象,访问orders时不会触发懒加载,直接返回null。解决方案:在序列化前先强制触发懒加载,或者在序列化时单独处理关联属性。第二,aggressiveLazyLoading=true时:MyBatis早期版本默认开启激进懒加载,访问任何属性时会触发所有懒加载字段的加载,失去了懒加载的意义,应该设为false。第三,在@Transactional事务外访问懒加载属性:Spring的事务是基于数据库连接的,事务结束后连接归还,此时再访问懒加载属性会报LazyInitializationException(不过这是Hibernate的问题,MyBatis稍有不同,但也需要保证Session在访问时是活跃的)。
追问2:MyBatis和Hibernate的区别?
我的回答:两者都是ORM框架,但设计哲学不同。MyBatis是半自动ORM,SQL由开发者手写,灵活可控,适合SQL复杂、需要精细调优的场景,学习成本低,但开发效率略低(需要手写SQL)。Hibernate是全自动ORM,基于对象和关系的映射,开发者操作对象,由Hibernate生成SQL,开发效率高,但生成的SQL有时不够优化,对复杂SQL的支持不够好。国内大厂(阿里、腾讯等)普遍用MyBatis,因为对SQL有更高的掌控需求;欧美企业用Hibernate/JPA的更多。
追问3:MyBatis的Plugin(拦截器)机制原理是什么?
我的回答:MyBatis的Plugin基于责任链+动态代理实现。可以拦截Executor(执行器)、StatementHandler(语句处理器)、ResultSetHandler(结果集处理器)、ParameterHandler(参数处理器)这四个核心接口的方法。插件实现Interceptor接口,在intercept()方法中做增强逻辑,然后调用invocation.proceed()执行原方法。多个Plugin形成链式调用,前一个Plugin的代理对象是后一个Plugin的target。常用场景:PageHelper分页插件(拦截StatementHandler,在SQL前后添加COUNT和LIMIT)、SQL打印日志、SQL耗时统计等。
五、同类题目举一反三
MyBatis如何防止SQL注入?除了#{}还有哪些方式?
第一,使用#{}预编译参数,最根本的防注入手段。第二,对于必须用${}的场景(动态表名、列名),在业务层做白名单校验,只允许指定的合法值。第三,使用MyBatis的<foreach>标签处理IN语句的列表,避免直接拼接字符串。第四,对用户输入进行转义(如JDBC的EscapedString),作为额外的防线。第五,数据库账号最小权限原则,应用账号只有SELECT/INSERT/UPDATE权限,没有DROP/DELETE权限,限制注入能造成的危害。
六、踩坑实录
坑一:二级缓存脏读,修改了数据但读到旧值
有次系统A修改了用户数据,但系统B(同一数据库,不同JVM)读到的还是旧数据,因为二级缓存没有清除。MyBatis二级缓存是JVM级别的,不同JVM之间无法同步,多服务实例下必然有脏读问题。从那以后,我们明确规定不使用MyBatis二级缓存,需要缓存就用Redis。
坑二:懒加载在Session关闭后访问,报空指针
一个同事的代码把User对象从Service层返回给了Controller层,Service层的@Transactional事务结束后Session关闭了。Controller层访问user.getOrders()时,代理对象试图查数据库,但Session已经关闭,报了异常(不是LazyInitializationException,而是MyBatis的Executor关闭异常)。解决方案:在Service层就把需要的关联数据全部加载完,或者将DTO(而不是Entity代理对象)返回给Controller层。
坑三:${column}导致的SQL注入
开篇提到的那个案例。一个搜索接口,排序字段从前端传入,直接用了ORDER BY ${column},攻击者传入了特殊字符触发了注入。从那以后,所有用${}的地方都要有白名单校验,这个规范写进了我们团队的代码规范文档。
七、总结
MyBatis三大面试点速记:
#{}和${}:
#{}:预编译,防SQL注入,99%的场景用这个${}:字符串替换,有注入风险,只用于动态表名/列名等,必须配合白名单校验
一级/二级缓存:
- 一级缓存:SqlSession级别,默认开启,Spring事务内有效,事务提交/更新后失效
- 二级缓存:Mapper级别,需显式开启,多服务实例下有脏读风险,生产不推荐
懒加载:
- 通过CGLIB动态代理实现,访问关联属性时才发SQL
- 需要保证Session活跃状态,序列化时需要注意
- 解决N+1问题的有效手段,但要避免在Session外访问
