# Batas Koneksi Postgres di PaaS: Bertahan dari max_connections

> Deploy-nya hijau dan tetap hijau. Dua hari kemudian platform menambah replica, masing-masing membuka pool sendiri, dan replica terbaru sama sekali tidak kebagian koneksi.

URL: https://www.matthewswong.com/id/blog/paas-postgres-connection-limits-survival/

- Author: Matthews Wong (https://www.matthewswong.com/id/about/)
- Published: 2026-09-24
- Updated: 2026-09-24
- Category: Backend

> **Key takeaway:** 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 itu aman, autoscale tidak

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.

## Rumusnya, dihitung dengan nilai default

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.

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.

## Kenapa ceiling-nya 100 dan bukan 100.000

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.

## Koneksi yang tidak pernah dihitung siapa pun

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.

*Dua band terbawah adalah yang paling sering luput dari perhitungan: session yang Anda buka untuk mendiagnosis outage, dan tiga slot yang tidak akan pernah diberikan server kepada role biasa sesepi apa pun keadaannya.*

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

## Kecilkan pool-nya, jangan dibesarkan

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.

1. Tulis anggarannya: max_connections dikurangi superuser_reserved_connections, dikurangi semua yang bukan web replica — worker, migration job, cron, exporter, dan satu slot untuk manusia yang memegang psql.
2. Bagi anggaran itu dengan maxReplicas, bukan dengan jumlah replica yang kebetulan berjalan hari ini. Ceiling autoscaler adalah satu-satunya angka yang aman diasumsikan oleh aritmetika ini.
3. Batasi hasilnya minimal dua. Kalau jawaban jujurnya satu, plan database-nya terlalu kecil untuk ceiling replica itu dan salah satu dari keduanya harus berubah — itu keputusan, bukan nilai konfigurasi.
4. Pasang timeout untuk pengambilan koneksi supaya pool yang kelaparan gagal cepat dan secara lokal alih-alih mengantre selamanya, lalu log setiap timeout. Baris log itulah peringatan dini Anda saat pengalinya bergerak lagi.

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.

## Pooler menaikkan ceiling dengan cara merusak semantik session

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.

> Pada 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:

## Masalah prepared statement, dan kenapa jawabannya bergeser

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.

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.

*Transaction pooling adalah mode yang benar-benar melipatgandakan ceiling Anda, dan dua baris tengah di panelnya adalah harga yang Anda setujui untuk pengali tersebut.*

## Apa yang dipantau, dan pesan yang benar-benar akan Anda lihat

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.

| 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

- Dokumentasi PostgreSQL — Connections and Authentication: max_connections, superuser_reserved_connections, dan reserved_connections
- Dokumentasi PostgreSQL — Architectural Fundamentals: server melakukan fork satu proses baru untuk setiap koneksi
- PgBouncer — Features: tiga pooling mode dan SQL feature map
- PgBouncer — Configuration: pool_mode, max_client_conn, default_pool_size, query_wait_timeout, dan max_prepared_statements
- PgBouncer — Changelog: prepared statement level protokol ditambahkan di 1.21.0, max_prepared_statements jadi default 200 di 1.24.0

## Source links

- https://www.postgresql.org/docs/current/runtime-config-connection.html
- https://www.postgresql.org/docs/current/tutorial-arch.html
- https://www.pgbouncer.org/features.html
- https://www.pgbouncer.org/config.html
- https://www.pgbouncer.org/changelog.html

## FAQ

### Bagaimana cara menghitung berapa koneksi Postgres yang dibutuhkan aplikasi PaaS saya?

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.

### Kenapa aplikasi saya kehabisan koneksi hanya saat autoscale, bukan saat deploy?

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.

### Apa arti error sorry, too many clients already di PostgreSQL?

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.

### Lebih baik menaikkan max_connections atau memasang PgBouncer?

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.

### Apakah prepared statement tetap jalan lewat PgBouncer di transaction mode?

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.

---

- HTML version: https://www.matthewswong.com/id/blog/paas-postgres-connection-limits-survival/
- English: https://www.matthewswong.com/en/blog/paas-postgres-connection-limits-survival/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
