Command Palette

Search for a command to run...

[Advanced Java] JDBC trong Java: Kết nối và thao tác Database

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.

Java một bên, ba database bên kia, JDBC ở giữa là API duy nhất

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, ResultSetDatabaseMetaData đề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.

Các tầng từ code của bạn xuống java.sql và DriverManager rồi tới ba driver jar và ba database

Toàn bộ phần cài đặt chỉ là một file jar:

Bash
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.db
Java
import 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());
        }
    }
}
Text
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.JDBC4Connection

Có hai chỗ trong output đó đáng dừng lại. conn classorg.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:

DatabaseURL
SQLite, dạng filejdbc:sqlite:/path/to/shop.db
SQLite, trong bộ nhớjdbc:sqlite::memory:
PostgreSQLjdbc:postgresql://host:5432/shop
MySQLjdbc:mysql://host:3306/shop
SQL Serverjdbc: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:

Text
java.sql.SQLException: No suitable driver found for jdbc:postgresql://localhost:5432/shop
  SQLState=08001 errorCode=0

Hai 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:

Java
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:

Java
// 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());
Text
=== 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 StatementResultSet 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:

Text
=== 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: true

Ba 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, binhadmin:

Java
// 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:

Text
' OR '1'='1
Text
[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.

Cùng một input qua Statement và PreparedStatement, đặt cạnh nhau cùng thứ mà database parse ra

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:

Java
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"));
        }
    }
}
Text
[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 ACCEPTED

Khô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:

Java
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:

Java
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ó:

Text
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:

Text
SQLite stored typeof(n) = text
  getString(n) = not a number
  getInt(n)    = 0

Mộ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:

Java
try (PreparedStatement ps = c.prepareStatement("SELECT * FROM ? WHERE id = 1")) {
    ps.setString(1, "account");
    ps.executeQuery();
}
Text
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:

Java
try (PreparedStatement ps = c.prepareStatement("SELECT ? FROM account")) {
    ps.setString(1, "owner");
    // ...
}
Text
  got: owner
  got: owner
  got: owner

Ba 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:

Java
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 của ResultSet đi qua năm vị trí, kèm thước column đánh số từ 1 và message out-of-bounds thật

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:

Java
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());
}
Text
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() = false

Column đượ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ệ:

Text
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:

Text
absolute(2) -> ResultSet is TYPE_FORWARD_ONLY
previous()  -> ResultSet is TYPE_FORWARD_ONLY

Nế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ờ:

Text
getInt on SQL NULL = 0, wasNull = true
getObject          = null

Vậ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:

Java
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());
Text
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:

Java
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));
}
Text
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=0

getColumnLabel 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:

MethodDùng choTrả về
executeQuerystatement sinh ra rowResultSet
executeUpdateINSERT, UPDATE, DELETE, DDLsố row bị ảnh hưởng, 0 với DDL
executekhi bạn không biết là loại nàotrue 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ì:

Text
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:

Text
java.sql.SQLException: query does not return ResultSet
  SQLState=SQLITE_DONE errorCode=101

Tô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:

Text
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:

Java
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:

Text
=== 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=30

Tà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:

Java
c.setAutoCommit(false);
try {
    move(c, 3, 999, 50);
    c.commit();
} catch (SQLException e) {
    c.rollback();
    throw e;
} finally {
    c.setAutoCommit(true);
}
Text
=== 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=80

Cù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ệnh return sớm bỏ qua nó sẽ âm thầm vứt hết công việc.
  • rollback() thuộc về catch, không phải finally — đặt trong finally thì nó chạy cả trên nhánh thành công, sau khi commit() đã kết thúc transaction.
  • Khôi phục setAutoCommit(true) trong finally, 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ó:

Java
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();
Text
  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=80

Phầ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:

Text
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ợ:

Text
  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 = 8

TRANSACTION_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:

Java
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);
}
Text
counts        = [1, 1, 1, 1, 1]
counts.length = 5  (statements sent in the batch = 5)
SUCCESS_NO_INFO=-2 EXECUTE_FAILED=-3

Mả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:

Text
=== 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 back

Mộ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:

Java
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));
    }
}
Text
executeUpdate returned 1
generated-key columns = 1, label = last_insert_rowid()
new id = 1

Key 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:

Text
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ự (23000 là vi phạm ràng buộc toàn vẹn, 08001 là 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:

Java
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:

Text
duplicate username:
  org.sqlite.SQLiteException: [SQLITE_CONSTRAINT_UNIQUE] A UNIQUE constraint failed (UNIQUE constraint failed: users.username)
    SQLState  = null
    errorCode = 19
  is it SQLIntegrityConstraintViolationException? false

Hai đ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 19SQLITE_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ó:

Text
hand-built chain:
  java.sql.SQLException: row 2 rejected
    SQLState  = 23000
    errorCode = 19
  java.sql.SQLException: row 7 rejected
    SQLState  = 23000
    errorCode = 19

Mộ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 prefixjdbc: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.forNameuser 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 columnsetBigDecimal 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, Savepointchỉ TRANSACTION_SERIALIZABLE là được hỗ trợ thật
addBatch, executeBatch và mảng countkhông có BatchUpdateException; chỉ một SQLiteException trơn
getGeneratedKeysRETURN_GENERATED_KEYSkey trả về cả khi không có cờ; label là last_insert_rowid()
getSQLState, getErrorCode, chuỗi exceptiongetSQLState()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:

Text
  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:

Text
  [SQLITE_ERROR] SQL error or missing database (Cannot add a NOT NULL column with default value NULL)
  with DEFAULT '' -> OK

JDBC 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?

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 Savepointrollback(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, StatementResultSet 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.

Bài viết liên quan

[Advanced Java] Set trong Java: HashSet, LinkedHashSet và TreeSet

HashSet, LinkedHashSet và TreeSet trên OpenJDK 21: Set thực sự đảm bảo điều gì, HashMap ẩn bên trong HashSet, các lookup của NavigableSet, hai quy tắc khác nhau để quyết định duplicate, compareTo mâu thuẫn với equals, ClassCastException và null trong TreeSet, EnumSet, và vì sao removeAll có thể thành quadratic.

[Advanced Java] Connection Pool với HikariCP: tối ưu kết nối database

Connection pool trong Java với HikariCP 5.1.0 trên OpenJDK 21: vì sao close() trả connection về pool chứ không đóng nó, proxy và unwrap, maximumPoolSize, minimumIdle, connectionTimeout, idleTimeout, maxLifetime và leakDetectionThreshold, một SQLTransientConnectionException thật khi hết connection, log leak thật kèm stack trace, metrics từ HikariPoolMXBean và cách chọn cỡ pool một cách trung thực.

[Advanced Java] Thread trong Java: Thread, Runnable và virtual thread

Thread trong Java trên OpenJDK 21: thread là gì, stack riêng và heap dùng chung, tạo thread bằng Thread, Runnable và lambda, start khác run ra sao, join, daemon thread, tên và priority, output không xác định giữa các lần chạy, virtual thread với Thread.ofVirtual, và interrupt.

[Advanced Java] Nguyên lý SOLID trong Java: Năm quy tắc và khi nào nên bỏ qua

Năm nguyên lý SOLID trong Java trên OpenJDK 21, mỗi nguyên lý một cặp before/after compile và chạy được: một class tách theo lý do thay đổi, một switch phình to thay bằng interface, một subclass phá caller mà không có warning nào, một UnsupportedOperationException lẽ ra compiler đã chặn được, một class không chạy nổi nếu thiếu file, và chỗ mà mỗi nguyên lý không còn đáng để áp dụng.