知识模块
☕ Java 知识模块
七、数据库与JDBC
PreparedStatement

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 = ?
  • 参数值:二进制格式的参数数据

服务端:

  1. 编译 SQL 模板
  2. 将参数值直接替换占位符(不进行 SQL 解析)
  3. 执行查询

批量操作优化

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 (...), (...), (...)

性能对比

单次执行

方式编译次数执行次数性能
Statement11基准
PreparedStatement11略慢(参数绑定开销)

多次执行(相同 SQL,不同参数)

方式编译次数执行次数性能
StatementNN
PreparedStatement1N

批量插入 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_SET

2. 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 注入?

回答要点:

  1. 参数预编译:SQL 模板先编译,参数后绑定
  2. 结构分离:参数值不参与 SQL 解析
  3. 自动转义:特殊字符被转义,不会改变 SQL 结构
攻击输入:' OR '1'='1
转义后:\' OR \'1\'=\'1(作为普通字符串)

Q2: PreparedStatement 一定比 Statement 快吗?

回答要点:

不一定。

  • 单次执行:Statement 略快(省去参数绑定开销)
  • 多次执行相同 SQL:PreparedStatement 快(只编译一次)
  • 不同 SQL:性能接近

Q3: $ 和 # 的区别?

对比项#$
处理方式预编译参数字符串替换
SQL 生成WHERE name = ?WHERE name = '张三'
安全性安全不安全
使用场景参数值表名、列名

Q4: 如何防止 SQL 注入?

  1. 使用 PreparedStatement:参数值使用 ?#{}
  2. 输入校验:白名单验证特殊字段
  3. 最小权限:数据库用户只给必要权限
  4. 敏感信息加密:密码等加密存储
  5. ORM 框架:MyBatis、JPA 默认防注入

总结

PreparedStatement 核心要点:
1. 防注入:参数和 SQL 结构分离
2. 提性能:相同 SQL 预编译复用
3. 批量优化:rewriteBatchedStatements
4. 注意事项:IN、LIKE、表名场景处理

最佳实践:

  • 参数值始终使用 PreparedStatement / #
  • 表名列名使用 $ 时必须白名单校验
  • 批量操作使用 addBatch + executeBatch
  • 使用 try-with-resources 管理资源