max_connections = 500 là cấu hình phổ biến, và nó thường sai. Phần này đo xem một máy chủ thật sự phục vụ được bao nhiêu kết nối trước khi thêm nữa chỉ làm mọi thứ tệ đi.

Thông lượng theo số kết nối, ba chi phí của một kết nối, và so sánh pool

Thông lượng theo số kết nối

Máy 16 lõi CPU, bảng 2 triệu dòng, truy vấn tra chỉ mục:

Kết nối tps Độ trễ TB (ms) tps mỗi kết nối
1 9.425 0,106 9.425
4 30.904 0,129 7.726
8 58.664 0,136 7.333
16 143.536 0,111 8.971
32 118.727 0,270 3.710
64 120.404 0,532 1.881
128 126.653 1,011 989
256 110.724 2,312 433

Đỉnh nằm ở 16 kết nối — đúng bằng số lõi CPU.

Từ 16 lên 256, thông lượng không tăng (thậm chí giảm nhẹ), trong khi độ trễ tăng 20 lần. Cột cuối cùng nói rõ nhất: mỗi kết nối phục vụ được 8.971 giao dịch mỗi giây khi có 16 kết nối, và chỉ 433 khi có 256.

Lý do đơn giản: máy chỉ có 16 lõi. Kết nối thứ 17 trở đi không có lõi nào để chạy — nó chỉ xếp hàng chờ, và việc chờ đó diễn ra bên trong cơ sở dữ liệu, nơi mỗi người chờ là một tiến trình hệ điều hành đầy đủ.

Ba chi phí của một kết nối

Chi phí mở. Đo bằng pgbench, giữ kết nối so với mở mới cho mỗi giao dịch:

tps Độ trễ
Giữ kết nối 30.880 0,130 ms
Mỗi giao dịch một kết nối mới 2.632 1,519 ms

Chậm 11,7 lần. Mở một kết nối PostgreSQL tốn khoảng 1,4 ms: bắt tay giao thức, xác thực, fork một tiến trình mới, nạp catalog.

Đây là lý do cơ bản nhất để có connection pool. Một ứng dụng web mở kết nối mới cho mỗi yêu cầu HTTP đang trả 1,4 ms cho mỗi lần — nhiều hơn thời gian chạy của phần lớn truy vấn.

Chi phí bộ nhớ. Tôi mở 100 kết nối rỗi — chúng chỉ chạy pg_sleep, không truy vấn gì:

trước: 1 kết nối,   PSS tổng   1 MB
sau  : 101 kết nối, PSS tổng 250 MB

2,55 MB mỗi kết nối, cho những kết nối không làm gì cả. Với max_connections = 500 và pool của ứng dụng giữ đủ 500, đó là 1,3 GB RAM trước khi có truy vấn nào chạy.

Và con số này chưa tính work_mem — phần 29 và 32 đã đo rằng mỗi nút của mỗi truy vấn có thể dùng tới một work_mem riêng.

Chi phí tiến trình. PostgreSQL dùng một tiến trình hệ điều hành cho mỗi kết nối, không dùng luồng. 500 kết nối là 500 tiến trình mà nhân hệ điều hành phải lập lịch, và mỗi lần chuyển ngữ cảnh là một lần xoá bộ nhớ đệm CPU.

Cùng 200 yêu cầu, hai cách phục vụ

tps Độ trễ TB RAM tiến trình
200 kết nối thẳng tới CSDL 131.251 1,524 ms ≈ 510 MB
Pool ở ứng dụng, 16 kết nối thật 125.363 0,128 ms ≈ 41 MB

Thông lượng gần bằng nhau — bản pool thấp hơn 5%. Nhưng độ trễ chênh 12 lần, và bộ nhớ chênh 12 lần.

Điều xảy ra là giống nhau ở cả hai: 200 yêu cầu, 16 lõi, nên 184 yêu cầu phải chờ. Khác biệt là chờ ở đâu.

Chờ trong cơ sở dữ liệu nghĩa là 200 tiến trình cùng tồn tại, cùng chiếm bộ nhớ, cùng bị lập lịch, cùng tranh nhau khoá nội bộ. Chờ trong pool ở ứng dụng nghĩa là 184 luồng Java đứng trước một hàng đợi — thứ mà JVM làm rất rẻ.

