Batas Koneksi Postgres di PaaS: Bertahan dari max_connections

Foto oleh Fabienne Serriere via Wikimedia Commons (CC BY-SA 3.0)
Kalikan jumlah replica maksimum autoscaler dengan pool max yang dikonfigurasi di aplikasi, lalu tambahkan semua pemegang lain: replica background worker dikali pool-nya sendiri, migration job rilis, task cron, metrics exporter, dan satu slot untuk manusia yang menjalankan psql. Bandingkan totalnya dengan max_connections dikurangi superuser_reserved_connections, yaitu angka yang benar-benar bisa dicapai role biasa. Kalau totalnya lebih besar, kegagalannya sudah terjadwal, bukan sekadar mungkin.
Deploy adalah penggantian bergulir, jadi jumlah replica relatif tetap dan beban ke database tidak berubah. Autoscale menambah replica, dan setiap replica membangun connection pool yang identik karena ukuran pool tinggal di dalam kode aplikasi Anda. Pengalinya bergerak tanpa ada rilis apa pun, dan itulah sebabnya perubahan yang merusaknya tidak ada di riwayat commit Anda.
Itu FATAL yang muncul ketika semua connection slot di luar cadangan superuser sudah terisi, sehingga server menolak melakukan fork backend process lagi untuk Anda. Tepat sebelum titik itu Anda mungkin melihat pesan berbeda yang menyebut slot tersisa dicadangkan untuk role dengan atribut SUPERUSER, artinya slot bebas sudah turun ke superuser_reserved_connections. Keduanya berarti sisi demand dari aritmetika koneksi Anda sudah melewati sisi supply.
Utamakan pooler, karena max_connections bukan batas yang sembarangan. PostgreSQL melakukan fork satu proses sistem operasi per koneksi dan menghitung shared memory dari nilai itu, dan work_mem adalah anggaran per operasi yang bisa dilipatgandakan beberapa session sekaligus. PgBouncer dengan transaction mode membuat banyak client aplikasi berbagi sekumpulan kecil server connection, sehingga concurrency yang terpakai naik tanpa menambah jumlah proses di server.
Prepared statement bernama di level protokol tetap jalan, asalkan max_prepared_statements tidak nol — PgBouncer menulis ulang nama statement-nya dan menyiapkannya ulang secara transparan di backend mana pun yang Anda dapat. Setelan itu default-nya nol saat fiturnya datang di 1.21.0 dan menjadi 200 secara default di 1.24.0, jadi jawabannya bergantung pada versi pooler Anda. PREPARE, EXECUTE, dan DEALLOCATE tingkat SQL diteruskan tanpa pelacakan dan ditandai tidak pernah bekerja di transaction mode.

