Nhiều hệ thống lớn sập PostgreSQL không phải vì truy vấn nặng, mà vì một thứ âm thầm hơn: quá nhiều kết nối. Mỗi kết nối tới Postgres là một process riêng tốn RAM, và max_connections có trần cứng. Khi app scale ra nhiều instance, mỗi cái mở một nắm kết nối (đa số ngồi idle), tổng vượt trần → database từ chối kết nối mới, cả hệ sập. Lời giải chuẩn là connection pooling, và công cụ kinh điển cho Postgres là PgBouncer. Bài này mổ xẻ và dựng thật.

Bài toán: kết nối Postgres đắt và có giới hạn cứng

Hai chi phí:

  1. Mỗi kết nối = một backend process. Postgres fork một process cho mỗi kết nối (~vài MB RAM mỗi cái). Ngàn kết nối = ngàn process = cạn RAM và tranh CPU.
  2. Thiết lập kết nối đắt. Bắt tay TCP + xác thực + cấp process tốn thời gian. App mở-đóng kết nối liên tục sẽ trả cái giá này mỗi lần.

Ta đo chi phí thứ hai bằng pgbench (cùng một truy vấn SELECT, khác ở chỗ giữ hay mở lại kết nối):

GIỮ kết nối (reuse)      : tps = 64.752   (latency 0,154 ms)
Kết nối MỚI mỗi txn (-C) : tps = 1.298
>> Mở kết nối mới mỗi lần chậm hơn ~50× — thiết lập kết nối rất đắt

Mở kết nối mới cho mỗi giao dịch chậm hơn ~50 lần so với tái dùng — đó là lý do phải giữ và tái dùng kết nối, không mở-đóng liên tục.

Cách giải: PgBouncer đứng giữa, gộp N client vào pool nhỏ

PgBouncer nhận kết nối từ nhiều client, nhưng chỉ giữ một pool nhỏ kết nối thật tới Postgres, và ánh xạ qua lại:

[databases]
demo = host=postgres port=5432 dbname=demo
[pgbouncer]
pool_mode = transaction        # trả server-conn về pool SAU MỖI transaction
default_pool_size = 5          # chỉ giữ 5 kết nối THẬT tới Postgres
max_client_conn = 1000        # nhưng nhận tới 1000 client cùng lúc

App chỉ cần đổi host/port trỏ vào PgBouncer thay vì Postgres:

psql -h pgbouncer -p 6432 -U postgres demo   # thay vì -h postgres -p 5432

Ảnh chụp đoạn mã nền tối minh hoạ PgBouncer gộp kết nối vì sao ngàn kết nối Postgres làm sập database mỗi kết nối Postgres 1 process connection pooling nhiều client pool nhỏ, một bài toán mỗi kết nối Postgres tốn một process cộng RAM và có giới hạn cứng Postgres fork 1 backend process cho mỗi kết nối vài MB RAM mỗi cái max_connections mặc định 100 app nhiều instance mở ngàn kết nối đa số idle cạn max_connections FATAL too many clients database từ chối sập thiết lập đóng kết nối cũng đắt bắt tay xác thực cấp process, hai cách giải PgBouncer đứng giữa gộp N client vào pool nhỏ server-conn pgbouncer.ini databases demo host postgres port 5432 dbname demo pgbouncer pool_mode transaction trả server-conn về pool sau mỗi transaction default_pool_size 5 chỉ giữ 5 kết nối thật tới Postgres max_client_conn 1000 nhận tới 1000 client cùng lúc, ba app nối tới PgBouncer thay vì Postgres đổi mỗi host port psql -h pgbouncer -p 6432 -U postgres demo thay vì -h postgres -p 5432 1000 client kết nối tới PgBouncer nhưng Postgres chỉ thấy 5 kết nối thật transaction mode giữa 2 transaction client không chiếm server-conn

Hình 1: Mỗi kết nối Postgres là một process (đắt, có trần); PgBouncer (pool_mode=transaction, default_pool_size) gộp nhiều client vào một pool nhỏ; app chỉ đổi host/port trỏ vào PgBouncer.

Đo THẬT: 30 client thành 5 backend

Ta dựng thật PostgreSQL 16 + PgBouncer (transaction mode, pool size 5) và đếm số backend process Postgres thật sự thấy khi có 30 client đồng thời:

Trực tiếp Postgres, 30 client -> 31 backend process (1 backend / client)
Qua PgBouncer, 30 client      ->  5 backend process (= default_pool_size)

Trực tiếp: 30 client = 31 backend (mỗi client một process). Qua PgBouncer: 30 client → Postgres chỉ thấy 5 backend (đúng pool size). Nhân lên quy mô thật: 1000 client app đi qua PgBouncer vẫn chỉ tạo ~5 kết nối Postgres → không bao giờ cạn max_connections. Đây là điều biến "ngàn kết nối làm sập DB" thành "ngàn client dùng chung một pool tí hon".

