PreparedStatement 与 SQL 注入防护
Statement vs PreparedStatement
Statement
Statement stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery("SELECT * FROM user WHERE name = '" + name + "'");问题:
- SQL 字符串拼接,容易 SQL 注入
- 每次执行都需要编译 SQL
- 性能较差
PreparedStatement
String sql = "SELECT * FROM user WHERE name = ?";
PreparedStatement pstmt = connection.prepareStatement(sql);
pstmt.setString(1, name);
ResultSet rs = pstmt.executeQuery();优势:
- 参数预编译,防止 SQL 注入
- SQL 预编译,可复用执行计划
- 支持批量操作
SQL 注入攻击
注入原理
// 危险代码:字符串拼接
String sql = "SELECT * FROM user WHERE name = '" + name + "' AND password = '" + password + "'";
// 攻击输入
String name = "admin";
String password = "' OR '1'='1";
// 实际执行的 SQL
// SELECT * FROM user WHERE name = 'admin' AND password = '' OR '1'='1'
// 结果:绕过密码验证,返回所有用户常见注入方式
| 类型 | 示例 | 危害 |
|---|---|---|
| 永真条件 | ' OR '1'='1 | 绕过验证 |
| 注释截断 | admin'-- | 忽略后续条件 |
| 联合查询 | ' UNION SELECT * FROM user-- | 获取其他表数据 |
| 堆叠查询 | '; DROP TABLE user;-- | 执行恶意命令 |
PreparedStatement 防注入原理
// 使用 PreparedStatement
String sql = "SELECT * FROM user WHERE name = ? AND password = ?";
PreparedStatement pstmt = connection.prepareStatement(sql);
pstmt.setString(1, "admin");
pstmt.setString(2, "' OR '1'='1");
// 实际执行的 SQL(参数被转义)
// SELECT * FROM user WHERE name = 'admin' AND password = '\' OR \'1\'=\'1'
// 参数值作为整体字符串处理,不会改变 SQL 结构关键:参数值和 SQL 结构分离
PreparedStatement 工作原理
执行流程
1. 预编译阶段
SQL: SELECT * FROM user WHERE name = ?
↓
解析语法、生成执行计划、缓存
2. 参数绑定阶段
setString(1, "张三")
↓
参数值转义、类型转换
3. 执行阶段
执行缓存的执行计划
↓
返回结果源码层面
MySQL 服务端接收到:
- SQL 模板:
SELECT * FROM user WHERE name = ? - 参数值:二进制格式的参数数据
服务端:
- 编译 SQL 模板
- 将参数值直接替换占位符(不进行 SQL 解析)
- 执行查询
批量操作优化
Statement 批量
Statement stmt = connection.createStatement();
for (User user : users) {
String sql = "INSERT INTO user(name, age) VALUES('" + user.getName() + "', " + user.getAge() + ")";
stmt.addBatch(sql);
}
stmt.executeBatch();问题:每条 SQL 都需要编译
PreparedStatement 批量
String sql = "INSERT INTO user(name, age) VALUES(?, ?)";
PreparedStatement pstmt = connection.prepareStatement(sql);
for (User user : users) {
pstmt.setString(1, user.getName());
pstmt.setInt(2, user.getAge());
pstmt.addBatch();
// 每 1000 条执行一次,避免内存溢出
if (i % 1000 == 0) {
pstmt.executeBatch();
pstmt.clearBatch();
}
}
pstmt.executeBatch(); // 执行剩余优势:
- SQL 只编译一次
- 参数批量发送
- 性能提升显著
JDBC 批量参数
// 开启批量优化(MySQL)
connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
// 设置批量大小
pstmt.setFetchSize(1000);
// MySQL 需要开启 rewriteBatchedStatements
// jdbc:mysql://localhost:3306/db?rewriteBatchedStatements=true
// 将多条 INSERT 合并为一条:INSERT INTO t VALUES (...), (...), (...)性能对比
单次执行
| 方式 | 编译次数 | 执行次数 | 性能 |
|---|---|---|---|
| Statement | 1 | 1 | 基准 |
| PreparedStatement | 1 | 1 | 略慢(参数绑定开销) |
多次执行(相同 SQL,不同参数)
| 方式 | 编译次数 | 执行次数 | 性能 |
|---|---|---|---|
| Statement | N | N | 慢 |
| PreparedStatement | 1 | N | 快 |
批量插入 10000 条数据
| 方式 | 耗时 |
|---|---|
| Statement 循环 | ~30s |
| PreparedStatement 循环 | ~10s |
| PreparedStatement 批量 | ~1s |
| PreparedStatement 批量 + rewriteBatchedStatements | ~0.3s |
使用注意事项
1. IN 语句处理
// 错误:PreparedStatement 不支持动态占位符数量
String sql = "SELECT * FROM user WHERE id IN (?)";
pstmt.setString(1, "1,2,3"); // 会被当作字符串 "1,2,3",而不是三个值
// 正确方式1:动态构建占位符
List<Integer> ids = Arrays.asList(1, 2, 3);
String placeholders = ids.stream().map(i -> "?").collect(Collectors.joining(","));
String sql = "SELECT * FROM user WHERE id IN (" + placeholders + ")";
PreparedStatement pstmt = connection.prepareStatement(sql);
for (int i = 0; i < ids.size(); i++) {
pstmt.setInt(i + 1, ids.get(i));
}
// 正确方式2:使用临时表或 FIND_IN_SET2. LIKE 查询
// 错误:占位符不能加引号
String sql = "SELECT * FROM user WHERE name LIKE '%?%'"; // 错误!
// 正确:占位符包含完整模式
String sql = "SELECT * FROM user WHERE name LIKE ?";
pstmt.setString(1, "%" + name + "%");3. 表名/列名动态
// 错误:PreparedStatement 不能用于表名、列名
String sql = "SELECT * FROM ? WHERE ? = ?"; // 不支持!
// 只能使用字符串拼接(需要手动校验防止注入)
String tableName = validateTableName(inputTable);
String sql = "SELECT * FROM " + tableName + " WHERE name = ?";
PreparedStatement pstmt = connection.prepareStatement(sql);
pstmt.setString(1, name);4. 资源释放
// 使用 try-with-resources 自动关闭
try (Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setString(1, name);
ResultSet rs = pstmt.executeQuery();
// 处理结果
} catch (SQLException e) {
// 异常处理
}MyBatis 中的预编译
# vs $
| 语法 | 说明 | 安全性 | 使用场景 |
|---|---|---|---|
#{} | 预编译参数,生成 ? | 安全,防注入 | 参数值 |
${} | 字符串替换 | 不安全,可能注入 | 表名、列名、排序字段 |
<!-- 安全:参数值使用 #{} -->
<select id="getUserByName" resultType="User">
SELECT * FROM user WHERE name = #{name}
</select>
<!-- 不安全:表名使用 ${},需要校验 -->
<select id="getUserFromTable" resultType="User">
SELECT * FROM ${tableName} WHERE name = #{name}
</select>
<!-- 排序字段需要 ${},需严格校验 -->
<select id="getUsersOrderBy" resultType="User">
SELECT * FROM user ORDER BY ${orderColumn} ${orderDirection}
</select>MyBatis 安全实践
// 白名单校验
public class OrderValidator {
private static final Set<String> ALLOWED_COLUMNS =
Set.of("id", "name", "create_time", "update_time");
public static String validateColumn(String column) {
if (!ALLOWED_COLUMNS.contains(column)) {
throw new IllegalArgumentException("Invalid column: " + column);
}
return column;
}
}面试高频问题
Q1: PreparedStatement 为什么能防止 SQL 注入?
回答要点:
- 参数预编译:SQL 模板先编译,参数后绑定
- 结构分离:参数值不参与 SQL 解析
- 自动转义:特殊字符被转义,不会改变 SQL 结构
攻击输入:' OR '1'='1
转义后:\' OR \'1\'=\'1(作为普通字符串)Q2: PreparedStatement 一定比 Statement 快吗?
回答要点:
不一定。
- 单次执行:Statement 略快(省去参数绑定开销)
- 多次执行相同 SQL:PreparedStatement 快(只编译一次)
- 不同 SQL:性能接近
Q3: $ 和 # 的区别?
| 对比项 | # | $ |
|---|---|---|
| 处理方式 | 预编译参数 | 字符串替换 |
| SQL 生成 | WHERE name = ? | WHERE name = '张三' |
| 安全性 | 安全 | 不安全 |
| 使用场景 | 参数值 | 表名、列名 |
Q4: 如何防止 SQL 注入?
- 使用 PreparedStatement:参数值使用
?或#{} - 输入校验:白名单验证特殊字段
- 最小权限:数据库用户只给必要权限
- 敏感信息加密:密码等加密存储
- ORM 框架:MyBatis、JPA 默认防注入
总结
PreparedStatement 核心要点:
1. 防注入:参数和 SQL 结构分离
2. 提性能:相同 SQL 预编译复用
3. 批量优化:rewriteBatchedStatements
4. 注意事项:IN、LIKE、表名场景处理最佳实践:
- 参数值始终使用 PreparedStatement / #
- 表名列名使用 $ 时必须白名单校验
- 批量操作使用 addBatch + executeBatch
- 使用 try-with-resources 管理资源