Chương 13 · JDBC

JDBC và cơ sở dữ liệu

JDBC (Java Database Connectivity) là API chuẩn để ứng dụng Java nói chuyện với cơ sở dữ liệu. Nó là nền móng của mọi thứ phía trên — Hibernate, JPA, Spring Data, MyBatis đều chạy trên JDBC. Hiểu tầng này giúp bạn gỡ lỗi được những vấn đề mà framework che giấu: truy vấn chậm, rò rỉ kết nối, giao dịch không rollback. Và quan trọng nhất — chương này dạy bạn cách không để lộ lỗ hổng SQL injection.

Độ dài: ~1600 dòng Bài tập: 5 Quiz: 8 Prerequisites: Chương 1-12
🎯 Mục tiêu chương
  • Hiểu kiến trúc JDBC: ứng dụng → JDBC API → driver → database.
  • Biết bốn loại driver và vì sao ngày nay chỉ dùng Type 4.
  • Mở Connection đúng cách và luôn đóng bằng try-with-resources.
  • Phân biệt Statement và PreparedStatement — và vì sao chỉ dùng cái sau.
  • Hiểu SQL injection tận gốc, tự tay tái hiện được.
  • Duyệt ResultSet; lấy khoá tự sinh.
  • Viết đủ CRUD và ánh xạ sang object.
  • Quản lý giao dịch: commit, rollback, savepoint, mức cô lập.
  • Dùng batch để tăng tốc ghi hàng loạt.
  • Dùng connection pool (HikariCP) — bắt buộc trong sản xuất.
  • Áp dụng mẫu DAO để tách tầng truy cập dữ liệu.

1. Kiến trúc JDBC

Ứng dụng Java
Code của bạn
DAO / Service
JDBC API
java.sql.*
Interface chuẩn
Driver
Cài đặt cụ thể
mysql-connector-j
Database
MySQL
PostgreSQL…
Code của bạn chỉ dùng interface trong java.sql. Driver là bản cài đặt cụ thể do nhà cung cấp database viết. Nhờ vậy đổi từ MySQL sang PostgreSQL chỉ cần đổi driver và chuỗi kết nối — trên lý thuyết.
Interface chínhVai tròTạo từ
DriverManagerClass (không phải interface) — quản lý driver, tạo kết nối—
DataSourceNguồn kết nối hiện đại, hỗ trợ pool ⭐Thư viện pool
ConnectionMột phiên làm việc với databaseDriverManager/DataSource
StatementThực thi SQL tĩnh — ⚠️ tránh dùngConnection
PreparedStatementSQL có tham số, đã biên dịch trước ⭐Connection
CallableStatementGọi stored procedureConnection
ResultSetCon trỏ duyệt kết quả truy vấnStatement
SQLExceptionMọi lỗi database (checked)—
Loại driverCơ chếTrạng thái
Type 1 — JDBC-ODBC BridgeQua cầu nối ODBC của hệ điều hành❌ Đã loại bỏ từ Java 8
Type 2 — Native APIGọi thư viện gốc của database (C/C++)❌ Cần cài thư viện gốc, không di động
Type 3 — Network ProtocolQua một máy chủ trung gian⚠️ Hiếm dùng
Type 4 — Thin Driver ⭐Thuần Java, nói trực tiếp giao thức mạng của database✅ Mọi driver hiện đại
pom.xml — thêm driver
<!-- MySQL -->
<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <version>9.1.0</version>
</dependency>

<!-- PostgreSQL -->
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <version>42.7.4</version>
</dependency>

<!-- SQLite — không cần cài server, rất hợp để học -->
<dependency>
    <groupId>org.xerial</groupId>
    <artifactId>sqlite-jdbc</artifactId>
    <version>3.47.1.0</version>
</dependency>

2. Connection

KetNoi.java
import java.sql.*;

// Chuỗi kết nối: jdbc:<database>://<host>:<port>/<tên_db>?<tham_số>
String url = "jdbc:mysql://localhost:3306/quan_ly"
           + "?useUnicode=true&characterEncoding=UTF-8"   // ⭐ cho tiếng Việt
           + "&serverTimezone=Asia/Ho_Chi_Minh"
           + "&useSSL=false";

// ⭐ LUÔN dùng try-with-resources (Connection là AutoCloseable)
try (Connection conn = DriverManager.getConnection(url, "root", matKhau)) {
    System.out.println("Kết nối thành công");

    DatabaseMetaData md = conn.getMetaData();
    System.out.println(md.getDatabaseProductName() + " "
                     + md.getDatabaseProductVersion());
} catch (SQLException e) {
    System.err.println("Mã lỗi: " + e.getErrorCode());     // mã riêng của DB
    System.err.println("SQLState: " + e.getSQLState());     // mã chuẩn 5 ký tự
    throw new RuntimeException("Không kết nối được database", e);
}

// ⚠️ Từ JDBC 4.0 (Java 6), KHÔNG cần Class.forName() nữa —
//    driver tự đăng ký qua cơ chế ServiceLoader.
// Class.forName("com.mysql.cj.jdbc.Driver");   // code cũ, không cần

// ⭐ KHÔNG BAO GIỜ ghi mật khẩu trong mã nguồn
String mk = System.getenv("DB_PASSWORD");
Objects.requireNonNull(mk, "Chưa đặt biến môi trường DB_PASSWORD");
⚠️ Rò rỉ kết nối — lỗi tốn kém nhất

Mỗi Connection chiếm một socket và một tiến trình/luồng phía database. Không đóng thì sau vài trăm lần bạn nhận Too many connections — và toàn bộ ứng dụng ngừng hoạt động, không chỉ phần code lỗi.

RoRi.java
// ❌ Lỗi giữa chừng → không bao giờ đóng
Connection conn = DriverManager.getConnection(url, u, p);
Statement st = conn.createStatement();
ResultSet rs = st.executeQuery(sql);   // ném lỗi ở đây → rò rỉ
conn.close();