Ảnh chụp bảng kết quả chạy thật PostgreSQL 16 cộng PgBouncer pgbench output thật, một chi phí thiết lập kết nối pgbench cùng truy vấn SELECT giữ kết nối reuse tps 64752 latency 0,154 ms kết nối mới mỗi txn -C tps 1298 mở kết nối mới mỗi lần chậm hơn 50 lần thiết lập kết nối rất đắt vì sao cần pool giữ tái dùng server-conn thay vì mở đóng liên tục, hai multiplexing 30 client đồng thời Postgres thấy bao nhiêu backend trực tiếp Postgres 30 client 31 backend process 1 backend mỗi client qua PgBouncer 30 client 5 backend process bằng default_pool_size PgBouncer gộp 30 client vào chỉ 5 kết nối thật tới Postgres nhân lên 1000 client app vẫn chỉ 5 backend Postgres không cạn max_connections, vì sao đây là nút thắt âm thầm ở quy mô lớn process RAM mỗi kết nối 1 backend process cộng vài MB ngàn kết nối ngàn process max_conn Postgres có trần cứng 100 mặc định vượt là từ chối kết nối mới app scale nhiều instance app pool riêng mỗi cái dễ vượt trần Postgres PgBouncer nhận ngàn client ánh xạ vào pool nhỏ transaction mode nhả nhanh đánh đổi transaction mode không dùng được session state prepared SET advisory lock

Hình 2: Chạy thật — thiết lập kết nối mới chậm hơn giữ kết nối ~50× (1.298 vs 64.752 TPS); và PgBouncer gộp 30 client thành chỉ 5 backend Postgres (vs 31 trực tiếp). Kèm vì sao đây là nút thắt âm thầm.

Ba chế độ pool và đánh đổi của chúng

PgBouncer có ba pool_mode, đánh đổi giữa mức tái dùng và tính năng session:

  • session — server-conn gắn với client suốt phiên (từ connect tới disconnect). An toàn nhất (mọi tính năng session hoạt động) nhưng tái dùng kém: một client idle vẫn giữ một server-conn.
  • transaction — server-conn trả về pool sau mỗi transaction. Tái dùng cao nhất (dùng phổ biến nhất), nhưng không dùng được trạng thái phiên vắt qua transaction: prepared statement kiểu cũ, SET phiên, advisory lock, LISTEN/NOTIFY.
  • statement — trả về sau mỗi câu lệnh. Cực đoan, cấm cả transaction nhiều câu.

Đánh đổi cần cân nhắc

Transaction mode phá vài tính năng — app phải hợp tác. Vì server-conn đổi giữa các transaction, đừng dựa vào trạng thái phiên. Nhiều driver có "prepared statement" bật mặc định sẽ vỡ; phải tắt hoặc dùng chế độ tương thích. Đây là cái giá của mức tái dùng cao — kiểm app trước khi bật transaction mode.

Pool size là knob then chốt. Pool quá nhỏ → client phải xếp hàng chờ server-conn rảnh (thêm độ trễ); quá lớn → mất tác dụng bảo vệ Postgres. Quy tắc thô: pool size ≈ số core Postgres (vì truy vấn CPU-bound chỉ chạy song song tới số core). Đo trên tải thật.

PgBouncer là một hop và một điểm cần làm sẵn sàng. Thêm một chặng mạng (nhỏ nếu chạy cùng host qua unix socket) và bản thân PgBouncer cần không thành single point of failure (chạy nhiều, hoặc mỗi app-host một PgBouncer). Nó giải bài toán kết nối của Postgres, nhưng thêm một thành phần phải vận hành.

Ba ý mang về

  1. Kết nối PostgreSQL đắt và có trần cứng: mỗi kết nối là một process; đo thật mở kết nối mới mỗi giao dịch chậm hơn ~50× giữ kết nối (1.298 vs 64.752 TPS) — và max_connections giới hạn tổng số.
  2. PgBouncer gộp nhiều client vào pool nhỏ: đo thật 30 client đồng thời chỉ thành 5 backend Postgres (vs 31 trực tiếp) — nên ngàn client app không làm cạn max_connections, biến một nút thắt sập-DB thành vô hại.
  3. Chọn pool_mode và pool size theo app: transaction mode tái dùng cao nhất nhưng cấm trạng thái phiên (prepared/SET/advisory lock); pool size nên ~ số core Postgres; và PgBouncer thêm một hop + một thành phần cần làm sẵn sàng.

Nguồn

Đây là bài cuối của đợt mở rộng loạt "Hệ thống lớn" (phần 7–12), tất cả đều dựng và đo trên công nghệ thật trong Docker. Sợi chỉ chung vẫn vậy: hệ thống lớn thắng bằng chọn đúng cấu trúc và đúng lớp trung gian cho đúng hình dạng bài toán — idempotency cho retry, ID nhúng-shard cho phân mảnh, bloom cho tra-miss, inverted index cho tìm kiếm, load balancer cho phân tán, connection pool cho kết nối.