Đây là toàn bộ lập luận cho connection pool, và nó không phải về việc "tiết kiệm thời gian mở kết nối" — đó chỉ là lợi ích thứ hai.

Chọn kích thước pool

Từ số liệu trên, điểm tối ưu là số lõi CPU. Công thức hay được nhắc tới:

kích thước pool = số lõi × 2 + số đĩa hiệu dụng

Con số nhân 2 là để bù lúc tiến trình chờ đọc đĩa. Với máy 16 lõi và ổ SSD, khoảng 20–32 là hợp lý — không phải 200.

Điều làm người ta khó chấp nhận con số này là trực giác "nhiều kết nối hơn thì phục vụ được nhiều người dùng hơn". Bảng đo ở trên nói ngược lại: với 256 kết nối bạn phục vụ ít hơn so với 16, và mỗi người dùng đợi lâu hơn 20 lần.

Nếu ứng dụng của bạn có nhiều máy chủ, tổng số kết nối là tổng của mọi pool. Bốn máy ứng dụng, mỗi máy pool 30, là 120 kết nối tới cùng một cơ sở dữ liệu — đã vượt xa điểm tối ưu. Khi đó cần một lớp gộp kết nối ở giữa như PgBouncer.

Khi chạm giới hạn thì không còn cách nào vào

Tôi dựng một máy chủ max_connections = 25 rồi mở 30 kết nối:

FATAL:  sorry, too many clients already

Năm kết nối bị từ chối — đúng như dự kiến. Nhưng điều đáng nhớ hơn là chính câu lệnh tôi dùng để đếm số kết nối cũng nhận đúng lỗi đó. Tôi không vào được để xem chuyện gì đang xảy ra.

Đó là lý do superuser_reserved_connections tồn tại. Mặc định là 3, và nó giữ lại ba chỗ chỉ dành cho superuser — để khi mọi thứ đầy, bạn vẫn còn đường vào mà chẩn đoán và huỷ kết nối.

Đừng đặt nó về 0, và đừng dùng tài khoản superuser cho ứng dụng — nếu ứng dụng chạy bằng superuser thì nó chiếm luôn cả ba chỗ dự phòng.

PostgreSQL 16 còn có reserved_connections cho vai pg_use_reserved_connections, dành cho công cụ giám sát không cần quyền superuser.

Theo dõi cái gì

select count(*) as tong,
       count(*) filter (where state = 'active') as dang_chay,
       count(*) filter (where state = 'idle') as roi,
       count(*) filter (where state = 'idle in transaction') as roi_trong_giao_dich,
       current_setting('max_connections')::int as gioi_han
from pg_stat_activity where backend_type = 'client backend';

Ba con số cần nhìn:

tong so với gioi_han. Trên 80% là lúc phải hành động.

dang_chay so với số lõi. Nếu dang_chay thường xuyên lớn hơn số lõi, pool của bạn quá lớn — thêm kết nối chỉ làm mọi người chậm đi.

roi_trong_giao_dich. Phần 21 đã đo hậu quả: mỗi kết nối như vậy chặn VACUUM trên toàn bộ cơ sở dữ liệu, và nó không tốn CPU nên không xuất hiện ở đâu khác.

Thử ba mươi giây

show max_connections;
select count(*) from pg_stat_activity where backend_type = 'client backend';

Rồi so với số lõi của máy chủ. Nếu max_connections lớn hơn số lõi mười lần, hãy hỏi: ứng dụng có bao giờ dùng hết không? Nếu có, số đo ở bài này nói rằng lúc đó nó đang chạy chậm hơn so với khi dùng ít hơn.

Cách sửa không phải hạ max_connections (điều đó chỉ gây lỗi kết nối), mà là hạ kích thước pool ở ứng dụng — rồi mới hạ max_connections xuống mức đủ dùng.

Phần sau đo shared_buffers và bộ nhớ đệm: bao nhiêu là đủ, và vì sao cấp cho nó nhiều hơn có thể làm chậm đi.