Phần trước cho thấy client reactive không nhanh hơn JDBC — cả hai đều nghẽn ở số kết nối tới cơ sở dữ liệu. Bài này đào vào chính con số đó: pool nên to bằng nào, và chuyện gì xảy ra khi bạn chọn sai.

PostgreSQL 16.15 trong Docker, bảng 200 000 dòng, Vert.x PG Client.

Truy vấn nhẹ: pool to không hại

Tra khoá chính, 500 kết nối HTTP đồng thời:

Pool Thông lượng p50 p99
4 16 772 req/s 28,0 ms 50,3 ms
20 35 693 req/s 12,9 ms 31,2 ms
50 39 224 req/s 11,8 ms 29,4 ms
200 46 618 req/s 9,7 ms 30,5 ms

Mọi thứ đều tốt lên hoặc đứng yên. Nếu chỉ đo cái này thì kết luận sẽ là "pool càng to càng tốt", và đó là kết luận nguy hiểm nhất trong bài.

Truy vấn nặng: chỗ pool to phản tác dụng

Đổi sang một truy vấn quét khoảng 20 000 dòng rồi tính tổng — vẫn là loại truy vấn hoàn toàn bình thường trong một ứng dụng thật. 200 kết nối đồng thời:

Pool Thông lượng p50 p95 p99
4 766 req/s 255,2 ms 282,2 313,6
10 1 312 req/s 148,0 ms 172,1 250,8
20 1 489 req/s 130,3 ms 155,1 213,3
50 1 541 req/s 124,1 ms 169,3 232,8
100 1 572 req/s 111,6 ms 233,9 419,8
200 1 612 req/s 64,2 ms 550,1 996,5

Đọc bảng này theo cột thì thấy ba câu chuyện khác nhau:

  • Thông lượng gần như đứng yên từ pool 20 trở đi: 1 489 → 1 612, thêm 8% cho một pool to gấp mười lần.
  • p50 lại tốt lên rõ rệt, từ 130,3 ms xuống 64,2 ms.
  • p99 sụp đổ: từ 213,3 ms lên 996,5 ms, tệ gấp 4,7 lần.

Việc p50 tốt lên trong khi p99 tệ đi là chữ ký của sự sụp đổ về công bằng. Với pool 20, mọi request xếp một hàng và ai cũng đợi xấp xỉ như nhau. Với pool 200, 200 truy vấn cùng lao vào một CSDL không đủ sức làm 200 việc song song; PostgreSQL chia CPU và I/O cho tất cả, nên một nửa số request về nhanh hơn trước còn nửa kia bị bỏ rơi. Ở cùng cấu hình pool 200, p50 là 64,2 ms còn p95 là 550,1 ms — chênh nhau 8,6 lần trong cùng một lần đo.

Điều làm chuyện này khó phát hiện là bảng theo dõi của bạn thường vẽ trung bình hoặc p50. Nới pool lên sẽ làm biểu đồ đẹp hơn thật, trong khi trải nghiệm của một phần mười số người dùng tệ đi gấp năm lần.

Hàng đợi phải nằm ở đâu đó. Pool nhỏ đặt hàng đợi trong ứng dụng, nơi bạn nhìn thấy nó, đo được nó, và từ chối sớm được nếu cần. Pool to đẩy hàng đợi xuống CSDL, nơi nó vô hình với ứng dụng và dùng chung với mọi khách hàng khác của CSDL đó. Đây chính là lập luận đã dùng cho vách ngăn ở phần 31, chỉ khác là ở đây tài nguyên hữu hạn nằm bên ngoài tiến trình.

Cách chọn thực dụng: tìm điểm mà thông lượng thôi tăng — ở đây là pool 20 — rồi dừng lại, đừng đi tiếp vì p50 vẫn còn đẹp thêm được.

Giao dịch: 100 đồng biến mất

withTransaction lo cả BEGIN, COMMITROLLBACK:

pg.withTransaction(cn ->
    cn.preparedQuery("update so_quy set so_du = so_du - 100 where id = 1").execute()
      .compose(x -> cn.preparedQuery("...cau lenh thu hai...").execute())
      .mapEmpty())
  .onSuccess(...)
  .onFailure(...);

Để so sánh, tôi viết bản y hệt nhưng gọi thẳng qua pg — nghĩa là mỗi câu lệnh chạy trên một kết nối riêng trong giao dịch ngầm của chính nó. Rồi cho câu lệnh thứ hai cố tình hỏng (vi phạm khoá chính):

Mã trả về Số dư trước Số dư sau
withTransaction 500 1=1000 2=1000 1=1000 2=1000
Không giao dịch 500 1=1000 2=1000 1=900 2=1000

Cả hai đều trả 500 cho khách. Nhưng bản không giao dịch đã trừ mất 100 đồng và không có gì đưa nó trở lại. Từ phía người gọi, hai trường hợp không phân biệt được — cùng mã lỗi, cùng thông báo. Sự khác nhau chỉ hiện ra trong dữ liệu, có thể nhiều tháng sau.