Foto oleh Fabienne Serriere via Wikimedia Commons (CC BY-SA 3.0)
Ringkasan Utama
Autoscaling di PaaS melipatgandakan koneksi database: jumlah replica dikali ukuran pool, ditambah migration job, background worker, dan session psql yang Anda buka untuk mendiagnosisnya. PostgreSQL default-nya 100 max_connections dan menyisakan tiga untuk superuser, jadi temboknya datang saat autoscale, bukan saat deploy. Kecilkan setiap pool, atau pasang PgBouncer dengan transaction mode.
Deploy-nya hijau. Health check lolos, smoke test lolos, dan service berjalan dua hari tanpa satu pun error database di log. Lalu traffic naik, platform melakukan persis apa yang dikonfigurasikan dan menambah replica untuk melayaninya, dan replica-replica terbaru gagal pada query pertamanya dengan pesan yang tidak pernah muncul di replica lama: sorry, too many clients already.
Tidak ada yang di-deploy. Tidak ada yang diubah. Yang bergerak hanya jumlah replica, dan itu menggerakkan satu angka yang hidup di dua tempat sekaligus tanpa dimiliki keduanya. Tulisan ini adalah aritmetika yang memprediksi kegagalan tersebut, alasan sebuah koneksi Postgres cukup mahal sampai ceiling-nya rendah, dan pilihan yang ada untuk menaikkannya. Semua nama parameter dan nilai default di bawah berasal dari dokumentasi PostgreSQL dan PgBouncer yang ditautkan di akhir; total yang dihitung adalah skenario yang disusun dari default tersebut, bukan pengukuran dari satu insiden tertentu.
Deploy mengganti replica; autoscale menambahnya. Satu perbedaan itulah alasan kehabisan koneksi menjadi kegagalan yang lolos dari staging dan dari checklist rilis. Rolling update menjaga jumlah replica relatif tetap sambil menukar container, jadi beban yang diberikan aplikasi ke database di akhir rilis sama dengan beban di awalnya. Tidak ada langkah di sana yang meminta database sesuatu yang belum diberikannya kemarin.
Autoscale mengubah pengalinya. Platform membaca target CPU atau request rate, memutuskan butuh delapan salinan container Anda alih-alih dua, lalu menjalankannya. Setiap salinan menjalankan kode yang sama, membaca DATABASE_URL yang sama, dan membangun connection pool yang sama dengan max yang sama, karena angka itu tinggal di dalam aplikasi Anda dan aplikasi Anda tidak tahu ada berapa salinan dirinya. Sementara itu ceiling di sisi seberang sudah dikunci saat server start oleh nilai yang ditetapkan seseorang tanpa pernah diberi tahu berapa autoscaler ceiling Anda.
Seluruh kegagalan ini adalah satu penjumlahan dengan sisi supply dan sisi demand. Supply adalah max_connections dikurangi slot yang tidak akan diberikan server kepada role biasa. Dokumentasi menyebut max_connections umumnya default 100, superuser_reserved_connections default tiga, dan reserved_connections default nol, serta menegaskan bahwa begitu slot bebas turun sampai batas cadangan itu, koneksi baru hanya diterima untuk superuser. Jadi angka yang benar-benar bisa dicapai aplikasi Anda adalah 97, bukan 100, dan plan managed yang kecil sering memasang ceiling lebih rendah dari default sejak awal.
# SUPPLY — both values are PostgreSQL defaults.
max_connections = 100
superuser_reserved_connections = 3 # never handed to an ordinary role
reserved_connections = 0
---
usable by your application role = 97
# DEMAND at peak. None of this is a steady state: a pool opens sockets lazily,
# so "8 replicas x 10" only materialises when all eight are genuinely busy —
# which is the exact condition that made the autoscaler create them.
web replicas 8 x pool max 10 = 80
worker replicas 2 x pool max 5 = 10
release migration 1 x pool max 5 = 5
cron / one-off 2 x 1 = 2
your own psql session = 1
---
98 > 97 -> FATAL
# Re-run it at four web replicas and the total is 58. Everything is fine.
# The deploy did not change the multiplier, so the deploy was green.
# The autoscale changed it, and nothing in the autoscale looked at this sum.Dua sifat dari penjumlahan itu layak direnungkan. Sisi demand adalah ceiling, bukan kondisi tunak, karena pool membuka socket secara lazy: delapan-kali-sepuluh baru terwujud penuh ketika kedelapan replica benar-benar sibuk, yang justru merupakan kondisi yang membuat autoscaler membuatnya. Dan totalnya melewati 97 di suatu titik antara empat replica dan delapan, jadi ada jumlah replica di mana semuanya baik-baik saja dan jumlah sedikit lebih besar di mana semuanya tidak, tanpa penurunan bertahap di antaranya sebagai peringatan.
Kegagalannya asimetris, dan asimetri itulah yang mengubah satu menit buruk menjadi outage. Replica yang sudah memegang koneksi tetap melayani. Replica baru tidak kebagian, jadi readiness check-nya gagal, jadi platform tidak pernah mengarahkan traffic ke sana, jadi beban terukur per replica yang ready tetap tinggi, jadi autoscaler meminta replica lagi, yang juga gagal connect. Scale-up justru memberi makan hal yang sedang merusaknya, dan grafik yang Anda pelototi memperlihatkan demand naik sementara kapasitas sehat datar.
Koneksi Postgres bukan socket dengan satu baris di sebuah tabel, dan itulah alasan angkanya cukup kecil untuk bisa habis. Bab arsitektur di dokumentasi menyatakannya tanpa ambigu: server menangani banyak koneksi bersamaan dengan melakukan fork satu proses baru untuk setiap koneksi, dan setelah itu client berbicara dengan backend process khususnya tanpa campur tangan proses supervisor. Setiap koneksi karenanya adalah proses sistem operasi dengan address space sendiri, file descriptor sendiri, dan jatah sendiri di scheduler.
Ongkosnya juga tidak berhenti di proses yang sedang berjalan, dan bagian inilah yang membuat parameter tersebut canggung dinaikkan. Entri max_connections menyebut bahwa PostgreSQL menghitung ukuran sejumlah resource langsung dari nilai itu, termasuk shared memory, jadi alokasinya dilakukan saat server start entah ada yang connect atau tidak. Menaikkan ceiling berarti membelanjakan memori di server yang idle, dan setiap slot yang benar-benar dipakai membelanjakan satu proses di server yang sibuk. Batas 100 bukan platform yang pelit; itu bentuk mesinnya.
Begitu aritmetikanya ditulis, pertanyaan menariknya berhenti pada seberapa besar pool Anda dan berpindah ke apa lagi yang memegang koneksi pada detik yang sama. Setiap setup yang pernah saya telusuri punya minimal satu pemegang yang tidak masuk hitungan siapa pun, dan itu tidak pernah web tier, karena web tier adalah bagian yang semua orang ingat.
Migration job saat rilis adalah yang paling tajam, karena ia berjalan tepat ketika pool sudah panas dan traffic sedang dipindahkan. Background worker biasanya adalah deployment terpisah dengan pool terpisah dan autoscaler sendiri, jadi ia berlipat secara independen dari web tier dan dengan pemicu yang berbeda. Cron dan task sekali jalan memegang koneksi beberapa detik saja dan lenyap di dashboard mana pun yang dirata-ratakan per menit. Metrics exporter dan agen backup memegang satu koneksi masing-masing, permanen. Dan yang terakhir adalah session yang Anda buka sendiri, dengan psql, untuk mencari tahu kenapa service-nya mati.

