Sang mảng kết nối. Có một câu người ta hay lặp lại: "PostgreSQL không thích nhiều kết nối, mỗi kết nối tốn ~10 MB RAM". Câu này đúng về kết luận nhưng sai về con số — và hiểu sai con số dẫn tới quyết định sai (như tăng max_connections để "cho phép nhiều kết nối"). Bài này đo thật cả mô hình tiến trình lẫn RAM tiêu tốn, và tách bạch con số ảo với con số thật.

Mô hình: mỗi kết nối là một tiến trình

Khác MySQL (một thread cho mỗi kết nối trong cùng tiến trình), PostgreSQL dùng mô hình tiến trình-mỗi-kết-nối: khi một client kết nối, postmaster fork một tiến trình backend hoàn toàn riêng. 60 kết nối nghĩa là 60 tiến trình hệ điều hành mới.

SHOW max_connections;   -- mặc định 100
SELECT count(*) FROM pg_stat_activity WHERE backend_type='client backend';

Đo thật: baseline có 6 tiến trình postgres; sau khi mở 60 kết nối, số tiến trình lên 66 — đúng 60 backend mới. Mỗi tiến trình có không gian địa chỉ riêng, ngăn xếp riêng, và các cache riêng. Đó là gốc rễ của chi phí RAM.

Ảnh chụp đoạn mã SQL nền tối minh hoạ vì sao nhiều kết nối tốn RAM mô hình tiến trình mỗi kết nối, PostgreSQL mỗi kết nối là một tiến trình riêng fork không phải thread khác MySQL thread-per-connection PostgreSQL fork một tiến trình backend riêng cho mỗi kết nối 60 kết nối bằng 60 tiến trình OS mới SHOW max_connections mặc định 100 SELECT count từ pg_stat_activity WHERE backend_type client backend, cạm bẫy đo ps RSS đếm trùng bộ nhớ chia sẻ mỗi backend map vùng shared_buffers 128MB vào không gian địa chỉ và ps RSS đếm nó trong mỗi tiến trình cộng lại phình khổng lồ ps -eo rss comm awk postgres tổng RSS đếm trùng shared 16 MB mỗi kết nối từ ps là con số ảo phần lớn là shared_buffers lặp lại, đo RAM thật hiệu của MemAvailable MemAvailable chỉ giảm theo bộ nhớ thật được cấp mới không đếm trùng shared awk MemAvailable proc meminfo trước và sau khi mở kết nối kết nối idle chưa chạy truy vấn thật rất rẻ khoảng 0,4 MB mỗi kết nối, nhưng kết nối hoạt động tốn hơn nhiều khi backend chạy truy vấn bộ nhớ riêng phình lên vì relcache catcache cache metadata bảng index plan cache của prepared statement work_mem mỗi thao tác sort hash bài work_mem nhân theo kết nối 200 kết nối cùng chạy sort có thể ngốn 200 nhân work_mem nhân số op cộng chi phí CPU kết nối hoạt động lớn hơn số core context switch điên đảo

Hình 1: PostgreSQL fork một tiến trình backend riêng cho mỗi kết nối (không phải thread). Đo RAM bằng ps RSS đếm trùng shared_buffers; đo đúng bằng hiệu MemAvailable. Kết nối idle rẻ, nhưng kết nối hoạt động tốn nhiều hơn (caches + work_mem/op) cộng chi phí CPU context switch.

Đo thật: 16 MB/kết nối là con số ảo

Đây là chỗ hầu hết mọi người bị lừa. Cách đo ngây thơ là cộng RSS của tất cả tiến trình postgres qua ps:

ps -eo rss,comm | awk '/postgres/{s+=$1} END{print s/1024 " MB"}'

Ảnh chụp bảng kết quả đo thật nền tối 60 kết nối bằng 60 tiến trình nhưng RAM thật rất khác con số ps container pg-lab shared_buffers 128MB max_connections 100 mở kết nối idle bằng pg_sleep PostgreSQL 16, mô hình tiến trình mỗi kết nối một backend baseline 6 tiến trình postgres sau khi mở 60 kết nối 66 tiến trình postgres cộng 60 backend fork thật 1 kết nối bằng 1 tiến trình OS riêng không phải thread, con số ps RSS gây hiểu lầm đếm trùng shared memory ps -eo rss tổng RSS postgres baseline 6 proc 344 MB sau 50 kết nối 1174 MB bằng 1174 trừ 344 chia 50 khoảng 16,6 MB mỗi kết nối sai phần lớn 16,6 MB là shared_buffers 128MB bị đếm lại trong mỗi backend, RAM thật hiệu MemAvailable không đếm trùng shared MemAvailable trước 6.529.484 kB sau 60 kết nối idle 6.501.924 kB giảm 26,9 MB cho 61 kết nối bằng khoảng 0,44 MB mỗi kết nối idle kết nối idle thật sự rẻ con số 16 MB của ps là ảo, bảng cách đo ps RSS tổng chia số kết nối khoảng 16,6 MB sai đếm trùng shared_buffers hiệu MemAvailable idle khoảng 0,44 MB đúng cho kết nối idle, vì sao nhiều kết nối vẫn nguy hiểm dù idle rẻ kết nối hoạt động tốn hơn nhiều relcache plan cache work_mem mỗi op work_mem nhân theo kết nối nhân số thao tác có thể hàng chục GB CPU kết nối đang chạy lớn hơn số core context switch nghiền nát throughput giải pháp không phải tăng max_connections mà là connection pooling

