Mọi ORM, mọi query builder và mọi JdbcTemplate trong một ứng dụng Java cuối cùng đều gọi xuống cùng vài interface: Connection, PreparedStatement, ResultSet. JDBC chính là tầng đó — API nằm trong package java.sql đi kèm JDK, và mỗi hãng database implement nó trong một driver jar riêng.
Học nó không phải chuyện tuỳ chọn kể cả khi bạn định dùng JPA. Những stack trace bạn sẽ phải đọc, cái connection bạn sẽ làm rò rỉ, cái transaction không rollback được và cái query bị nối chuỗi thay vì dùng parameter đều nằm ở tầng này. Bài viết đi hết bề mặt của nó: mở connection, đóng cho đúng, vì sao placeholder là một biện pháp bảo mật chứ không phải mẹo tăng tốc, ngữ nghĩa cursor của ResultSet, transaction và savepoint, batching, generated key, và một SQLException thực sự đang nói gì với bạn.
![]()
Mọi chương trình bên dưới đều được compile và chạy trên OpenJDK 21.0.6 (arm64) với một file database SQLite thật, dùng driver Xerial SQLite JDBC 3.46.1.3. Chọn SQLite vì nó không cần server, nghĩa là bạn tái hiện được từng dòng ở đây chỉ với một JDK và một file jar tải về. Chỗ nào là đặc thù của SQLite hoặc của driver này đều được ghi rõ; phần còn lại là JDBC thuần và chạy y hệt với PostgreSQL hay MySQL.
JDBC là gì: một API toàn interface và một driver implement chúng
java.sql là một phần của JDK — trên OpenJDK 21 nó là module java.sql@21.0.6 — và gần như không chứa implementation nào. Connection, Statement, PreparedStatement, ResultSet và DatabaseMetaData đều là interface. Các class đứng sau chúng đến từ driver jar bạn đặt lên classpath, còn DriverManager là thứ quyết định bạn nhận được driver nào.

Toàn bộ phần cài đặt chỉ là một file jar:
curl -sSO https://repo1.maven.org/maven2/org/xerial/sqlite-jdbc/3.46.1.3/sqlite-jdbc-3.46.1.3.jar
javac -cp sqlite-jdbc-3.46.1.3.jar Arch.java
java -cp .:sqlite-jdbc-3.46.1.3.jar Arch shop.dbimport java.sql.*;
public class Arch {
public static void main(String[] args) throws Exception {
// Không có Class.forName ở bất kỳ đâu trong file này.
System.out.println("registered drivers:");
DriverManager.drivers().forEach(d -> System.out.println(" "
+ d.getClass().getName() + " " + d.getMajorVersion() + "." + d.getMinorVersion()));
try (Connection c = DriverManager.getConnection("jdbc:sqlite:" + args[0])) {
DatabaseMetaData md = c.getMetaData();
System.out.println("product = " + md.getDatabaseProductName() + " " + md.getDatabaseProductVersion());
System.out.println("driver = " + md.getDriverName() + " " + md.getDriverVersion());
System.out.println("jdbc version = " + md.getJDBCMajorVersion() + "." + md.getJDBCMinorVersion());
System.out.println("conn class = " + c.getClass().getName());
}
}
}registered drivers:
org.sqlite.JDBC 3.46
product = SQLite 3.46.1
driver = SQLite JDBC 3.46.1.3
jdbc version = 4.2
conn class = org.sqlite.jdbc4.JDBC4ConnectionCó hai chỗ trong output đó đáng dừng lại. conn class là org.sqlite.jdbc4.JDBC4Connection — một class nằm trong driver jar, chỉ được chạm tới qua interface Connection, đó là lý do phần code còn lại của bạn không hề nhắc tới SQLite. Và driver đã được đăng ký dù chương trình chưa bao giờ gọi Class.forName("org.sqlite.JDBC"). Lời gọi đó không còn cần thiết từ JDBC 4.0: DriverManager dùng ServiceLoader để đọc META-INF/services/java.sql.Driver trong mọi jar trên classpath rồi tự đăng ký thứ nó tìm thấy. Những tutorial vẫn mở đầu bằng Class.forName đang chép lại một thói quen từ thời Java 5. Tôi đã kiểm chứng bằng cách bỏ hẳn lời gọi đó — output ở trên đến từ một file không hề chứa nó.
Sau đó DriverManager.getConnection(url) duyệt qua các driver đã đăng ký và hỏi từng cái có nhận URL này không. Cái prefix là toàn bộ quyết định định tuyến:
| Database | URL |
|---|---|
| SQLite, dạng file | jdbc:sqlite:/path/to/shop.db |
| SQLite, trong bộ nhớ | jdbc:sqlite::memory: |
| PostgreSQL | jdbc:postgresql://host:5432/shop |
| MySQL | jdbc:mysql://host:3306/shop |
| SQL Server | jdbc:sqlserver://host:1433;databaseName=shop |
Nếu không driver nào nhận URL, bạn nhận một message rất dễ nhận ra. Đây là hình dạng của một dependency bị thiếu, không phải lỗi mạng:
java.sql.SQLException: No suitable driver found for jdbc:postgresql://localhost:5432/shop
SQLState=08001 errorCode=0Hai ghi chú đặc thù SQLite trước khi đi tiếp. jdbc:sqlite: tự tạo file database nếu nó chưa tồn tại — tôi chạy thử với một đường dẫn không có thật, File.exists() trả về false trước đó và true sau đó, nên một lỗi gõ nhầm đường dẫn sẽ lặng lẽ cho bạn một database rỗng mới thay vì báo lỗi. Và SQLite không có tài khoản người dùng, nên getConnection(url, "anybody", "anything") vẫn thành công còn getMetaData().getUserName() trả về null. Với PostgreSQL hay MySQL thì cả hai trường hợp đều là lỗi authentication.
Thêm một điều về connection: mở một cái rất tốn kém, và trong một server bạn không bao giờ mở một cái cho mỗi request. Đó là việc của connection pool, và cũng là chủ đề của bài kế tiếp — bài này cố tình mở connection theo kiểu ngây thơ để vòng đời của nó luôn hiện rõ.
Connection, Statement và ResultSet đều là resource
Cả ba đều implement AutoCloseable, và cả ba đều phải được đóng. Cách viết đúng là một khối try-with-resources duy nhất chứa hết, tự đóng theo thứ tự ngược lại:
String sql = "SELECT id, owner, balance FROM account WHERE balance > ?";
try (Connection c = DriverManager.getConnection(URL);
PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, 100);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
System.out.println(rs.getLong("id") + " " + rs.getString("owner") + " " + rs.getLong("balance"));
}
}
}ResultSet nằm trong một khối lồng bên trong vì một PreparedStatement thường được execute nhiều hơn một lần; khi nó chỉ chạy đúng một lần thì bạn đặt cả ba vào chung một danh sách resource cũng được.
Giờ tới phần nên đo thay vì đoán. Đặc tả JDBC nói đóng một Statement sẽ đóng ResultSet của nó, và đóng một Connection sẽ giải phóng tài nguyên của nó — nhưng các driver thể hiện điều đó trung thực ở mức khác nhau. Đây là những gì driver này thực sự làm:
// chỉ đóng Connection
Connection c2 = DriverManager.getConnection(URL);
Statement s2 = c2.createStatement();
ResultSet r2 = s2.executeQuery("SELECT id FROM account");
c2.close();
System.out.println("conn=" + c2.isClosed() + " stmt=" + s2.isClosed() + " rs=" + r2.isClosed());
// chỉ đóng Statement
Statement s3 = c3.createStatement();
ResultSet r3 = s3.executeQuery("SELECT id FROM account");
s3.close();
System.out.println("stmt=" + s3.isClosed() + " rs=" + r3.isClosed());=== close order with try-with-resources ===
inside : conn=false stmt=false rs=false
after : conn=true stmt=true rs=true
=== closing only the Connection ===
conn=true stmt=false rs=false
=== closing only the Statement ===
stmt=true rs=trueĐọc lại khối ở giữa. Đóng Connection để lại Statement và ResultSet vẫn báo isClosed() == false trên driver này. Ngược lại, đóng Statement thì đúng là có đóng ResultSet của nó. Vậy nên câu "tôi đóng connection rồi, mấy cái kia tự lo" là một niềm tin về driver của bạn, không phải một bảo đảm từ API — và nó sai hoàn toàn ở đúng trường hợp quan trọng nhất trong production, khi connection sống lâu vì nó đến từ một pool. Ở đó, đóng nó chỉ là trả nó về pool; mọi statement và result set bạn để mở trên nó vẫn mở, và chúng dồn lại suốt vòng đời của connection ấy:
=== statements piling up on one long-lived connection ===
iteration 0 count=3 stmtClosed=false
iteration 1 count=3 stmtClosed=false
iteration 2 count=3 stmtClosed=false
connection still open: trueBa vòng lặp thì vô hại. Ba triệu request trên một pooled connection là một cursor leak, và với một server database nó hiện ra dưới dạng ORA-01000 hay too many open cursors chứ không phải hết bộ nhớ. Đóng cả ba, luôn luôn, bằng try-with-resources.
PreparedStatement so với Statement: lý do thật sự là SQL injection
Phần lớn tutorial giới thiệu PreparedStatement như lựa chọn nhanh hơn, nhắc parameter binding như một tiện nghi, rồi đi tiếp. Cách đóng khung đó ngược, và nó là lý do SQL nối chuỗi vẫn tiếp tục được đẩy lên production. Lý do phải dùng placeholder là vì một query nối chuỗi cho phép input của người dùng đổi cấu trúc của câu SQL, còn một bound parameter thì không.
Đây là một đoạn kiểm tra login viết theo kiểu không bao giờ nên viết. Table users có ba row — an, binh và admin:
// TUYỆT ĐỐI ĐỪNG LÀM THẾ NÀY.
static void concatenated(Connection c, String user, String pass) throws SQLException {
String sql = "SELECT id, username, role FROM users "
+ "WHERE username = '" + user + "' AND password = '" + pass + "'";
try (Statement st = c.createStatement(); ResultSet rs = st.executeQuery(sql)) {
int n = 0;
while (rs.next()) {
n++;
System.out.println(" row : " + rs.getInt("id") + " "
+ rs.getString("username") + " " + rs.getString("role"));
}
System.out.println(" rows returned = " + n + (n > 0 ? " -> LOGIN ACCEPTED" : " -> login rejected"));
}
}Với input tử tế thì nó chạy đúng như ý định. Giờ gõ chuỗi này vào cả hai ô, username lẫn password:
' OR '1'='1[1] Statement + concatenation, honest input:
sql sent : SELECT id, username, role FROM users WHERE username = 'an' AND password = 'x9f2'
row : 1 an user
rows returned = 1 -> LOGIN ACCEPTED
[2] Statement + concatenation, input = ' OR '1'='1
sql sent : SELECT id, username, role FROM users WHERE username = '' OR '1'='1' AND password = '' OR '1'='1'
row : 1 an user
row : 2 binh user
row : 3 admin admin
rows returned = 3 -> LOGIN ACCEPTEDĐó là nguyên cả table, kể cả tài khoản administrator, trả về từ một form login bởi một người gõ đúng mười một ký tự. Không có gì bị "hack" cả. Dấu nháy đầu tiên đóng string literal sớm, mọi thứ sau nó được parse như SQL chứ không phải data, và mệnh đề WHERE mà database nhìn thấy không phải mệnh đề nằm trong file source của bạn. Code Java của bạn chứa một đoạn kiểm tra login; câu SQL đã chạy thì không.

