PreparedStatement能彻底阻断SQL注入,因其在数据库端预编译SQL模板,将用户输入作为纯数据参数通过二进制协议传递,绝不拼入SQL文本,从根本上杜绝解析为代码的可能。

PreparedStatement 是 Java 中杜绝 SQL 注入最直接、最可靠的手段。它不是“能防”,而是只要正确使用,就根本不会把用户输入当作 SQL 代码解析——这是底层 JDBC 驱动的硬性保障,不依赖开发者对特殊字符的理解或转义逻辑。
为什么 PreparedStatement 能彻底阻断 SQL 注入
关键在「编译阶段分离」:SQL 模板在 prepareStatement() 时就被数据库解析并编译成执行计划,此时问号 ? 占位符已固定为参数槽位;后续调用 setString()、setInt() 等方法传入的值,只会被 JDBC 驱动以二进制协议原样送入对应参数位置,**绝不会拼进 SQL 文本里**。
这意味着即使传入 "admin' OR '1'='1",数据库收到的仍是带引号的完整字符串字面量,不是语句逻辑的一部分。
- 错误认知:“它会自动转义单引号” → 实际上它根本不做字符串转义,而是绕过文本解析环节
- 必须配合
Connection.prepareStatement()使用,不能拿Statement+setXXX()模拟(无效) - 预编译开销极小,现代数据库普遍缓存执行计划,重复使用同一模板时性能反而更好
哪些地方必须强制用 PreparedStatement
凡是涉及用户可控输入参与 SQL 构建的场景,无一例外:
立即学习“Java免费学习笔记(深入)”;
-
WHERE条件中的字段值(用户名、ID、邮箱、状态码等) -
INSERT INTO ... VALUES (...)的每一列数据 -
UPDATE ... SET col = ?的右侧表达式 -
DELETE FROM ... WHERE ...的过滤条件 - 即使输入是数字类型,也必须用
setInt()而非字符串拼接(防止123; DROP TABLE users类攻击)
反例:"SELECT * FROM user WHERE id = " + userId —— 这类拼接哪怕加了 Integer.parseInt() 校验,只要没走 PreparedStatement,就仍存在风险(比如整数溢出后触发异常路径,或未来被误改)。
常见误用与坑点
很多团队声称“用了 PreparedStatement”,但实际仍可被注入,问题往往出在边界模糊处:
-
ORDER BY、GROUP BY、表名、列名不能用?占位 → 这些属于 SQL 结构,不是数据。必须白名单校验或枚举映射,例如:if (!Arrays.asList("name", "age", "created_at").contains(sortField)) throw new IllegalArgumentException(); - IN 子句动态长度:不能写
WHERE id IN (?)(只占一个位),得按需生成WHERE id IN (?, ?, ?)再 set → 推荐用 MyBatis 的<foreach></foreach>或 JPA Criteria API 替代手写 - 调用
setString()前未判空或截断超长输入 → 不导致注入,但可能引发日志污染、存储异常或 DoS,属于配套治理项 - 连接池配置未开启
cachePrepStmts=true(MySQL)或prepareThreshold(PostgreSQL)→ 不影响安全性,但会损失预编译收益
MyBatis 和 JPA 中的等效实践
框架层只是封装,底层仍靠 PreparedStatement。关键看是否生成了带 ? 的语句:
- MyBatis:必须用
#{}(如WHERE username = #{username}),禁用${}(字符串拼接,等同于手拼 SQL) - JPA:优先用
TypedQuery+setParameter(),避免createQuery("... " + userInput) - HQL/JPQL 中的命名参数(
:name)和位置参数(?1)均安全,但原生 SQL 查询仍需手动确保参数化
框架再厚,也挡不住你在 @Query(value = "SELECT * FROM user WHERE ${dynamicCondition}", nativeQuery = true) 里用 ${} 拼接——这种写法等于裸奔。
真正难的不是写出安全的查询,而是让所有开发人员在所有分支路径(包括异常处理、日志上下文、导出报表、定时任务)中都保持对 PreparedStatement 的条件反射。一旦某处漏掉,整个防线就从那个点瓦解。


















