99 Ngày Spring — Ngày 35: JdbcTemplate & JdbcClient
Mười bốn ngày qua (21→34) ta xây cả tầng dữ liệu trên JPA — và hôm nay là bài học trưởng thành tiếp theo: biết lúc nào không dùng nó. Báo cáo JOIN năm bảng, batch trăm nghìn dòng, câu SQL cần tối ưu tay — đấy là lãnh địa của SQL thuần, và Spring có sẵn hai cây cầu: JdbcTemplate công thần từ 2004 với RowMapper, và JdbcClient mới từ Boot 3.2 — API fluent, tham số có tên, tự map record. Cuối bài là công thức trộn JPA + JDBC trong một app và cái bẫy flush kinh điển khi hai thế giới chung một transaction.

Khi nào bỏ JPA — và bước xuống một tầng
// 14 ngày JPA (21→34) — và hôm nay: khi nào KHÔNG dùng JPA?
// JPA tỏa sáng: CRUD theo domain, quan hệ, dirty checking, cache 1st-level
// JPA đuối sức:
// 1. BÁO CÁO phức tạp — JOIN 5 bảng, GROUP BY, window function
// → entity graph không mô tả nổi; JPQL bó tay với window function
// 2. BATCH lớn — update 100k dòng
// → JPA load từng entity vào persistence context rồi dirty-check: RAM khóc
// → SQL thuần: MỘT câu UPDATE ... WHERE — DB làm việc của DB
// 3. Truy vấn TỐI ƯU TAY — hint, CTE, index cụ thể
// → qua JPA là đoán mò xem nó sinh SQL gì; viết tay là biết chính xác
// Đó là lúc bước xuống MỘT tầng: Spring JDBC
// KHÔNG phải JDBC trần (Connection/PreparedStatement/try-finally dài dòng) —
// Spring lo mở/đóng connection, dịch SQLException, map kết quả
// Quy tắc thực dụng của cả bài:
// GHI theo domain → JPA repository (như 14 ngày qua)
// ĐỌC báo cáo / batch / SQL tối ưu tay → JdbcClient
// Hai cách CÙNG DataSource, CÙNG transaction — trộn thoải mái trong 1 appJPA không phải búa vạn năng: báo cáo phức tạp (JOIN nhiều bảng, window function — JPQL bó tay), batch lớn (load từng entity để dirty-check là tự sát RAM — một câu
UPDATE ... WHERExong việc), và SQL tối ưu tay (qua JPA là đoán mò SQL sinh ra)."Bước xuống một tầng" không có nghĩa JDBC trần: Spring JDBC lo mở/đóng connection, dịch SQLException, map kết quả — bạn chỉ viết SQL và khai kiểu trả về.
Quy tắc thực dụng xuyên suốt: ghi theo domain → JPA, đọc báo cáo / batch / SQL tay → JdbcClient — cùng
DataSource, cùng transaction, trộn được trong một app.
JdbcTemplate — công thần hai mươi năm
// JdbcTemplate — công thần từ 2004, gặp trong MỌI codebase Spring
@Repository
public class BaoCaoRepository {
private final JdbcTemplate jdbc;
BaoCaoRepository(JdbcTemplate jdbc) { this.jdbc = jdbc; } // Boot tạo sẵn bean
// Truy vấn ra record — RowMapper: (ResultSet, số dòng) → object
record DoanhThuThang(String thang, BigDecimal tong, int soDon) {}
List<DoanhThuThang> doanhThuTheoThang(int nam) {
return jdbc.query("""
SELECT to_char(created_at,'YYYY-MM') AS thang,
sum(total) AS tong, count(*) AS so_don
FROM orders WHERE extract(year FROM created_at) = ?
GROUP BY 1 ORDER BY 1""",
(rs, i) -> new DoanhThuThang( // RowMapper = lambda
rs.getString("thang"),
rs.getBigDecimal("tong"),
rs.getInt("so_don")),
nam); // tham số theo VỊ TRÍ dấu ?
}
// Một giá trị đơn:
int demDonTreo() {
return jdbc.queryForObject(
"SELECT count(*) FROM orders WHERE status = 'PENDING'", Integer.class);
}
// Batch — 100k dòng trong MỘT round-trip lô:
void capNhatTrangThai(List<Long> ids) {
jdbc.batchUpdate("UPDATE orders SET status = 'SHIPPED' WHERE id = ?",
ids, 500, (ps, id) -> ps.setLong(1, id)); // lô 500 câu/lần
}
}
// Điểm gợn sau 20 năm: tham số ? đếm theo vị trí — câu SQL 8 dấu ? là trò dò mìnRowMapperlà trái tim: lambda(rs, i) -> ...biến từng dòngResultSetthành object — ghép với record (Ngày 20 Java) thành cặp bài trùng cho read model.queryForObjectcho giá trị đơn,batchUpdatevới kích thước lô cho trăm nghìn dòng — đúng việc DB giỏi nhất, không entity nào bị load.Điểm gợn sau hai mươi năm: tham số
?đếm theo vị trí — câu SQL tám dấu hỏi là trò dò mìn khi refactor; đó chính là lý do JdbcClient ra đời.
JdbcClient — lớp áo mới từ Boot 3.2
// JdbcClient — Spring 6.1 / Boot 3.2 (2023): cùng động cơ, API fluent
@Repository
public class BaoCaoRepository {
private final JdbcClient db;
BaoCaoRepository(JdbcClient db) { this.db = db; } // Boot cũng tạo sẵn bean
record DoanhThuThang(String thang, BigDecimal tong, int soDon) {}
List<DoanhThuThang> doanhThuTheoThang(int nam) {
return db.sql("""
SELECT to_char(created_at,'YYYY-MM') AS thang,
sum(total) AS tong, count(*) AS so_don
FROM orders WHERE extract(year FROM created_at) = :nam
GROUP BY 1 ORDER BY 1""")
.param("nam", nam) // tham số CÓ TÊN — hết dò mìn
.query(DoanhThuThang.class) // map record TỰ ĐỘNG theo tên cột
.list(); // (so_don → soDon: snake→camel)
}
// Optional thay vì exception khi không thấy:
Optional<DonHang> timDon(long id) {
return db.sql("SELECT * FROM orders WHERE id = :id")
.param("id", id)
.query(DonHang.class)
.optional(); // JdbcTemplate: EmptyResultDataAccessException 🙈
}
long demTheoTrangThai(String status) {
return db.sql("SELECT count(*) FROM orders WHERE status = :s")
.param("s", status)
.query(Long.class).single();
}
}
// Ba nâng cấp so với JdbcTemplate:
// 1. Tham số có tên :nam — đọc được, đảo thứ tự vô tư
// 2. Tự map record/class theo TÊN CỘT — RowMapper thủ công chỉ còn cho case lắt léo
// 3. .optional()/.single()/.list() — nói rõ kỳ vọng, hết exception bất ngờ
// JdbcTemplate không lỗi thời — JdbcClient là lớp áo mới trên cùng nền tảngBa nâng cấp gọn: tham số có tên
:nam(đọc được, đảo thứ tự vô tư), tự map record theo tên cột kèm chuyển snake_case → camelCase (RowMapper thủ công chỉ còn cho case lắt léo), và bộ.optional()/.single()/.list()nói rõ kỳ vọng — hếtEmptyResultDataAccessExceptionbất ngờ.Cùng động cơ bên dưới (vẫn là hạ tầng JdbcTemplate/NamedParameterJdbcTemplate) nên không có chuyện lỗi thời: codebase cũ giữ JdbcTemplate chạy tốt, code mới viết JdbcClient — kiến thức dùng chung.
Boot tự cấu hình sẵn cả hai bean từ
DataSource— inject là dùng, không thêm một dòng config.
Trộn JPA + JDBC — và cái bẫy flush
// Trộn JPA + JdbcClient trong MỘT service — thực đơn chuẩn
@Service
public class DonHangService {
private final DonHangRepository jpaRepo; // JPA — ghi theo domain
private final BaoCaoRepository baoCao; // JdbcClient — đọc báo cáo
@Transactional // MỘT transaction bao cả hai
public void giaoDon(long id) {
DonHang don = jpaRepo.findById(id).orElseThrow();
don.chuyenTrangThai(TrangThai.SHIPPED); // dirty checking lo UPDATE
}
@Transactional(readOnly = true)
public List<DoanhThuThang> baoCaoNam(int nam) {
return baoCao.doanhThuTheoThang(nam); // SQL thuần — không entity nào bị load
}
}
// Cùng DataSource + cùng transaction manager → trộn an toàn. NHƯNG một bẫy:
// ⚠️ BẪY FLUSH: trong CÙNG transaction, ghi qua JPA rồi ĐỌC qua JDBC
@Transactional
public void bay() {
jpaRepo.save(new DonHang(...)); // mới nằm trong persistence context!
baoCao.demDonTreo(); // JDBC đọc THẲNG DB — chưa thấy đơn mới 😱
// JPA chỉ flush SQL xuống DB trước query JPA/lúc commit — JDBC không kích flush
// Cần đọc thấy ngay? → em.flush() trước, hoặc tách transaction, hoặc đừng trộn
}
// Chọn công cụ theo việc — bảng cuối:
// CRUD domain, quan hệ, lifecycle → JPA (21→34)
// Báo cáo JOIN/GROUP BY, read model → JdbcClient
// Batch chục nghìn dòng → JdbcClient batchUpdate / SQL một câu
// Câu SQL cần hint/CTE/tối ưu tay → JdbcClient
// Dự án THUẦN truy vấn tay? → cân nhắc bỏ hẳn JPA: nhẹ, khởi động nhanhThực đơn chuẩn trong một service:
@Transactionalbao cả hai — JPA ghi qua dirty checking, JdbcClient đọc báo cáo vớireadOnly = true; cùng transaction manager nên an toàn.Bẫy kinh điển: trong cùng một transaction, ghi qua JPA rồi đọc ngay qua JDBC — entity mới còn nằm trong persistence context, chưa flush xuống DB, nên JDBC không thấy. Cần đọc-thấy-ngay:
em.flush()trước, tách transaction, hoặc đừng trộn đường ghi.Góc nhìn kiến trúc (blog này là ví dụ sống): pattern CQRS nhẹ — command dùng JPA cho ngữ nghĩa domain, query dùng SQL thuần cho tốc độ và độ chính xác — chính là đường ranh service/repository mà series này vẫn vẽ.
Checklist SQL-thuần tử tế
Text block
"""(Java 15+) cho mọi câu SQL nhiều dòng — hết cộng chuỗi lệch dấu cách.Tham số luôn bind (
:tenhay?) — không bao giờ nối chuỗi giá trị vào SQL: SQL injection Ngày 21 vẫn là luật sắt.Kết quả đổ vào record — read model bất biến, khớp tinh thần DTO projection Ngày 33.
Batch:
batchUpdatetheo lô 500–1000, hoặc gói cả logic vào một câu SQL nếu diễn đạt được — đo bằng log SQL trước khi tin.Truy vấn viết tay thì test bằng Testcontainers (Ngày 10) — H2 không hiểu window function của PostgreSQL đâu.
Bài tập nhỏ
Thêm
BaoCaoRepositoryvào project Task Manager (checkpoint Ngày 30): câu JOIN tasks với users,GROUP BYuser, đếm task theo status — viết bằng JdbcClient đổ vào record.Viết lại đúng truy vấn đó bằng JdbcTemplate + RowMapper — cảm nhận khác biệt tham số vị trí vs có tên.
Benchmark batch: chèn 10.000 dòng bằng
saveAllcủa JPA vsbatchUpdatelô 500 — bật log SQL, đếm câu lệnh và đo thời gian.Tái hiện bẫy flush: trong một
@Transactional,saveentity rồicountbằng JdbcClient — xem con số thiếu; thêmem.flush()và xem nó đủ.Thử
.query(BeanCuaBan.class)với cột không khớp tên field — đọc kỹ lỗi để quen mặt nó.
Kết luận
Bốn ý mang về: JPA không phải câu trả lời duy nhất — báo cáo, batch, SQL tối ưu tay là đất của Spring JDBC; JdbcTemplate hai mươi năm vẫn chạy tốt với RowMapper + batchUpdate, còn JdbcClient (Boot 3.2) thêm tham số có tên, tự map record, .optional(); công thức trộn: ghi JPA, đọc JdbcClient, cùng transaction — nhưng nhớ bẫy flush khi ghi bên này đọc bên kia. Ngày 36 ta tăng tốc độ đọc theo cách khác hẳn — không chạm DB nữa: Caching với @Cacheable — cache abstraction, @CacheEvict/@CachePut, key và TTL. Hẹn gặp lại!
Site Admin
Engineer and writer. Building things with TypeScript and distributed systems.
Bình luận (0)
Bạn cần đăng nhập bằng Google để bình luận.
Hãy là người bình luận đầu tiên.


