MyBatis 是 Java 生态里最流行的持久层框架之一。它把 JDBC 那堆重复的模板代码收拢成几行注解与 XML,让你专注写 SQL,同时保留对语句的完全掌控。本文带你从零接入 MyBatis,再深入到动态 SQL、缓存与生产避坑,建立一套可落地的持久层实践。
一、为什么还需要 MyBatis
原生 JDBC 写一次查询要经历加载驱动、建立连接、创建 Statement、手动映射结果集、关闭资源等七八步,重复且容易漏关连接。JPA / Hibernate 这类全自动 ORM 能让你“不写 SQL”,但代价是对生成语句的掌控变弱,复杂报表、多表关联、性能调优时常常被底层方言绑架。
MyBatis 走的是“半自动”路线:你写 SQL,它负责参数绑定与结果映射。既摆脱了 JDBC 的样板代码,又保留了 SQL 的可读性与可控性,这正是它在国内大厂后端经久不衰的原因。反观全自动 ORM,新人常栽在“懒加载触发 N+1 查询”“关联对象意外全表加载”这类隐性性能问题上——SQL 藏在框架内部,不翻日志根本发现不了。
| 维度 | 原生 JDBC | MyBatis | JPA / Hibernate |
|---|---|---|---|
| SQL 掌控力 | 完全手写 | 完全手写 | 框架生成 |
| 样板代码 | 极多 | 少 | 极少 |
| 学习曲线 | 低 | 中 | 高 |
| 复杂查询友好度 | 低 | 高 | 中 |
| 动态 SQL | 字符串拼接 | 标签支持 | 受限 |
二、Spring Boot 快速接入
在 Spring Boot 里集成 MyBatis 只需一个 starter。下面以 Maven 为例,先引入依赖:
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.3</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
接着在 application.yml 中声明 Mapper 扫描路径与实体别名包:
mybatis:
mapper-locations: classpath*:mapper/**/*.xml
type-aliases-package: com.example.demo.entity
configuration:
map-underscore-to-camel-case: true # 下划线自动转驼峰
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
启动类上加 @MapperScan("com.example.demo.mapper"),MyBatis 就会把接口扫描成代理实现。关于 Java 接口如何被动态代理成实例,可参考我们之前的 Java 动态代理实战;其底层大量依赖 Java 反射 来读写实体字段。若你偏好纯配置而非 starter 自动装配,也可以手写 SqlSessionFactoryBean 指向一个 mybatis-config.xml,把环境、插件、类型别名集中管理——小项目用 starter 足够,多数据源或需要精细控制插件链时,显式配置反而更清晰。
三、两种映射方式:注解与 XML
最简单的查询可以直接写在 Mapper 接口上,用注解搞定:
@Mapper
public interface UserMapper {
@Select("SELECT id, name, email FROM user WHERE id = #{id}")
User selectById(@Param("id") Long id);
@Insert("INSERT INTO user(name,email) VALUES(#{name},#{email})")
@Options(useGeneratedKeys = true, keyProperty = "id")
int insert(User user);
}
当 SQL 变长、需要动态条件或多表关联时,XML 更清晰。一个典型的映射文件如下:
<?xml version="1.0" encoding="UTF-8"?>
<mapper namespace="com.example.demo.mapper.UserMapper">
<resultMap id="userMap" type="User">
<id column="id" property="id"/>
<result column="user_name" property="userName"/>
<result column="email" property="email"/>
</resultMap>
<select id="selectById" resultMap="userMap">
SELECT id, user_name, email FROM user WHERE id = #{id}
</select>
</mapper>
经验法则:单表简单 CRUD 用注解,复杂查询、动态 SQL 一律 XML,二者混用反而增加维护成本。注解方式虽快,但字段较多时直接在接口上写长 SQL 会显得拥挤,且不利于 DBA 评审;XML 的优势是把 SQL 集中到 resources 目录,结构清晰、还能享受 IDE 的标签校验。如果你的实体字段与列名差异很大,也可以配合 @Results 注解做字段映射,只是在接口里写一长串映射同样臃肿,不如统一交给 XML 的 resultMap。
四、动态 SQL:MyBatis 的精髓
动态 SQL 是 MyBatis 区别于拼接字符串的核心能力。常用标签有 if、choose/when、foreach、trim。下面这个按条件拼接的查询,在没有对应条件时会自动省略片段:
<select id="search" resultMap="userMap">
SELECT id, user_name, email FROM user
<where>
<if test="name != null">
AND user_name LIKE CONCAT('%', #{name}, '%')
</if>
<if test="minAge != null">
AND age >= #{minAge}
</if>
<if test="tagIds != null and tagIds.size() > 0">
AND id IN
<foreach collection="tagIds" item="tid" open="(" close=")" separator=",">
#{tid}
</foreach>
</if>
</where>
</select>
<where> 会自动剔除首条多余的 AND/OR,foreach 则优雅地处理 IN 列表,避免手写逗号分隔的风险。除此之外,<trim> 可以自由控制前后缀的增删,<set> 专门用于 UPDATE 时智能去掉末尾逗号,<bind> 能在 OGNL 表达式里先算好一个变量(比如拼接模糊查询的百分号),避免把拼接逻辑写进 SQL。更妙的是 <sql> 片段配合 <include> 可以把重复的列名、查询条件抽出来复用,既减少出错也方便统一改字段——这对字段频繁变动的业务表尤其有用。
五、#{} 与 ${}:一个决定安全,一个决定灵活
这是新手最容易踩的安全坑。#{} 走预编译参数绑定(PreparedStatement),能防 SQL 注入;${} 是字符串直接替换,绝不能用于用户输入。
| 写法 | 处理方式 | 安全性 | 适用场景 |
|---|---|---|---|
| #{name} | 预编译占位符 | 安全 | 所有用户输入 |
| ${column} | 文本替换 | 有注入风险 | 表名 / 排序列等元字段 |
-- 正确:参数绑定
SELECT * FROM user WHERE name = #{name}
-- 危险:直接拼接(column 来自用户输入时会被注入)
SELECT * FROM user ORDER BY ${column} ${order}
即便必须用 ${}(如动态表名),也应在 Java 侧用白名单校验取值,绝不能直接拼接到 SQL 中。再补充一点:#{} 还支持指定 jdbcType 与 typeHandler,当字段是枚举、JSON 或自定义对象时,通过注册 TypeHandler 可以把 Java 类型与数据库类型优雅地互转,而不必在业务代码里反复手动序列化。参数位置也能用 #{param1}、#{0} 或具名 @Param 三种方式引用,团队统一约定一种风格能显著降低沟通成本。
六、一级缓存、二级缓存与它们的坑
6.1 一级缓存
一级缓存是 SqlSession 级别的,同一个会话内重复查询同一条记录会命中内存。它默认开启,但一旦执行写操作或手动 clearCache() 就会失效。分布式环境下,多个应用节点各自持有一级缓存,极易读到旧值——这也是为什么生产上往往更依赖数据库层的一致性。值得注意的是,一级缓存的命中前提是“完全相同的查询参数与相同的 SqlSession”,一旦会话关闭或提交,缓存随之消失,所以它更多是减少单次请求内的重复查询,而非跨请求的共享能力。
6.2 二级缓存
二级缓存是 Mapper(namespace)级别、跨 SqlSession 共享的。它需要在映射文件加 <cache/> 且实体实现 Serializable。但凡是带写操作的表,二级缓存的脏读风险极高,多数团队选择关掉它,改用 Redis 这类集中式缓存 来承载热点数据。原因很直接:二级缓存只认本应用节点的 namespace,无法感知其他节点或旁路系统的写入,一旦多实例部署就会出现“A 节点改了数据、B 节点仍返回旧缓存”的经典脏读。因此真正要做多级缓存,应当把共享层交给带失效机制的集中式缓存,而不是依赖 MyBatis 的二级缓存。
| 缓存 | 作用范围 | 默认 | 生产建议 |
|---|---|---|---|
| 一级缓存 | SqlSession | 开启 | 保留,注意写后失效 |
| 二级缓存 | Mapper namespace | 关闭 | 谨慎开启,易脏读 |
七、大厂持久层最佳实践
1)连接池交给 Druid / HikariCP,绝不用 MyBatis 自带的托管连接;慢 SQL 的优化思路可对照 PostgreSQL 慢查询排查 的方法论迁移到 MySQL。
2)分页用 PageHelper 等成熟插件,避免手写 LIMIT 偏移量过大的深翻页:
PageHelper.startPage(1, 10);
List<User> list = userMapper.search(cond);
PageInfo<User> page = new PageInfo<>(list); // 自动拿到 total
3)开启 StdOut / 日志插件观察最终执行的 SQL 与参数,定位 N+1 查询。4)事务边界用 Spring 的 @Transactional 管理,Mapper 方法只管 SQL,不碰事务。5)本地联调用 Docker 起一个 MySQL 容器,保证环境与生产一致。6)实体类与表结构改动频繁时,考虑用 MyBatis Generator 或 Flyway 做 schema 版本管理,避免人工改表导致的映射漂移。7)批量插入优先用 foreach 拼单条多值 INSERT 或 ExecutorType.BATCH,比循环单条插入快一个数量级;但也要警惕一次拼接过多值导致 SQL 包过大,必要时分批提交。
八、常见坑与排障清单
| 现象 | 根因 | 对策 |
|---|---|---|
| Invalid bound statement | XML namespace 或 id 与接口不匹配 | 核对 namespace / 方法名一致 |
| 字段为 null | 列名与属性名未映射 | 开 map-underscore-to-camel-case 或用 resultMap |
| 参数绑定报错 | 多参未加 @Param | 每个参数显式 @Param 注解 |
| SQL 注入告警 | 误用 ${} | 改 #{} 并对元字段白名单校验 |
九、小结
MyBatis 的价值在于“可控”:你写的每一条 SQL 都清晰可见,性能与语义都在自己手里。掌握注解 / XML 双模式、动态 SQL、#{} 与 ${} 的安全边界,再配合连接池、分页插件与规范的事务边界,就能搭出一套稳健、可维护的 Java 持久层。它不会替你思考业务,但会让你把思考变成扎实的代码。当你面对报表、分库分表、读写分离这类 JPA 力不从心的场景时,MyBatis 的“显式”反而成了最大的优势——毕竟,能看见并掌控的 SQL,才谈得上优化。