// ✅ try-with-resources — đóng theo THỨ TỰ NGƯỢC: rs → ps → conn
try (Connection conn = DriverManager.getConnection(url, u, p);
     PreparedStatement ps = conn.prepareStatement(sql);
     ResultSet rs = ps.executeQuery()) {
    while (rs.next()) { ... }
}

3. Statement vs PreparedStatement — và SQL injection

SqlInjection.java
// ❌❌ CỰC KỲ NGUY HIỂM — nối chuỗi SQL
String ten = nguoiDungNhap;
String sql = "SELECT * FROM nguoi_dung WHERE ten = '" + ten + "'";
try (Statement st = conn.createStatement();
     ResultSet rs = st.executeQuery(sql)) { ... }

// Người dùng nhập:  ' OR '1'='1
// SQL thành:  SELECT * FROM nguoi_dung WHERE ten = '' OR '1'='1'
//             → trả về TOÀN BỘ bảng ⚠️

// Người dùng nhập:  '; DROP TABLE nguoi_dung; --
// SQL thành:  SELECT * FROM nguoi_dung WHERE ten = ''; DROP TABLE nguoi_dung; --'
//             → 💥 MẤT TOÀN BỘ BẢNG

// Người dùng nhập:  ' UNION SELECT ma_the, cvv, NULL FROM the_tin_dung --
// → đọc được dữ liệu bảng khác ⚠️


// ✅ AN TOÀN — PreparedStatement với tham số ?
String sql2 = "SELECT * FROM nguoi_dung WHERE ten = ?";
try (PreparedStatement ps = conn.prepareStatement(sql2)) {
    ps.setString(1, ten);          // ⚠️ chỉ số bắt đầu từ 1, KHÔNG phải 0!
    try (ResultSet rs = ps.executeQuery()) { ... }
}
// Dù người dùng nhập gì, nó LUÔN được coi là một CHUỖI GIÁ TRỊ,
// không bao giờ được diễn dịch thành cú pháp SQL.

Vì sao PreparedStatement an toàn tuyệt đối

Nó gửi câu lệnh và dữ liệu riêng biệt tới database. Database phân tích cú pháp câu lệnh trước, tạo ra kế hoạch thực thi với các "chỗ trống", rồi mới điền dữ liệu vào. Dữ liệu không bao giờ đi qua bộ phân tích cú pháp SQL — nên nó không thể trở thành lệnh.

Đây là khác biệt về cơ chế, không phải về "lọc ký tự đặc biệt". Không có chuỗi đầu vào nào có thể phá được nó. So sánh: cố gắng tự escape dấu nháy là cuộc chiến bạn không bao giờ thắng — có hàng chục cách mã hoá và cách vượt qua tuỳ database.

StatementPreparedStatement ⭐
SQL injection❌ Dễ bị tấn công✅ An toàn tuyệt đối
Biên dịchMỗi lần chạy phân tích lạiPhân tích một lần, tái dùng kế hoạch
Hiệu năng khi lặpChậmNhanh hơn nhiều
Kiểu dữ liệuTự nối chuỗi — dễ sai định dạng ngàysetDate, setBigDecimal… đúng kiểu
Ký tự đặc biệtPhải tự escape (rất dễ sai)Tự động xử lý
BatchHạn chếHỗ trợ đầy đủ
Khi nào dùngDDL không tham số (CREATE TABLE)Mọi trường hợp còn lại
Không có ngoại lệ: mọi giá trị đi vào SQL phải qua tham số ?. Nếu bạn thấy dấu + nối chuỗi trong một câu SQL, đó là lỗ hổng — bất kể dữ liệu "chắc chắn an toàn" tới đâu.
🕳️ Tên bảng và tên cột không tham số hoá được
TenCot.java
// ❌ Không hoạt động — ? chỉ dùng cho GIÁ TRỊ
String sql = "SELECT * FROM nguoi_dung ORDER BY ?";
ps.setString(1, "ten");   // → ORDER BY 'ten' (chuỗi hằng), không sắp gì cả

// ✅ Phải dùng DANH SÁCH TRẮNG
private static final Set<String> COT_HOP_LE =
    Set.of("ten", "tuoi", "ngay_tao");

if (!COT_HOP_LE.contains(cot))
    throw new IllegalArgumentException("Cột không hợp lệ: " + cot);
String sql2 = "SELECT * FROM nguoi_dung ORDER BY " + cot;   // giờ mới an toàn

// Tương tự với danh sách IN (...): phải sinh đúng số dấu ?
String dauHoi = String.join(",", Collections.nCopies(ids.size(), "?"));
String sql3 = "SELECT * FROM nguoi_dung WHERE id IN (" + dauHoi + ")";
for (int i = 0; i < ids.size(); i++) ps.setLong(i + 1, ids.get(i));

4. ResultSet

ResultSet.java
String sql = "SELECT id, ho_ten, diem, ngay_sinh FROM sinh_vien WHERE lop = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
    ps.setString(1, "CNTT-K18");

    try (ResultSet rs = ps.executeQuery()) {
        while (rs.next()) {           // ⭐ con trỏ bắt đầu TRƯỚC hàng đầu tiên
            long   id  = rs.getLong("id");            // theo TÊN cột ✅ rõ ràng
            String ten = rs.getString("ho_ten");
            double d   = rs.getDouble("diem");
            LocalDate ns = rs.getObject("ngay_sinh", LocalDate.class);  // JDBC 4.2+

            // ⚠️ Kiểu nguyên thủy: NULL trở thành 0 / false — không phân biệt được!
            int tuoi = rs.getInt("tuoi");
            if (rs.wasNull()) tuoi = -1;              // kiểm tra ngay SAU khi đọc

            // ✅ Hoặc dùng getObject để nhận null thật
            Integer tuoi2 = rs.getObject("tuoi", Integer.class);
        }
    }
}