Pasang application_name yang berbeda di connection string setiap service — web app, worker, migration job, dan psql Anda sendiri. Ongkosnya satu query parameter dan hasilnya mengubah pg_stat_activity dari sekadar hitungan menjadi jawaban. Alih-alih sembilan puluh delapan koneksi, Anda dapat delapan puluh dari web, sepuluh dari worker, lima dari migrate, yang langsung menunjukkan angka mana yang harus diubah.
Naluri pertama saat pool kelaparan adalah menaikkan max-nya. Di PaaS itu terbalik, karena sumber daya yang langka adalah database, bukan aplikasi. Anda bisa menjalankan sepuluh salinan container; Postgres-nya cuma satu, dan model satu proses per koneksi berarti backend tambahan memperebutkan core dan memori yang sama, bukan menambah kapasitas. Langkah yang benar adalah membagi anggaran tetap dengan ceiling autoscaler dan menerima pool yang kecil per replica.
// Wrong: the pool that was right when this ran as one container.
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
});
// Nothing in this file knows the platform may run ten copies of it. The number
// that actually reaches Postgres is 10 * replicas, and "replicas" is set in a
// dashboard by whoever configured autoscaling.
// Right: derive the per-replica ceiling from a shared, written-down budget.
const CONNECTION_BUDGET = 78; // 97 usable, minus workers, migration and one psql
const MAX_REPLICAS = 10; // the SAME value as the autoscaler ceiling
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: Math.max(2, Math.floor(CONNECTION_BUDGET / MAX_REPLICAS)), // 7
// Queue locally rather than failing remotely. A request that waits 40 ms for
// a pool slot is slow; a request that gets "too many clients already" is a
// 500. This timeout is the only place you can choose which one happens.
connectionTimeoutMillis: 5000,
idleTimeoutMillis: 30000,
});Pool yang kecil memindahkan antrean keluar dari database dan masuk ke aplikasi, dan di situlah tempatnya. Menunggu slot pool adalah penundaan yang terbatas dan terukur di dalam proses yang Anda kendalikan, dengan timeout yang Anda pilih dan metrik yang bisa Anda grafikkan. Menunggu slot koneksi di server bukan menunggu sama sekali: server menolak, driver melempar error, dan request-nya jadi 500. Mengantre lebih baik daripada gagal, dan pool Anda adalah satu-satunya tempat Anda bisa mengantre dengan sengaja.
Kontribusi sebenarnya dari PgBouncer adalah client connection dan server connection berhenti menjadi objek yang sama. max_client_conn, jumlah client yang mau diterima pooler, default-nya 100 dan ongkosnya sekitar dua kilobyte per client. default_pool_size, jumlah server connection yang akan dibuka per pasangan user dan database, default-nya 20. Hanya angka kedua yang dibelanjakan terhadap max_connections. Itulah seluruh pengalinya: banyak client aplikasi berbagi sekumpulan kecil backend yang jumlahnya tetap.
Pengali itu hanya Anda dapatkan pada pool_mode yang tepat, dan default-nya bukan yang itu. pool_mode default-nya session, yang menahan server connection selama client masih terhubung — mode ini mendukung semua fitur PostgreSQL dan sama sekali tidak memberi Anda concurrency lebih dibanding koneksi langsung. Transaction mode melepas server begitu transaksi selesai, dan dokumentasi PgBouncer terang-terangan soal harganya: mode ini memang dirancang melanggar ekspektasi client terhadap server, dan hanya bisa dipakai kalau aplikasi mau bekerja sama dengan tidak memakai fitur yang rusak di dalamnya.
[databases]
appdb = host=db.internal port=5432 dbname=appdb
[pgbouncer]
listen_port = 6432
; Default is "session", which holds a backend for the whole client connection
; and therefore gives you no more concurrency than connecting directly.
pool_mode = transaction
; Clients PgBouncer will ACCEPT. Costs about 2 kB each. Default: 100.
max_client_conn = 1000
; Server connections PgBouncer will OPEN, per user/database pair.
; THIS is the number that counts against max_connections. Default: 20.
default_pool_size = 20
; A client with no server assigned within this window is disconnected rather
; than queued for ever. Default: 120.0 seconds — so "errors at exactly two
; minutes" is a pool-starvation signature, not a network one.
query_wait_timeout = 120
; Default 200 since PgBouncer 1.24.0; it was 0 from 1.21.0, when protocol-level
; prepared statement support was introduced. Zero means no support in
; transaction mode at all.
max_prepared_statements = 200Pada transaction pooling, feature map PgBouncer menandai SET dan RESET, LISTEN, WITH HOLD cursor, temp table PRESERVE dan DELETE ROWS, session-level advisory lock, serta PREPARE tingkat SQL sebagai Never — bukan berkurang, tapi tidak pernah. NOTIFY, temp table ON COMMIT DROP, dan WITHOUT HOLD cursor tetap jalan. Tabel lengkap per mode punya tulisan tersendiri di situs ini:
PgBouncer Pooling Modes: Session vs Transaction vs Statement
Prepared statement adalah satu-satunya entri di daftar itu yang dikenai kebanyakan aplikasi tanpa pernah memilihnya, karena driver dan ORM memakainya secara default. Yang menentukan apakah ia selamat di transaction pooling adalah level protokol versus level SQL. Driver yang memakai extended query protocol mengirim Parse bernama, dan PgBouncer bisa melacaknya: ia menulis ulang nama statement Anda menjadi nama internal, menyiapkannya di backend mana pun yang sedang Anda dapat, lalu menyiapkannya ulang secara transparan ketika transaksi berikutnya mendarat di backend lain. PREPARE, EXECUTE, dan DEALLOCATE tingkat SQL diteruskan begitu saja tanpa pelacakan sama sekali, dan karena itu feature map menandainya Never.
-- WORKS in transaction pooling: protocol-level named prepared statements.
-- The driver sends a named Parse over the extended query protocol. PgBouncer
-- renames it internally to PGBOUNCER_<unique id>, prepares it on whichever
-- backend you were assigned, and re-prepares it transparently when the next
-- transaction lands on a different one.
-- node-postgres: client.query({ name: 'find_user', text: '...', values: [1] })
-- asyncpg, JDBC, Prisma: prepared by default, nothing to enable
-- NEVER works in transaction pooling: SQL-level PREPARE is forwarded straight
-- through with no tracking, so it lands on one backend and EXECUTE may not.
PREPARE find_user (int) AS SELECT * FROM users WHERE id = $1;
EXECUTE find_user(1); -- "prepared statement find_user does not exist"
-- The failure that appears during a RELEASE, not during development: a plan
-- cached on a shared backend outlives the DDL that invalidated it.
-- ALTER TABLE users ADD COLUMN locale text;
-- ERROR: cached plan must not change result type
-- The documented fix is on the pooler's admin console, after the migration:
RECONNECT;Riwayat versinya penting, karena saran yang benar sudah berubah dua kali. Dukungan level protokol datang di PgBouncer 1.21.0 dan harus dinyalakan sendiri, sebab max_prepared_statements default-nya nol. Di 1.24.0 default-nya menjadi 200 dan changelog menyebutnya sebagai menyalakan dukungan prepared statement secara default. Jadi pertanyaan apakah prepared statement jalan di belakang pooler Anda adalah pertanyaan versi lebih dulu dan pertanyaan konfigurasi kemudian, dan pada pooler yang managed versinya tidak selalu Anda yang menentukan. Dokumentasi juga menyebut jebakan yang muncul saat rilis dan bukan saat pengembangan: plan yang di-cache di backend bersama hidup lebih lama dari DDL yang membatalkannya, yang menghasilkan cached plan must not change result type, dengan RECONNECT di admin console sebagai perbaikan yang didokumentasikan.