Giờ vẫn query đó nhưng dùng placeholder. Thay đổi duy nhất là các giá trị được bind thay vì nối chuỗi:
static void prepared(Connection c, String user, String pass) throws SQLException {
String sql = "SELECT id, username, role FROM users WHERE username = ? AND password = ?";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, user);
ps.setString(2, pass);
try (ResultSet rs = ps.executeQuery()) {
int n = 0;
while (rs.next()) { n++; /* ... */ }
System.out.println(" rows returned = " + n + (n > 0 ? " -> LOGIN ACCEPTED" : " -> login rejected"));
}
}
}[3] PreparedStatement, input = ' OR '1'='1
sql sent : SELECT id, username, role FROM users WHERE username = ? AND password = ?
rows returned = 0 -> login rejected
[4] PreparedStatement, honest input:
sql sent : SELECT id, username, role FROM users WHERE username = ? AND password = ?
row : 1 an user
rows returned = 1 -> LOGIN ACCEPTEDKhông row nào cho cú tấn công, đúng một row cho người dùng thật. Cơ chế nằm ở thứ tự: prepareStatement gửi câu SQL xuống database và database parse nó trước khi có bất kỳ giá trị nào. Tới lúc setString chạy thì hình dạng của statement đã cố định — placeholder là một ô trống trong cây query đã parse, không phải một lỗ hổng trong một chuỗi. Input ' OR '1'='1 sau đó được so sánh, với tư cách một username mười một ký tự, với một column không chứa username nào như vậy. Nó không thể trở thành một toán tử, một mệnh đề hay một statement thứ hai, vì việc parse đã xong từ trước.
Hai thói quen rút ra từ đây, và đáng nêu thành quy tắc:
- Đừng bao giờ dựng SQL bằng cách nối bất cứ thứ gì đến từ bên ngoài chương trình. Không phải một ô form, không phải một query parameter, không phải một header, không phải một tên file, không phải một giá trị bạn vừa đọc lên từ chính database. Tự escape bằng tay không thay thế được; bạn sẽ làm sai một trường hợp biên nào đó.
- Một placeholder cho mọi giá trị, mọi lần, kể cả những giá trị bạn "biết chắc" là an toàn, ví dụ một số nguyên đã validate. Quy tắc này rẻ khi áp dụng đồng loạt và đắt khi áp dụng chọn lọc, vì bản chọn lọc sẽ hỏng âm thầm vào ngày ai đó đổi type của một field.
Lập luận về hiệu năng là có thật nhưng là thứ yếu: database có thể cache lại plan đã parse và tái sử dụng qua nhiều lần execute, nên một PreparedStatement chạy trong vòng lặp chỉ gửi lại phần parameter. Coi đó là phần thưởng thêm. Lý do chính vẫn là injection.
Typed setter, setNull, và SQLite làm gì với chúng
Parameter được set theo index bắt đầu từ 1 với một setter có type, và type bạn chọn chính là thứ driver gửi đi:
try (PreparedStatement ps = c.prepareStatement(
"INSERT INTO typed(s, i, d, b, dt, dec, flag) VALUES (?,?,?,?,?,?,?)")) {
ps.setString(1, "Xin chào");
ps.setInt(2, 42);
ps.setDouble(3, 3.5);
ps.setBytes(4, new byte[]{1, 2, 3});
ps.setString(5, LocalDate.of(2026, 9, 17).toString());
ps.setBigDecimal(6, new BigDecimal("12.345"));
ps.setBoolean(7, true);
ps.executeUpdate();
}Một giá trị null cần setNull, vì setString(1, null) thì hợp lệ nhưng setInt không có null nào để truyền. setNull nhận SQL type từ java.sql.Types để driver biết phải gửi loại null nào:
ps.setNull(1, Types.VARCHAR);
ps.setNull(2, Types.INTEGER);
ps.setNull(6, Types.DECIMAL);Đọc row đó lên cho thấy SQLite thực sự lưu cái gì, qua hàm typeof() của nó:
s=Xin chào(text) i=42(integer) d=3.5(real) dec=12.345(real) flag=true(integer)Để ý dec=12.345(real). Đặc thù SQLite: SQLite có năm storage class — null, integer, real, text, blob — và hoàn toàn không có kiểu decimal, nên setBigDecimal rơi vào một real và bạn mất phép tính thập phân chính xác. Với PostgreSQL thì cùng lời gọi đó ghi ra một numeric thật. Cũng không có boolean: setBoolean(7, true) lưu thành một integer. Và cách định type của SQLite là động, nên một column khai báo INTEGER vẫn vui vẻ chứa text:
SQLite stored typeof(n) = text
getString(n) = not a number
getInt(n) = 0Một server database sẽ từ chối insert đó. SQLite nhận, rồi getInt lặng lẽ trả về 0. Đừng học kỷ luật về type từ SQLite.
Placeholder không thể đứng thay tên table hay tên column
Đây là giới hạn của cơ chế và ai cũng vấp một lần. Placeholder đánh dấu vị trí của một giá trị trong một statement đã được parse. Tên table, tên column và chiều của ORDER BY là phần cấu trúc của statement, mà cấu trúc thì đã cố định từ lúc parse — nên không có gì để driver thay vào cả.
Thử với tên table thì lỗi ngay ở bước parse:
try (PreparedStatement ps = c.prepareStatement("SELECT * FROM ? WHERE id = 1")) {
ps.setString(1, "account");
ps.executeQuery();
}org.sqlite.SQLiteException: [SQLITE_ERROR] SQL error or missing database (near "?": syntax error)Báo lỗi đã là trường hợp may. Trường hợp nguy hiểm là tên column, nó không lỗi — nó được parse như một giá trị và lặng lẽ trả về đúng chuỗi literal ấy, mỗi row một lần:
try (PreparedStatement ps = c.prepareStatement("SELECT ? FROM account")) {
ps.setString(1, "owner");
// ...
} got: owner
got: owner
got: ownerBa row, mỗi row chứa chữ owner thay vì tên chủ tài khoản. ORDER BY ? cũng vậy: nó sắp xếp theo một hằng số, tức là chẳng làm gì, và trả về các row theo thứ tự nào mà database tự chọn. Cả hai đều là chuẩn SQL hoạt động đúng thiết kế, không phải một quirk của SQLite.
Khi bạn thật sự cần một column hay chiều sắp xếp động, giá trị đó tuyệt đối không được đi vào SQL dưới dạng text. Validate nó với một allow-list cố định rồi để code của chính bạn chọn ra literal:
private static final Set<String> SORTABLE = Set.of("id", "owner", "balance");
static String orderBy(String requested, boolean desc) {
if (!SORTABLE.contains(requested)) throw new IllegalArgumentException("bad sort column: " + requested);
return " ORDER BY " + requested + (desc ? " DESC" : " ASC");
}Phép nối chuỗi ở đó an toàn vì những chuỗi duy nhất có thể tới được nó là ba literal viết sẵn trong file source của bạn. Toàn bộ khác biệt nằm ở đúng chỗ ấy.
ResultSet là một cursor, không phải collection
ResultSet không phải một danh sách row được trao cho bạn. Nó là một cursor đặt trên các row mà driver — và thường là cả server — vẫn đang giữ, và nó chỉ sống chừng nào Statement của nó còn mở. Đúng một sự thật đó giải thích gần hết API của nó.