// --- Metadata: khi không biết trước cấu trúc kết quả ---
ResultSetMetaData md = rs.getMetaData();
for (int i = 1; i <= md.getColumnCount(); i++) {
    System.out.printf("%s (%s)%n",
        md.getColumnLabel(i), md.getColumnTypeName(i));
}
Kiểu SQLPhương thức ResultSetKiểu Java
INTgetInt / getObject(…, Integer.class)int / Integer
BIGINTgetLonglong
VARCHAR, TEXTgetStringString
DECIMAL, NUMERICgetBigDecimal ⭐BigDecimal — tiền tệ
DATEgetObject(…, LocalDate.class)LocalDate
TIMESTAMPgetObject(…, LocalDateTime.class)LocalDateTime
BOOLEAN, TINYINT(1)getBooleanboolean
BLOBgetBytes / getBinaryStreambyte[]
⚠️ Đừng dùng java.sql.Date và java.util.Date

Chúng là API cũ, có Date lại chứa cả giờ, tháng đánh số từ 0, và không an toàn luồng. Từ JDBC 4.2 (Java 8), dùng getObject(cot, LocalDate.class) và setObject(i, localDate) để làm việc thẳng với java.time — API ngày giờ hiện đại, bất biến, rõ ràng.

5. CRUD đầy đủ

Crud.java
record SinhVien(Long id, String maSv, String hoTen,
                 BigDecimal diem, LocalDate ngaySinh) { }

// ---------- CREATE: lấy khoá tự sinh ----------
long them(Connection conn, SinhVien sv) throws SQLException {
    String sql = """
        INSERT INTO sinh_vien (ma_sv, ho_ten, diem, ngay_sinh)
        VALUES (?, ?, ?, ?)
        """;
    try (PreparedStatement ps =
             conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
        ps.setString(1, sv.maSv());
        ps.setString(2, sv.hoTen());
        ps.setBigDecimal(3, sv.diem());
        ps.setObject(4, sv.ngaySinh());

        int soHang = ps.executeUpdate();       // trả về SỐ HÀNG bị ảnh hưởng
        if (soHang == 0)
            throw new SQLException("Thêm thất bại, không có hàng nào");

        try (ResultSet khoa = ps.getGeneratedKeys()) {
            if (khoa.next()) return khoa.getLong(1);
            throw new SQLException("Không lấy được id tự sinh");
        }
    }
}

// ---------- READ ----------
Optional<SinhVien> timTheoId(Connection conn, long id) throws SQLException {
    String sql = "SELECT * FROM sinh_vien WHERE id = ?";
    try (PreparedStatement ps = conn.prepareStatement(sql)) {
        ps.setLong(1, id);
        try (ResultSet rs = ps.executeQuery()) {
            return rs.next() ? Optional.of(anhXa(rs)) : Optional.empty();
        }
    }
}

List<SinhVien> timTheoLop(Connection conn, String lop,
                             int trang, int kichThuoc) throws SQLException {
    String sql = """
        SELECT * FROM sinh_vien WHERE lop = ?
        ORDER BY diem DESC, ho_ten
        LIMIT ? OFFSET ?
        """;
    try (PreparedStatement ps = conn.prepareStatement(sql)) {
        ps.setString(1, lop);
        ps.setInt(2, kichThuoc);
        ps.setInt(3, trang * kichThuoc);
        try (ResultSet rs = ps.executeQuery()) {
            List<SinhVien> ds = new ArrayList<>();
            while (rs.next()) ds.add(anhXa(rs));
            return ds;
        }
    }
}

// ---------- UPDATE / DELETE ----------
boolean capNhat(Connection conn, SinhVien sv) throws SQLException {
    String sql = "UPDATE sinh_vien SET ho_ten = ?, diem = ? WHERE id = ?";
    try (PreparedStatement ps = conn.prepareStatement(sql)) {
        ps.setString(1, sv.hoTen());
        ps.setBigDecimal(2, sv.diem());
        ps.setLong(3, sv.id());
        return ps.executeUpdate() > 0;
    }
}

// ---------- Ánh xạ dùng chung ----------
private static SinhVien anhXa(ResultSet rs) throws SQLException {
    return new SinhVien(
        rs.getLong("id"),
        rs.getString("ma_sv"),
        rs.getString("ho_ten"),
        rs.getBigDecimal("diem"),
        rs.getObject("ngay_sinh", LocalDate.class)
    );
}

6. Giao dịch (Transaction)

Transaction.java
// ⚠️ Mặc định JDBC ở chế độ AUTO-COMMIT: mỗi câu lệnh là một giao dịch riêng.
//    Với thao tác nhiều bước, điều đó là THẢM HOẠ.

void chuyenTien(Connection conn, long tu, long den, BigDecimal tien)
        throws SQLException {

    conn.setAutoCommit(false);        // ⭐ BẮT ĐẦU giao dịch
    try {
        // Bước 1: trừ tiền — có kiểm tra số dư ngay trong SQL
        try (PreparedStatement ps = conn.prepareStatement(
                "UPDATE tai_khoan SET so_du = so_du - ? WHERE id = ? AND so_du >= ?")) {
            ps.setBigDecimal(1, tien);
            ps.setLong(2, tu);
            ps.setBigDecimal(3, tien);
            if (ps.executeUpdate() == 0)
                throw new SQLException("Không đủ số dư hoặc tài khoản không tồn tại");
        }

        // Bước 2: cộng tiền
        try (PreparedStatement ps = conn.prepareStatement(
                "UPDATE tai_khoan SET so_du = so_du + ? WHERE id = ?")) {
            ps.setBigDecimal(1, tien);
            ps.setLong(2, den);
            if (ps.executeUpdate() == 0)
                throw new SQLException("Tài khoản đích không tồn tại");
        }

        // Bước 3: ghi nhật ký
        ghiNhatKy(conn, tu, den, tien);

        conn.commit();                  // ⭐ mọi bước thành công → LƯU

    } catch (SQLException e) {
        conn.rollback();                // ⭐ bất kỳ bước nào lỗi → HOÀN TÁC TẤT CẢ
        throw e;
    } finally {
        conn.setAutoCommit(true);      // khôi phục cho lần dùng sau (quan trọng với pool!)
    }
}

