- 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
Statementvà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
DAO / Service
java.sql.*Interface chuẩn
mysql-connector-j
PostgreSQL…
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ính | Vai trò | Tạo từ |
|---|---|---|
DriverManager | Class (không phải interface) — quản lý driver, tạo kết nối | — |
DataSource | Nguồn kết nối hiện đại, hỗ trợ pool ⭐ | Thư viện pool |
Connection | Một phiên làm việc với database | DriverManager/DataSource |
Statement | Thực thi SQL tĩnh — ⚠️ tránh dùng | Connection |
PreparedStatement | SQL có tham số, đã biên dịch trước ⭐ | Connection |
CallableStatement | Gọi stored procedure | Connection |
ResultSet | Con trỏ duyệt kết quả truy vấn | Statement |
SQLException | Mọi lỗi database (checked) | — |
| Loại driver | Cơ chế | Trạng thái |
|---|---|---|
| Type 1 — JDBC-ODBC Bridge | Qua cầu nối ODBC của hệ điều hành | ❌ Đã loại bỏ từ Java 8 |
| Type 2 — Native API | Gọ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 Protocol | Qua 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 |
<!-- 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
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");
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.
// ❌ 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
// ❌❌ 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.
Statement | PreparedStatement ⭐ | |
|---|---|---|
| SQL injection | ❌ Dễ bị tấn công | ✅ An toàn tuyệt đối |
| Biên dịch | Mỗi lần chạy phân tích lại | Phân tích một lần, tái dùng kế hoạch |
| Hiệu năng khi lặp | Chậm | Nhanh hơn nhiều |
| Kiểu dữ liệu | Tự nối chuỗi — dễ sai định dạng ngày | setDate, setBigDecimal… đúng kiểu |
| Ký tự đặc biệt | Phải tự escape (rất dễ sai) | Tự động xử lý |
| Batch | Hạn chế | Hỗ trợ đầy đủ |
| Khi nào dùng | DDL không tham số (CREATE TABLE) | Mọi trường hợp còn lại |
?. 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.
// ❌ 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
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 SQL | Phương thức ResultSet | Kiểu Java |
|---|---|---|
INT | getInt / getObject(…, Integer.class) | int / Integer |
BIGINT | getLong | long |
VARCHAR, TEXT | getString | String |
DECIMAL, NUMERIC | getBigDecimal ⭐ | BigDecimal — tiền tệ |
DATE | getObject(…, LocalDate.class) | LocalDate |
TIMESTAMP | getObject(…, LocalDateTime.class) | LocalDateTime |
BOOLEAN, TINYINT(1) | getBoolean | boolean |
BLOB | getBytes / getBinaryStream | byte[] |
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 đủ
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)
// ⚠️ 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();
| ACID | Nghĩ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án | Database luôn ở trạng thái hợp lệ (ràng buộc được giữ) |
| Isolation — Cô lập | Giao 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ập | Dirty read | Non-repeatable read | Phantom read | Ghi 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 |
- 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
// ❌ 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".
<!-- 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
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
// ① 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));
}
}
10. Cheat sheet chương 13
| Tình huống | Cá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ên | try-with-resources cho Connection, Statement, ResultSet |
| Truy vấn | executeQuery() → ResultSet |
| Thêm/sửa/xoá | executeUpdate() → số hàng bị ảnh hưởng |
| Lấy id tự sinh | prepareStatement(sql, RETURN_GENERATED_KEYS) + getGeneratedKeys() |
| Duyệt kết quả | while (rs.next()), đọc theo tên cột |
| Kiểm tra NULL | rs.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ịch | Khôi phục setAutoCommit(true) trong finally |
| Ghi hàng loạt | addBatch() + executeBatch() mỗi 1000 bản |
| Sản xuất | Connection 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 động | Danh sách trắng — ? không dùng được |
| Mật khẩu | Biến môi trường — không bao giờ trong mã nguồn |
| Kiến trúc | Mẫ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:
- Viết hàm đăng nhập dễ bị tấn công bằng
Statementvà nối chuỗi. - Đă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. - Dùng
UNION SELECTđể đọc dữ liệu từ một bảng khác. - Xoá bảng bằng
'; DROP TABLE …; --(nhớ sao lưu trước). - Viết lại bằng
PreparedStatement. Thử tất cả các payload trên — ghi lại kết quả. - 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 đủ:
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
recordlàm entity. muonSach(sachId, docGiaId): một giao dịch — kiểm tra còn sách, giảmso_luong, tạo bản ghi mượn. Hết sách thì rollback.traSach(muonId): cập nhậtngay_tra, tăngso_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:
Statement, auto-commit, không pool.PreparedStatement, auto-commit, không pool.PreparedStatement+ giao dịch (một commit duy nhất).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:
- Dirty read: kết nối A sửa một hàng nhưng chưa commit; B đọc. Thử ở
READ_UNCOMMITTEDvàREAD_COMMITTED— khác nhau thế nào? - 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?
- 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.
- 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.
- Rollback: thực hiện ba câu
UPDATErồ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):
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
--infoin 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
SQL injection là gì? Vì sao PreparedStatement chống được?
Xem đá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:
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.
Vì sao phải đóng Connection, Statement, ResultSet?
Xem đá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ậnToo many connectionsvà 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:
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.
Giao dịch là gì? Vì sao cần setAutoCommit(false)?
Xem đá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ạ:
// 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:
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.
Vì sao cần connection pool? close() khi có pool nghĩa là gì?
Xem đá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ỉ.
Batch giúp gì? Vì sao nó nhanh hơn nhiều?
Xem đá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.
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 —
PreparedStatementchỉ 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.
Mức cô lập giao dịch là gì? Nêu ba hiện tượng bất thường.
Xem đá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ức | Dirty | Non-repeat | Phantom |
|---|---|---|---|
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ý.
Vì sao dùng mẫu DAO thay vì viết SQL rải rác trong code nghiệp vụ?
Xem đá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áothrows SQLExceptionkhắ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ì.
Vì sao dùng BigDecimal cho tiền và LocalDate thay java.sql.Date?
Xem đá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:
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.
Connectionluôn trong try-with-resources; mật khẩu qua biến môi trường.PreparedStatementlà 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.
BigDecimalcho tiền,java.timecho ngày giờ.
Kết nối
- Chương 2 (Kiểu dữ liệu) — vì sao
BigDecimalchứ không phảidoublecho tiền. - Chương 5 (OOP nâng cao) —
recordlàm entity; interface DAO cho phép nhiều cài đặt. - Chương 6 (Ngoại lệ) —
SQLExceptionlà 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:
sqlite3của Python cũng dùng tham số?với đúng lý do; API tương tự nhưng gọn hơn.