专注解决:预编译什么情况有效、什么情况失效、拼接和占位符的本质区别


作者简介

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'

关键ORAND-- 等 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);
}

九、终极总结(全文核心)

  1. 预编译防护的是「数据」,不防护「结构」,仅值类型参数可使用 #{} 防注入

  2. 不是用了 PreparedStatement 就安全,提前拼接 SQL 会直接让预编译失效

  3. 表名、列名、排序、分组等 SQL 结构,无法参数化,只能用 ${}

  4. 所有 ${} 动态拼接场景,必须做白名单/枚举映射安全校验

  5. #{} 安全无隐患,${} 高危仅可用于可控固定场景


一句话终极口诀

值用井号防注入,结构美元必校验,先拼模板后绑参,注入漏洞无处藏


如果本文帮你彻底吃透 MyBatis 预编译与 SQL 注入原理,欢迎点赞、收藏、转发,助力更多开发者摆脱框架黑盒认知!

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