MyBatis 入门实战
Java 中级 · 持久层框架
MyBatis 是「半自动」ORM:SQL 由你写,映射由它做。相比 JPA 全自动,它把 SQL 控制权交还开发者,因此在国内互联网公司几乎是持久层标配。本篇不只会用,更要讲清一次查询从 Mapper 接口到 JDBC 的完整链路和缓存的坑。
一、核心组件与执行链路
| 组件 | 职责 |
|---|---|
SqlSessionFactory | 全局单例,构建 SqlSession |
SqlSession | 一次会话,非线程安全,用完即关 |
Executor | 执行器(SIMPLE / REUSE / BATCH),含一二级缓存逻辑 |
StatementHandler | 创建/设置 PreparedStatement |
ParameterHandler | #{} 参数绑定 |
ResultSetHandler | 结果集 → 实体映射 |
1.1 Mapper 接口没有实现类,为什么能调用?
MyBatis 用 JDK 动态代理为每个 Mapper 接口生成 MapperProxy。调用接口方法时,代理根据「接口全限定名 + 方法名」找到对应的 MappedStatement,再交给 Executor 执行。
// 关键:namespace + id 唯一定位一条 SQL
// namespace = com.demo.mapper.UserMapper
// id = selectById
// 合起来 = com.demo.mapper.UserMapper.selectById
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
User u = mapper.selectById(1L); // 实际走代理 → Executor二、#{} 与 ${}:防注入的关键
| 写法 | 编译结果 | 是否预编译 | 安全性 |
|---|---|---|---|
#{id} | ? 占位符 | ✅ PreparedStatement | 安全 |
${id} | 字符串直接拼接 | ❌ | SQL 注入风险 |
<!-- 正确:占位符,参数安全 -->
<select id="selectById" resultType="User">
SELECT * FROM user WHERE id = #{id}
</select>
<!-- 危险:字符串拼接,${} 会原样塞入 SQL -->
<select id="selectByColumn" resultType="User">
SELECT * FROM user ORDER BY ${column} <!-- 排序字段只能这样,但要白名单校验 -->
</select>为什么 #{} 能防注入
#{} 会生成 ?,由 JDBC 的 PreparedStatement 做参数绑定。SQL 的结构在编译期就已确定,用户输入永远只当「值」而非「SQL 片段」。${} 则把用户输入当 SQL 拼进去,1' OR '1'='1 就能绕过条件。
${} 的合法用途
只有表名、字段名、排序方向这类「SQL 结构」无法用占位符时才用 ${},且必须做白名单校验(如只允许 asc/desc、只允许预定义的列名)。
三、动态 SQL
<select id="search" resultType="User">
SELECT * FROM user
<where>
<if test="name != null and name != ''">
AND name LIKE CONCAT('%', #{name}, '%')
</if>
<if test="status != null">
AND status = #{status}
</if>
</where>
<choose>
<when test="orderBy != null">ORDER BY ${orderBy}</when>
<otherwise>ORDER BY id DESC</otherwise>
</choose>
</select>
<!-- foreach 批量查询 -->
<select id="selectByIds" resultType="User">
SELECT * FROM user WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>| 标签 | 作用 |
|---|---|
<if> | 条件拼接 |
<where> | 智能去掉首个 AND/OR |
<set> | 更新语句智能去掉尾部逗号 |
<foreach> | 遍历集合(IN 查询、批量插入) |
<choose>/<when>/<otherwise> | 分支(类似 switch) |
<trim> | 自定义前缀/后缀修剪 |
四、一级/二级缓存及其坑
| 缓存 | 作用域 | 默认 | 失效时机 |
|---|---|---|---|
| 一级缓存 | SqlSession 内 | 开启 | SQLSession 关闭/清空、执行 update、commit |
| 二级缓存 | namespace(Mapper) | 关闭 | 该 namespace 有任何增删改 |
一二级缓存的生产坑
- 一级缓存:同一
SqlSession内两次查询返回同一个对象(改一个会影响另一个),且 Spring 整合后每个请求一个 Session,问题被掩盖但存在。 - 二级缓存跨 SqlSession 共享,多表关联时一个表更新不会清另一个表的缓存,读到脏数据。
- 结论:生产建议关闭二级缓存,用 Redis 做应用级缓存,可控性更强。
五、关联查询:嵌套结果 vs 嵌套查询
<!-- 方式一:嵌套结果(一条 SQL JOIN,推荐,避免 N+1) -->
<resultMap id="userWithOrders" type="User">
<id property="id" column="id"/>
<result property="name" column="name"/>
<collection property="orders" ofType="Order">
<id property="id" column="oid"/>
<result property="amount" column="amount"/>
</collection>
</resultMap>
<!-- 方式二:嵌套查询(会触发 N+1) -->
<resultMap id="userWithOrders2" type="User">
<collection property="orders" column="id"
select="com.demo.mapper.OrderMapper.selectByUserId"/>
</resultMap>N+1 查询
<collection select=...> 会对主表每一行再发一条子查询,N 行 → 1+N 条 SQL。数据量大时是性能灾难。用 fetchType="lazy" 或改成 JOIN 的嵌套结果方式。
六、最佳实践
- 一律用
#{},${}只在必要时且白名单校验。 - Mapper 接口 + XML 绑定,
namespace必须等于接口全限定名。 - 避免 N+1:优先 JOIN 的嵌套结果,或延迟加载 + 批量查询。
resultMap显式映射,比resultType自动映射更可控(尤其下划线转驼峰需开mapUnderscoreToCamelCase)。- 批量操作走
ExecutorType.BATCH或<foreach>,别在循环里单条 insert。 - 分页用插件(
PageHelper)或 MyBatis-Plus 分页,别手写LIMIT到各处。
📚 官方文档与参考资料
✏️ 练习与自测
- Mapper 接口没有实现类却能调用,底层用了什么设计模式?如何定位到具体 SQL?
#{}与${}的区别是什么?为什么#{}能防 SQL 注入?${}有没有合法场景?- 一级缓存和二级缓存的作用域分别是什么?为什么生产环境建议关闭二级缓存?
- 什么是 N+1 查询?用 MyBatis 的两种关联写法各举一例,并说明哪种更好。
本节小结
MyBatis 的执行链是 Mapper 代理 → SqlSession → Executor → StatementHandler → Parameter/ResultSetHandler;#{} 预编译防注入、${} 拼接需白名单;一级缓存 Session 级默认开、二级缓存 namespace 级需手动开且有脏读风险;关联查询警惕 N+1。