Phần trước đo được rằng thông lượng đạt đỉnh ở đúng số lõi CPU, và mỗi kết nối rỗi tốn 2,55 MB. PgBouncer là câu trả lời cho vấn đề đó khi ứng dụng có nhiều máy chủ. Phần này đo cả lợi ích lẫn cái giá — và cái giá không phải hiệu năng.

Kiến trúc gộp kết nối, ba chế độ, và rò rỉ trạng thái phiên

Nó làm được gì

Bố trí: PostgreSQL max_connections = 100, PgBouncer với default_pool_size = 16, và 300 client đồng thời.

300 client  ->  PgBouncer  ->  PostgreSQL thấy 17 kết nối

Con số 17 là 16 kết nối của pool cộng một kết nối tôi dùng để đếm.

Ba trăm client chạy được trên một máy chủ chỉ cho phép 100 kết nối. Và theo số đo ở phần 42, 300 kết nối thẳng sẽ tốn khoảng 765 MB RAM cho các tiến trình backend, còn 17 kết nối tốn 43 MB.

Ba chế độ gộp

pool_mode tps Độ trễ TB Kết nối tới PostgreSQL Giữ được gì
session 67.033 4,475 ms 17 mọi thứ
transaction 57.486 5,219 ms 17 chỉ trong một giao dịch
statement 56.924 5,270 ms 17 chỉ trong một câu lệnh

Ba chế độ chênh nhau 15% về thông lượng — không nhiều. Tôi vào phép đo này nghĩ rằng transaction sẽ nhanh hơn hẳn vì nó gộp tốt hơn, nhưng ở quy mô này không thấy.

Khác biệt thật nằm ở cột cuối.

session: một client giữ một kết nối máy chủ từ lúc kết nối tới lúc ngắt. Gộp được ít nhất — nếu ứng dụng của bạn mở kết nối rồi giữ mãi (đúng cách một connection pool ở tầng ứng dụng làm việc), chế độ này gần như không gộp gì.

transaction: kết nối máy chủ được trả về pool sau mỗi giao dịch. Đây là chế độ người ta dùng thật, vì ứng dụng web thường có nhiều kết nối rỗi giữa các yêu cầu.

statement: trả về sau mỗi câu lệnh, và cấm hẳn giao dịch nhiều câu lệnh. Chỉ hợp với tải chỉ đọc.

Trạng thái phiên rò rỉ giữa các client

Đây là phần quan trọng nhất, và nó không phải chuyện hiệu năng.

Với default_pool_size = 1 để bảo đảm hai client dùng chung một kết nối máy chủ:

-- client A
set work_mem = '77MB';
-- ngắt kết nối

-- client B, kết nối mới
show work_mem;
pool_mode Client B đọc được
session 4MB — sạch
transaction 77MB — nhận đúng thiết lập của A

Client B chưa từng đặt work_mem, và nó nhận được giá trị của một client khác.

Lý do nằm ở hai tham số, đọc được bằng SHOW CONFIG trong cơ sở dữ liệu quản trị pgbouncer:

server_reset_query        = DISCARD ALL
server_reset_query_always = 0

server_reset_query_always = 0 nghĩa là câu DISCARD ALL chỉ chạy ở chế độ session. Ở chế độ transaction, PgBouncer giả định ứng dụng không dùng trạng thái phiên, nên nó bỏ qua bước dọn để tiết kiệm một vòng đi–về.

Giả định đó hợp lý về hiệu năng nhưng nguy hiểm nếu sai. Với work_mem thì hậu quả là một truy vấn bất kỳ đột nhiên được cấp 77 MB — phần 29 đã đo rằng điều đó có thể làm nó chậm hơn, và nhân với số kết nối thì là rủi ro hết RAM.

Với những thứ khác thì tệ hơn: search_path bị đổi làm truy vấn trỏ sai schema, role bị đổi làm phân quyền sai, statement_timeout bị đổi làm mất lưới an toàn ở phần 26.

Nếu ứng dụng của bạn có dùng SET mà vẫn cần chế độ transaction, hãy đặt:

server_reset_query_always = 1

Bạn trả một vòng đi–về cho mỗi giao dịch, và đổi lại không có gì rò rỉ.

Cái gì không dùng được ở chế độ transaction

Bốn thứ, tất cả đều là trạng thái sống qua nhiều giao dịch:

