Gọi taxi cho từng hành khách hay đi một chuyến buýt dùng lại ghế — nếu chỉ có dăm khách thì chẳng khác gì nhau, cả hai đến nơi cùng lúc. Khác biệt chỉ lộ ra khi có hàng nghìn khách: kiểu taxi cần hàng nghìn tài xế, kiểu buýt vẫn từng ấy ghế. JDBC là taxi — một luồng ngồi chờ suốt một truy vấn; reactive là buýt — vài event loop phục vụ tất cả. Lý do người ta đưa ra để dùng client CSDL reactive gần như luôn là "nhanh hơn", nên tôi dựng PostgreSQL 16.15 thật và cho hai bên chạy cùng một truy vấn ở cùng mức đồng thời để xem cái "nhanh hơn" ấy có thật không.

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.

Muốn biết mình thật sự đang mở bao nhiêu kết nối — chứ không phải bao nhiêu mình nghĩ — hỏi thẳng PostgreSQL:

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().

Mẫu số chung

Bài học lớn nhất: so hai công cụ theo chỗ mỗi cái đụng tường, đừng theo con số chúng chạm hôm nay. Reactive và JDBC hoà nhau từng req/s ở pool vài chục, nên nếu chỉ nhìn bảng thông lượng thì kết luận "như nhau, chọn cái dễ viết" — đúng, cho tới lúc bạn cần vài nghìn truy vấn đồng thời, và mô hình một-luồng-một-truy-vấn đâm vào tường số luồng còn reactive thì không. Cả hai đang nghẽn ở cùng một chỗ (số kết nối CSDL), nên khác biệt hôm nay bằng không; khác biệt chỉ hiện ở hình dạng cái trần. Cùng câu chuyện ở một thuật toán O(n) hoà O(log n) khi n nhỏ, một thiết kế chạy ngon tới đúng mốc 10× tải, một cấu trúc dữ liệu vừa vặn tới ngày dữ liệu tràn cache. Khi chọn, đừng hỏi "cái nào nhanh hơn bây giờ" mà hỏi "khi tôi lớn gấp mười, cái nào đụng tường trước, và cái tường đó là gì?"

Điều thứ hai, một thói quen vận hành cứu người: con số bạn khai không phải con số thật — hãy kiểm ở nguồn có thẩm quyền. Cấu hình ghi pool 20; pg_stat_activity nói 800, vì một cái pool nằm trong Verticle nhân lên theo số bản sao. Cái ý định (config, cờ, lời hứa của một tính năng) và cái hiện thực (kết nối thật, hành vi thật) luôn có một khe hở, và production sống trong khe hở đó. Luồng ảo "hứa" chặn thoải mái — thực tế synchronized ghim nó lại; pool "khai" 20 — thực tế mở 800. Nên với mọi con số quan trọng, đừng đọc nó từ nơi bạn viết ra, hãy đo nó từ nơi nó xảy ra — hỏi thẳng CSDL, đếm luồng thật, chạy thử trên đúng driver của mình. Cái config nói cho bạn điều bạn muốn; chỉ hệ thống thật mới nói điều bạn có.

Phần sau bàn về giao dịch và các mẫu truy cập dữ liệu trên client reactive.