MyBatis 整合实战:Mapper、动态 SQL 与分页
先破一个误解:MyBatis 不替你写 SQL。它只做一件很朴素的事——你写好 SQL,它负责把方法参数搬进 SQL、把查询回来的行搬进 Java 对象。中间那些重复劳动(拿连接、预编译、setXxx 传参、遍历 ResultSet、逐字段赋值、异常包装、归还连接)全被它接管了。所以学 MyBatis 的真正重点是两件事:SQL 写在哪、怎么让它和 Java 对得上。
六个词一句话解释(后面全文都用):
- Mapper 接口:你只声明方法、不写实现的那个 Java 接口,例如
UserMapper.findById(Long id)。它是「点菜单」,不是厨房 - XML 映射文件:一段段带
namespace的 SQL 清单,namespace必须是接口的全限定名,id必须与方法名一致——两者靠这两条线缝在一起 - SqlSession:MyBatis 的执行入口对象,可以理解成「这次会话期间的窗口柜员」,
selectOne/insert/update都从它走 - Executor:SqlSession 背后的调度工,决定要不要查缓存、要不要复用语句、批量还是逐条执行
- resultMap / resultType:结果怎么装回对象的两种写法。列名与字段能对上就用
resultType,对不上(或有关联对象)就得写resultMap #{}与${}:前者是「参数」(预编译,安全),后者是「字符串拼接」(改 SQL 结构,危险)。这是本篇最重要的一条分界线
MyBatis 是一位翻译官。客户(Java 代码)说的是中文,柜台(数据库)只听英文,翻译官的责任就是在两边之间搬运信息,并且绝不替客户编造诉求。对应关系很整齐:Mapper 接口 = 你递过去的那张「要翻译什么」的单子;XML 里的 SQL = 翻译官起草好的英文稿;#{} = 稿件里留的空格,最后由专人填数字(谁也改不了句式);${} = 直接把客户原话抄进句式里(他要是塞一句「顺便删表」,句子就真的变了);resultMap = 对方说完后逐条记回你的表格;驼峰开关没开 = 翻译官把 user_name 直译成「用户名」,可你实体里存的是 userName,于是这一栏永远空白。翻译官只管搬运,不负责替你思考——这条心法能让你避开 MyBatis 八成的坑。

上面这张「标签家族树」就是本篇的地图:日常你能遇到的拼接需求,几乎都落在这六个分支里。第五节会逐个给可抄片段。
学完这一篇,你应该能回答三个问题:
- 我只写了一个接口、连实现类都没有,
userMapper.findById(1L)到底是谁在执行? Invalid bound statement (not found)这句话出现时,我应该按哪三条线索去查?<if test="keyword != null">为什么还不够?空串会让 SQL 变成什么样子?
先用沙盘感受一下「映射」这件事有多容易翻车——切换两个开关,右侧的控制台输出立刻变:
===> Preparing: SELECT id, user_name, status FROM t_user WHERE id = ?===> Parameters: 1(Long)<=== Row: 1 | alice | 1User{id=1, userName='alice', status=1}# 列名 user_name 经驼峰规则命中 userName,不用写一行映射代码
这四个格子里,只有右下角那种「一半对一半空」最难自查——因为它既不报错也不为空。真实工程里请养成一个习惯:看日志里的 <=== Row 那一行和对象的打印结果是否吻合,不吻合就先怀疑映射,而不是 SQL。
做数据访问层,几乎绕不开一个灵魂拷问:SQL 到底该不该由框架替你写? 完全交给框架(JPA/Hibernate),CRUD 飞快,但一旦遇到复杂报表、多表联查、性能调优,你就得和框架生成的 SQL 搏斗;完全手写(JdbcTemplate),SQL 尽在掌握,可 ResultSet 到对象的搬运能把人累死。
MyBatis 的答案是「半自动」:它不替你写 SQL,只负责把 SQL 与 Java 对象之间那道繁琐的搬运缝起来。你给出 SQL,它负责预编译、参数绑定、结果映射、缓存、事务衔接。