Hình 2: 60 kết nối → 66 tiến trình. ps RSS cho ~16,6 MB/kết nối, nhưng đó là con số sai vì đếm trùng shared_buffers 128MB trong mỗi backend. Hiệu MemAvailable cho RAM thật của kết nối idle chỉ ~0,44 MB. Vấn đề thật nằm ở kết nối hoạt động và context switch.

  • ps RSS (con số ảo): tổng RSS từ 344 MB (6 tiến trình) lên 1174 MB sau 50 kết nối → có vẻ 16,6 MB/kết nối. Nhưng RSS đếm cả bộ nhớ chia sẻ (shared_buffers 128MB) mà mỗi backend map vào, nên 128MB đó bị đếm lại 50 lần. Con số 16,6 MB phần lớn là ảo.
  • Hiệu MemAvailable (con số thật): MemAvailable chỉ giảm theo bộ nhớ thật được cấp mới, không đếm trùng shared. Trước khi mở kết nối: 6.529.484 kB; sau 60 kết nối idle: 6.501.924 kB → giảm 26,9 MB cho 61 kết nối = ~0,44 MB/kết nối idle.

Kết nối idle thật sự rất rẻ — chưa tới nửa MB. Nếu chỉ có vậy thì "nhiều kết nối" chẳng đáng lo. Vấn đề nằm ở chỗ khác.

Vì sao "nhiều kết nối" vẫn nguy hiểm

1. Kết nối HOẠT ĐỘNG tốn hơn nhiều. Khi backend thực sự chạy truy vấn, bộ nhớ riêng của nó phình lên: relcache/catcache (cache metadata của bảng, index đã dùng), plan cache của prepared statement, và quan trọng nhất — work_mem cho mỗi thao tác sort/hash. Như bài work_mem đã đo, con số này nhân theo số kết nối × số thao tác: 200 kết nối cùng chạy truy vấn có sort có thể ngốn 200 × work_mem × vài op = hàng chục GB. Đây mới là chỗ RAM biến mất thật sự, và nó tỉ lệ với số kết nối đang làm việc, không phải số kết nối idle.

2. Chi phí CPU: context switch. Khi số kết nối đang chạy vượt quá số core CPU, hệ điều hành phải liên tục chuyển ngữ cảnh (context switch) giữa các tiến trình — mỗi lần chuyển tốn CPU cho việc lưu/khôi phục trạng thái và làm nguội cache. Vượt ngưỡng, throughput giảm dù bạn thêm kết nối. Đây là lý do một server 16 core thường xử lý tốt nhất với vài chục kết nối hoạt động, không phải hàng trăm.

Kết luận quan trọng: giải pháp cho "ứng dụng cần 500 kết nối" không phải tăng max_connections lên 500 — mà là connection pooling: một lớp trung gian (PgBouncer, hay pool trong ứng dụng) giữ vài chục kết nối thật tới PostgreSQL và cho hàng trăm client dùng chung. Đó là chủ đề các bài sau.

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

Tăng max_connections không "miễn phí" dù kết nối idle rẻ. Mỗi slot kết nối cấp sẵn một ít bộ nhớ cấu trúc trong shared memory (lock table, proc array) ngay khi khởi động, và mở đường cho nguy cơ hàng trăm kết nối cùng hoạt động làm cạn RAM/CPU. Đặt max_connections cao là mời gọi vấn đề, không phải giải quyết nó.

Con số "MB mỗi kết nối" luôn phụ thuộc tải. Đừng trích một con số cố định. Kết nối idle ~0,5 MB; kết nối chạy truy vấn phức tạp với sort lớn có thể tạm thời dùng hàng trăm MB (work_mem × op). Khi ước lượng RAM, tính theo số kết nối hoạt động đồng thời × work_mem × số op điển hình, cộng phần cache dần tích lũy.

Đo RAM bằng đúng công cụ. ps/top RSS đếm trùng shared memory nên luôn phóng đại. Dùng hiệu MemAvailable, hoặc PSS (Proportional Set Size, chia đều shared memory) nếu đọc được /proc/PID/smaps. Con số bạn báo cáo chỉ đúng khi công cụ đo đúng.

Ba ý mang về

  1. PostgreSQL fork một tiến trình riêng cho mỗi kết nối: đo thật, 60 kết nối tạo 60 backend (6→66 tiến trình) — mô hình tiến trình-mỗi-kết-nối này là gốc rễ chi phí RAM và CPU khi có nhiều kết nối.
  2. Con số "16 MB/kết nối" từ ps là ảo: nó đếm trùng shared_buffers 128MB trong mỗi backend; đo bằng hiệu MemAvailable cho thấy kết nối idle thật sự chỉ tốn ~0,44 MB — dùng đúng công cụ đo (MemAvailable/PSS), đừng tin ps RSS.
  3. Vấn đề thật là kết nối hoạt động, không phải idle: relcache + plan cache + work_mem × op (nhân theo kết nối, có thể hàng chục GB) cộng context switch khi kết nối chạy vượt số core — giải pháp là connection pooling, không phải tăng max_connections.

Phần sau ta bóc tách chi tiết hơn con số vừa ước lượng: Phần sau đo chính xác chi phí một kết nối PostgreSQL — bộ nhớ khởi tạo, cache tích lũy khi chạy truy vấn, và vì sao con số tăng dần theo thời gian sống của kết nối.