在 JDBC 開發中,PreparedStatement 不是可選優化,而是強制性標準。本文從代碼可讀性、執行效能、SQL 注入防護三個維度,鐵證如山地論證為什麼任何場合都不應使用 Statement。
String sql = "INSERT INTO tb_user (name, email, age, status) VALUES ("
+ "'" + name + "', " // 字串需單引號
+ "'" + email + "', " // 易漏引號、轉義
+ age + ", " // 數字無引號
+ "'" + status + "')"; // 維護噩夢
stmt.executeUpdate(sql);
String sql = "INSERT INTO tb_user (name, email, age, status) VALUES (?, ?, ?, ?)"; PreparedStatement ps = conn.prepareStatement(sql); ps.setString(1, name); // 索引從 1 開始 ps.setString(2, email); ps.setInt(3, age); ps.setString(4, status); ps.executeUpdate();
| 維度 | Statement | PreparedStatement |
|---|---|---|
| SQL 可讀性 | 拼接碎片,難以辨識完整語句 | 完整 SQL 模板,一目了然 |
| 參數對應 | 靠位置拼接,易錯位 | 索引綁定,編譯器檢查類型 |
| 特殊字符處理 | 手動轉義單引號、反斜線 | 驅動自動處理,零負擔 |
| 修改維護 | 改欄位順序需重構拼接邏輯 | 調整 SQL 與 setX 對應即可 |
| 日誌除錯 | 只見拼接後 SQL,參數值丟失 | 可單獨記錄 SQL 模板與參數陣列 |
// Statement 每次執行流程
SQL 字串 → 詞法分析 → 語法分析 → 語義分析 → 優化器生成執行計劃 → 執行
↑ 每次都要完整走一遍
// PreparedStatement 流程
首次: SQL 模板 → 解析 → 優化 → 緩存執行計劃 (Key: 模板文本)
後續: 參數綁定 → 直接使用緩存計劃 → 執行
↑ 省去解析優化開銷
// 批量插入 10,000 條測試 (MySQL 8.0, 本地) Statement: ~ 8.2 秒 (每次解析優化) PreparedStatement: ~ 1.3 秒 (複用計劃) Batch + PreparedStatement: ~ 0.4 秒 (再加批次優化) // 結論: 預編譯 + 批次 = 數量級提升
// 登入查詢
String sql = "SELECT * FROM users WHERE username = '" + username
+ "' AND password = '" + password + "'";
// 攻擊輸入
username: "admin' --"
password: "任意"
// 實際執行 SQL
SELECT * FROM users WHERE username = 'admin' --' AND password = '任意'
// -- 註解掉後續條件,直接以 admin 身份登入!
// 更嚴重:聯合注入
password: "'; DROP TABLE users; --"
→ 執行 DROP TABLE,資料全無
// 預編譯階段已確定語法結構 SELECT * FROM users WHERE username = ? AND password = ? // 參數綁定階段:參數視為純數據,絕不參與語法解析 ps.setString(1, "admin' --"); // 視為字面字符串 "admin' --" ps.setString(2, "anything"); // 實際查詢:尋找用戶名字面為 "admin' --" 的記錄 // 無法改變 SQL 邏輯結構
代碼與數據分離:預編譯階段確定 AST (抽象語法樹),參數只填入葉子節點值,永不改變樹結構。
String sql = "INSERT INTO orders (user_id, amount, status) VALUES (?, ?, ?)";
PreparedStatement ps = conn.prepareStatement(sql);
for (Order order : orders) {
ps.setLong(1, order.getUserId());
ps.setBigDecimal(2, order.getAmount());
ps.setString(3, order.getStatus());
ps.addBatch(); // 累積批次
}
int[] results = ps.executeBatch(); // 一次性提交
// results[i] 為各條影響行數
ps.setBlob(1, inputStream, length); ps.setClob(2, reader, length); // 或 ps.setBinaryStream(1, inputStream, length); ps.setCharacterStream(2, reader, length);
反模式與常見誤區
| 誤區 | 正確做法 |
|---|---|
| 以為 PreparedStatement 只能用於 INSERT/UPDATE | SELECT、DELETE、DDL 同樣適用 |
| 動態 SQL (IN 列表、可選 WHERE) 無法用 PreparedStatement | 動態構建 ? 佔位符字串,參數索引對應綁定 |
| 每次執行 new PreparedStatement | 長連接場景複用 PreparedStatement 物件 |
| setString 用於所有類型 | 嚴格匹配 setInt/setLong/setTimestamp/setBigDecimal 等 |
| 以為 PreparedStatement 完全杜絕注入 | 表名、欄位名、ORDER BY 子句無法參數化,需白名單驗證 |
= ?"); params.add(ageMin); }
if (ageMax != null) { sql.append(" AND age <= ?"); params.add(ageMax); }
PreparedStatement ps = conn.prepareStatement(sql.toString());
for (int i = 0; i < params.size(); i++) {
ps.setObject(i + 1, params.get(i));
}
Statement (除非執行純靜態 DDL 如建表)PreparedStatement 不是「更好」,而是「唯一正確」的選擇。這不僅是最佳實踐,更是專業開發者的底線。