3. JDBC执行SQL与结果集处理
上一节我们建立了数据库连接,本节要解决的核心问题是:拿到 Connection 之后,如何安全、高效地执行 SQL,并把结果正确地取出来。围绕这条主线,本节覆盖五个关键点:PreparedStatement 预编译、CRUD 操作、批量更新、可滚动与可更新结果集,以及 SQL 注入防范。
一、为什么优先使用 PreparedStatement
Statement 与 PreparedStatement 都能执行 SQL,但工程中应默认选择后者。原因有三层:
- 预编译与执行计划复用:SQL 模板先发送给数据库编译,后续只传参数,数据库可复用执行计划,减少解析开销。
- 参数绑定而非字符串拼接:参数通过
setXxx()绑定,值不会被当作 SQL 语法解析,从根本上阻断注入。 - 类型安全与可读性:
setInt、setString、setDate等按类型传参,避免手工拼接引号、日期格式等易错点。
SQL 模板中用 ? 作为占位符,参数下标从 1 开始,这一点与数组从 0 开始不同,是初学者最常见的错误来源。
String sql = "INSERT INTO account(name, balance) VALUES(?, ?)";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, "张三");
ps.setBigDecimal(2, new BigDecimal("1000.00"));
int rows = ps.executeUpdate(); // 返回受影响行数
}
二、CRUD 的四种执行入口
执行 SQL 的方法要按“是否有结果集”来选择,选错会直接抛异常或拿不到数据:
| 方法 | 适用语句 | 返回值 |
|---|---|---|
executeUpdate() | INSERT / UPDATE / DELETE / DDL | 受影响行数 int |
executeQuery() | SELECT | ResultSet |
execute() | 不确定类型或返回多结果 | boolean,再用 getResultSet()/getUpdateCount() |
executeBatch() | 批量 DML | int[] 每条语句的影响行数 |
查询结果的读取必须遵循“先 next() 再取值”的顺序。next() 把游标移到下一行,返回 false 表示没有更多行。取值时优先用列名而非列下标,列名可读性更好,也不易因 SQL 字段顺序调整而出错。
String sql = "SELECT id, name, balance FROM account WHERE balance > ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setBigDecimal(1, new BigDecimal("500"));
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
long id = rs.getLong("id");
String name = rs.getString("name");
BigDecimal balance = rs.getBigDecimal("balance");
System.out.println(id + " | " + name + " | " + balance);
}
}
}
注意 getLong 等数值方法在列值为 SQL NULL 时返回 0,无法区分“真的是 0”和“是 NULL”。需要区分时应先用 rs.getObject("id") 判断是否为 null,或调用 rs.wasNull()。
三、批量更新:把 N 次往返压成 1 次
逐条执行 1000 条 INSERT,意味着 1000 次网络往返和 1000 次事务开销。批量更新把多条语句攒起来一次性提交,性能提升通常非常明显。标准写法是关闭自动提交、循环 addBatch()、最后 executeBatch()、统一 commit()。
conn.setAutoCommit(false);
String sql = "INSERT INTO account(name, balance) VALUES(?, ?)";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
for (int i = 1; i <= 1000; i++) {
ps.setString(1, "user" + i);
ps.setBigDecimal(2, new BigDecimal("100.00"));
ps.addBatch();
if (i % 500 == 0) { // 分批提交,避免批次过大
ps.executeBatch();
ps.clearBatch();
}
}
ps.executeBatch();
conn.commit();
} catch (SQLException e) {
conn.rollback();
throw e;
} finally {
conn.setAutoCommit(true);
}
批量大小需要权衡:太小则往返次数多,太大则占用内存、失败回滚代价高。经验上 500~1000 是一个常见起点,实际应结合数据库和网络情况压测确定。另外,executeBatch() 返回的 int[] 中可能出现 Statement.SUCCESS_NO_INFO(-2),表示该条成功但驱动未提供具体行数,不能简单当作失败。
四、可滚动与可更新结果集
默认的 ResultSet 只能向前、只读。创建 PreparedStatement 时可以指定两个参数改变这一行为:
PreparedStatement ps = conn.prepareStatement(
sql,
ResultSet.TYPE_SCROLL_INSENSITIVE, // 可滚动,对底层变化不敏感
ResultSet.CONCUR_UPDATABLE // 可通过结果集更新
);
可滚动结果集支持 first()、last()、absolute(n)、previous()、beforeFirst() 等定位方法,适合分页展示或需要来回遍历的场景。可更新结果集则允许直接 updateXxx() 后调用 updateRow(),或 moveToInsertRow() + insertRow() 插入新行。
但要注意:可更新结果集对 SQL 有要求,通常只能针对单表、且必须包含主键列,否则驱动可能拒绝更新。它依赖驱动实现,跨数据库可移植性一般,工程中更常见的做法仍是显式写 UPDATE 语句。因此把它当作“知道有、按需用”的能力即可,不必强求。
五、SQL 注入防范
SQL 注入的本质是“用户输入被当成了 SQL 语法”。看一个反例:
// 危险:字符串拼接
String sql = "SELECT * FROM account WHERE name = '" + name + "'";
// 若 name 传入 ' OR '1'='1
// 实际执行:SELECT * FROM account WHERE name = '' OR '1'='1'
// 条件恒真,全表数据被返回
更严重的输入如 '; DROP TABLE account; -- 可能直接破坏数据。防范手段按优先级排列:
- 首选参数化查询:所有可变值都用
?占位,通过setXxx()绑定,这是最有效也最省事的方式。 - 动态表名/列名白名单校验:
?只能绑定值,不能绑定表名、列名、ORDER BY 字段。这类拼接必须用白名单枚举校验,绝不接受任意输入。 - 最小权限:应用连接使用的数据库账号只授予必要权限,避免 DROP、GRANT 等高危操作。
- 输入校验与错误信息收敛:对外不暴露原始 SQL 异常堆栈,减少攻击者探测信息。
常见误区:认为“用了 PreparedStatement 就绝对安全”。如果 SQL 模板本身仍是拼接出来的(例如把用户输入拼进 ORDER BY 子句),注入依然存在。参数化保护的是“值”,不是“结构”。
六、资源释放与异常处理
ResultSet、Statement、Connection 都必须关闭,且关闭顺序与创建顺序相反。推荐使用 try-with-resources,由编译器保证关闭,避免遗漏。若手动关闭,应在 finally 中按 rs → ps → conn 的顺序关闭,并对每个 close() 单独捕获异常,防止前一个关闭失败导致后续资源泄漏。
下面这张图把本节的核心决策串起来:从拿到连接开始,按 SQL 类型选择执行入口,再决定结果集形态,最后统一走资源释放与事务收尾。
把本节要点压缩成一句可执行的口诀:模板用 ?、执行看类型、批量要分批、结果先 next、资源逆序关、拼接必白名单。掌握这六点,JDBC 的 SQL 执行与结果集处理就基本不会踩坑。