Cursor bắt đầu ở trước row đầu tiên. next() đẩy nó tới một row và trả về việc nó có đáp xuống row nào không, đó là lý do vòng lặp là while chứ không phải do/while:
try (Statement st = c.createStatement();
ResultSet rs = st.executeQuery("SELECT id, owner, email, balance FROM account ORDER BY id")) {
while (rs.next()) {
System.out.printf("id=%d owner=%s balance=%d%n",
rs.getInt("id"), rs.getString("owner"), rs.getInt("balance"));
}
System.out.println("after the loop, next() = " + rs.next());
}rows:
id=1 owner=Nguyen Van An email=an@example.com wasNull=false balance=500 ownerAgain=Nguyen Van An
id=2 owner=Tran Thi Binh email=null wasNull=true balance=120 ownerAgain=Tran Thi Binh
id=3 owner=Le Van Cuong email=cuong@example.com wasNull=false balance=80 ownerAgain=Le Van Cuong
after the loop, next() = falseColumn được đánh số từ 1, không phải từ 0. Index 0 không phải column đầu tiên, nó là một lỗi, và message in ra luôn khoảng hợp lệ:
java.sql.SQLException: column 0 out of bounds [1,2]Bạn nhận được message cùng dạng khi vượt quá số column (column 3 out of bounds [1,2]) và một message khác khi label không tồn tại (no such column: 'name'). Dùng index thì nhanh hơn một chút và dễ hỏng hơn nhiều khi có người sửa danh sách SELECT; trong code ứng dụng nên dùng label.
Hai cảnh báo về tính khả chuyển khi đọc. Đặc tả JDBC nói một lời gọi getX trước next() đầu tiên là lỗi, và phần lớn driver ném ResultSet is before the first row. Đặc thù driver: driver SQLite này thì không — nó trả về giá trị của row đầu tiên từ một cursor chưa hề được đẩy đi. Tương tự, đặc tả cho phép driver yêu cầu mỗi column chỉ được đọc một lần và theo đúng thứ tự trong SELECT, vì một streaming driver có thể không quay lui được; driver này cho phép đọc lại và đọc sai thứ tự, như cột ownerAgain ở trên cho thấy. Không cái nào trong hai điều đó là khả chuyển. Gọi next() trước, đọc mỗi column đúng một lần, và bạn an toàn ở mọi nơi.
Mặc định thì không cuộn ngược được. Type mặc định là TYPE_FORWARD_ONLY và driver này báo không hỗ trợ type nào khác, nên cả hai lời gọi sau đều lỗi:
absolute(2) -> ResultSet is TYPE_FORWARD_ONLY
previous() -> ResultSet is TYPE_FORWARD_ONLYNếu bạn cần duyệt các row nhiều hơn một lần, hãy chép chúng sang một List chứa record type của riêng bạn ngay trong lúc duyệt. Đừng trả một ResultSet ra khỏi method đã mở nó — tới lúc phía gọi đọc tới thì khối try đã đóng statement bên dưới nó rồi.
wasNull, vì một primitive không thể null
getInt trả về int, mà int thì không có null. Khi column là SQL NULL, JDBC trả về giá trị zero của type đó rồi bật một cờ:
getInt on SQL NULL = 0, wasNull = true
getObject = nullVậy 0 từ getInt là nhập nhằng — nó có thể là số 0 đã lưu, cũng có thể là NULL — và rs.wasNull() là cách phân biệt. Cái bẫy là wasNull() mô tả lời gọi getX gần nhất trên row này, không phải một column. Đọc thêm bất cứ thứ gì ở giữa là phá mất câu trả lời:
String email = rs.getString("email");
System.out.println("right : email=" + email + " -> wasNull()=" + rs.wasNull());
String e2 = rs.getString("email");
int bal = rs.getInt("balance");
System.out.println("wrong : email=" + e2 + ", then getInt(balance)=" + bal + " -> wasNull()=" + rs.wasNull());right : email=null -> wasNull()=true
wrong : email=null, then getInt(balance)=120 -> wasNull()=false (asks about balance, not email)Gọi wasNull() ngay dòng sau lời gọi getX mà nó thuộc về, hoặc tránh nó hẳn bằng getObject("balance", Long.class) — cái này trả về một Long có thể null và không thể đọc nhầm.
Đọc column một cách tổng quát với ResultSetMetaData
Khi bạn không biết trước hình dạng của query — một công cụ báo cáo, một chỗ export CSV, một bản dump để debug — ResultSetMetaData mô tả nó cho bạn:
ResultSetMetaData md = rs.getMetaData();
for (int i = 1; i <= md.getColumnCount(); i++) {
System.out.printf(" %d label=%-8s type=%-8s javaType=%-17s nullable=%d%n",
i, md.getColumnLabel(i), md.getColumnTypeName(i), md.getColumnClassName(i), md.isNullable(i));
}columns = 4
1 label=id type=INTEGER javaType=java.lang.Integer nullable=1
2 label=owner type=TEXT javaType=java.lang.String nullable=0
3 label=email type=TEXT javaType=java.lang.String nullable=1
4 label=balance type=INTEGER javaType=java.lang.Integer nullable=0getColumnLabel cho ra alias AS khi có, còn không thì cho tên column — đúng thứ bạn cần cho phần header. isNullable trả về một trong columnNoNulls (0), columnNullable (1) hoặc columnNullableUnknown (2) — để ý nó báo 1 cho primary key id, đây là một quirk khi SQLite báo cáo metadata chứ không phải điều bạn nên tin.
execute, executeQuery và executeUpdate
Ba cách chạy một statement, và chọn nhầm cái sẽ cho ra một lỗi khó hiểu:
| Method | Dùng cho | Trả về |
|---|---|---|
executeQuery | statement sinh ra row | ResultSet |
executeUpdate | INSERT, UPDATE, DELETE, DDL | số row bị ảnh hưởng, 0 với DDL |
execute | khi bạn không biết là loại nào | true nếu có ResultSet |
execute là dạng tổng quát; sau đó bạn gọi getResultSet() hoặc getUpdateCount() tuỳ theo nó báo gì:
execute(SELECT) -> true, getUpdateCount=-1
rows = 4
execute(UPDATE) -> false, getUpdateCount=2, getResultSet=null
executeUpdate(DDL) -> 0
executeUpdate(DROP) -> 0Để ý giá trị canh chừng: getUpdateCount() trả về -1 khi kết quả là một ResultSet, còn getResultSet() trả về null khi kết quả là số row. Với những con số có thể vượt Integer.MAX_VALUE thì có executeLargeUpdate, trả về long và được driver này hỗ trợ.
Giờ tới cái lỗi đáng thuộc lòng. Gọi executeQuery trên một INSERT sẽ ném exception — nhưng lệnh insert vẫn chạy:
java.sql.SQLException: query does not return ResultSet
SQLState=SQLITE_DONE errorCode=101Tôi kiểm tra lại table ngay sau đó và row mới đã nằm ở đấy. Driver gửi statement đi, database thực thi nó, rồi driver mới phát hiện ra không có result set nào để trả về. Vậy nên một exception từ executeQuery không phải bằng chứng rằng chưa có gì được ghi. Đó là một cái bẫy rất thật trong một khối catch phải quyết định có retry hay không.
Hai quan sát đặc thù SQLite cùng chủ đề, cả hai đều là chuyện driver dễ dãi ở nơi đặc tả nghiêm khắc: executeUpdate trên một SELECT không ném lỗi ở đây, và chạy một PreparedStatement còn sót một parameter chưa set cũng không ném — driver bind nó thành NULL rồi chạy query. Một server driver sẽ từ chối parameter chưa set thay vì tự đoán ra một giá trị. Đừng dựa vào cách hành xử dễ dãi đó; nó không khả chuyển, và nó che bug.
Transaction: setAutoCommit(false), commit và rollback
Một JDBC connection khởi đầu ở chế độ auto-commit, nên mỗi statement là một transaction riêng:
autoCommit default = trueĐó là điều sai hoàn toàn với bất cứ việc gì có nhiều hơn một bước. Đây là một lệnh chuyển tiền giữa hai tài khoản. Nó trừ một row và cộng vào một row khác, còn tài khoản đích thì không tồn tại:
static int move(Connection c, int from, int to, int amount) throws SQLException {
try (PreparedStatement debit = c.prepareStatement(
"UPDATE account SET balance = balance - ? WHERE id = ? AND balance >= ?")) {
debit.setInt(1, amount); debit.setInt(2, from); debit.setInt(3, amount);
int d = debit.executeUpdate();
if (d == 0) throw new SQLException("insufficient funds on account " + from);
}
try (PreparedStatement credit = c.prepareStatement(
"UPDATE account SET balance = balance + ? WHERE id = ?")) {
credit.setInt(1, amount); credit.setInt(2, to);
int u = credit.executeUpdate();
if (u == 0) throw new SQLException("no such destination account " + to);
}
return 2;
}Chạy với auto-commit vẫn bật, chuyển 50 từ tài khoản 3 sang một tài khoản không có thật:
=== the SAME transfer with autoCommit left on ===
before: Nguyen Van An=500 Tran Thi Binh=120 Le Van Cuong=80
debit updated 1 row(s)
credit updated 0 row(s)
failed: no such destination account 999
after : Nguyen Van An=500 Tran Thi Binh=120 Le Van Cuong=30Tài khoản đi từ 80 xuống 30 và 50 kia biến mất. Lệnh trừ đã commit ngay khoảnh khắc nó chạy, lệnh cộng không khớp row nào, và không có nút undo. Giờ vẫn lời gọi ấy nhưng nằm trong một transaction:
c.setAutoCommit(false);
try {
move(c, 3, 999, 50);
c.commit();
} catch (SQLException e) {
c.rollback();
throw e;
} finally {
c.setAutoCommit(true);
}=== transfer that fails, wrapped in a transaction ===
before: Nguyen Van An=500 Tran Thi Binh=120 Le Van Cuong=80
debit updated 1 row(s)
credit updated 0 row(s)
failed: no such destination account 999
rolled back
after : Nguyen Van An=500 Tran Thi Binh=120 Le Van Cuong=80Cùng một lỗi, và số dư giống hệt lúc trước tới từng byte. Những quy tắc làm nó chạy được đáng liệt kê ra, vì mỗi quy tắc là một bug ai đó đã đẩy lên production:
setAutoCommit(false)mở transaction; JDBC không cóbegin().- Không có gì bền vững cho tới khi
commit(). Một lệnhreturnsớm bỏ qua nó sẽ âm thầm vứt hết công việc. rollback()thuộc vềcatch, không phảifinally— đặt trongfinallythì nó chạy cả trên nhánh thành công, sau khicommit()đã kết thúc transaction.- Khôi phục
setAutoCommit(true)trongfinally, nhất là khi connection đến từ một pool. Một connection bị trả về giữa chừng một transaction sẽ đầu độc người dùng kế tiếp của nó. - Transaction sống trên
Connection. Hai connection là hai transaction; bạn không thể trải một transaction qua cả hai nếu không có distributed transaction manager.
Savepoint: rollback một phần của transaction
Một Savepoint là một điểm đánh dấu bên trong transaction đang mở, cho phép bạn quay lui về đó mà không vứt bỏ mọi thứ trước nó:
c.setAutoCommit(false);
ps.setInt(1, 10); ps.setInt(2, 1); ps.executeUpdate(); // +10, giữ lại
Savepoint sp = c.setSavepoint("after_bonus");
ps.setInt(1, 1000); ps.setInt(2, 1); ps.executeUpdate(); // +1000, bỏ đi
c.rollback(sp);
c.releaseSavepoint(sp);
c.commit(); savepoint name = after_bonus
inside tx, after both updates: Nguyen Van An=1510 Tran Thi Binh=120 Le Van Cuong=80
after rollback(savepoint) : Nguyen Van An=510 Tran Thi Binh=120 Le Van Cuong=80
after commit: Nguyen Van An=510 Tran Thi Binh=120 Le Van Cuong=80Phần +10 sống sót, phần +1000 thì không, và lệnh commit làm cho nửa còn lại trở nên bền vững. rollback(savepoint) không kết thúc transaction; chỉ commit() hoặc một rollback() trần mới làm điều đó.
Isolation level, và SQLite thực sự có những mức nào
Interface Connection đưa ra năm hằng số. Database hỗ trợ được cái nào lại là chuyện khác, và DatabaseMetaData sẽ trả lời thành thật:
isolation default = 8 (SERIALIZABLE=8, READ_UNCOMMITTED=1)
supportsTransactions = true
supports NONE = false
supports READ_UNCOMMITTED = false
supports READ_COMMITTED = false
supports REPEATABLE_READ = false
supports SERIALIZABLE = trueĐặc thù SQLite: nó báo đúng một mức được hỗ trợ, TRANSACTION_SERIALIZABLE, và đó cũng là mặc định. Điều này là hệ quả của mô hình khoá của nó chứ không phải một hạn chế cần né tránh — một writer giữ write lock trên toàn database, nên các transaction không đan xen theo kiểu như trên một server.
Có một chi tiết đáng biết, vì nó chính là loại chuyện sinh ra sự tự tin sai chỗ. setTransactionIsolation chấp nhận cả những mức mà metadata vừa nói là không hỗ trợ:
setTransactionIsolation(NONE) -> Unsupported transaction isolation level: 0. Must be one of TRANSACTION_READ_UNCOMMITTED, TRANSACTION_READ_COMMITTED, TRANSACTION_REPEATABLE_READ, or TRANSACTION_SERIALIZABLE in java.sql.Connection
setTransactionIsolation(READ_UNCOMMITTED) accepted, now = 1
setTransactionIsolation(READ_COMMITTED) accepted, now = 2
setTransactionIsolation(REPEATABLE_READ) accepted, now = 4
setTransactionIsolation(SERIALIZABLE) accepted, now = 8TRANSACTION_NONE bị từ chối; ba mức còn lại được chấp nhận và lưu lại dù supportsTransactionIsolationLevel trả về false cho chúng. SQLite thực chất chỉ có hai hành vi — serializable, và một chế độ read_uncommitted cho các connection dùng shared cache — nên đặt READ_COMMITTED ở đây chỉ đổi một con số chứ không đổi gì khác. Hãy tin supportsTransactionIsolationLevel, đừng tin việc không có exception.
Trên một server database thì các mức đó khác nhau thật và mặc định cũng không giống nhau: PostgreSQL mặc định READ COMMITTED và âm thầm ánh xạ READ UNCOMMITTED vào chính nó, còn MySQL với InnoDB mặc định REPEATABLE READ. Nếu tính đúng đắn của bạn phụ thuộc vào một mức nào đó, hãy set nó tường minh rồi kiểm chứng bằng getTransactionIsolation() sau khi connect.
Batching: một executeBatch thay vì N executeUpdate
addBatch xếp hàng một bộ parameter đã bind; executeBatch gửi cả hàng đợi đi như một khối và trả về một con số cho mỗi statement:
c.setAutoCommit(false);
try (PreparedStatement ps = c.prepareStatement("INSERT INTO product(name, price) VALUES (?, ?)")) {
for (String[] r : rows) {
ps.setString(1, r[0]);
ps.setInt(2, Integer.parseInt(r[1]));
ps.addBatch();
}
int[] counts = ps.executeBatch();
c.commit();
} finally {
c.setAutoCommit(true);
}counts = [1, 1, 1, 1, 1]
counts.length = 5 (statements sent in the batch = 5)
SUCCESS_NO_INFO=-2 EXECUTE_FAILED=-3Mảng có đúng một phần tử cho mỗi statement đã thêm, theo thứ tự. Mỗi phần tử là số row bị ảnh hưởng, hoặc Statement.SUCCESS_NO_INFO (-2) khi driver thành công nhưng không đếm, hoặc Statement.EXECUTE_FAILED (-3) khi riêng statement đó hỏng mà batch vẫn chạy tiếp. Luôn kiểm tra mảng này; một batch "chạy được" vẫn có thể chứa một số -3.
Lợi ích ở đây là về cấu trúc, không phải phép màu, nên hãy phát biểu nó theo cấu trúc. Insert 200 row từng cái một là 200 lời gọi executeUpdate; cũng 200 row đó dưới dạng batch là một lời gọi executeBatch trả về một mảng dài 200 với tổng bằng 200. Với một server database thì 200 lời gọi kia là 200 lượt đi về qua mạng còn batch chỉ là một, và đó chính là toàn bộ lý do batching tồn tại. Đặc thù SQLite: SQLite là một thư viện chạy trong process chứ không phải server, nên ở đây không có lượt đi về qua mạng nào để tiết kiệm cả — thứ bạn tiết kiệm được trên SQLite là lần commit cho từng statement, và đó là lý do batch ở trên được bọc trong một transaction tường minh. Đừng mang một con số benchmark batching của SQLite sang PostgreSQL, và ngược lại cũng vậy.
Chuyện xử lý lỗi trong batch là nơi các driver lệch nhau mạnh nhất. Đặc tả nói một batch hỏng sẽ ném BatchUpdateException, class này kế thừa SQLException và mang theo getUpdateCounts() để bạn biết nó đi được tới đâu. Driver này thì không:
=== a batch with one bad row ===
caught org.sqlite.SQLiteException: [SQLITE_CONSTRAINT_UNIQUE] A UNIQUE constraint failed (UNIQUE constraint failed: product.name)
is it a BatchUpdateException? false
SQLState=null errorCode=19
rolled backMột SQLiteException trơn, không kèm update count nào. Vậy nên hãy viết catch cho SQLException rồi kiểm tra instanceof BatchUpdateException trước khi với tay lấy getUpdateCounts(), thay vì mặc định là nó có sẵn. Batch bên trong một transaction và rollback khi hỏng, thì câu hỏi nó đi được tới đâu cũng không còn quan trọng nữa.
Hai lưu ý thực dụng. Cắt những batch rất lớn thành từng khối vài nghìn để buffer của driver và transaction log của database còn giới hạn được, và nhớ rằng addBatch tích luỹ trên statement — gọi clearBatch() nếu bạn dùng lại nó sau một lần lỗi.
getGeneratedKeys: đọc lại id mà database vừa gán
Khi primary key do database sinh ra, giá trị bạn cần không trở về từ executeUpdate — cái đó trả về số row. Phải yêu cầu key một cách tường minh ngay lúc prepare:
String sql = "INSERT INTO product(name, price) VALUES (?, ?)";
try (PreparedStatement ps = c.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
ps.setString(1, "Bàn phím cơ");
ps.setInt(2, 1290000);
ps.executeUpdate();
try (ResultSet keys = ps.getGeneratedKeys()) {
while (keys.next()) System.out.println("new id = " + keys.getLong(1));
}
}executeUpdate returned 1
generated-key columns = 1, label = last_insert_rowid()
new id = 1Key trở về dưới dạng một ResultSet bình thường, nên nó cũng cần được đóng như mọi result set khác, và bạn phải gọi next() trước khi đọc. Label của column là đặc thù của driver — ở đây là last_insert_rowid(), còn với PostgreSQL bạn sẽ nhận tên column thật — nên hãy đọc key theo index 1, đừng đọc theo label.
Hai điều không khả chuyển. supportsGetGeneratedKeys() trả về true ở đây, nhưng một số driver và một số kiểu column không trả về gì cả, nên hãy kiểm tra thay vì mặc định. Và trên driver này key vẫn trở về kể cả khi không có RETURN_GENERATED_KEYS:
no RETURN_GENERATED_KEYS -> keys.next() = trueĐó là sự tiện tay của SQLite, không phải hợp đồng của API. Cứ truyền cờ đó vào. Ngoài ra còn dạng prepareStatement(sql, new String[]{"id"}) khi bạn muốn nêu tên các column cần trả về, đây là dạng mà PostgreSQL ưa dùng.
Đọc một SQLException: getSQLState, getErrorCode và chuỗi exception
SQLException mang theo ba mẩu thông tin ngoài phần message, và biết nên rẽ nhánh theo cái nào sẽ đỡ phải đoán rất nhiều:
getMessage()— chữ dành cho người, do driver viết. Đừng bao giờ rẽ nhánh theo nó.getSQLState()— một mã chuẩn năm ký tự (23000là vi phạm ràng buộc toàn vẹn,08001là lỗi kết nối). Trên lý thuyết thì khả chuyển.getErrorCode()— mã số riêng của hãng. Chính xác, và hoàn toàn không khả chuyển.
SQLException cũng implement Iterable<Throwable>, nên một vòng lặp duyệt được cả chuỗi getNextException lẫn chuỗi getCause:
static void report(SQLException e) {
for (Throwable t : e) {
System.out.println(" " + t.getClass().getName() + ": " + t.getMessage());
if (t instanceof SQLException s) {
System.out.println(" SQLState = " + s.getSQLState());
System.out.println(" errorCode = " + s.getErrorCode());
}
}
}Và đây là thứ một key trùng thực sự sinh ra:
duplicate username:
org.sqlite.SQLiteException: [SQLITE_CONSTRAINT_UNIQUE] A UNIQUE constraint failed (UNIQUE constraint failed: users.username)
SQLState = null
errorCode = 19
is it SQLIntegrityConstraintViolationException? falseHai điều thất vọng trong năm dòng, và cả hai đều thành thật. Đặc thù SQLite: getSQLState() trả về null. Cái mã chuẩn mà bạn định rẽ nhánh theo không hề có ở đó, và DatabaseMetaData.getSQLStateType() trả về 2, tức hằng số sqlStateSQL99 — nên driver tuyên bố là nó nói chuẩn state, trong khi không cấp state nào cho lỗi này. Mã errorCode riêng bằng 19 là SQLITE_CONSTRAINT và đáng tin, với cái giá là hard-code một hằng số của SQLite. Trong khi đó JDBC 4.0 đã bổ sung một họ subclass có type — SQLIntegrityConstraintViolationException, SQLTimeoutException, SQLTransientConnectionException — cho phép bạn catch theo ý nghĩa thay vì theo mã số, mà driver này không ném cái nào trong số đó.
Hệ quả thực dụng: catch và dịch ở biên. Bọc exception của driver vào exception domain của bạn ngay trong DAO, quyết định ở đó xem nó có retry được không, và giữ các mã của hãng ở đúng một chỗ thay vì rải khắp tầng service. Như vậy việc chuyển sang database khác chỉ là một file. Để tham khảo: PostgreSQL điền SQLState đầy đủ (23505 cho vi phạm unique) và MySQL Connector/J ném đúng những subclass có type, nên đoạn code trông có vẻ khả chuyển mà bạn viết cho chúng chính là thứ SQLite sẽ không cho bạn.
Chuỗi getNextException quan trọng nhất với batch và với warning, nơi một thao tác sinh ra nhiều vấn đề cùng lúc. Dựng thủ công một chuỗi cho thấy rõ hình dạng của nó:
hand-built chain:
java.sql.SQLException: row 2 rejected
SQLState = 23000
errorCode = 19
java.sql.SQLException: row 7 rejected
SQLState = 23000
errorCode = 19Một khối catch (SQLException e) { log(e); } trơn chỉ in ra cái đầu tiên. Vòng for ở trên in hết, và nguyên nhân thật thường nằm ở đó.
Chỗ nào là đặc thù SQLite, chỗ nào là JDBC chuẩn
Vì tất cả những gì ở trên đều chạy với SQLite, tách bạch hai nhóm ra là việc đáng làm. Cột trái sẽ giống hệt nếu chạy với PostgreSQL hay MySQL; cột phải thì không:
| JDBC chuẩn, khả chuyển | Đặc thù SQLite hoặc driver |
|---|---|
DriverManager.getConnection(url) và định tuyến theo prefix | jdbc:sqlite: tự tạo file còn thiếu thay vì báo lỗi |
Driver tự đăng ký từ JDBC 4.0, không cần Class.forName | user và password trên URL được nhận rồi bỏ qua |
try-with-resources trên cả ba đối tượng | đóng Connection để isClosed() của statement vẫn là false |
PreparedStatement, placeholder, an toàn trước injection | định type động: text lọt được vào column INTEGER |
| Placeholder không thể là tên table hay tên column | setBigDecimal tụt xuống real; không có kiểu boolean |
Column index từ 1 và wasNull() | getX trước lần next() đầu tiên vẫn được cho phép |
setAutoCommit, commit, rollback, Savepoint | chỉ TRANSACTION_SERIALIZABLE là được hỗ trợ thật |
addBatch, executeBatch và mảng count | không có BatchUpdateException; chỉ một SQLiteException trơn |
getGeneratedKeys và RETURN_GENERATED_KEYS | key trả về cả khi không có cờ; label là last_insert_rowid() |
getSQLState, getErrorCode, chuỗi exception | getSQLState() là null; không có subclass JDBC 4 nào |
Còn một điểm nữa hay làm khó người chuyển đổi: ALTER TABLE của SQLite bị giới hạn nặng. Kiểm chứng trên chính database này:
OK ALTER TABLE alt_t ADD COLUMN c TEXT
OK ALTER TABLE alt_t RENAME COLUMN b TO b2
OK ALTER TABLE alt_t DROP COLUMN c
FAIL ALTER TABLE alt_t ALTER COLUMN a TYPE TEXT
[SQLITE_ERROR] SQL error or missing database (near "ALTER": syntax error)
FAIL ALTER TABLE alt_t ADD CONSTRAINT chk CHECK (a > 0)
[SQLITE_ERROR] SQL error or missing database (near "CONSTRAINT": syntax error)Thêm, đổi tên và xoá một column thì được; đổi type của column hay thêm constraint thì không hề tồn tại, và cách làm tiêu chuẩn là tạo table mới rồi chép dữ liệu sang. Thêm một column NOT NULL vào table đã có row cũng lỗi trừ khi bạn cho một giá trị mặc định:
[SQLITE_ERROR] SQL error or missing database (Cannot add a NOT NULL column with default value NULL)
with DEFAULT '' -> OKJDBC không phải là cái gì
JDBC nằm ở đáy của stack, và nó nhỏ một cách có chủ đích: nó chuyển các chuỗi SQL cùng giá trị parameter xuống database rồi mang row trở lên. Nó không biết entity là gì, nó không map một ResultSet vào record của bạn, nó không sinh SQL, và nó không quản lý một transaction trải qua hai lời gọi method giúp bạn. Mọi thứ làm được những việc đó đều nằm bên trên nó và gọi xuống nó. JPA và Hibernate thêm phần object-relational mapping, một persistence context và lazy loading, đổi lại là một mô hình lớn mà bạn phải hiểu đủ sâu để đoán được câu SQL nó phát ra. jOOQ giữ SQL làm mô hình lập trình nhưng làm cho nó có type và được kiểm tra với schema thật của bạn. JdbcTemplate của Spring bỏ đi phần boilerplate — xử lý resource, map row, dịch exception sang một cây unchecked — mà không giấu SQL đi. Spring Data JPA ngồi trên tất cả những thứ đó và sinh repository từ tên method. Mỗi cái trong số chúng đều mở một Connection, prepare một statement và duyệt một ResultSet đúng như bài viết này làm; khi một trong số chúng chạy sai, thứ bạn sẽ phải đọc là một stack trace JDBC, và đó là lý do thực dụng để hiểu tầng này kể cả khi bạn không bao giờ viết tay nó.
FAQ
Có còn cần Class.forName để load JDBC driver không?
Không. Từ JDBC 4.0, driver jar tự khai báo mình trong META-INF/services/java.sql.Driver, và DriverManager tìm ra nó qua ServiceLoader ngay lần đầu được dùng. Tôi kiểm chứng bằng cách chạy một chương trình không có Class.forName ở bất kỳ đâu: DriverManager.drivers() liệt kê org.sqlite.JDBC 3.46 và connection mở bình thường. Giữ lại lời gọi đó cũng vô hại, nhưng nó là nhiễu, và nó ép code của bạn phụ thuộc vào một tên class của hãng mà lẽ ra nó không bao giờ nhắc tới. Chỉ vài tình huống hiếm mới còn cần: một driver cũ tới mức có trước JDBC 4.0, hoặc một cấu hình classloader khiến file service không nhìn thấy được.
PreparedStatement có thật sự nhanh hơn Statement không?
Đôi khi, và đó là lý do sai để dùng nó. Lập luận hiệu năng là database parse và lập plan cho câu SQL một lần rồi tái sử dụng plan đó cho các lần execute sau với parameter khác, nên một statement chạy trong vòng lặp chỉ gửi lại phần giá trị. Lợi ích đó là thật trên một server database có plan cache và gần như bằng không với một statement chỉ chạy một lần. Lý do để dùng PreparedStatement vô điều kiện là nó khiến SQL injection trở nên bất khả thi về mặt cấu trúc: statement được parse trước khi có giá trị nào, nên một giá trị không bao giờ trở thành một phần cấu trúc của query. Ở trên, cùng một input trả về nguyên table users qua một Statement nối chuỗi và không row nào qua một PreparedStatement. Cứ coi phần nhanh hơn là quà tặng kèm.
Vì sao ResultSet của tôi lỗi sau khi tôi đóng Statement?
Vì ResultSet là một cursor trên các row mà driver vẫn đang giữ, không phải một bản sao được trao cho bạn. Đóng Statement của nó là đóng luôn nó — hành vi này do đặc tả yêu cầu và driver này tuân thủ, như dòng stmt=true rs=true ở output phía trên cho thấy. Cách hay gặp nhất để dính lỗi này là trả một ResultSet ra khỏi một method mà khối try-with-resources của nó đã kết thúc, hoặc cất một cái vào field. Hãy đọc thứ bạn cần ngay trong khối đó và trả về đối tượng của riêng bạn: một List các record, một Map, một Optional. Nếu bạn thấy mình muốn giữ một ResultSet mở xuyên qua nhiều method, thứ bạn thực sự muốn là một Stream được dựng bên trong method sở hữu các resource ấy.
Khác nhau giữa execute, executeQuery và executeUpdate là gì?
executeQuery dành cho statement trả về row và cho bạn một ResultSet; executeUpdate dành cho INSERT, UPDATE, DELETE và DDL, cho bạn số row bị ảnh hưởng (0 với DDL); execute dành cho lúc bạn không biết là loại nào, nó trả về một boolean để báo, sau đó bạn gọi getResultSet() hoặc getUpdateCount(). Cái bẫy quan trọng là gọi nhầm không phải lúc nào cũng vô hại: executeQuery trên một INSERT ném query does not return ResultSet ở đây, còn row thì vẫn được insert. Một exception từ executeQuery không phải bằng chứng rằng statement không có tác dụng gì, và điều đó rất quan trọng nếu khối catch của bạn phải quyết định có retry hay không.
Làm sao để một thao tác database nhiều bước là được hết hoặc không gì cả?
Gọi setAutoCommit(false) trên connection, làm hết các bước, gọi commit() khi thành công và rollback() trong catch, rồi khôi phục setAutoCommit(true) trong finally. Phần trình bày ở trên chạy cùng một lệnh chuyển tiền hỏng theo cả hai cách: với auto-commit bật, tài khoản 3 mất 50 mà không tới đâu cả, còn bên trong một transaction thì số dư trở lại y hệt lúc trước. Hai chi tiết quyết định nó có chạy thật hay không: mọi thứ phải diễn ra trên một Connection, vì transaction sống ở đó, và không nhánh code nào được return trước commit(). Nếu một phần công việc cần sống sót qua lỗi cục bộ, hãy dùng Savepoint và rollback(savepoint).
Vì sao getSQLState là null trên SQLite, và nên rẽ nhánh theo cái gì?
Vì driver này không điền nó cho phần lớn lỗi — một key trùng cho ra SQLState = null với errorCode = 19 của hãng. SQLState là mã chuẩn năm ký tự và là thứ đúng để rẽ nhánh khi nó tồn tại (PostgreSQL dùng 23505 cho vi phạm unique), còn getErrorCode thì chính xác nhưng gắn với hãng. Vì cả hai đều không đáng tin xuyên qua các database, câu trả lời khả chuyển là dịch ở biên DAO: catch SQLException, quyết định ngay tại đó xem nó nghĩa là "trùng", "deadlock, thử lại" hay "chết hẳn", rồi ném exception của riêng bạn. Khi ấy các mã của hãng nằm gọn trong một class thay vì rải khắp tầng service.
Batching có làm insert của tôi nhanh hơn không?
Nó giảm số lượt đi về, và đó là một phát biểu về cấu trúc mà bạn đếm được chứ không cần bấm giờ. Hai trăm row insert lẻ là 200 lời gọi executeUpdate; dưới dạng batch nó là một lời gọi executeBatch trả về một mảng 200 con số. Với một server database thì đó là 200 lượt đi về qua mạng so với một lượt, và thường là phần thắng lớn nhất có được trong một lần nạp dữ liệu hàng loạt. Với SQLite thì không có mạng nào cả — nó chạy ngay trong process của bạn — nên thứ batching tiết kiệm ở đó là lần commit cho từng statement, và đó là lý do bạn nên bọc batch trong một transaction tường minh. Dù là database nào, hãy kiểm tra mảng count trả về xem có EXECUTE_FAILED (-3) không thay vì mặc định là thành công, và cắt những batch quá lớn thành từng khối.
Có dùng được placeholder cho tên table hay column sắp xếp không?
Không. Placeholder đánh dấu vị trí của một giá trị trong một statement đã được parse, mà tên table, tên column hay chiều sắp xếp là phần cấu trúc ấy. SELECT * FROM ? lỗi syntax ngay tại dấu ?. Tệ hơn, SELECT ? FROM account không lỗi: nó trả về chuỗi literal owner một lần cho mỗi row, còn ORDER BY ? thì lặng lẽ sắp xếp theo một hằng số. Khi bạn cần một định danh động, hãy validate chuỗi đầu vào với một allow-list viết cứng — một Set.of("id", "owner", "balance") trong source của bạn — rồi chỉ nối vào đúng cái literal mà allow-list đã khớp. Đừng bao giờ nối chính chuỗi của phía gọi.
Kết luận
JDBC là một API nhỏ với vài cạnh sắc, và gần như toàn bộ nỗi đau nó gây ra đến từ bốn cạnh đó. Connection, Statement và ResultSet là các resource có vòng đời lồng nhau, và đóng mỗi cái ngoài cùng sẽ để hai cái kia mở — đã chứng minh ở trên, trên một driver báo stmt=false rs=false sau khi connection đã đóng. PreparedStatement không phải một phép tối ưu; nó là khác biệt giữa một form login và một bản dump nguyên table. Một ResultSet là cursor bắt đầu trước row đầu tiên, đánh số column từ 1, và chết theo statement của nó. Còn auto-commit nghĩa là mỗi statement đứng riêng một mình, điều sai chính xác với mọi thao tác có hai bước.
Mọi thứ khác trong bài đều là chi tiết treo vào bốn cạnh ấy. Batching là một lời gọi thay vì N. getGeneratedKeys là cách bạn biết id mà database đã chọn. SQLState và chuỗi exception là cách bạn biết chuyện gì đã hỏng, khi driver chịu nói cho bạn. Chạy với SQLite khiến tất cả tái hiện được trên một chiếc laptop chỉ với một file jar tải về, đổi lại là một nhúm hành vi — SQLState null, định type động, đúng một isolation level thật, không có BatchUpdateException — vốn thuộc về driver chứ không thuộc về JDBC, và đã được ghi rõ ở trên để bạn không bất ngờ lần đầu trỏ cùng đoạn code ấy sang PostgreSQL.
Bài kế tiếp bắt đúng chỗ bài này cố tình dừng lại: mở một Connection cho mỗi request là quá đắt để làm trong một service thật, và bài 27 nói về connection pooling với HikariCP — một pool thay đổi vòng đời bạn vừa học thế nào, chỉnh kích thước ra sao, và nó tìm ra những chỗ rò rỉ mà bài này vừa cảnh báo bằng cách nào.