// --- Savepoint: rollback một phần ---
conn.setAutoCommit(false);
buoc1(conn);
Savepoint sp = conn.setSavepoint("sau_buoc_1");
try {
    buoc2CoTheLoi(conn);
} catch (SQLException e) {
    conn.rollback(sp);              // chỉ huỷ bước 2, giữ bước 1
}
conn.commit();
ACIDNghĩa là
Atomicity — Nguyên tửHoặc tất cả thành công, hoặc không gì cả
Consistency — Nhất quánDatabase luôn ở trạng thái hợp lệ (ràng buộc được giữ)
Isolation — Cô lậpGiao dịch đồng thời không thấy trạng thái dở dang của nhau
Durability — Bền vữngĐã commit thì mất điện cũng không mất dữ liệu
Mức cô lậpDirty readNon-repeatable readPhantom readGhi chú
READ_UNCOMMITTED❌ Có thể❌ Có thể❌ Có thểNhanh nhất, gần như không bao giờ dùng
READ_COMMITTED✅ Không❌ Có thể❌ Có thểMặc định của PostgreSQL, Oracle, SQL Server
REPEATABLE_READ✅✅❌ Có thểMặc định của MySQL/InnoDB
SERIALIZABLE✅✅✅An toàn nhất, chậm nhất
📌 Ba hiện tượng bất thường
  • Dirty read — đọc được dữ liệu mà giao dịch khác chưa commit; nếu nó rollback thì bạn đã đọc dữ liệu không bao giờ tồn tại.
  • Non-repeatable read — đọc cùng một hàng hai lần trong một giao dịch nhưng nhận hai giá trị khác nhau (vì giao dịch khác đã sửa và commit ở giữa).
  • Phantom read — chạy cùng một truy vấn hai lần nhưng lần sau có thêm hàng mới xuất hiện.

Đặt mức: conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED). Trong hầu hết ứng dụng, giữ mặc định của database là hợp lý — chỉ nâng lên khi có yêu cầu nghiệp vụ cụ thể, vì mức cao hơn nghĩa là nhiều khoá hơn và ít đồng thời hơn.

7. Batch — ghi hàng loạt

Batch.java
// ❌ CHẬM: 10.000 lượt đi về mạng
for (SinhVien sv : ds) {
    ps.setString(1, sv.maSv());
    ps.executeUpdate();        // mỗi lần một lượt đi về
}
// 10.000 bản ghi ≈ 60 giây

// ✅ NHANH: gom thành lô
conn.setAutoCommit(false);
try (PreparedStatement ps = conn.prepareStatement(
        "INSERT INTO sinh_vien (ma_sv, ho_ten, diem) VALUES (?, ?, ?)")) {

    int dem = 0;
    for (SinhVien sv : ds) {
        ps.setString(1, sv.maSv());
        ps.setString(2, sv.hoTen());
        ps.setBigDecimal(3, sv.diem());
        ps.addBatch();                  // ⭐ thêm vào lô, chưa gửi

        if (++dem % 1000 == 0) {
            ps.executeBatch();           // gửi mỗi 1000 bản — tránh phình bộ nhớ
            ps.clearBatch();
        }
    }
    ps.executeBatch();                  // phần còn lại
    conn.commit();
} catch (SQLException e) {
    conn.rollback();
    throw e;
} finally {
    conn.setAutoCommit(true);
}
// 10.000 bản ghi ≈ 1 giây — nhanh hơn khoảng 50-60 lần

// ⭐ MySQL: thêm tham số này vào chuỗi kết nối để batch thực sự hiệu quả
// ?rewriteBatchedStatements=true
//   → driver gộp thành một câu INSERT nhiều VALUES, nhanh thêm vài lần nữa

// Xử lý lỗi trong batch
try {
    ps.executeBatch();
} catch (BatchUpdateException e) {
    int[] kq = e.getUpdateCounts();   // biết bản ghi thứ mấy hỏng
}

8. Connection pool — bắt buộc trong sản xuất

Vì sao cần pool

Mở một Connection tốn 50-200 ms: bắt tay TCP, xác thực, thiết lập phiên, có thể cả bắt tay TLS. Với một API xử lý 1.000 request mỗi giây, chi phí đó lớn hơn cả bản thân truy vấn nhiều lần.

Pool giữ sẵn một số kết nối mở. getConnection() lấy một cái từ pool (mất vài micro giây), và close() không đóng thật mà trả lại pool. Đây là lý do bạn vẫn phải gọi close() — nó có nghĩa "tôi dùng xong rồi", không phải "hãy ngắt kết nối".

HikariCP.java
<!-- pom.xml -->
<dependency>
    <groupId>com.zaxxer</groupId>
    <artifactId>HikariCP</artifactId>
    <version>6.2.1</version>
</dependency>

import com.zaxxer.hikari.*;

public class Db {
    private static final HikariDataSource DS;

    static {
        HikariConfig cf = new HikariConfig();
        cf.setJdbcUrl(System.getenv("DB_URL"));
        cf.setUsername(System.getenv("DB_USER"));
        cf.setPassword(System.getenv("DB_PASSWORD"));

        cf.setMaximumPoolSize(10);            // xem callout về cách chọn
        cf.setMinimumIdle(5);
        cf.setConnectionTimeout(30_000);      // chờ lấy kết nối tối đa 30s
        cf.setIdleTimeout(600_000);           // 10 phút không dùng thì đóng
        cf.setMaxLifetime(1_800_000);         // 30 phút thì làm mới
        cf.setLeakDetectionThreshold(60_000); // ⭐ cảnh báo nếu giữ quá 60s

        // Tối ưu cho MySQL
        cf.addDataSourceProperty("cachePrepStmts", "true");
        cf.addDataSourceProperty("prepStmtCacheSize", "250");
        cf.addDataSourceProperty("rewriteBatchedStatements", "true");

        DS = new HikariDataSource(cf);
    }

    public static Connection layKetNoi() throws SQLException {
        return DS.getConnection();
    }
    public static void dong() { DS.close(); }
}