Dua angka memberi tahu apakah Anda sebentar lagi kena: berapa banyak client backend yang ada sekarang, dan berapa ceiling-nya sebenarnya. Baca yang kedua, jangan diasumsikan, karena plan managed sering menetapkan max_connections di bawah default yang didokumentasikan pada instance kecil, dan aritmetika yang Anda kerjakan di atas angka 100 langsung salah sejak baris pertama.
-- 1. How close to the wall are you right now? Read the ceiling rather than
-- assuming 100 — a managed plan often sets it lower on a small instance.
SELECT current_setting('max_connections')::int AS ceiling,
count(*) AS in_use,
count(*) FILTER (WHERE state = 'active') AS active,
count(*) FILTER (WHERE state = 'idle in transaction') AS idle_in_txn
FROM pg_stat_activity
WHERE backend_type = 'client backend';
-- 2. Who is holding them? This is why application_name is worth setting in
-- every service's connection string.
SELECT application_name, state, count(*)
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY 1, 2
ORDER BY 3 DESC;
-- 3. On the pooler, the same question in different words.
-- psql -p 6432 pgbouncer -c "SHOW POOLS"
-- cl_waiting clients that sent a query and have no server yet
-- sv_idle server connections free right now
-- maxwait seconds the OLDEST waiting client has waited.
-- Climbing maxwait is the pooler's version of the FATAL.| Yang Anda lihat | Artinya | Tempat memeriksanya |
|---|---|---|
| FATAL: sorry, too many clients already | Semua slot di luar cadangan superuser sudah terpakai; sisi demand melewati sisi supply | pg_stat_activity, dikelompokkan per application_name |
| FATAL: remaining connection slots are reserved for roles with the SUPERUSER attribute | Slot bebas sudah turun ke superuser_reserved_connections — Anda tiga koneksi dari tembok, belum menabraknya | Query yang sama, dijalankan sebagai superuser, yang masih bisa masuk |
| Replica baru tidak pernah ready sementara yang lama melayani dengan baik | Pool hanya habis untuk proses yang paling terakhir start, jadi autoscaler terus meminta tambahan | Log startup replica yang gagal itu sendiri, bukan dashboard platform |
| maxwait naik terus di SHOW POOLS | Client sedang diantrekan karena default_pool_size terlalu kecil atau server-nya kelebihan beban | Admin console PgBouncer, bukan Postgres |
| Error di sisi client tepat sekitar dua menit | query_wait_timeout memutus client yang tidak pernah kebagian server; default-nya 120 detik | Log PgBouncer, bersama cl_waiting di SHOW POOLS |
Perbedaan yang sebenarnya digambar tabel itu adalah dua ceiling yang berbeda. Postgres menolak, jadi gejalanya error di batas keras. PgBouncer tidak menolak, ia mengantrekan, jadi gejalanya latensi: maxwait adalah jumlah detik client tertua yang sudah menunggu, dan dokumentasi menyebut dua penyebabnya sebagai server yang kelebihan beban atau sekadar pool_size yang terlalu kecil. Pantau keduanya, karena memasang pooler di depan mengubah satu mode kegagalan menjadi mode yang lain, bukan menghilangkannya.
Aturan yang layak dibawa pulang dari sini adalah bahwa ukuran connection pool bukan properti aplikasi Anda. Ia properti dari ceiling replica platform yang dibagikan ke anggaran koneksi database, dan tempatnya di sebuah konstanta bernama di sebelah pool dengan aritmetikanya ditulis di komentar tepat di atasnya. Ulangi pembagian itu setiap kali Anda menyentuh maxReplicas atau mengganti plan database. Itu satu-satunya angka di sistem yang diubah dua tim secara terpisah dan tidak dimiliki keduanya.
Sumber