Không dùng được Vì sao
SET ở mức phiên Giao dịch sau có thể ở kết nối máy chủ khác
Bảng tạm Nằm trên kết nối máy chủ cụ thể
PREPARE ở mức phiên Câu lệnh chuẩn bị sẵn thuộc về kết nối
pg_advisory_lock mức phiên Đúng cái bẫy đã đo ở phần 28

Mục cuối đáng nhấn mạnh vì nó nối lại với một phép đo trước. Phần 28 đã đo rằng khoá tư vấn mức phiên bị kẹt trong pool kết nối và làm công việc định kỳ ngừng chạy im lặng. Với PgBouncer ở chế độ transaction, vấn đề đó tệ hơn: khoá không chỉ kẹt mà còn có thể nằm trên một kết nối máy chủ mà client tiếp theo không hề biết.

Kết luận vẫn thế: luôn dùng bản pg_advisory_xact_lock.

Về câu lệnh chuẩn bị sẵn, PgBouncer 1.21 trở lên có hỗ trợ ở chế độ transaction thông qua max_prepared_statements. Trước đó thì phải tắt hẳn ở tầng driver — với JDBC là prepareThreshold=0.

Đặt kích thước pool bao nhiêu

Từ phần 42: điểm tối ưu của PostgreSQL là khoảng số lõi CPU. Đó cũng là con số cho default_pool_size.

default_pool_size = 20         ; khoảng số lõi
max_client_conn = 1000         ; số client PgBouncer chấp nhận
reserve_pool_size = 5          ; dự phòng khi pool đầy
reserve_pool_timeout = 3       ; đợi bao lâu mới dùng dự phòng

max_client_conn có thể lớn tuỳ ý vì mỗi client ở phía PgBouncer chỉ là một socket, không phải một tiến trình. Đó là toàn bộ ý tưởng: chuyển chỗ xếp hàng từ cơ sở dữ liệu — nơi mỗi người chờ là một tiến trình 2,55 MB — sang PgBouncer, nơi mỗi người chờ là vài kilobyte.

Theo dõi

PgBouncer có một cơ sở dữ liệu ảo tên pgbouncer:

\c pgbouncer
show pools;
show clients;
show servers;
show stats;

Hai cột trong show pools cần nhìn:

cl_waiting — số client đang đợi một kết nối máy chủ. Lớn hơn 0 liên tục nghĩa là default_pool_size quá nhỏ, hoặc có truy vấn chạy quá lâu đang giữ kết nối.

maxwait — client đợi lâu nhất, tính bằng giây. Con số này lớn là dấu hiệu sớm nhất của tắc nghẽn, và nó xuất hiện trước khi ứng dụng bắt đầu báo lỗi hết thời gian.

Khi nào cần PgBouncer

Cần khi tổng số kết nối từ mọi máy ứng dụng vượt xa số lõi của cơ sở dữ liệu. Bốn máy ứng dụng, mỗi máy pool 30, là 120 kết nối — trên máy 16 lõi thì đã vượt điểm tối ưu ba lần.

Cần khi ứng dụng mở kết nối ngắn hạn — hàm serverless, script cron, công cụ báo cáo. Phần 42 đã đo: mở kết nối mới cho mỗi giao dịch làm thông lượng giảm 11,7 lần.

Không cần khi bạn chỉ có một máy ứng dụng và pool ở tầng ứng dụng đã đặt đúng kích thước. Thêm một lớp nữa vào đường dữ liệu là thêm một thứ có thể hỏng, và ở đây nó không mua được gì.

Thử ba mươi giây

Nếu bạn đang chạy PgBouncer ở chế độ transaction, kiểm xem ứng dụng có dùng SET không:

select query from pg_stat_statements
where query ~* '^\s*(set|prepare|create temp)'
limit 20;

Mỗi kết quả là một chỗ có thể rò rỉ sang client khác. Nếu danh sách không rỗng, hoặc sửa ứng dụng, hoặc đặt server_reset_query_always = 1.

Và nếu chưa dùng PgBouncer, đếm tổng số kết nối từ mọi nguồn rồi so với số lõi:

select client_addr, count(*) from pg_stat_activity
where backend_type = 'client backend' group by client_addr order by count(*) desc;

Phần sau đo shared_buffers và bộ nhớ đệm: tỉ lệ trúng đệm thật là bao nhiêu, và vì sao cấp thêm bộ nhớ có lúc làm chậm đi.