// Dùng — y hệt như trước, chỉ đổi nguồn kết nối
try (Connection conn = Db.layKetNoi()) {
    ...
}   // close() TRẢ LẠI POOL, không ngắt kết nối thật
💡 Chọn kích thước pool — nhỏ hơn bạn nghĩ

Trực giác nói "nhiều kết nối hơn = nhanh hơn", nhưng thực tế ngược lại. Database chỉ xử lý được một số truy vấn đồng thời nhất định; vượt quá thì chúng tranh chấp CPU, đĩa và khoá, làm mọi truy vấn chậm lại.

Công thức tham khảo của HikariCP: số_kết_nối = ((số_nhân_CPU_của_DB × 2) + số_đĩa). Với server database 4 nhân và SSD, con số hợp lý là khoảng 10 — không phải 100.

leakDetectionThreshold là tính năng đáng giá nhất: nó in stack trace của chỗ lấy kết nối mà quên trả, giúp bạn tìm ra chính xác dòng code rò rỉ.

9. Mẫu DAO

Dao.java
// ① Interface — tầng nghiệp vụ chỉ biết cái này
public interface SinhVienDao {
    long them(SinhVien sv);
    Optional<SinhVien> timTheoId(long id);
    List<SinhVien> timTheoLop(String lop);
    boolean capNhat(SinhVien sv);
    boolean xoa(long id);
}

// ② Cài đặt JDBC — bọc SQLException thành unchecked (chương 6)
public class SinhVienDaoJdbc implements SinhVienDao {
    private final DataSource ds;

    public SinhVienDaoJdbc(DataSource ds) { this.ds = ds; }

    @Override
    public Optional<SinhVien> timTheoId(long id) {
        String sql = "SELECT * FROM sinh_vien WHERE id = ?";
        try (Connection c = ds.getConnection();
             PreparedStatement ps = c.prepareStatement(sql)) {
            ps.setLong(1, id);
            try (ResultSet rs = ps.executeQuery()) {
                return rs.next() ? Optional.of(anhXa(rs)) : Optional.empty();
            }
        } catch (SQLException e) {
            throw new DataAccessException("Lỗi khi tìm sinh viên id=" + id, e);
        }
    }
    ...
}

// ③ Exception riêng của tầng dữ liệu — unchecked
public class DataAccessException extends RuntimeException {
    public DataAccessException(String msg, Throwable nguyenNhan) {
        super(msg, nguyenNhan);      // ⭐ giữ nguyên nhân gốc
    }
}

// ④ Tầng service — không biết gì về JDBC
public class SinhVienService {
    private final SinhVienDao dao;
    public SinhVienService(SinhVienDao dao) { this.dao = dao; }

    public SinhVien layBatBuoc(long id) {
        return dao.timTheoId(id).orElseThrow(
            () -> new KhongTimThayException("Sinh viên id=" + id));
    }
}
Ba lợi ích của DAO: (1) tầng nghiệp vụ không phụ thuộc JDBC — đổi sang JPA hay MongoDB chỉ cần đổi cài đặt; (2) test được — dùng cài đặt giả trong bộ nhớ cho unit test, không cần database thật; (3) SQL tập trung một chỗ, dễ rà soát lỗi bảo mật và tối ưu.

10. Cheat sheet chương 13

Tình huốngCách viết
Mọi truy vấn có tham sốPreparedStatement + ? — không bao giờ nối chuỗi
Chỉ số tham sốBắt đầu từ 1, không phải 0
Quản lý tài nguyêntry-with-resources cho Connection, Statement, ResultSet
Truy vấnexecuteQuery() → ResultSet
Thêm/sửa/xoáexecuteUpdate() → số hàng bị ảnh hưởng
Lấy id tự sinhprepareStatement(sql, RETURN_GENERATED_KEYS) + getGeneratedKeys()
Duyệt kết quảwhile (rs.next()), đọc theo tên cột
Kiểm tra NULLrs.wasNull() ngay sau khi đọc, hoặc dùng getObject
Tiền tệgetBigDecimal / setBigDecimal
Ngày giờgetObject(cot, LocalDate.class) — không dùng java.sql.Date
Nhiều bước phải nguyên tửsetAutoCommit(false) + commit/rollback
Sau giao dịchKhôi phục setAutoCommit(true) trong finally
Ghi hàng loạtaddBatch() + executeBatch() mỗi 1000 bản
Sản xuấtConnection pool (HikariCP) — không dùng DriverManager trực tiếp
Kích thước pool(nhân_CPU × 2) + số_đĩa — nhỏ hơn bạn nghĩ
Tên bảng/cột độngDanh sách trắng — ? không dùng được
Mật khẩuBiến môi trường — không bao giờ trong mã nguồn
Kiến trúcMẫu DAO: interface + cài đặt + exception riêng

Bài tập

Tự tay tái hiện SQL injection

Trên database SQLite cục bộ (an toàn, không cần server), tạo bảng nguoi_dung(id, ten, mat_khau, vai_tro) với vài bản ghi, rồi:

  1. Viết hàm đăng nhập dễ bị tấn công bằng Statement và nối chuỗi.
  2. Đăng nhập thành công không cần biết mật khẩu, dùng ' OR '1'='1. Chép lại câu SQL cuối cùng.
  3. Dùng UNION SELECT để đọc dữ liệu từ một bảng khác.
  4. Xoá bảng bằng '; DROP TABLE …; -- (nhớ sao lưu trước).
  5. Viết lại bằng PreparedStatement. Thử tất cả các payload trên — ghi lại kết quả.
  6. In ra giá trị mà database thực sự nhận được sau setString — chứng minh nó chỉ là chuỗi.
Gợi ý

SQLite là lựa chọn tốt để thực hành: chỉ cần thêm dependency, không cần cài server, và database là một tệp bạn xoá được. URL: jdbc:sqlite:test.db.
Lưu ý: nhiều driver mặc định tắt nhiều câu lệnh trong một lần gửi (allowMultiQueries=false ở MySQL), nên payload DROP TABLE có thể không chạy. Đó là lớp phòng thủ thứ hai, không phải lý do để nối chuỗi — payload OR '1'='1 và UNION vẫn hoạt động bình thường.

