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í:
- 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.
- 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

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".

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ũ,
SETphiê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ề
- 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_connectionsgiới hạn tổng số. - 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. - 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
- PgBouncer Documentation — pool_mode / config: https://www.pgbouncer.org/config.html
- PostgreSQL Wiki — Number Of Database Connections: https://wiki.postgresql.org/wiki/Number_Of_Database_Connections
Đâ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.