预编译 SQL 为什么能够防止 SQL 注入

前言

最近一位搞网络安全的朋友问我,为什么用 PreparedStatement 预编译的 SQL 就不会有被 SQL 注入的风险?
初听之下,我想到了面试八股文中关于 MyBatis 的 ${}#{} 区别的问题,但再细想其中缘由,却不甚明了。
于是,我决定研究一下:预编译到底是什么?它如何与数据库交互?PreparedStatement 在这一过程中又做了什么?


一、数据库预编译

1. 什么是预编译 SQL?

以 MySQL 为例,所谓预编译是指先提交带占位符的 SQL 模板,数据库为其生成一个 key 并进行编译,随后用户通过 key 和参数让数据库执行完整的 SQL。类似于 Python 的 format 函数。
示例如下:

1
2
3
PREPARE stmt FROM 'SELECT * FROM users WHERE username = ?';
SET @name = 'john';
EXECUTE stmt USING @name;

最终执行的 SQL:

1
SELECT * FROM users WHERE username = 'john';

2. 原理

预编译的核心在于:SQL 模板在 PREPARE 阶段已完成语法检查与编译,后续传入的参数只会替换占位符,不参与 SQL 的语法解析。例如:

  • FROM 后的表名作为占位符:
    1
    2
    PREPARE stmt FROM 'SELECT * FROM ? WHERE username = ?';
    -- Error: SQL syntax error
    编译失败,因为表名必须在语法检查阶段确定。
  • 参数化字段:
    1
    2
    3
    PREPARE stmt FROM 'SELECT ? FROM users';
    SET @field = 'username';
    EXECUTE stmt USING @field;
    最终的 SQL:
    1
    SELECT 'username' FROM users;
    参数被视为字符串,未作为 SQL 关键字处理。

这说明,在预编译阶段,SQL 模板的结构已定型,后续参数无法改变语法树,避免了 SQL 注入。


二、JDBC 中的预编译

1. PreparedStatement

在 Java 中,JDBC 提供了 PreparedStatement,它是预编译 SQL 的实现。常见用法如下:

1
2
3
4
String sql = "SELECT * FROM users WHERE username = ?";
PreparedStatement ps = connection.prepareStatement(sql);
ps.setString(1, "john");
ResultSet rs = ps.executeQuery();

其中,setString 等方法负责将参数填充到占位符中。

2. 虚假的“预编译”

默认情况下,MySQL 的 PreparedStatement 使用的是客户端预编译。参数的转义在 Java 层完成,并在执行前将模板与参数拼接为普通 SQL。
示例:

1
2
3
4
String sql = "SELECT * FROM users WHERE username = ?";
PreparedStatement ps = connection.prepareStatement(sql);
ps.setString(1, "john' OR '1'='1");
ResultSet rs = ps.executeQuery();

数据库日志:

1
Query SELECT * FROM users WHERE username = 'john'' OR ''1''=''1'

可以看到,参数已被转义,但最终 SQL 并未使用数据库的预编译功能。

3. 真正的预编译

要启用真正的预编译,可以在 JDBC URL 中设置 useServerPrepStmts=true

1
jdbc:mysql://localhost:3306/test?useServerPrepStmts=true

启用后:

  • 数据库日志显示 SQL 模板和参数分离:
    1
    2
    Prepare SELECT * FROM users WHERE username = ?
    Execute SELECT * FROM users WHERE username = 'john'
  • SQL 的编译和参数绑定均由数据库完成,真正实现了预编译。

三、MyBatis 的 #{}${}

1. #{} 的行为

#{} 会将参数解析为占位符 ?,并通过 PreparedStatement 设置参数值:

1
SELECT * FROM users WHERE username = ?

2. ${} 的行为

${} 会直接将参数值嵌入到 SQL 模板中:

1
SELECT * FROM users WHERE username = 'john'

这种方式容易导致 SQL 注入问题。

MyBatis 源码验证:

  • #{} 使用 GenericTokenParser 解析为 ?,并绑定参数。
  • ${} 使用参数直接替换占位符。

3. 为什么 #{} 更安全?

#{} 使用占位符,参数值始终作为数据处理,不会影响 SQL 模板的结构;而 ${} 会直接将参数拼接到 SQL 中,可能破坏原始语法结构。


总结

  • 预编译的本质:通过固定 SQL 模板结构,将参数视为数据进行转义,防止注入。
  • JDBC 的预编译:默认情况下,MySQL 的 PreparedStatement 为客户端预编译,仅在参数设置时进行转义;设置 useServerPrepStmts=true 后,可启用真正的预编译。
  • MyBatis 的占位符#{} 通过占位符与参数绑定,更安全;${} 直接拼接参数,易受 SQL 注入影响。

最后,尽管预编译能显著降低 SQL 注入风险,但仍需遵循安全开发规范,避免不必要的隐患。