DAO hoàn chỉnh cho quản lý thư viện

Thiết kế lược đồ và xây dựng tầng DAO đầy đủ:

Lược đồ
sach(id, isbn UNIQUE, tieu_de, tac_gia, nam_xb, so_luong)
doc_gia(id, ma_the UNIQUE, ho_ten, email, ngay_dang_ky)
muon_tra(id, sach_id FK, doc_gia_id FK, ngay_muon, ngay_hen_tra, ngay_tra)
  • DAO cho cả ba bảng với đầy đủ CRUD, dùng record làm entity.
  • muonSach(sachId, docGiaId): một giao dịch — kiểm tra còn sách, giảm so_luong, tạo bản ghi mượn. Hết sách thì rollback.
  • traSach(muonId): cập nhật ngay_tra, tăng so_luong, tính tiền phạt nếu quá hạn.
  • Truy vấn báo cáo: sách đang được mượn nhiều nhất, độc giả mượn quá hạn, sách chưa ai mượn.
  • Phân trang cho danh sách sách với LIMIT/OFFSET.
  • Dùng HikariCP; mật khẩu qua biến môi trường.
Gợi ý

Giảm số lượng an toàn với đồng thời: dùng UPDATE sach SET so_luong = so_luong - 1 WHERE id = ? AND so_luong > 0 rồi kiểm tra executeUpdate() == 1. Cách này để database lo việc kiểm tra nguyên tử — an toàn hơn nhiều so với đọc rồi ghi ở tầng Java (đó chính là race condition ở chương 12, nhưng ở tầng database).
Truy vấn báo cáo là dịp tốt để ôn JOIN, GROUP BY, LEFT JOIN … WHERE … IS NULL.

Đo hiệu năng batch và pool

Đo và lập bảng bốn cấu hình khi chèn 10.000 bản ghi:

  1. Statement, auto-commit, không pool.
  2. PreparedStatement, auto-commit, không pool.
  3. PreparedStatement + giao dịch (một commit duy nhất).
  4. PreparedStatement + giao dịch + addBatch (lô 1000).

Sau đó thêm rewriteBatchedStatements=true (MySQL) và đo lại (4). Cuối cùng, so sánh thời gian getConnection() có pool và không pool, lặp 1.000 lần.

Gợi ý

Kết quả điển hình: (1) → (4) nhanh hơn khoảng 50-100 lần. Phần lớn cải thiện đến từ giao dịch (bước 3) chứ không phải batch — vì mỗi commit là một lần ghi đĩa đồng bộ (fsync).
rewriteBatchedStatements cho thêm 3-5 lần nữa vì driver gộp nhiều INSERT thành một câu INSERT … VALUES (…),(…),(…).
getConnection(): không pool khoảng 50-200 ms, có pool khoảng 0,01 ms — chênh nhau hàng nghìn lần.

Thí nghiệm giao dịch và mức cô lập

Mở hai kết nối song song trong cùng chương trình và quan sát:

  1. Dirty read: kết nối A sửa một hàng nhưng chưa commit; B đọc. Thử ở READ_UNCOMMITTED và READ_COMMITTED — khác nhau thế nào?
  2. Non-repeatable read: B đọc một hàng, A sửa và commit, B đọc lại. Ở mức nào B thấy giá trị mới?
  3. Phantom read: B đếm số hàng thoả điều kiện, A chèn hàng mới và commit, B đếm lại.
  4. Deadlock database: A khoá hàng 1 rồi chờ hàng 2; B khoá hàng 2 rồi chờ hàng 1. Database phản ứng thế nào? Chép lại thông điệp lỗi.
  5. Rollback: thực hiện ba câu UPDATE rồi ném lỗi ở câu thứ ba. Xác nhận cả ba đều bị hoàn tác.
Gợi ý

Dùng hai luồng với hai Connection riêng, và CountDownLatch để đồng bộ thứ tự các bước — nếu không, kết quả sẽ ngẫu nhiên và không rút ra được gì.
(4): database tự phát hiện deadlock và huỷ một trong hai giao dịch với thông điệp như "Deadlock found when trying to get lock; try restarting transaction". Khác với deadlock Java (chương 12) — nơi không ai phát hiện và chương trình treo vĩnh viễn. Bài học: khi gặp lỗi này, ứng dụng nên thử lại giao dịch.

Trình chạy migration mini

Viết công cụ quản lý phiên bản lược đồ database (bản rút gọn của Flyway):

Cấu trúc
migrations/
├── V1__tao_bang_sach.sql
├── V2__them_cot_isbn.sql
└── V3__tao_bang_doc_gia.sql
  • Tạo bảng schema_version(phien_ban, ten, chay_luc, checksum) nếu chưa có.
  • Quét thư mục, sắp theo số phiên bản, chỉ chạy những bản chưa áp dụng.
  • Mỗi migration chạy trong một giao dịch riêng; lỗi thì rollback và dừng hẳn.
  • Tính checksum (SHA-256) mỗi tệp; nếu tệp đã chạy bị sửa đổi thì báo lỗi và dừng.
  • Lệnh --info in bảng trạng thái: bản nào đã chạy, chạy lúc nào, bản nào đang chờ.
  • Xử lý an toàn khi hai tiến trình cùng chạy migration (dùng khoá ở tầng database).
Gợi ý

Checksum bảo vệ khỏi lỗi nguy hiểm nhất: ai đó sửa một migration đã chạy trên production, khiến môi trường dev và production khác nhau âm thầm.
Khoá liên tiến trình: MySQL có SELECT GET_LOCK('migration', 10), PostgreSQL có pg_advisory_lock. Không có khoá thì hai container khởi động cùng lúc sẽ cùng chạy migration và gây lỗi.
Lưu ý: một số DDL (như CREATE TABLE ở MySQL) tự động commit, nên rollback không hoàn tác được — đó là lý do mỗi migration nên nhỏ và làm đúng một việc.

Quiz

Q1

SQL injection là gì? Vì sao PreparedStatement chống được?