| 方案 | SQL 控制力 | 开发效率 | 学习成本 | 典型场景 |
|---|---|---|---|---|
| JdbcTemplate | 100% 手写,最强 | 低(样板代码多) | 低 | 报表、批量、极简工具 |
| MyBatis | 高(SQL 自己写,可控) | 中高 | 中 | 复杂查询、性能敏感、需要改 SQL |
| JPA / Hibernate | 低(框架生成 SQL) | 高(CRUD 几乎零代码) | 高 | 领域模型清晰、以 CRUD 为主 |
选型没有绝对优劣,只有匹配度。SQL 复杂、要调优、团队熟 SQL 的项目,MyBatis 往往更顺手;模型稳定、以增删改查为主 的项目,JPA 的开发效率更高。下面先看清 MyBatis 这条链上每一层在干什么,再动手整合。
Spring Boot 不会自动带上 MyBatis,需要官方 starter。三件套一次配齐,之后基本不用再碰配置:
<dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.3</version> <!-- 适配 Spring Boot 3.x / JDK 17+ --></dependency><dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope></dependency>接着是 application.yml。这三个配置项是新手最容易漏、又最容易出诡异 bug 的地方:
mybatis: mapper-locations: classpath:mapper/*.xml # XML 放哪,按需扫描 type-aliases-package: com.example.demo.entity # 实体包名,XML 里可省略全限定名 configuration: map-underscore-to-camel-case: true # user_name → userName,必开 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 控制台打印 SQL# 可选:想用 PageHelper 分页插件,见第七节启动类上加上扫描注解,让容器知道去哪个包找 Mapper 接口:
@SpringBootApplication@MapperScan("com.example.demo.mapper") // 一次扫全,免得每个接口都写 @Mapperpublic class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); }}mapper-locations默认是空,XML 必须放对位置才会被加载,否则报Invalid bound statement (not found)map-underscore-to-camel-case: true开启后,数据库的user_name自动映射到实体userName,不写@Results也能对上@MapperScan与逐个接口标@Mapper二选一即可,前者更省事
坑:XML 放在 src/main/java 下的包目录里,编译后默认不会被复制到 target/classes。请把 XML 放到 src/main/resources/mapper/ 下,或给 pom 加 resources 配置,否则一定报 Invalid bound statement。
依赖和配置各有三四种组合,别背——勾一遍,看它们各自生成什么。先勾 pom:只留 MyBatis + MySQL 能得到什么,加上 H2 和 Test 之后测试类能少写几行,scope 又分别落在哪一档:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version> <!-- 版本由 BOM 统管,子依赖不写 version -->
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>demo-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.3</version>
<!-- 第三方 starter:必须写版本 -->
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>再勾 yml:只勾「数据源」是能让 Mapper 跑起来的最小集,加上「日志」才看得见 ===> Preparing 那几行(第八、十一节全都靠它),「profile」则演示开发库与生产库怎么分文件配:
server:
port: 8080
spring:
application:
name: demo-service
datasource:
url: jdbc:mysql://127.0.0.1:3306/bee_order?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: ${DB_USER:root} # ${} 占位符:环境变量优先,冒号后是默认值
password: ${DB_PASS:}
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
max-lifetime: 1740000 # 必须小于 MySQL 的 wait_timeout
pool-name: beeHikari
logging:
level:
root: INFO
com.example.demoservice: DEBUG
org.springframework.jdbc.core.JdbcTemplate: DEBUG # 打 SQL 与参数
file:
name: logs/app.log
logback:
rollingpolicy: { max-file-size: 50MB, max-history: 14 }
这两份产物加起来就是本节那三件套的完整答案。留一个自检动作:每勾一项都问一句「去掉它,哪一行日志或哪条报错会消失」——这比记住配置名有用得多。
先看实体、接口、XML 三件套的完整样子。这是后面所有内容的骨架:
// 实体:字段用驼峰,数据库列名可不一样,靠 map-underscore 或 resultMap 对上public class User { private Long id; private String userName; // DB 列 user_name private Integer status; // getter / setter 省略}public interface UserMapper { User findById(Long id); List<User> findByStatus(@Param("status") Integer status); int insert(User user);}<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"><mapper namespace="com.example.demo.mapper.UserMapper"> <select id="findById" resultType="User"> SELECT id, user_name, status FROM t_user WHERE id = #{id} </select> <select id="findByStatus" resultType="User"> SELECT id, user_name, status FROM t_user WHERE status = #{status} </select> <insert id="insert" useGeneratedKeys="true" keyProperty="id"> INSERT INTO t_user (user_name, status) VALUES (#{userName}, #{status}) </insert></mapper>
上一张讲的是「一条 SQL 走了几步」,这一张回答新手更关心的问题:接口里没有实现类,那到底是谁在替我干活? 七棒接力,把 MapperProxy、SqlSession、Executor、StatementHandler、ResultSetHandler 五个名字一次认全。

接口方法名必须与 XML 里的 id 一致,namespace 必须是接口全限定名——这两条对不上,启动就直接失败。
现在进入本节重点:#{} 与 ${} 的区别不是「一个带引号一个不带」,而是「预编译」与「字符串拼接」的区别。
| 写法 | 底层行为 | 防注入 | 正确用途 |
|---|---|---|---|
#{name} | 生成 PreparedStatement 的 ?,参数走 setXxx | 安全 | 99% 的参数传值 |
${name} | 直接把值拼接进 SQL 字符串 | 有风险 | 表名 / 列名 / 排序字段等结构片段 |
看一个真实的注入现场。下面这段想按前端传来的字段排序:
@Select("SELECT * FROM t_user ORDER BY ${orderBy}")List<User> listOrderBy(@Param("orderBy") String orderBy);如果 orderBy 直接来自请求参数,攻击者传 id; DROP TABLE t_user--,拼接后的 SQL 就变成 ... ORDER BY id; DROP TABLE t_user--。${} 不会预编译,SQL 结构被篡改,注入就此发生。
// 安全写法:排序字段走白名单校验,只允许固定集合里的值,再用 ${} 拼结构private static final Set<String> ALLOWED = Set.of("id", "user_name", "status", "created_at");public List<User> listOrderBy(String raw) { if (raw == null || !ALLOWED.contains(raw)) { raw = "id"; // 非法输入直接回退到安全默认值 } return userMapper.listOrderBy(raw); // 此时 raw 已在白名单内}#{}:参数化查询,数据库把值当「数据」处理,永远无法改变 SQL 语法${}:字符串替换,值会被当作「SQL 结构」的一部分,只能用于表名、列名、排序方向这类无法参数化的位置- 结论一句话:能用
#{}就绝不写${};非用${}不可时,先过白名单
上面这三行是结论,「分岔发生在哪一步」得看动图。注意第 ③④ 帧——SQL 结构在 PreparedStatement 那一刻就已经定死了,之后进来的值只能填进槽位,永远改不动句式;而 ${} 是在第 ① 帧之前就把值抄进了句式本身:

再亲手跑一遍这个实验:切到「注入现场」,看一句 ' OR 1=1 -- 是怎么把 WHERE 条件整段吃掉的;切到「必须拼接的时候」,看 ORDER BY 那个位置为什么 #{} 无能为力——这是本节唯一一处 #{} 真正用不了的地方:
规则只有一条,但能用在哪、不能用在哪才是新手真正会卡住的地方——因为「这里能不能参数化」取决于这个位置在 SQL 语法里扮演什么角色。来玩一局配对:左边是 SQL 里的位置,右边点它该用的写法;配错了当场告诉你原因。
单参数时 MyBatis 直接取,多个参数就得用 @Param 起名,否则只能靠 arg0/param1 这种难记的别名:
// 多参数必须 @Param:否则 XML 里只能用 #{arg0} #{arg1} 或 #{param1} #{param2}List<User> search(@Param("keyword") String keyword, @Param("status") Integer status, @Param("offset") int offset, @Param("size") int size);字段名与列名对不上时,用 resultMap 显式声明;关联查询则用 association(一对一)和 collection(一对多):
<resultMap id="OrderWithItems" type="Order"> <id property="id" column="order_id"/> <result property="orderNo" column="order_no"/> <result property="createdAt" column="created_at"/> <!-- 一对一:订单 → 用户 --> <association property="user" javaType="User"> <id property="id" column="u_id"/> <result property="userName" column="u_name"/> </association> <!-- 一对多:订单 → 明细列表 --> <collection property="items" ofType="OrderItem"> <id property="id" column="item_id"/> <result property="productId" column="product_id"/> <result property="quantity" column="quantity"/> </collection></resultMap><select id="findOrderDetail" resultMap="OrderWithItems"> SELECT o.id AS order_id, o.order_no, o.created_at, u.id AS u_id, u.user_name AS u_name, i.id AS item_id, i.product_id, i.quantity FROM t_order o JOIN t_user u ON u.id = o.user_id LEFT JOIN t_order_item i ON i.order_id = o.id WHERE o.id = #{id}</select>association处理「一个订单属于一个用户」,collection处理「一个订单包含多条明细」- 关键在 SQL:用别名把不同表的同名列区分开(
u_id/item_id),resultMap再按别名归位 - 若字段与列一致且驼峰能对上,完全可以省掉
resultMap,直接resultType
上面的交互演示跑的是 /users/42 这条请求。看一遍你会发现,Mapper 只是链路最末端的一环:Controller 接参 → Service 编排业务和事务 → Mapper 执行 SQL。理解了这条链,你才能想清楚「事务该加在哪一层」。
那条链读起来像七个名词,但它其实是一段可以被单步执行的代码。下面把 userMapper.findById(1L) 摊开:左边七行是真实走过的顺序,右边同步刷新「此刻手里拿着什么」。连点下一步,重点盯第 3 步——那句 Invalid bound statement (not found) 就诞生在那一格:
User u = userMapper.findById(1L); // 你手里只有接口,没有实现类// MapperProxy.invoke(proxy, method, args) // JDK 动态代理是唯一入口// mapperInterface + methodName 拼出 msId // 形如 com.example.UserMapper.findByIdMappedStatement ms = cfg.getMappedStatement(msId); // 拿这条钥匙去找 SQL// SqlSession.selectOne(ms, param) // 执行入口,你平时看不到它// Executor.query(ms, param) // 先问一级缓存,没命中才发 SQL// ResultSetHandler 把行装回 User // 驼峰或 resultMap 在这一格接手| userMapper 实际是 | JDK 代理对象 |
| 方法名 | findById |
| 有没有实现类 | 没有 |
UserService.listUserMapper.findById动态 SQL 是 MyBatis 最实用的特性:同一段 XML 能根据参数拼出不同 SQL。下面按标签逐个给片段,可以直接抄。
动态 SQL 就像填空式的公文模板。你写好一份「兹有 ___ 同志,因 ___ 请假」的模板,盖章前只把当天真正需要的句子填进去;而 <where> / <set> 是那个特别较真的校对员——开头多出来的「AND」、结尾多出来的逗号,它都帮你删掉,免得整份文件语法不通(SQL 报 syntax error)。<foreach> 则像把一串名字用顿号连起来写成「张三、李四、王五」,一旦一个名字都没有,写出来就是空的「()」,读的人当场看不懂(IN () 语法错)。记住这三个角色,标签名就再也不用背了。
随堂第一题,很轻:
<select id="search" resultType="User"> SELECT * FROM t_user WHERE 1 = 1 <if test="keyword != null and keyword != ''"> AND user_name LIKE CONCAT('%', #{keyword}, '%') </if> <if test="status != null"> AND status = #{status} </if></select>test里是 OGNL 表达式,字符串判空要同时判!= null和!= ''WHERE 1 = 1是老写法,用来兜住第一个AND;下一节的<where>会把它优雅地干掉
<choose> <when test="orderBy == 'price'">ORDER BY price ASC</when> <when test="orderBy == 'sales'">ORDER BY sales DESC</when> <otherwise>ORDER BY id DESC</otherwise> <!-- 兜底,避免无排序 --></choose><select id="search2" resultType="User"> SELECT * FROM t_user <where> <if test="keyword != null and keyword != ''"> AND user_name LIKE CONCAT('%', #{keyword}, '%') </if> <if test="status != null">AND status = #{status}</if> </where></select><update id="updateSelective"> UPDATE t_user <set> <if test="userName != null">user_name = #{userName},</if> <if test="status != null">status = #{status},</if> </set> WHERE id = #{id}</update><where>:有内容才加 WHERE,且自动剥掉开头的AND/OR<set>:有内容才加 SET,且自动剥掉结尾多余的逗号<trim prefix="(" suffix=")" prefixOverrides="AND |OR ">是二者的通用形态,需要自定义前后缀时用它
<!-- in 查询:把集合展开成 (1, 2, 3) --><select id="findByIds" resultType="User"> SELECT * FROM t_user WHERE id IN <foreach collection="ids" item="id" open="(" separator="," close=")"> #{id} </foreach></select><!-- 批量插入:一次 INSERT 多条 VALUES --><insert id="batchInsert"> INSERT INTO t_user (user_name, status) VALUES <foreach collection="list" item="u" separator=","> (#{u.userName}, #{u.status}) </foreach></insert>collection:接口传List时写list,传数组写array,@Param命名后写参数名open/close/separator分别控制包裹符号与分隔符,#{id}在foreach内会自动映射到当前项
<sql id="baseColumns">id, user_name, status, created_at</sql><select id="findById" resultType="User"> SELECT <include refid="baseColumns"/> FROM t_user WHERE id = #{id}</select>- 把重复的列清单抽成
<sql>,改一处全局生效 <include>里还能塞<property>做参数化,适合更复杂的复用场景
简单 SQL 直接写在接口上更直观,快速原型阶段很香:
public interface UserMapper { @Select("SELECT id, user_name, status FROM t_user WHERE id = #{id}") @Results(id = "userMap", value = { @Result(column = "id", property = "id", id = true), @Result(column = "user_name", property = "userName"), @Result(column = "status", property = "status") }) User findById(Long id); @Insert("INSERT INTO t_user (user_name, status) VALUES (#{userName}, #{status})") @Options(useGeneratedKeys = true, keyProperty = "id") int insert(User user); @Update("UPDATE t_user SET status = #{status} WHERE id = #{id}") int updateStatus(@Param("id") Long id, @Param("status") Integer status);}注解与 XML 各自的主场很清楚:
| 维度 | 注解写法 | XML 写法 |
|---|---|---|
| 可读性 | 简单 SQL 直观 | 复杂 SQL 层次分明 |
| 动态 SQL | 需 <script> 包裹,很别扭 | 原生标签,天然好用 |
| 复用 | 片段难以复用 | <sql> / <include> 随手抽 |
| 关联映射 | @Results 能写但很挤 | resultMap 清晰 |
| 改 SQL 是否要重新编译 | 要 | 不用,改 XML 即可 |
动态 SQL、复杂关联、需要复用片段 这三种情况,一律用 XML;只有「一两行的简单查询」才考虑注解。别为了少一个文件把三种情况硬塞进注解,那只会让维护成本翻倍。
最原始的方式是手动把分页参数拼进 SQL:
<select id="page" resultType="User"> SELECT * FROM t_user ORDER BY id DESC LIMIT #{offset}, #{size}</select>能跑,但每次都得多写一条 COUNT(*) 查询,参数、偏移量全靠手算,不推荐。更常用的是 PageHelper 插件。
PageHelper 的原理很巧妙:调用查询前一秒写下分页参数,它用一个 ThreadLocal 把参数存起来,再通过 MyBatis 的拦截器改写即将执行的 SQL(自动追加 LIMIT 并额外跑一次 COUNT),查询结束后清理 ThreadLocal。
// 1. 依赖:pagehelper-spring-boot-starter(版本与 Boot 对齐)// 2. 用法:紧挨着查询,中间不能夹别的 SQLPageHelper.startPage(1, 10); // 第 1 页,每页 10 条List<User> list = userMapper.search(null, 1, null, null);PageInfo<User> pageInfo = new PageInfo<>(list); // 包装出总记录数、总页数System.out.println(pageInfo.getTotal());System.out.println(pageInfo.getPages());startPage与真正的查询必须紧挨,中间插入任何别的查询都会让分页参数「串味」PageInfo会读ThreadLocal里那次 COUNT 的结果,算出total/pages- 切记:
PageHelper.startPage只对紧随其后的第一条 SQL 生效
「只对第一条生效」这句话,只有把改写过程摊开看才会真的相信。下面这五格点着走一遍,重点在第 ① 格(参数存在哪)和第 ⑤ 格(什么时候被清掉)——它俩共同决定了「中间夹一条别的 SQL 会发生什么」:
当数据量到百万级以上,LIMIT offset, size 的深分页会越来越慢(要扫过前面所有行)。这时用游标分页,靠主键做「下一页」的锚点:
<!-- 游标分页:WHERE id < #{lastId} ORDER BY id DESC LIMIT #{size} 永远只扫 size 行,翻到第几页都一样快 --><select id="nextPage" resultType="User"> SELECT * FROM t_user <where> <if test="lastId != null">AND id < #{lastId}</if> </where> ORDER BY id DESC LIMIT #{size}</select>坑:游标分页换不来「跳页」。它适合信息流、订单列表这种「一直往下看」的场景;需要精确跳到第 N 页的后台表格,仍得用 LIMIT offset 或 PageHelper。
分页之外,MyBatis 还有一个真数值旋钮,而且它的代价上一篇刚见过:一条没人管的慢 SQL 会一直攥着连接,直到连接池被拖干(第 28 篇第十二节那个画面)。default-statement-timeout 就是给每条语句上的一道保险丝,单位是秒、默认 0 表示完全不设限。拖一遍看它在哪个区间最有用:
- 5~10 秒是绝大多数在线系统的落点
- 超过就中断:连接立刻归还,池子不会被一条语句拖干
- 失败是显式的、带语句的,日志里能直接看到是哪条 SQL 超时
- 配套还要有 MySQL 侧的 long_query_time,两边一起看才有因果
MyBatis 有两级缓存,默认行为差异极大,踩坑和它俩脱不了干系:
| 级别 | 作用域 | 默认 | 开关 | 典型坑 |
|---|---|---|---|---|
| 一级缓存 | 同一个 SqlSession | 开启 | 无法关闭(仅可调整级别) | 同会话读到旧数据 |
| 二级缓存 | 同一个 namespace(跨 SqlSession) | 关闭 | <cache/> 或 @CacheNamespace | 分布式多节点各缓存一份,改库后互相脏读 |
两级缓存的差别全在「作用域」这一个词上:一级跟着 SqlSession 走(Spring 下≈一个事务),二级跟着 namespace 走(跨会话、但只在本进程内)。把左右两栏分清楚,下面那两类「诡异行为」就各自有了归属:

一级缓存藏在 SqlSession 里。同一个 SqlSession 中,相同 SQL 与参数只查一次,第二次直接命中缓存。这在同一个事务里通常没问题,但有三个触发失效/脏读的场景要牢记:
// 场景:同一 SqlSession(Spring 中同一事务)内User u1 = mapper.findById(1L); // 查库,写入一级缓存User u2 = mapper.findById(1L); // 命中一级缓存,不再查库// 该缓存会被清除的情况:// 1) 执行了任意 insert / update / delete(flushCache)// 2) 显式调用 sqlSession.clearCache()// 3) SqlSession 关闭(事务结束)真正的坑在于:你以为每次调用 Mapper 都会重新查库,其实在同一个事务里,第二次读的是缓存。如果中间有别的线程改了这行数据,你会读到旧值——这就是所谓「同一 SqlSession 脏读」。
二级缓存默认关闭。开启后,同一 namespace 的查询结果会被缓存到跨 SqlSession 的地方,能省下跨请求的重复查询。但在集群部署下,每个节点各有一份缓存,A 节点改了数据,B 节点的二级缓存不会自动失效,于是读到脏数据。所以:
- 单机、读多写少、且能接受短时不一致的字典类数据,才考虑开二级缓存
- 集群环境下,优先用 Redis 这类集中式缓存,而不是本地二级缓存
- 无论哪级缓存,只要涉及「必须读到最新值」的写后立即读,就显式清缓存或直接绕过
随堂第二题,这题要求你把「事务边界」和「缓存边界」对上(很多人工作三年也没意识到它们是同一件事):
坑一:map-underscore-to-camel-case 没开,字段全是 null。
数据库列是 user_name,实体属性是 userName。开关没打开时,MyBatis 按列名精确匹配,找不到 userName 这个列,属性就一直是 null。表里有数据、SQL 也查到了行,但对象字段全空——排查半天以为是 SQL 问题,其实是映射开关。记住:统一开启该开关,或老老实实写 resultMap。
坑二:排序字段用 #{} 无效、用 ${} 有风险。
ORDER BY #{orderBy} 会被预编译成 ORDER BY ?,数据库把 ? 当成一个「常量值」,排序直接失效(甚至报错)。可换成 ${orderBy} 又暴露了注入面。唯一正解是白名单:只允许提前定义好的列名集合通过,其余一律回退默认值,见第三节的 ALLOWED 写法。这条规则同样适用于动态表名、动态查询列。
任何时候看到 ${},都要立刻自问一句「这个值用户能控制吗」。能,就必须白名单;不能,才勉强可用。
这一节的四个实验跑的是真内核(WASM),点一下参数就重跑一遍。建议顺序:先用第一个建立「接口 → SQL」的整体感觉,再用第二个看动态 SQL 怎么拼,第三个看缓存为什么会在同一个事务里骗你,第四个看分页插件到底改写了什么。
每个参数你要盯住的东西不一样:
| 参数 | 你会看到 | 对应正文小节 |
|---|---|---|
| 一次查询全链路 | MapperProxy → SqlSession → Executor → StatementHandler → ResultSetHandler 的完整接力 | 第三节的动图 |
| 动态 SQL 拼装 | <if> / <where> 根据参数决定片段进不进最终 SQL | 第五节 |
| 一级缓存 | 同一 SqlSession 内第二次相同查询不再发 SQL | 第八节 |
| 二级缓存 | 跨 SqlSession 命中 namespace 级缓存,写入会整块清空 | 第八节 |
| 分页插件改写 SQL | 原 SQL 被追加 LIMIT,并额外跑一次 COUNT | 第七节 |
把「一级缓存」和「分页插件」连着看两遍,你会顺手理解两件常被混在一起的事——PageHelper 的 ThreadLocal 只在第一条 SQL 上生效,而一级缓存的作用域是 SqlSession(≈一个事务)。这两条性质解释了 MyBatis 大部分「诡异行为」。
MyBatis 接管的工作,在 JdbcTemplate 时代是你自己写的。切到「ResultSet → 对象」看逐列取值的样板代码,再切到「忘记释放会怎样」看连接怎么漏——这就是「半自动 ORM」到底帮你省掉了哪几层。
Mapper 只负责产生 SQL,SQL 还得借一条连接才能跑。把这条实验切到「达到上限排队」和「等待超时」,你会看到:只要有一条 foreach 展开成上千个 IN 值的查询长期占着连接,整个池就开始排队、最后大面积超时。MyBatis 的性能问题,症状常常出现在连接池上。
<update> 返回的影响行数是 0,不代表「SQL 失败」——它可能只是没匹配到行;而如果外层方法抛异常回滚,你前面成功的更新也会一起消失。切到「回滚规则」这个参数,观察 rollbackFor 与受检异常的组合:
这三层关系值得记牢——Mapper 产生 SQL → 连接池提供连接 → 事务决定这些改动是否算数。诊断任何数据访问问题,都按这三层从上往下剥。
实验按到最后,换成命令行自己敲一遍。下面这台控制台连着浏览器里的同一个内核,回答全部由内核算出来:先 beans 看 Mapper 是不是真的被注册成了 Bean,再用 cond 把条件开关拨下来看它如何让位,最后逐条 lab 把本篇那三个主题(预编译、动态 SQL、一级缓存)敲出来:
lab mybatis l1 之后紧接着 lab pool timeout 是值得的一组:前者演示「同一个 SqlSession 里第二条相同查询不再发 SQL」,后者演示「一条长期占住连接的 SQL 怎么把整个池拖干」。同一个「少发一条 SQL」的愿望,一个在帮你省,一个在替你埋雷。
| 报错原文(片段) | 真实原因 | 30 秒自救 | 深挖看第几篇 |
|---|---|---|---|
org.apache.ibatis.binding.BindingException: Invalid bound statement (not found) | 接口方法与 XML 没绑上:namespace 写错、id 与方法名不一致、XML 没被 mapper-locations 扫到,或 XML 放在 src/main/java 下没被打包 | 三查:① namespace 是否等于接口全限定名 ② id 是否等于方法名 ③ 编译产物 target/classes/mapper/ 里到底有没有那个 XML | 本篇第二、三节 |
java.sql.SQLSyntaxErrorException: ... near 'DROP TABLE' / 语法错误 | ${} 把外部输入拼进了 SQL 结构(SQL 注入现场) | 把该处改成 #{};确需动态表名/列名则先在服务端过白名单,见第三节 ALLOWED | 本篇第三节 |
SQLSyntaxErrorException: ... near ')',日志里出现 WHERE id IN () | <foreach> 收到的集合为空,open="(" close=")" 照样输出,于是生成空的括号 | 调用前判空并短路返回空列表;或用 <if test="ids != null and ids.size() > 0"> 包住整个 IN (...) 段 | 本篇 5.4 |
不报错,但实体字段全是 null(表里明明有数据) | map-underscore-to-camel-case 没开,或 resultMap 里 column 写成了实体属性名 | 先开驼峰开关;跨表/别名场景检查每个 <result column="..."/> 是否与 SQL 里的列别名完全一致 | 本篇第九节 |
TooManyResultsException: Expected one result (or null) to be returned by selectOne(), but found: N | 方法返回单个对象,可 SQL 实际返回多行(常见于 join 后行数放大或忘了加唯一条件) | 返回类型改成 List<T>,或让 SQL 保证至多一行(唯一索引 / LIMIT 1) | 本篇第四节 |
org.apache.ibatis.exceptions.PersistenceException: Parameter 'status' not found. Available parameters are [arg1, arg0, param1, param2] | 多参数方法没加 @Param,XML 里只能拿到 arg0/param1 这类别名 | 给每个参数补 @Param("status");用对象作参数则直接写属性名 | 本篇第四节 |
BindingException: Error evaluating expression 'keyword != null'. Cause: ... OgnlException | <if test> 里引用了未声明的参数名,OGNL 找不到变量 | 核对 @Param 名称与 test 表达式中的名字大小写;字符串判空要写 keyword != null and keyword != '' | 本篇第五节 |
| 集群部署后读到旧数据,重启某个节点又对了 | 本地二级缓存各存一份,A 节点写入不会失效 B 节点的缓存 | 关掉 <cache/>,改用 Redis 等集中式缓存;字典类数据也建议加过期时间 | 本篇第八节 |
这张表里最阴的两行根本不报错(字段全 null、读到旧数据)。它们的共同解法是同一件事:打开 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl,永远以「SQL 真的发了几条、参数是什么、回来的行是什么」为准。
表里第一行的报错,新手往往读不懂它在说什么——它其实已经把答案写在文案里了。下面这段是真实堆栈,先别看解析,点出你认为的凶手行:
照着教程写完 Mapper 与 XML,启动正常、注入也没问题,一调 findById 就抛 BindingException。新人第一反应是「MyBatis 有 bug」。
目标:跑通「接口只有一行、SQL 全在 XML」的标准结构,并亲眼看见动态 SQL 拼出三种不同语句。四份文件全部给出,可直接抄。
package com.example.demo.entity;public class User { private Long id; private String userName; // DB 列 user_name private Integer status; // getter / setter / toString 省略}package com.example.demo.mapper;import com.example.demo.entity.User;import org.apache.ibatis.annotations.Mapper;import org.apache.ibatis.annotations.Param;import java.util.List;@Mapperpublic interface UserMapper { User findById(Long id); List<User> search(@Param("keyword") String keyword, @Param("status") Integer status); List<User> findByIds(@Param("ids") List<Long> ids); int batchInsert(@Param("list") List<User> list); int updateSelective(User user);}src/main/resources/mapper/UserMapper.xml(注意:一定放 resources 下,放 java 目录里就会 Invalid bound statement):
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"><mapper namespace="com.example.demo.mapper.UserMapper"> <sql id="baseColumns">id, user_name, status</sql> <select id="findById" resultType="User"> SELECT <include refid="baseColumns"/> FROM t_user WHERE id = #{id} </select> <select id="search" resultType="User"> SELECT <include refid="baseColumns"/> FROM t_user <where> <if test="keyword != null and keyword != ''"> AND user_name LIKE CONCAT('%', #{keyword}, '%') </if> <if test="status != null">AND status = #{status}</if> </where> ORDER BY id DESC </select> <select id="findByIds" resultType="User"> SELECT <include refid="baseColumns"/> FROM t_user WHERE id IN <foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach> </select> <insert id="batchInsert"> INSERT INTO t_user (user_name, status) VALUES <foreach collection="list" item="u" separator=",">(#{u.userName}, #{u.status})</foreach> </insert> <update id="updateSelective"> UPDATE t_user <set> <if test="userName != null">user_name = #{userName},</if> <if test="status != null">status = #{status},</if> </set> WHERE id = #{id} </update></mapper>驱动它的测试类:
@SpringBootTestclass UserMapperTest { @Autowired UserMapper mapper; @Test void dynamicSql() { mapper.search(null, null); // ① 无筛选 mapper.search("ali", null); // ② 只有关键词 mapper.search(null, 1); // ③ 只有状态 }}预期控制台 SQL 日志(开了 log-impl 才有;形状必须一致):
===> Preparing: SELECT id, user_name, status FROM t_user ORDER BY id DESC===> Parameters: <>===> Preparing: SELECT id, user_name, status FROM t_user WHERE user_name LIKE ? ORDER BY id DESC===> Parameters: %ali%(String)===> Preparing: SELECT id, user_name, status FROM t_user WHERE status = ? ORDER BY id DESC===> Parameters: 1(Integer)===> Preparing: INSERT INTO t_user (user_name, status) VALUES (?, ?), (?, ?), (?, ?)===> Parameters: alice(String), 1(Integer), bob(String), 1(Integer), cindy(String), 0(Integer)===> Preparing: UPDATE t_user SET status = ? WHERE id = ?===> Parameters: 2(Integer), 1(Long)对着日志确认四件事:
- ①里 连 WHERE 都没有 —— 这就是
<where>的行为,不是 bug - ②③里开头的
AND被剥掉了,且参数一律是?(#{}的功劳) <sql>+<include>让三处列清单只维护一份UPDATE只出现了status,因为<set>只拼非空字段并删掉了尾逗号
- 把
map-underscore-to-camel-case改成false重跑 → 你会观察到:<=== Row明明有数据,但toString()里userName=null。这就是坑一的现场,比读十遍文字都管用。 - 把
search的<where>换成硬写的WHERE 1 = 1→ 你会观察到:结果一样,但如果哪天把AND前缀忘了加,SQL 立刻语法错。体会<where>到底替你兜了什么。 - 给
findByIds(List.of())传一个空集合 → 你会观察到:日志出现WHERE id IN ()并抛SQLSyntaxErrorException。修法自己试:加<if test="ids != null and ids.size() > 0">包住,或在 Service 里判空短路。 - 把
ORDER BY ${orderBy}加进search,然后传入id; DROP TABLE t_user--→ 你会观察到:日志里 SQL 结构真的变了。跑完请立刻删掉这行代码,并在笔记里写下「${}只能接白名单值」这条铁律。 - 把
findById的返回类型从User改成List<User>却仍写SELECT * FROM t_user(不加WHERE)→ 反过来观察TooManyResultsException长什么样,以后见到就能一眼认出。
需求:实现 GET /admin/orders?keyword=&status=&page=1&size=20&sortField=id&order=desc,用 MyBatis 完成多条件筛选 + 分页 + 排序。验收清单:
- [ ] Mapper 接口只有一个方法,SQL 全在 XML;筛选条件用
<where>+<if>,不许出现WHERE 1=1 - [ ] 排序字段走服务端白名单(
Set<String> ALLOWED),非法值回退id;顺序只允许ASC/DESC两个字面量,用<choose>表达 - [ ] 分页有两种实现并各自说明适用场景:PageHelper(
startPage紧贴查询)与游标分页(WHERE id < #{lastId}),文档里写清「跳页」需求该选哪个 - [ ] 状态多选参数用
<foreach>展开,并且空集合时不发 SQL,直接返回空页 - [ ] 打开
log-impl,把上面每种组合实际产生的 SQL 贴进 README(至少 4 条:无条件 / 单条件 / 多条件+排序 / 游标翻页) - [ ] 压测 100 并发调这个接口,同时抓
hikaricp.connections.pending与 MySQL 慢查询日志,判断瓶颈在 SQL、映射还是连接池,并写 5 行结论 - [ ] 额外加分:把某条查询配成二级缓存
<cache/>,然后在两个实例间改一次数据,复现「另一个节点读到旧值」的现象,再解释你为什么在生产选择 Redis 而不是本地二级缓存
做完这三档,你对 MyBatis 的掌握就从「会写标签」升级到「知道它每一步在干什么、出错时知道往哪一层查」。
Invalid bound statement (not found) 出现时,你的三条排查线索分别是什么?答:namespace 是否等于接口全限定名、id 是否等于方法名、编译后的 target/classes 里有没有那个 XML。
#{} 和 ${} 的本质差别用一句话说?答:前者是预编译参数(值进不了句式),后者是字符串拼接(值能改句式)。
<where> 在没有一个 <if> 成立时输出什么?答:什么都不输出,连 WHERE 关键字都不加——所以不需要 WHERE 1=1。
同一个事务里第二次调用 findById(1L) 会不会发 SQL?答:默认不会,一级缓存在 SqlSession 内命中;这也是「读不到别人刚提交的值」的根因。
集群环境下为什么不敢开二级缓存?答:每个 JVM 各存一份,A 节点写入不会失效 B 节点的缓存,于是脏读。集中式缓存才是答案。
接口点菜、XML 掌勺,驼峰不开字段全空;#{} 填数、${} 改句,非用不可先过白名单;<where> 清头、<set> 去尾,<foreach> 遇空别硬撑;一级缓存跟事务走,二级缓存别上集群。
MyBatis 的全部功力可以浓缩成四句话——能 #{} 就别 ${},非用 ${} 先过白名单;动态 SQL 靠标签拼,复杂查询交给 XML;关联映射用 resultMap,字段对不上先查驼峰开关;缓存默认只信一级,集群下别指望二级。把这四句记牢,绝大多数 MyBatis 的坑你都能提前绕开。