Chỗ dễ nhầm nhất là biến cn trong lambda. Phải dùng đúng nó cho mọi câu lệnh trong giao dịch; gọi nhầm pg (biến pool) thì câu lệnh đó chạy trên một kết nối khác, nằm ngoài giao dịch, và sẽ không bao giờ được lùi lại. Trình biên dịch không bắt được — cả hai đều hợp lệ, cùng trả Future.

Về chi phí, giao dịch không miễn phí: 2 012 req/s so với 2 827 req/s của bản không giao dịch, tức chậm hơn khoảng 29%. Đó là giá của BEGINCOMMIT cộng với việc giữ một kết nối suốt cả nhóm câu lệnh thay vì trả lại pool giữa chừng. Trả 29% để không mất tiền là một món hời, nhưng nó cũng nhắc rằng đừng bọc giao dịch quanh những thứ không cần.

Cursor bắt buộc nằm trong giao dịch

Đọc 50 000 dòng bằng cursor, viết theo cách hiển nhiên nhất:

pg.withConnection(cn -> cn.prepare("select ma, ten from san_pham limit 50000").compose(ps -> {
    Cursor cur = ps.cursor();
    ...
}))

Kết quả:

ERROR: portal "df9b6987-ab6b-4f11-a02a-2bd520559434" does not exist (34000)

Cursor của PostgreSQL sống trong một portal, mà portal bị huỷ khi giao dịch kết thúc. Ngoài giao dịch, mỗi câu lệnh chạy trong giao dịch ngầm của riêng nó — nên portal chết ngay sau lần read đầu tiên. Đổi withConnection thành withTransaction là chạy đúng, đọc đủ 50 000 dòng.

Thông báo lỗi không hề gợi ý điều đó. Nó nói portal không tồn tại, còn nguyên nhân là giao dịch đã đóng — hai chuyện nghe chẳng liên quan nếu bạn chưa biết cơ chế portal.

Cursor để làm gì thì số đo trả lời rõ:

Thông lượng RSS đỉnh
Đọc một phát 50 000 dòng 448 req/s 1 100 MB
Cursor, mỗi lần 500 dòng 264 req/s 724 MB

Cursor chậm hơn 41% vì nó đi lại 100 lần thay vì một lần, nhưng tốn ít hơn 34% bộ nhớ vì không bao giờ giữ quá 500 dòng cùng lúc. Đó là toàn bộ lý do tồn tại của nó: không phải để nhanh hơn, mà để một kết quả lớn không quyết định được kích thước heap của bạn. Với 20 request đồng thời mỗi cái đọc 50 000 dòng, bản đọc một phát đã lên 1,1 GB — nhân tiếp vài lần nữa là hết bộ nhớ.

Cache prepared statement: không thấy khác biệt

setCachePreparedStatements(true) được nhắc tới như một nút chỉnh hiệu năng hiển nhiên. Tôi bật tắt nó rồi đo ba lần mỗi cấu hình:

co cache : 32 010 | 35 938 | 35 379 req/s
TAT cache: 34 584 | 36 235 | 37 112 req/s

Tắt cache nhanh hơn hoặc bằng ở cả ba lần. Trên truy vấn nặng thì cũng vậy: 1 425 req/s khi tắt so với 1 263 khi bật.

Tôi không có lời giải thích nào mà mình kiểm chứng được, nên nói thẳng: trong hai loại truy vấn tôi đo, cache prepared statement không mang lại lợi ích nào đo được, và chênh lệch nhỏ mà tôi thấy nghiêng về phía tắt. Điều này không có nghĩa nút đó vô dụng — nó nghĩa là lợi ích của nó không nằm ở kịch bản tôi dựng (hai câu lệnh duy nhất, lặp lại hàng vạn lần, trên một CSDL cục bộ độ trễ cực thấp). Nếu bạn đang tin vào nút này, hãy đo trên chính truy vấn của mình trước.

Ba luật rút ra

  • Chọn pool ở điểm thông lượng thôi tăng, không phải ở điểm p50 đẹp nhất. Hai điểm đó khác nhau, và đi quá điểm thứ nhất thì bạn đang đổi độ trễ đuôi lấy độ trễ trung vị.
  • Luôn dùng withTransaction khi có từ hai câu lệnh ghi trở lên, và kiểm lại rằng mọi câu lệnh trong đó dùng cn chứ không dùng pool.
  • Cursor cần giao dịch, và dùng nó vì bộ nhớ chứ đừng mong nó nhanh.

Thử ba mươi giây

Tìm xem pool của bạn có đang quá to không, không cần đổi cấu hình:

select wait_event_type, wait_event, count(*)
from pg_stat_activity where datname = 'csdl_cua_ban' and state = 'active'
group by 1, 2 order by 3 desc;

Nếu phần lớn kết nối đang active mà nằm chờ LWLock, IO hay BufferPin, thì chúng đang tranh nhau chứ không đang làm việc — pool nhỏ hơn sẽ cho cùng thông lượng với độ trễ đuôi tốt hơn nhiều.

Phần sau nối Kafka bằng client của Vert.x và đo cái bẫy poll trên event loop.