Lý do người ta đưa ra để dùng client cơ sở dữ liệu reactive gần như luôn là "nhanh hơn". Tôi dựng PostgreSQL 16.15 thật trong Docker, 10 000 dòng, rồi cho Vert.x PG Client và JDBC chạy đúng cùng một truy vấn ở cùng mức đồng thời, cùng kích thước pool.
Sàn diễn
Cùng một truy vấn, ba cách gọi trong cùng một ứng dụng:
// 1. reactive
pg.preparedQuery("select ten, so_luong from san_pham where ma = $1")
.execute(Tuple.of(ma))
.onSuccess(rs -> ...);
// 2. JDBC tren pool luong worker
thoJdbc.executeBlocking(() -> {
try (Connection cn = hikari.getConnection();
PreparedStatement ps = cn.prepareStatement("select ten, so_luong from san_pham where ma = ?")) {
ps.setString(1, ma);
try (ResultSet rs = ps.executeQuery()) { return rs.next() ? rs.getString(1) : "?"; }
}
}, false).onSuccess(...);
// 3. JDBC tren luong ao cua Java 21
Thread.ofVirtual().start(() -> { /* y het tren */ });
Pool 50 cho cả PgPool lẫn HikariCP, 500 kết nối HTTP đồng thời, 10 giây mỗi lượt đo.
Thông lượng: bằng nhau
| Lần 1 | Lần 2 | p50 | |
|---|---|---|---|
| Vert.x PG Client | 40 392 req/s | 41 310 | 11,7 ms |
| JDBC trên worker pool | 40 388 req/s | 40 322 | 11,8 ms |
| JDBC trên luồng ảo | 35 509 req/s | 34 775 | 13,6 ms |
Reactive và JDBC chênh nhau bốn request mỗi giây ở lần đo đầu. Không có khác biệt nào. Tôi chạy lại ở pool 20 và pool 200 để chắc chắn đây không phải trùng hợp:
| Pool | Reactive | JDBC |
|---|---|---|
| 20 | 35 251 req/s | 32 026 |
| 50 | 38 029 req/s | 37 321 |
| 200 | 44 565 req/s | 43 280 |
Reactive nhỉnh hơn 2–10%, tức là trong dải nhiễu giữa các lần chạy. Không ở đâu có chuyện "nhanh gấp mấy lần".
Lý do thì rõ khi nghĩ về chỗ nghẽn: cả hai đều bị chặn ở cùng một chỗ — số kết nối tới PostgreSQL. Với pool 50, cả hai đều gửi tối đa 50 truy vấn đồng thời xuống CSDL và chờ. Việc luồng gọi bị chặn hay không chẳng đổi được thời gian PostgreSQL cần để trả lời. Truy vấn ở đây là một lần tra khoá chính, tức là gần như chỉ có chi phí đi lại — trường hợp có lợi nhất cho reactive mà cũng không tạo ra khác biệt.
Cái reactive thật sự mua
Đây mới là chỗ hai bên khác nhau, và nó không nằm trong bảng thông lượng:
| Pool | Tổng luồng JVM | Event loop | Luồng worker cho JDBC | RSS |
|---|---|---|---|---|
| 20 | 35 | 5 | 20 | 358 MB |
| 50 | 66 | 5 | 50 | — |
| 200 | 216 | 5 | 200 | 810 MB |
Đường reactive dùng 5 event loop bất kể pool to bằng nào. Đường JDBC cần đúng một luồng cho mỗi chỗ trong pool, vì một truy vấn JDBC giữ luồng của nó cho tới khi CSDL trả lời. Muốn 200 truy vấn đồng thời thì phải có 200 luồng đang nằm chờ.
Chênh lệch bộ nhớ 358 → 810 MB đi kèm 181 luồng thêm vào. Tôi không quy hết 452 MB đó cho luồng — trong đó có cả bộ đệm của 180 kết nối JDBC nữa — nhưng cả hai khoản đều là hệ quả trực tiếp của mô hình "một luồng một truy vấn", và chúng không tồn tại ở phía reactive.
Nói cho gọn: reactive không mua thông lượng, nó mua trần khả năng mở rộng. Chừng nào pool của bạn còn ở mức vài chục thì hai bên như nhau và JDBC dễ viết hơn nhiều. Khi bạn cần hàng nghìn thao tác CSDL đồng thời — hoặc chạy trong container bị giới hạn bộ nhớ chặt — thì mô hình một luồng một truy vấn đâm vào tường, còn reactive thì không.
Luồng ảo chậm nhất, và tôi không định giả vờ ngạc nhiên hộ ai
Luồng ảo của Java 21 lẽ ra là câu trả lời cho đúng vấn đề trên: chặn thoải mái, luồng rẻ như không. Nhưng nó thua đều đặn 14% qua cả hai lần đo, và p95 tệ hơn hẳn (26,1 ms so với 15,6 ms).
Tôi không có phép đo nào chứng minh dứt khoát nguyên nhân, nên chỉ nêu cái đo được và cái nghi ngờ: mỗi request tạo một luồng ảo mới, còn HikariCP và driver JDBC có những đoạn synchronized — mà khối synchronized thì ghim luồng ảo vào luồng mang, làm mất chính ưu điểm của nó. Đây là hạn chế đã biết của Java 21 (JEP 444 nêu rõ synchronized gây ghim; các bản Java sau này gỡ dần chuyện đó).
Điều đáng nói với người đang đứng trước lựa chọn: luồng ảo không tự động cứu mã JDBC. Nếu bạn định dựa vào chúng để chạy JDBC ở mức đồng thời cao, hãy đo trên đúng driver và đúng pool của bạn trước, đừng tin vào lời hứa chung chung.
Hai cái bẫy tôi dính khi dựng
PgPool.pool() gọi trong Verticle nhân lên theo số bản sao. Lần chạy đầu tôi khai pool 20 rồi đếm kết nối thật trên máy chủ:
select count(*) from pg_stat_activity where datname='khodb'; -- 101
Bốn bản sao Verticle, mỗi bản một pool 20, cộng 20 của Hikari, cộng một kết nối của chính lệnh đếm. Với pool 200 thì thành 800 kết nối yêu cầu, vượt max_connections và tôi nhận 25 525 lỗi.
Đây đúng là cái bẫy đã gặp với bộ giới hạn tần suất ở phần 29: mọi tài nguyên khai bên trong một Verticle đều nhân với setInstances(). Với bộ đếm tần suất thì hậu quả là hạn mức nới ra âm thầm; với pool CSDL thì hậu quả là bạn mở gấp N lần số kết nối mình tưởng — và CSDL là nơi số kết nối rất đắt. Cách vá là tạo pool một lần rồi dùng chung.
Vì thế hãy luôn kiểm chứng bằng pg_stat_activity, đừng tin con số trong cấu hình. Sau khi sửa, pool 50 cho ra đúng 101 kết nối = 50 PG + 50 Hikari + 1 của lệnh đếm.
Vert.x 4.5 cần đúng phiên bản thư viện SCRAM. PostgreSQL 16 mặc định xác thực bằng scram-sha-256. Tôi thêm com.ongres.scram:scram-client:3.1 và nhận lại:
com/ongres/scram/common/stringprep/StringPreparation
Một NoClassDefFoundError trần trụi, không nói được gì. vertx-pg-client 4.5.11 cần bố cục gói của dòng 2.x — com.ongres.scram:client:2.1. Đổi lại là chạy. Lỗi này chỉ nổ ra lúc kết nối chứ không phải lúc khởi động, nên nó rất dễ lọt qua và chỉ lộ ra trên môi trường thật.
Chọn thế nào
- Pool vài chục, tải vừa → dùng cái nào cũng được; JDBC có hệ sinh thái lớn hơn nhiều (JPA, migration, công cụ). Số đo không cho reactive lợi thế nào ở đây.
- Cần đồng thời rất cao, hoặc bộ nhớ bị siết → reactive, vì trần của nó không phải số luồng.
- Đã ở trong Vert.x rồi → dùng client reactive, không phải vì nhanh hơn mà vì bạn khỏi phải dựng vách ngăn cho pool luồng chặn — đúng cái việc đã phải làm ở phần 31. Đó là lý do vận hành, và nó tốt hơn lý do hiệu năng.
- Đang tính dùng luồng ảo để giữ JDBC → đo trước. Ở đây nó chậm nhất trong ba cách.
Và trong mọi trường hợp, hãy nhớ rằng pool CSDL to hơn không phải lúc nào cũng tốt hơn: từ pool 20 lên 200 chỉ mua thêm 26% thông lượng, đổi lại 400 kết nối trên máy chủ CSDL và 452 MB bộ nhớ trong ứng dụng.
Thử ba mươi giây
Kiểm tra xem bạn thật sự đang mở bao nhiêu kết nối, chứ không phải bao nhiêu kết nối bạn nghĩ:
select count(*), application_name, state
from pg_stat_activity where datname = 'ten_csdl_cua_ban'
group by 2, 3 order by 1 desc;
Con số lớn hơn maxSize bạn khai thì có ai đó đang tạo pool nhiều lần — thường là một pool nằm trong Verticle được triển khai nhiều bản sao, hoặc một WebClient/Pool tạo bên trong handler thay vì trong start().
Phần sau bàn về giao dịch và các mẫu truy cập dữ liệu trên client reactive.