Xem đáp án
✓ Đáp án

SQL injection xảy ra khi dữ liệu người dùng được nối thẳng vào câu SQL, khiến nó không còn là dữ liệu mà trở thành mã lệnh:

TanCong.java
String sql = "SELECT * FROM nd WHERE ten = '" + input + "'";
// input = ' OR '1'='1        → trả về TOÀN BỘ bảng
// input = '; DROP TABLE nd; -- → mất bảng
// input = ' UNION SELECT cvv, ... → đọc bảng khác

PreparedStatement chống được ở tầng cơ chế, không phải bằng cách lọc ký tự: nó gửi câu lệnh và dữ liệu tới database riêng biệt. Database phân tích cú pháp câu lệnh trước, tạo kế hoạch thực thi với các chỗ trống, rồi mới điền dữ liệu vào. Dữ liệu không bao giờ đi qua bộ phân tích SQL, nên không thể trở thành lệnh — dù nội dung là gì.

Lưu ý: ? chỉ dùng cho giá trị. Tên bảng và tên cột phải kiểm tra bằng danh sách trắng.

Q2

Vì sao phải đóng Connection, Statement, ResultSet?

Xem đáp án
✓ Đáp án

Vì mỗi thứ chiếm tài nguyên ở cả hai phía:

  • Connection — một socket TCP phía client, một tiến trình/luồng phía database. Không đóng thì sau vài trăm lần bạn nhận Too many connections và toàn bộ ứng dụng ngừng hoạt động.
  • Statement — con trỏ và kế hoạch thực thi ở phía database.
  • ResultSet — bộ đệm kết quả, có thể rất lớn.

Cách đúng là try-with-resources, đóng theo thứ tự ngược (rs → ps → conn) và hoạt động kể cả khi có exception:

Dung.java
try (Connection c = ds.getConnection();
     PreparedStatement ps = c.prepareStatement(sql);
     ResultSet rs = ps.executeQuery()) { ... }

Với connection pool, close() không ngắt kết nối thật mà trả nó lại pool — nên vẫn phải gọi, và càng quan trọng hơn: quên trả thì pool cạn.

Q3

Giao dịch là gì? Vì sao cần setAutoCommit(false)?

Xem đáp án
✓ Đáp án

Giao dịch là một nhóm thao tác được coi như một đơn vị không thể chia nhỏ: hoặc tất cả thành công (commit), hoặc không gì cả (rollback).

JDBC mặc định ở chế độ auto-commit — mỗi câu lệnh là một giao dịch riêng, commit ngay. Với thao tác nhiều bước, đó là thảm hoạ:

ThamHoa.java
// Auto-commit BẬT:
truTien(tu, 1000);        // ✅ commit ngay — tiền đã bị trừ
congTien(den, 1000);     // 💥 lỗi mạng ở đây
// → Tiền BỐC HƠI. Không hoàn tác được.

Mẫu chuẩn:

Mau.java
conn.setAutoCommit(false);
try {
    buoc1(); buoc2(); buoc3();
    conn.commit();
} catch (SQLException e) {
    conn.rollback();
    throw e;
} finally {
    conn.setAutoCommit(true);   // ⚠️ BẮT BUỘC với pool
}

Bước finally rất quan trọng: kết nối được trả lại pool và tái sử dụng — nếu để nguyên autoCommit=false, đoạn code tiếp theo dùng kết nối đó sẽ không commit và mất dữ liệu một cách bí ẩn.

Q4

Vì sao cần connection pool? close() khi có pool nghĩa là gì?

Xem đáp án
✓ Đáp án

Vì mở một kết nối tốn 50-200 ms: bắt tay TCP, xác thực, thiết lập phiên, có thể cả TLS. Với API xử lý 1.000 request mỗi giây, chi phí đó lớn hơn bản thân truy vấn nhiều lần.

Pool giữ sẵn một số kết nối đã mở. getConnection() lấy một cái từ pool — mất vài micro giây thay vì mili giây. Chênh lệch khoảng 1.000 lần.

Với pool, close() không ngắt kết nối — nó trả kết nối về pool. Nghĩa là "tôi dùng xong rồi", không phải "hãy đóng lại". Vì vậy bạn vẫn phải gọi close(): quên thì kết nối không bao giờ được trả, pool cạn dần, và ứng dụng treo với Connection is not available, request timed out.

Chọn kích thước: nhỏ hơn trực giác. Công thức HikariCP: (nhân_CPU_của_DB × 2) + số_đĩa — với server 4 nhân và SSD thì khoảng 10, không phải 100. Quá nhiều kết nối khiến database tranh chấp tài nguyên và chậm hơn.
Bật leakDetectionThreshold để HikariCP in stack trace chỗ rò rỉ.

Q5

Batch giúp gì? Vì sao nó nhanh hơn nhiều?

Xem đáp án
✓ Đáp án

Batch gom nhiều câu lệnh rồi gửi một lần, thay vì một lượt đi-về mạng cho mỗi câu. Với 10.000 bản ghi: từ khoảng 60 giây xuống còn khoảng 1 giây.

Batch.java
conn.setAutoCommit(false);
for (var x : ds) {
    ps.setString(1, x.ten());
    ps.addBatch();
    if (++i % 1000 == 0) { ps.executeBatch(); ps.clearBatch(); }
}
ps.executeBatch();
conn.commit();

Ba nguồn cải thiện, theo mức độ:

  • Giao dịch — lớn nhất. Mỗi commit là một lần ghi đĩa đồng bộ (fsync); gộp 10.000 commit thành một là tiết kiệm khổng lồ.
  • Giảm lượt đi-về mạng — mỗi lượt tốn độ trễ mạng.
  • Tái dùng kế hoạch thực thi — PreparedStatement chỉ phân tích cú pháp một lần.

Chia lô mỗi 1.000 bản để tránh phình bộ nhớ. Với MySQL, thêm rewriteBatchedStatements=true vào chuỗi kết nối để driver gộp thành một câu INSERT … VALUES (…),(…) — nhanh thêm vài lần nữa.

Q6

