读完这篇,你能做什么
能沿 Mapper 参数、XML 与结果映射排查问题,并拒绝不可信 SQL 拼接。
学习理由、准备要求与知识点
为什么学它
映射框架连接 Java 参数、SQL 和结果对象,任一环不匹配都会造成请求时故障。
先读这些内容
知识点清单
- 参数占位与预编译
${} 文本替换风险- 动态结构白名单
- namespace
/id对应 - Param 名称绑定
- 字段与
resultMap - 动态空条件和空集合
- 分页 SQL 验证
运行前先确认Java 17、MyBatis 3.5.19,JDBC 参数语义以目标驱动为准。源码定位 DefaultParameterHandler;算法只生成带占位符的隔离教学 SQL 计划,不执行删除,也不实现完整 XML 动态 SQL 或结果集映射。
01先弄懂它是什么
MyBatis 把一次 Java 方法调用接到数据库查询上:先按 Mapper 方法找到映射语句,再展开动态 SQL、绑定参数、交给 JDBC 执行,最后把结果填进返回对象。中间任一名称或类型没对上,都可能让你看到“SQL 没错,调用却失败”或“查到了行,字段却是空”。排查时沿这条顺序逐步核对。
#{id} 通常把值放进问号参数中;${column} 则把文字直接替换进 SQL,会改变语句结构。不能把用户文本随意传给后者。排序列名也不能用普通值参数代替:应把用户选择映射到服务端预先允许的 SQL 分支。先区分数据值和 SQL 结构,比试图用复杂转义补救任意拼接更可靠。
从 Mapper 接口到返回对象,各处名称要对得上:namespace 和语句 id 决定找到哪条 SQL,@Param 名称与 DTO 属性决定从哪里取参数,结果映射决定把哪一列填到哪个 Java 字段。
列名和属性名不同时,可以用别名或 resultMap;自动驼峰映射还要看配置是否开启。SQL 在数据库工具里查到数据,只能证明那条语句可执行,不能证明这次 Java 调用的参数和返回字段也正确。
文中这几个词,先认一认
- MappedStatement
MyBatis中一条映射语句的定义,记录 SQL、参数处理和结果转换的配置。- BoundSql
- 某次调用实际生成的 SQL,以及按顺序绑定的参数信息和动态生成的附加参数。
- TypeHandler
- 负责在 Java 值与 JDBC 参数或查询结果之间转换的组件。
- 动态条件
- 按输入决定是否加入的 SQL 条件,要提前处理
null、空集合以及不传条件时查多大范围。
02看看它怎么工作
以按商户名称查询为例:MyBatis 先记下方法参数的名称和值,再根据输入拼出本次使用的 SQL,形成 BoundSql。每个 #{name} 都会对应参数列表中的一个位置。JDBC 语句准备好后,参数处理器按这个顺序取值,再让 TypeHandler 转成驱动需要的形式,依次填入第 1、第 2 个问号。
读日志时要分开看 SQL 模板和参数值;日志为了方便阅读拼出的文字,不一定就是实际发送和执行的形式。
动态 SQL 的空条件尤其影响写操作。批量删除的 ID 集合为空时,如果条件节点直接消失,剩下的语句可能扩大为全部记录。正确行为应在契约中选择拒绝、返回零影响或显式不匹配,而不是依赖 XML 恰好生成一个语法错误。数据归属条件也不能因可选参数为空而一起被省略。
结果映射需要检查实际列标签、属性类型与空值语义。数据库 NULL 映射到基本类型和包装类型会有不同限制,聚合查询的别名也必须对应接收结构。排障时用一个可手算输入,核对语句标识、最终 SQL、参数类型、返回列和 Java 结果,逐层定位,避免只在实体类随意添加字段。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart TD
A["Mapper 方法与参数"] --> B["定位 MappedStatement"]
B --> C["展开动态 SQL"]
C --> D["BoundSql 与有序参数映射"]
D --> E["TypeHandler 绑定 JDBC 参数"]
E --> F["数据库执行"]
F --> G["读取列标签与值"]
G --> H["结果映射到 Java 类型"]按顺序看,每一步做了什么
- 确认 namespace、方法与语句 id 的对应关系。
- 检查动态条件展开后的 SQL 是否保留必要约束。
- 逐项核对参数名、来源、索引、Java 类型与 JDBC 类型。
- 核对列别名和结果映射,再用空条件与边界输入验证。
03对照实现代码
MyBatis 3.5.19 · org.apache.ibatis.scripting.defaults.DefaultParameterHandler#setParameters
已核验真实文件。下面为原创 Map 参数局部模型,保留附加参数优先和有序绑定;省略 OUT 参数、简单类型整体绑定、MetaObject 与 JDBC 空值类型政策,不是完整 MyBatis 实现。
import java.util.*;
class SourceModel {
interface Binder { void bind(int jdbcIndex,Object value); }
static void bind(List<String> properties,Map<String,Object> input,
Map<String,Object> additional,Binder binder) {
for(int i=0;i<properties.size();i++) {
String name=properties.get(i); Object value;
if(additional.containsKey(name)) value=additional.get(name); // 动态展开的附加参数优先
else if(input==null) value=null;
else {
if(!input.containsKey(name)) throw new IllegalArgumentException("未定义参数: "+name);
value=input.get(name); // 已定义但值为 null,与缺失名称不同
}
binder.bind(i+1,value); // JDBC 参数索引从 1 开始
}
}
}真实的 setParameters 会按 BoundSql 中的 ParameterMapping 顺序处理输入参数,跳过只负责输出的 OUT 参数。取值时先找动态 SQL 生成的附加参数,再检查整个参数对象是否为空、是否能按简单类型整体处理,最后才可能通过 MetaObject 读取对象属性。
所以排查 foreach 的值从哪里来时,要看展开后的附加参数和映射列表,不能只盯着 Mapper 方法最外层的参数名。
取到值后,参数映射给出 TypeHandler 与 JDBC 类型。值为 null 且未明确 JDBC 类型时还会使用配置中的空值类型政策。参数名正确并不保证驱动就能正确表达空值,尤其在跨数据库或自定义类型时,需要把 Java 类型、JDBC 类型和处理器一起核对。
TypeHandler.setParameter 最后拿到 i+1 这个 JDBC 参数位置,所以 SQL 里的问号顺序必须与参数列表一致。动态 SQL 改了问号顺序、参数却没同步,可能不报语法错而是把值填错地方。后面的算法把 SQL 和参数作为一份结果一起返回,避免各改各的。
#{ } 展开与参数绑定发生在不同阶段,DefaultParameterHandler 不负责解析整份 XML,也不负责结果集映射。阅读这个方法能解释输入方向,但返回字段为空仍应回到 resultMap、列标签和类型处理路径。局部源码解释应明确覆盖范围,而不是把一个处理器称为所有 MyBatis 行为的入口。
代码先调用谁,再调用谁
- 动态
SqlSource为当前调用形成BoundSql。 StatementHandler准备 JDBC 语句。DefaultParameterHandler按映射顺序取值。TypeHandler使用一基索引绑定参数,执行后进入结果映射。
04动手写一个例子
生成不会丢失归属条件的批量删除计划
输入可信当前用户编号与 1 至 100 个正订单编号,去掉重复编号但保留首次顺序,生成固定模板 DELETE FROM learn_order WHERE user_id = ? AND id IN (...) 及参数列表。空集合、null、非法编号或超出上限都拒绝;只生成计划,不执行 SQL。
- 输入 1
user=7, ids=[10,11]- 输出 1
三个问号,参数[7,10,11]
- 输入 2
user=7, ids=[10,10,11]- 输出 2
参数[7,10,11]
- 输入 3
user=7, ids=[]- 输出 3
IllegalArgumentException
- 输入 4
user=7, ids=[-1]- 输出 4
IllegalArgumentException
先验证请求规模与所有编号,再构建去重集合,避免生成一半 SQL 后才发现非法输入。所有权编号必须来自可信上下文,算法接收它并不替调用方完成认证。
用 LinkedHashSet 在去重时保留首次顺序,再为每个保留编号生成一个问号。SQL 字符串中只出现固定结构和占位符,不插入编号文本;参数列表先放用户编号,再按相同顺序放订单编号。
计划同时返回 SQL 与不可变参数列表。空集合直接拒绝,因此没有任何分支会省略 IN 条件后继续执行。main 验证正常、重复、最大 long、空集合、非法编号和数量上限,并核对问号个数等于参数数。
为什么这样写是对的所有返回计划都同时具有用户归属条件与至少一个订单条件;每个问号恰好对应同位置参数,没有用户值进入 SQL 结构。
n 个编号经过验证与哈希去重,模板及参数输出随去重后的编号数增长。数据库真正执行删除的成本不包含在计划构造中。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.*;
public class Main {
record Plan(String sql,List<Long> values) {}
static Plan build(long user,List<Long> ids) {
if(user<=0 || ids==null || ids.isEmpty() || ids.size()>100)
throw new IllegalArgumentException("批量范围不合法");
LinkedHashSet<Long> unique=new LinkedHashSet<>();
for(Long id:ids) {
if(id==null || id<=0) throw new IllegalArgumentException("非法订单编号");
unique.add(id); // 去重同时保留首次出现的绑定顺序
}
StringJoiner marks=new StringJoiner(",");
List<Long> values=new ArrayList<>(); values.add(user); // 归属条件始终在第一个位置
for(Long id:unique) { marks.add("?"); values.add(id); }
String sql="DELETE FROM learn_order WHERE user_id = ? AND id IN ("+marks+")";
return new Plan(sql,List.copyOf(values)); // 模板与不可变参数一起交付
}
static void check(boolean ok) { if(!ok) throw new AssertionError(); }
static void fails(Runnable action) {
try { action.run(); throw new AssertionError("应拒绝输入"); }
catch(IllegalArgumentException expected) { /* 错误路径 */ }
}
public static void main(String[] args) {
Plan normal=build(7,List.of(10L,11L));
check(normal.sql().equals("DELETE FROM learn_order WHERE user_id = ? AND id IN (?,?)"));
check(normal.values().equals(List.of(7L,10L,11L)));
check(build(7,List.of(10L,10L,11L)).values().equals(List.of(7L,10L,11L)));
check(build(7,List.of(Long.MAX_VALUE)).values().equals(List.of(7L,Long.MAX_VALUE)));
fails(()->build(7,List.of())); fails(()->build(7,List.of(-1L)));
fails(()->build(0,List.of(1L))); fails(()->build(7,Collections.nCopies(101,1L)));
check(normal.sql().chars().filter(c->c=='?').count()==normal.values().size());
System.out.println("PASS 9 SQL plan assertions");
}
}这些特殊情况也要试一试
- 空 ID 列表绝不能退化为只有用户条件甚至没有 WHERE 的写操作。
- 重复编号政策公开为保留首次顺序,不影响参数对应。
- 编号类型正确不证明调用方有权限,真实执行仍需可信身份与业务授权。
进阶选读 · 生产拓展:大批量拆分与事务边界
场景大量 ID 超过驱动、数据库或业务可接受的单次限制,需要分批处理。
做法先定义是否要求整批原子、是否允许部分成功和如何重试,再按边界拆批;每批保留完整归属条件与稳定业务标识,并记录实际影响行数。
复杂度计划构建总体可保持期望 O(n),但每批数据库扫描、锁持有和往返成本需分别计算。
好处与代价大事务便于表达整体结果,却持锁更久;小批次降低单次压力,但需要更复杂的部分成功和恢复协议。
05项目里怎么用
动态 SQL 的检查应围绕真实边界输入:空串、null、空集合、带引号文本和重复值。读取全部与拒绝请求是不同业务选择,写操作尤其应默认保护范围。分页插件或拦截器会继续改写 SQL,需要查看真正执行的语句与总数查询,不只看 XML。
结果映射建议对重要查询使用清晰列清单和别名,避免 SELECT 星号让表结构变化悄悄影响外部结果。自定义 TypeHandler 应覆盖 null、异常和驱动类型差异。映射测试需要实际列标签与 Java 属性证据,不能仅用 mock 返回预制实体。
SQL 日志有助排查,但参数可能含个人数据和秘密,需要按环境脱敏。测性能还应记录参数分布与数据量,同一 SQL 模板在不同筛选条件下可能成本差很多。参数绑定只解决数据怎样安全传进 SQL,不负责索引、事务或用户权限。
容易踩的坑
- 把客户端排序字段直接放进
${}。 - 用 ORDER BY #{column} 期待值参数变成列名。
- 空集合让动态 WHERE 片段被省略后执行写操作。
- 只验证 SQL 返回行,未验证 Java 结果字段与类型。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
显式 SQL 与 resultMap | 映射关系清楚,适合复杂查询审查。 | 需要维护列与属性对应。 | 聚合、连接和长期稳定契约。 |
| 自动映射与命名约定 | 减少简单 CRUD 配置。 | 依赖一致命名与明确框架设置。 | 结构简单且有映射测试的查询。 |
| 受控动态 SQL | 表达可选条件与批量结构。 | 必须覆盖空值组合和范围安全。 | 确有多种条件组合且输入政策明确的接口。 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01#{ } 与 ${ } 的根本区别是什么?
展开参考答案与推导
前者通常表达 JDBC 值参数与映射,后者直接替换 SQL 文本结构。结构位置不能靠普通值绑定伪装,应从可信固定选项选择。
02为什么 TypeHandler 需要知道 JDBC 类型?
展开参考答案与推导
Java 值和数据库参数表达不完全相同,尤其是 null 和自定义类型。处理器需要合适的 JDBC 方式将值交给驱动。
03SQL 在工具里正确为何 Java 字段仍为空?
展开参考答案与推导
还要检查 Mapper 标识、实际参数、返回列标签、属性类型和 resultMap 或自动映射配置。数据库执行只是链路中一段。
换个情况,还会处理吗
01批量删除偶尔删多了,优先检查什么?
展开参考答案与推导
检查空 ID 与动态条件省略,核对最终执行 WHERE 和可信归属条件;保留输入规模、语句模板与影响行数证据,避免直接扩大权限或加重试。
02foreach 内部参数无法找到,怎样读源码定位?
展开参考答案与推导
检查动态展开的附加参数及 ParameterMapping 名称,DefaultParameterHandler 会优先读取 BoundSql 的附加参数,不只看外层 @Param。
03带引号名称查询失败,是否应手工删除引号?
展开参考答案与推导
先确认是否错误使用文本拼接或 ${}。合法名称可能包含引号,应作为值绑定并由驱动处理,而不是随意改变用户数据。
写代码试一试
01保证占位符与参数列表始终对应。
- 输入 / 条件
user=7,ids=[10,11]- 输出 / 预期
问号数3且参数顺序[7,10,11]
提示 两者在同一次构造中生成。
展开参考答案与推导
先固定用户条件,再按有序去重结果同时追加问号与参数;写断言检查数量和顺序,不分别在两个方法里推导。
02对读取接口的空 ID 列表定义另一种安全行为。
- 输入 / 条件
ids=[]- 输出 / 预期
直接返回空结果,不发送不受限查询
提示 读取与删除可有不同契约。
展开参考答案与推导
在 Mapper 调用前显式处理空集合,或者生成明确不匹配条件;不可把它默认为查全表而不告知调用方。
03增加白名单排序选项。
- 输入 / 条件
sort=PRICE_DESC- 输出 / 预期
固定片段 ORDER BY price DESC
提示 外部枚举映射到服务端预定义结构。
展开参考答案与推导
通过 switch 或枚举返回有限 SQL 片段,未知选项拒绝。值条件仍使用绑定参数,白名单只处理确实需要变化的结构。
读完之后,独立完成
课后作业:自己做一遍
本篇共 12 道作业,先完成 9 道基础与综合任务,再按前置知识挑战进阶题。其中包含 2 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能沿 Mapper 参数、XML 与结果映射排查问题,并拒绝不可信 SQL 拼接。
本篇重点加练
重点加练参数绑定、结果映射和动态边界,覆盖 SQL 能执行却违反业务合同的情况。
- 参数占位与预编译
- 字段与
resultMap - 动态空条件和空集合
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01原理问答
查询条件包含姓名值、排序列和排序方向。分别说明三者如何进入最终 SQL,并针对输入姓名 O'Brien、排序列 unknown 写出需要验证的合同。
- 提交内容
- SQL 结构与参数边界图、测试样本。
- 怎样算完成
- 数据值与 SQL 结构分开
- 允许的动态结构有完整范围
练到的知识点参数占位与预编译 · ${} 文本替换风险 · 动态结构白名单
- 作业 02排错实验
在最小 Mapper 中定义按
tenantId与userId查询,依次制造 namespace、方法 id 和参数名称不匹配;每次只保留一个故障并记录调用入口及异常。- 提交内容
- Mapper
/XML、三组错误记录和修复后测试。 - 怎样算完成
- 绑定链可追溯
- 故障之间互不污染
练到的知识点namespace/id 对应 · Param 名称绑定
综合应用
- 作业 03编程实践
为
user_id、display_name、status_code三列建立明确结果映射,实现查询 DTO;测试包含null列值、无记录和两条不同记录,核对每个字段。- 提交内容
- 本地 Mapper、隔离建表脚本、自写测试及运行证据。
- 怎样算完成
- 数据只写练习表
- 按字段验证映射
- 无记录合同明确
练到的知识点Param 名称绑定 · 字段与 resultMap
- 作业 04设计评审
租户管理批量查询接收 ids 和分页参数,ids 可能为空、含重复或达到五千项。设计空条件、排序平价和分页规模合同,并说明如何验证生成 SQL。
- 提交内容
- 接口合同、SQL 形状表和验收计划。
- 怎样算完成
- 始终限定租户归属
- 空集合政策可验证
- 分页顺序可稳定复现
练到的知识点动态空条件和空集合 · 分页 SQL 验证
- 作业 05面试场景
数据库工具手工执行 SQL 有结果,接口却返回字段缺失或分页数量不符。建立参数、SQL、数据库行、结果对象四层证据链,并按最能缩小问题范围的顺序安排排查步骤。
- 提交内容
- 排查树与每层需要保留的脱敏证据。
- 怎样算完成
- 不直接归咎数据库或框架
- 能定位值与结构在哪层偏离
练到的知识点参数占位与预编译 · 动态结构白名单 · 字段与 resultMap · 分页 SQL 验证
- 作业 06编程实践
实现按备注精确查询的练习 Mapper,样本含单引号、百分号、下划线与中文;明确是否采用精确或模式匹配,并核对每个映射字段。
- 提交内容
- 隔离表、Mapper、自写字符及映射测试和运行证据。
- 怎样算完成
- 输入语义由查询合同决定
- 绑定值与结果字段均有检查
练到的知识点参数占位与预编译 · 字段与 resultMap
- 作业 07排错实验
同一结果集包含两个表的 id 和 name 列,先记录列标签与 DTO 字段映射,再改为明确标签重复验证;数据只使用题目专用表。
- 提交内容
- 两版 SQL、映射配置和字段级证据。
- 怎样算完成
- 问题定位到具体列和属性
- 测试数据能分辨两表来源
练到的知识点字段与 resultMap
- 作业 08设计评审
搜索接口可选
tenantId、状态与日期,业务规定租户永远必填。列出参数缺失、空串、空集合与全条件缺省时允许生成的 SQL 范围。- 提交内容
- 参数矩阵和 SQL 形状验收集。
- 怎样算完成
- 必要归属条件不能被可选条件替代
- 每种空状态有明确合同
练到的知识点动态空条件和空集合
- 作业 09代码推演
动态更新请求可修改名称或描述,两个字段都缺失、一个显式
null和空字符串分别出现。逐例描述需要生成的结构与绑定参数,注明无需更新时的政策。- 提交内容
- 更新语义表及参数对应记录。
- 怎样算完成
- 结构选择与值绑定分别可查
- 缺失与清空不混同
练到的知识点参数占位与预编译 · 动态空条件和空集合
进阶挑战 · 掌握前置后选做
- 作业 10编程实践
掌握事务后:实现分批按租户读取 ID 集合的计划生成器,每批至多三项,输入含重复 ID,结果需保持明确顺序;只生成 SQL 模板和绑定参数,不自动执行删除。
- 提交内容
- Java 实现、自写边界和参数对应测试及运行证据。
- 怎样算完成
- 占位符与绑定位置可核验
- 分批不丢不重且归属条件完整
- 生成与执行边界分离
练到的知识点动态结构白名单 · 动态空条件和空集合 · 分页 SQL 验证
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 11思维拓展 · 条件变化
查询新增“按用户指定字段分组”,字段来自外部文本。重新评审预编译参数覆盖的范围与 SQL 结构合同,提出有限业务字段集下的可行接口。
- 提交内容
- 输入语言边界、样本与拒绝规格。
- 怎样算完成
- 输入能力有明确上限
- 论证不依赖未经检验的转义技巧
练到的知识点参数占位与预编译 · 动态空条件和空集合
- 作业 12思维拓展 · 跨域迁移
把同一 Mapper 结果用于 JSON API 和 CSV 导出,两者对空值、列名和顺序要求不同。设计映射边界与共享测试,说明哪些合同应留在 SQL 层。
- 提交内容
- 数据流图及双格式验收样本。
- 怎样算完成
- 数据库字段与输出格式分开
- 重复逻辑的保留或提取有理由
练到的知识点字段与 resultMap · 动态空条件和空集合
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读参考文档英文
MyBatis:Mapper XML (新标签页)↗
阅读参数、结果映射及 #{} 与
${},检查映射链而不拼接不可信 SQL。 - 实践参考文档英文
MyBatis:动态 SQL (新标签页)↗
练习 if、where、set、foreach,特别检查空集合和空条件行为。
- 深入教程英文
Oracle JDBC:PreparedStatement (新标签页)↗
练习参数绑定、查询和更新;教程采用早期 JDK,标准 JDBC 原则仍适用于 Java 17。
- 实践实践指南中文
MyBatis 映射:让 SQL 参数和 Java 对象对得上 (新标签页)↗
先读 select、参数和结果映射的简单例子。小练习:把
user_name列映射到userName属性,分别用列别名和resultMap完成;再解释为何用户输入应使用参数绑定。 文档公开,初学者可先读指定小节;MyBatis依赖与 Spring 集成版本需要配套。 - 深入参考文档英文
MyBatis 3.5.19 DefaultParameterHandler (新标签页)↗
核对附加参数优先级、空值政策和 i+1 绑定。