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.