Mức cô lập giao dịch là gì? Nêu ba hiện tượng bất thường.

Xem đáp án
✓ Đáp án

Mức cô lập quy định các giao dịch đồng thời "nhìn thấy" nhau tới đâu. Càng cô lập cao càng an toàn nhưng càng ít đồng thời (nhiều khoá hơn).

Ba hiện tượng:

  • Dirty read — đọc được dữ liệu mà giao dịch khác chưa commit. Nếu nó rollback, bạn đã đọc dữ liệu chưa bao giờ tồn tại.
  • Non-repeatable read — đọc cùng một hàng hai lần trong một giao dịch mà nhận hai giá trị khác nhau.
  • Phantom read — chạy cùng một truy vấn hai lần, lần sau xuất hiện thêm hàng mới.
MứcDirtyNon-repeatPhantom
READ_UNCOMMITTED❌❌❌
READ_COMMITTED✅❌❌
REPEATABLE_READ✅✅❌
SERIALIZABLE✅✅✅

Mặc định: READ_COMMITTED ở PostgreSQL/Oracle/SQL Server, REPEATABLE_READ ở MySQL InnoDB. Trong hầu hết ứng dụng, giữ mặc định là hợp lý.

Q7

Vì sao dùng mẫu DAO thay vì viết SQL rải rác trong code nghiệp vụ?

Xem đáp án
✓ Đáp án

Bốn lý do:

  • Tách tầng — code nghiệp vụ không biết gì về JDBC. Đổi sang JPA, MyBatis hay MongoDB chỉ cần viết cài đặt mới, tầng service không đổi một dòng.
  • Test được — quan trọng nhất. Với interface, unit test dùng cài đặt giả trong bộ nhớ, chạy trong mili giây, không cần database thật.
  • SQL tập trung — mọi câu SQL nằm một chỗ, dễ rà soát lỗ hổng injection, dễ tối ưu chỉ mục, dễ tìm khi lược đồ đổi.
  • Xử lý lỗi nhất quán — DAO bọc SQLException (checked, đặc thù JDBC) thành exception unchecked của riêng ứng dụng, giữ nguyên nhân gốc. Tầng trên không phải khai báo throws SQLException khắp nơi.

Đây chính là mẫu mà Spring Data dùng: bạn khai báo interface Repository, Spring sinh cài đặt lúc chạy. Hiểu DAO thủ công giúp bạn hiểu framework đó đang làm gì.

Q8

Vì sao dùng BigDecimal cho tiền và LocalDate thay java.sql.Date?

Xem đáp án
✓ Đáp án

BigDecimal cho tiền — vì double theo IEEE 754 không biểu diễn chính xác được nhiều phân số thập phân (chương 2). Cột DECIMAL(19,2) trong database là chính xác; đọc nó vào double là tự nguyện làm hỏng dữ liệu. Dùng rs.getBigDecimal() và ps.setBigDecimal().

LocalDate thay java.sql.Date — API ngày giờ cũ có nhiều khiếm khuyết: java.util.Date thực chất chứa cả giờ (tên gây hiểu lầm), tháng đánh số từ 0, năm tính từ 1900, object thay đổi được nên không an toàn luồng, và java.sql.Date kế thừa nó một cách gượng ép.

Từ JDBC 4.2 (Java 8), dùng thẳng java.time:

NgayGio.java
LocalDate d = rs.getObject("ngay_sinh", LocalDate.class);
ps.setObject(1, LocalDate.now());
LocalDateTime t = rs.getObject("tao_luc", LocalDateTime.class);
OffsetDateTime o = rs.getObject("tao_luc", OffsetDateTime.class);  // có múi giờ

Nhớ đặt serverTimezone trong chuỗi kết nối để tránh lệch múi giờ giữa ứng dụng và database — nguồn bug rất khó tìm.

Tổng kết

Sau chương 13, bạn đã nắm:

  • Kiến trúc JDBC: ứng dụng → API → driver Type 4 → database.
  • Connection luôn trong try-with-resources; mật khẩu qua biến môi trường.
  • PreparedStatement là bắt buộc — cơ chế tách câu lệnh khỏi dữ liệu chống SQL injection tuyệt đối.
  • Tên bảng/cột không tham số hoá được — phải dùng danh sách trắng.
  • ResultSet: while (rs.next()), đọc theo tên cột, wasNull() cho kiểu nguyên thủy.
  • CRUD đầy đủ, lấy khoá tự sinh, phân trang, ánh xạ sang record.
  • Giao dịch: ACID, commit/rollback, savepoint, bốn mức cô lập.
  • Batch tăng tốc ghi hàng loạt 50-100 lần — phần lớn nhờ gộp commit.
  • Connection pool bắt buộc trong sản xuất; kích thước nhỏ hơn trực giác.
  • Mẫu DAO: tách tầng, test được, SQL tập trung, exception nhất quán.
  • BigDecimal cho tiền, java.time cho ngày giờ.

Kết nối

  • Chương 2 (Kiểu dữ liệu) — vì sao BigDecimal chứ không phải double cho tiền.
  • Chương 5 (OOP nâng cao) — record làm entity; interface DAO cho phép nhiều cài đặt.
  • Chương 6 (Ngoại lệ) — SQLException là checked; bọc thành unchecked ở biên và giữ nguyên nhân gốc.
  • Chương 9 (Generics) — mẫu Repository<T, ID> tổng quát.
  • Chương 11 (Tệp tin) — cùng mẫu try-with-resources cho mọi AutoCloseable.
  • Chương 12 (Đa luồng) — pool là một dạng resource pool; deadlock ở tầng database và cách xử lý (thử lại).
  • Chương 14 (Dự án) — ghép DAO vào ứng dụng hoàn chỉnh, có test với database trong bộ nhớ.
  • Trụ cột Database — SQL, chỉ mục, chuẩn hoá, kế hoạch thực thi truy vấn.
  • Python Chương 14 — đối chiếu: sqlite3 của Python cũng dùng tham số ? với đúng lý do; API tương tự nhưng gọn hơn.