Hầu hết mọi người viết Java hôm nay dùng JPA hoặc Spring Data, không viết JDBC trần. Nhưng JDBC vẫn nằm dưới tất cả những thứ đó, và ba trong bốn sự cố cơ sở dữ liệu tôi từng phải đi tìm đều nằm ở tầng này chứ không ở tầng ORM.
Bài này chạy trên PostgreSQL 16 thật trong container, mọi con số đều đo được.
Bốn đối tượng, và try phải bọc cả ba
try (Connection c = DriverManager.getConnection(url, user, pass);
PreparedStatement ps = c.prepareStatement("select id, ten, luong from nhan_vien where luong > ?")) {
ps.setInt(1, 20_000_000);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next())
System.out.printf("#%d %s %d%n", rs.getInt("id"), rs.getString("ten"), rs.getLong("luong"));
}
}
#3 Hùng 32,000,000
#1 Minh 25,000,000
Connection là một phiên làm việc, PreparedStatement là một câu lệnh đã biên dịch, ResultSet là con trỏ chạy trên kết quả.
Cả ba đều là tài nguyên phải đóng, và cả ba đều phải nằm trong try-với-tài-nguyên. Rò rỉ Connection là loại lỗi tàn nhẫn nhất ở đây: ứng dụng chạy tốt hàng giờ, rồi pool cạn, rồi mọi request đứng lại cùng lúc — và dấu vết ngăn xếp chỉ ra chỗ đang chờ kết nối chứ không chỉ ra chỗ đã làm rò rỉ nó.
Chỉ số 1 chứ không phải 0: ps.setInt(1, ...) là tham số đầu tiên. JDBC đếm từ 1, khác mọi thứ khác trong Java.
Nối chuỗi SQL: xem nó hỏng thế nào
Đây là phần chính của bài. Giả sử một ô tìm kiếm theo tên, và mã viết như thế này:
String sql = "select ten from nhan_vien where ten = '" + doNguoiDungNhap + "'";
Người dùng gõ vào ô đó chuỗi ' or '1'='1:
câu lệnh dựng ra : select ten from nhan_vien where ten = '' or '1'='1'
-> trả về 4 dòng (bảng có 4 dòng)
Toàn bộ bảng. Điều kiện lọc bị vô hiệu vì '1'='1' luôn đúng.
Cùng chuỗi đó với PreparedStatement:
-> trả về 0 dòng
Không dòng nào, vì cơ sở dữ liệu đi tìm một nhân viên tên đúng là ' or '1'='1 — và không có ai tên vậy.
Đó là toàn bộ khác biệt, và nó không phải chuyện "lọc ký tự đặc biệt". Với PreparedStatement, câu lệnh được gửi sang máy chủ trước, tham số gửi sau, theo một kênh riêng. Máy chủ đã phân tích xong cấu trúc câu lệnh rồi mới nhận giá trị, nên không có cách nào để dữ liệu biến thành cú pháp. Nối chuỗi thì ngược lại: dữ liệu và cú pháp trộn làm một trước khi ai kịp phân biệt.
Và nó không dừng ở việc đọc trộm
Tôi tạo một bảng tên neu_bi_xoa, rồi cho vào ô nhập chuỗi này:
x'; drop table neu_bi_xoa; --
bảng neu_bi_xoa còn tồn tại? false
Bảng biến mất. Và chú ý điều này: không có ngoại lệ nào được ném ra. Mã Java chạy trơn tru, không log lỗi, không cảnh báo. Dấu -- biến phần còn lại của câu lệnh thành chú thích, nên mọi thứ đều hợp lệ.
Một ô nhập liệu, một chuỗi 28 ký tự, và một bảng không còn nữa.
Quy tắc thì đơn giản: mọi giá trị đến từ bên ngoài đều đi qua dấu ?, không có ngoại lệ.
Một chỗ cần biết vì nó hay làm người ta bối rối: dấu ? chỉ thay được giá trị, không thay được tên bảng, tên cột hay hướng sắp xếp. order by ? không chạy. Khi cần sắp xếp động, đừng ghép chuỗi từ tham số người dùng — hãy đối chiếu với một danh sách tên cột cho phép rồi mới ghép:
if (!Set.of("ten", "luong", "ngay_vao").contains(cot)) throw new IllegalArgumentException("cột không hợp lệ");
getInt trên cột NULL trả về 0
getInt trên cột NULL : 0 <- không phải null!
rs.wasNull() : true
getObject(Integer) : null
Các phương thức getInt, getLong, getDouble trả về kiểu nguyên thuỷ, mà kiểu nguyên thuỷ không có null. Nên NULL trong cơ sở dữ liệu thành 0 trong Java, và không có gì báo cho bạn.
Hậu quả rất thực tế: một cột thuong để trống nghĩa là "chưa xét thưởng", nhưng mã của bạn đọc ra 0 và hiểu thành "thưởng bằng không". Hai chuyện khác hẳn nhau, và báo cáo tổng hợp sẽ sai mà không ai biết.
Hai cách xử lý. Cách cũ là hỏi lại rs.wasNull() ngay sau lời gọi getInt — đúng nhưng dễ quên, và thứ tự quan trọng vì nó nói về lần đọc gần nhất. Cách tôi khuyên là dùng getObject với kiểu bọc:
Integer thuong = rs.getObject("thuong", Integer.class); // null vẫn là null
Một dòng, không cần nhớ gọi thêm gì. Với LocalDate, Instant cũng vậy — rs.getObject("ngay", LocalDate.class) tránh được cả java.sql.Date lẫn chuyện múi giờ ở bài trước.
Chèn hàng loạt: 1403 ms, 965 ms, hay 19 ms
Chèn 5000 dòng, ba cách viết:
từng dòng, autocommit bật : 1403 ms
từng dòng, một giao dịch : 965 ms
addBatch theo lô 500 : 19 ms
Bảy mươi tư lần. Và mã không khác nhau nhiều:
c.setAutoCommit(false);
try (PreparedStatement ps = c.prepareStatement("insert into t values (?,?)")) {
for (int i = 0; i < N; i++) {
ps.setInt(1, i); ps.setString(2, "ten" + i);
ps.addBatch();
if (i % 500 == 499) ps.executeBatch(); // xả theo lô
}
ps.executeBatch();
}
c.commit();
Hai thứ tạo ra khác biệt, và nên hiểu tách bạch:
Tắt autocommit đưa 5000 giao dịch thành một. Mỗi lần commit là một lần ghi bền vững xuống đĩa, nên đây là phần từ 1403 xuống 965 ms.
addBatch gom nhiều câu lệnh vào một lần đi mạng thay vì 5000 lần khứ hồi. Đây là phần lớn hơn nhiều: từ 965 xuống 19 ms.
Chú ý dòng xả theo lô. Gom cả 5000 rồi mới executeBatch một lần thì toàn bộ nằm trong bộ nhớ — với vài triệu dòng là hết RAM. Lô 500 tới 1000 là khoảng hợp lý.
Với PostgreSQL còn một tham số đáng bật trong chuỗi kết nối: reWriteBatchedInserts=true, cho driver gộp nhiều insert thành một câu lệnh nhiều dòng giá trị.
Giao dịch, và chuyện xảy ra khi bạn quên commit
đã chèn 2 dòng, chưa commit -> đếm được: 2
sau rollback -> đếm được: 0
Trong cùng kết nối, bạn thấy thay đổi của chính mình trước khi commit. Kết nối khác thì không:
kết nối A chèn 1 dòng, không commit
kết nối B nhìn thấy : 0 dòng
đóng kết nối A (không commit) rồi, B nhìn thấy: 0 dòng
Đóng kết nối mà chưa commit thì dữ liệu mất, im lặng. Không ngoại lệ, không cảnh báo — đúng hành vi đã khai, nhưng là thứ khiến người ta ngồi tìm cả buổi vì "insert chạy rồi mà không thấy dữ liệu đâu".
Khuôn viết đúng:
c.setAutoCommit(false);
try {
// ... các thao tác ghi
c.commit();
} catch (SQLException e) {
c.rollback();
throw e;
}
Và nếu dùng pool kết nối, hãy nhớ trả lại autocommit về true trước khi trả kết nối, vì kết nối được tái sử dụng và người dùng sau sẽ thừa hưởng trạng thái bạn để lại. HikariCP làm sẵn việc này; tự viết thì phải tự nhớ.
Lấy khoá tự sinh sau khi chèn:
c.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
// ...
try (ResultSet r = ps.getGeneratedKeys()) { r.next(); int id = r.getInt(1); }
id vừa sinh : 1
Cái bẫy lớn nhất: ResultSet nạp hết vào RAM
Một bảng 300.000 dòng, mỗi dòng khoảng 200 ký tự. Đọc bằng vòng while (rs.next()) bình thường:
-Xmx96m mặc định : chạy xong
-Xmx64m mặc định : OutOfMemoryError: Java heap space
Vòng lặp trông như đang xử lý từng dòng một, nhưng driver PostgreSQL đã tải toàn bộ kết quả về bộ nhớ trước khi next() chạy lần đầu. Vòng lặp chỉ đang duyệt một mảng có sẵn.
Bảo nó lấy theo lô:
c.setAutoCommit(false); // BẮT BUỘC
ps.setFetchSize(1000);
-Xmx64m setFetchSize : chạy xong
-Xmx32m setFetchSize : chạy xong
-Xmx16m setFetchSize : chạy xong
Mười sáu megabyte heap vẫn đọc trọn 300.000 dòng — và nó sẽ đọc được cả 300 triệu dòng, vì bộ nhớ dùng không còn phụ thuộc kích thước kết quả nữa.
Nhưng dòng setAutoCommit(false) mới là chỗ tôi muốn bạn nhớ. Bỏ nó đi, giữ nguyên setFetchSize(1000):
autocommit bật + setFetchSize, -Xmx64m : OutOfMemoryError: Java heap space
setFetchSize bị bỏ qua hoàn toàn, không một lời cảnh báo. Con trỏ phía máy chủ của PostgreSQL cần một giao dịch đang mở để tồn tại; không có giao dịch thì driver lặng lẽ quay về chế độ tải hết.
Đây là kiểu lỗi tệ nhất trong bài: bạn đã viết đúng dòng mình nghĩ là quan trọng, mã trông hoàn toàn hợp lý khi đọc lại, và nó vẫn hỏng. Hai dòng ấy phải đi cùng nhau.
Chi tiết này là đặc thù của PostgreSQL. MySQL cần useCursorFetch=true hoặc setFetchSize(Integer.MIN_VALUE); Oracle thì mặc định đã lấy theo lô. Đổi cơ sở dữ liệu là phải đọc lại tài liệu driver ở đúng chỗ này.
Trong ứng dụng thật
Đừng dùng DriverManager trực tiếp. Mỗi lời gọi mở một kết nối TCP mới cộng bắt tay xác thực. Dùng pool — HikariCP là mặc định của Spring Boot và gần như không cần cấu hình gì.
JdbcTemplate hoặc JDBI bỏ được phần lớn mã lặp ở trên mà vẫn cho bạn viết SQL thật. Với truy vấn phức tạp, tôi thích chúng hơn JPA.
JPA vẫn chạy trên JDBC. Biết tầng dưới giúp bạn đọc được log SQL, hiểu vì sao có N+1 truy vấn, và biết chỗ nào cần hạ xuống SQL trần.
Thử ba mươi giây
Tìm trong dự án của bạn một chuỗi SQL được nối bằng dấu +. Nếu có, thử nhập vào ô tương ứng chuỗi ' or '1'='1 và xem nó trả về gì.
Còn nếu bạn đang đọc dữ liệu bằng vòng while (rs.next()) cho một bảng lớn, kiểm hai dòng: có setFetchSize không, và có setAutoCommit(false) đi kèm không. Thiếu dòng thứ hai thì dòng thứ nhất không làm gì cả, và sự cố sẽ đến vào đúng ngày bảng đó đủ lớn.
Ngày mai: pool kết nối và HikariCP — vì sao mở kết nối lại đắt tới vậy, và cách chọn kích thước pool cho đúng thay vì đặt đại một trăm.