MyBatis 采用预编译就不会 SQL 注入?一文带你彻底搞懂!
专注解决:预编译什么情况有效、什么情况失效、拼接和占位符的本质区别
作者简介
CodeStats|8年Java后端架构实践者、WWAIC(全周AI编程)范式创始人、开源自研Java全栈框架CodeStats作者
深耕Java底层原理与框架源码拆解,专注手写复刻主流框架、剖析技术底层本质,拒绝黑盒式开发。致力于用通俗语言拆解晦涩技术,通过自研项目实证AI编程价值,帮助数万开发者从"会用框架"进阶到"懂架构、懂原理"。主打底层源码解析、手写框架实战、AI工程化落地干货。
前言
几乎所有Java开发者都听过:MyBatis 的 #{} 预编译可以杜绝 SQL 注入。
但日常开发中,很多人都遇到过这些无解难题:
-
明明用了
#{},为什么依然出现 SQL 注入漏洞? -
排序场景
ORDER BY不能用#{},只能用${},原理是什么? -
预编译到底"预"了什么?数据库底层究竟如何执行 SQL?
本文抛开空话、直击本质,从 SQL 完整执行流程、预编译核心原理、源码底层差异、失效场景全方位拆解,一次性彻底搞懂 MyBatis 防注入的底层逻辑。
一、SQL 在数据库的完整执行流程
一条 SQL 从字符串到最终返回结果,会经历三大核心阶段,这是理解预编译的基础。
text
原始SQL字符串
↓
┌─────────────────────────────────────────────┐
│ 1. 解析阶段(Parsing)—— 确定SQL结构 │
│ - 词法分析:拆分关键字、标识符、运算符Token │
│ - 语法分析:生成抽象语法树AST │
│ - 语义分析:校验表、字段权限与合法性 │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ 2. 编译优化阶段(Compile & Optimize) │
│ - 重写SQL语句、合并视图/子查询 │
│ - 优选索引、JOIN顺序,生成最优执行计划 │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ 3. 执行阶段(Execute)—— 仅处理数据 │
│ - 调用存储引擎读取数据 │
│ - 完成过滤、排序、聚合,返回结果集 │
└─────────────────────────────────────────────┘
↓
返回结果
核心结论:SQL 的语法结构、执行规则,在解析和优化阶段就已固定,执行阶段仅负责数据读取,无法改变 SQL 逻辑。
二、预编译的核心本质:复用执行计划
2.1 普通 SQL 执行(无预编译)
每次请求都会完整执行「解析→优化→执行」全流程,重复操作、性能低效,且存在注入风险。
text
SQL字符串 → 解析 → 优化 → 执行 → 返回结果 (每次请求全量重复执行)
2.2 预编译 SQL 执行(PreparedStatement)
预编译的核心:提前固化 SQL 结构,仅动态替换数据。首次执行完成解析、优化并缓存执行计划,后续仅绑定参数执行。
text
SQL模板(带?占位符)→ 解析 → 优化 → 缓存执行计划
↓
参数动态绑定 → 复用执行计划 → 直接执行
2.3 JDBC 预编译核心源码
这是所有框架预编译的底层根基,MyBatis、JPA 均基于此实现。
java
// 1. 发送SQL模板,完成预编译(固化结构) String sql = "SELECT * FROM users WHERE id = ?"; PreparedStatement ps = conn.prepareStatement(sql); // 2. 仅绑定纯数据,不改变SQL结构 ps.setInt(1, 100); ResultSet rs = ps.executeQuery(); // 3. 复用执行计划,无需重新解析优化 ps.setInt(1, 200); ResultSet rs2 = ps.executeQuery();
2.4 数据库底层预编译逻辑
sql
-- 首次:预编译模板,缓存执行计划 PREPARE stmt FROM 'SELECT * FROM users WHERE id = ?'; -- 二次、三次执行:仅绑定参数,复用计划 SET @id = 100; EXECUTE stmt USING @id; SET @id = 200; EXECUTE stmt USING @id;
三、预编译为什么能防 SQL 注入?
核心原理:SQL 结构解析与参数数据绑定完全隔离
用户输入的所有内容,只会被当作纯数据处理,永远不会被数据库解析为 SQL 语法。
模拟恶意输入:1 OR 1=1
text
1. 模板解析阶段(固化结构) SQL:SELECT * FROM users WHERE id = ? (此时无用户输入,SQL逻辑完全固定) 2. 参数绑定阶段(仅传数据) ? = "1 OR 1=1" // 整体视为普通字符串数据 3. 最终执行SQL SELECT * FROM users WHERE id = '1 OR 1=1'
关键:OR、AND、-- 等 SQL 关键字,在解析阶段不存在,无法篡改 SQL 逻辑,从根源杜绝注入。
四、彻底分清:占位符 ? 能替换什么、不能替换什么
很多人用错 #{},核心是不懂:预编译占位符仅支持「数据值」,不支持「SQL 结构」。
4.1 ✅ 可参数化(预编译有效)
sql
-- 条件值 SELECT * FROM users WHERE id = ? SELECT * FROM users WHERE name LIKE ? -- 增改数值 INSERT INTO users (name, age) VALUES (?, ?) UPDATE users SET name = ? WHERE id = ? -- 批量、函数参数 SELECT * FROM users WHERE id IN (?, ?, ?) SELECT * FROM users WHERE created_at > DATE(?)
4.2 ❌ 不可参数化(预编译失效)
sql
-- 表名、列名 SELECT * FROM ? -- ❌ 语法错误 SELECT ? FROM users -- ❌ 列名不能参数化 -- 排序、分组 SELECT * FROM users ORDER BY ? -- ❌ SELECT * FROM users GROUP BY ? -- ❌ -- 运算符、SQL关键字 SELECT * FROM users WHERE age ? 20 -- ❌
4.3 底层原因
数据库解析阶段必须确认完整 SQL 结构(表名、字段、排序规则、运算符),结构缺失则无法生成合法执行计划,因此结构类内容无法用占位符替代。
五、MyBatis:#{} 与 ${} 本质区别(源码级拆解)
5.1 核心特性对比
| 特性 | #{} |
${} |
|---|---|---|
| 底层实现 | PreparedStatement 预编译 | Statement 字符串直接拼接 |
| 防注入能力 | ✅ 安全,彻底防注入 | ❌ 高危,存在注入漏洞 |
| 解析时机 | 先解析模板,后绑定参数 | 先拼接字符串,后整体执行 |
| 适用场景 | 所有普通参数值 | 动态表名、列名、排序字段 |
| 类型处理 | 自动适配数据类型 | 纯字符串替换,无类型适配 |
5.2 底层源码差异
java
// #{} 底层:安全预编译
String sql = "SELECT * FROM users WHERE id = ?";
PreparedStatement ps = connection.prepareStatement(sql);
ps.setInt(1, userId); // 仅绑定数据,结构固定
// ${} 底层:危险字符串拼接
String sql = "SELECT * FROM users WHERE id = " + userId;
Statement stmt = connection.createStatement();
stmt.executeQuery(sql); // 拼接后直接执行,无防护
5.3 经典错误案例
错误用法:用 #{} 传递表名,触发语法异常
xml
<!-- 错误写法 -->
<select id="selectFromTable" resultType="map">
SELECT * FROM #{tableName}
</select>
<!-- 最终错误SQL:表名被加上引号,语法报错 -->
SELECT * FROM 'user_table'
xml
<!-- 正确写法:动态结构只能用${},必须配合安全校验 -->
<select id="selectFromTable" resultType="map">
SELECT * FROM ${tableName}
</select>
六、SQL 注入的本质:一句话看穿漏洞根源
6.1 注入成立的必要条件
用户输入在数据库解析阶段,参与了 SQL 结构拼接,即可触发注入攻击。
6.2 注入攻击原理演示
text
恶意输入:1 OR 1=1
【拼接模式(${})—— 被注入】
输入拼接后SQL:SELECT * FROM users WHERE id = 1 OR 1=1
解析阶段识别OR关键字,篡改查询逻辑,查询全部数据
【预编译模式(#{})—— 安全】
SQL模板:SELECT * FROM users WHERE id = ?
参数绑定:? = "1 OR 1=1"
最终执行:仅匹配字符串数据,无逻辑篡改
七、MyBatis 完整执行链路(源码层级)
从 Mapper 接口到数据库执行,完整看懂预编译执行链路:
text
Mapper接口(注解/XML SQL)
↓
MapperProxy动态代理(拦截方法、解析SQL)
↓
SqlSession(事务管理、调用执行器)
↓
Executor(一级缓存、调度StatementHandler)
↓
StatementHandler(#{}转?、构建预编译SQL)
↓
ParameterHandler(参数类型处理、纯数据绑定)
↓
JDBC PreparedStatement(底层预编译)
↓
数据库(解析模板、复用执行计划、执行查询)
核心源码逻辑:PreparedStatementHandler 负责生成预编译模板,DefaultParameterHandler 仅做数据绑定,全程不修改 SQL 结构。
八、预编译失效的所有场景(避坑重点)
预编译失效唯一核心:SQL 模板在预编译前,已完成字符串拼接。
8.1 原生 JDBC 失效案例
java
// 高危!先拼接再预编译,防护完全失效 String sql = "SELECT * FROM users WHERE id = " + userInput; PreparedStatement ps = conn.prepareStatement(sql); // 恶意输入会直接嵌入SQL结构,预编译毫无作用
8.2 MyBatis 常见失效场景
-
普通参数错误使用
${}拼接 -
动态表名、列名、排序字段使用
${}且无任何校验 -
自定义 SQL 拼接用户输入,未做过滤
8.3 动态结构安全解决方案
必须使用 ${} 时,强制搭配白名单校验,杜绝用户可控输入:
java
public List<User> selectByColumn(String columnName, String value) {
// 白名单过滤:仅允许合法字段
Set<String> allowedColumns = Set.of("id", "name", "email", "age");
if (!allowedColumns.contains(columnName)) {
throw new IllegalArgumentException("非法查询字段");
}
return userMapper.selectByColumn(columnName, value);
}
九、终极总结(全文核心)
-
预编译防护的是「数据」,不防护「结构」,仅值类型参数可使用
#{}防注入 -
不是用了
PreparedStatement就安全,提前拼接 SQL 会直接让预编译失效 -
表名、列名、排序、分组等 SQL 结构,无法参数化,只能用
${} -
所有
${}动态拼接场景,必须做白名单/枚举映射安全校验 -
#{}安全无隐患,${}高危仅可用于可控固定场景
一句话终极口诀
值用井号防注入,结构美元必校验,先拼模板后绑参,注入漏洞无处藏
如果本文帮你彻底吃透 MyBatis 预编译与 SQL 注入原理,欢迎点赞、收藏、转发,助力更多开发者摆脱框架黑盒认知!
更多推荐



所有评论(0)