Connection pool adalah sekumpulan koneksi database yang dibuka sekali, lalu dipinjamkan bergantian ke setiap request: pinjam, jalankan query, kembalikan. Bayangin menelepon call center bank. Sebelum boleh bertanya, kamu dengar menu, sebut nomor rekening, lalu jawab pertanyaan verifikasi. Kalau pertanyaanmu cuma "saldo saya berapa?", verifikasinya jauh lebih lama dari pertanyaannya. Call center juga cuma punya sekian operator, jadi kalau semuanya sibuk, kamu disuruh menunggu. Membuka koneksi ke database itu verifikasinya, dan jumlah operator itu batas koneksi database.
🧪 Tebakan cepat: pool Kedai Senja berisi 2 koneksi, dan dua-duanya sedang dipakai. Request ketiga datang. Apa yang terjadi: request itu error, pool membuka koneksi ketiga, atau request itu menunggu? Tebak dulu, jawabannya ada di bagian "Pool penuh: antre".
Apa yang terjadi saat aplikasi konek ke database
Satu koneksi (connection) adalah jalur komunikasi antara aplikasi dan server database. Sebelum query pertama bisa dikirim, keduanya harus kenalan dulu: TCP membuka jalur jaringan, TLS menyepakati enkripsi, lalu database mengecek username dan password (autentikasi). PostgreSQL juga menyiapkan satu proses khusus untuk setiap koneksi, yang memakai memory beberapa MB selama koneksinya terbuka.
Semua langkah kenalan ini kita sebut handshake. Di Lab, handshake makan 50 ms dan query-nya 20 ms. Ini angka model contoh, bukan hasil benchmark: database di mesin yang sama bisa tersambung dalam 1 sampai 2 ms, database di region lain bisa ratusan ms. Polanya tetap sama:
| Cara | Waktu per request (model Lab) |
|---|---|
| Buka koneksi baru tiap request | 70 ms: 50 handshake + 20 query |
| Pinjam dari pool | 20 ms: query saja, handshake sudah dibayar saat koneksinya dibuka |
Itu sebabnya aplikasi production butuh pool: membuka koneksi baru (TCP, TLS, autentikasi) untuk setiap request itu boros. Pool memakai ulang koneksi yang sudah tersambung.
Connection pool: pinjam, pakai, kembalikan
Di Node.js, library pg sudah punya pool:
import { Pool } from 'pg'
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 2, // paling banyak 2 koneksi
idleTimeoutMillis: 10000, // koneksi nganggur 10 detik ditutup
connectionTimeoutMillis: 2000, // antre lebih dari 2 detik: error
})
app.get('/orders', async (req, res) => {
// pinjam koneksi, kirim query, kembalikan ke pool: semuanya otomatis
const { rows } = await pool.query('SELECT * FROM orders ORDER BY id DESC LIMIT 10')
res.json(rows)
})
max: 2adalah ukuran pool: berapa koneksi paling banyak yang boleh dibuka pool ini. Bawaanpgadalah 10.pool.query(...)meminjam koneksi yang sedang nganggur (idle), menjalankan query, lalu mengembalikannya. Koneksinya gak ditutup, jadi request berikutnya gak perlu handshake lagi.idleTimeoutMillismenutup koneksi yang terlalu lama nganggur.connectionTimeoutMillismembatasi waktu antre; lewat dari itu,pgmelemparError: timeout exceeded when trying to connect.
Pool penuh: antre
Tiga request datang hampir bersamaan, selisih 2 ms, ke pool berisi 2 koneksi. Ini mode Pool di Lab dengan 3 request:
| Waktu | Kejadian | Aktif | Antre |
|---|---|---|---|
| 0 ms | R1 pinjam koneksi #1 | 1 | 0 |
| 2 ms | R2 pinjam koneksi #2 | 2 | 0 |
| 4 ms | R3 datang, pool penuh: R3 antre | 2 | 1 |
| 20 ms | R1 selesai, koneksi #1 kembali, R3 langsung memakainya | 2 | 0 |
| 22 ms | R2 selesai, koneksi #2 kembali | 1 | 0 |
| 40 ms | R3 selesai | 0 | 0 |
R3 gak error, dan pool gak membuka koneksi ketiga: R3 menunggu 16 ms sampai ada koneksi yang dikembalikan. Itu jawaban tebakannya.
Latihannya memodelkan persis tabel ini dengan class ConnectionPool(maxSize):
acquire()saatactive < maxSize: naikkanactive, kembalikan"ok". Saat penuh: request masuk antrean, kembalikan"queued", danactivegak naik.release(): turunkanactive. Kalau ada yang antre, keluarkan satu dari antrean dan naikkanactivelagi. Hasilnyaactivetetap 2 danqueuedjadi 0, persis baris 20 ms di tabel.stats(): kembalikan{ active, queued }.
max_connections: batas di sisi database
Database juga punya batas. PostgreSQL menerima paling banyak max_connections koneksi sekaligus, bawaannya 100. Koneksi berikutnya langsung ditolak dengan FATAL: sorry, too many clients already.
Lab memakai model kecil dengan max_connections = 8 supaya batasnya cepat kena. Kirim 12 request tanpa pool: R1 sampai R8 masing-masing membuka koneksi, R9 sampai R12 ditolak dengan pesan di atas. Dengan pool berisi 4 koneksi, ke-12 request selesai dalam 66 ms dan database cuma pernah melihat 4 koneksi. Request yang gak kebagian koneksi menunggu sebentar, bukan ditolak.
Pool hidup di dalam satu proses aplikasi. Kalau aplikasimu jalan di beberapa server (instance), setiap instance punya pool sendiri, dan totalnya dikali: 10 instance dengan max: 20 berarti sampai 200 koneksi, padahal batasnya 100. Aturan praktisnya: max per instance sekitar max_connections dibagi jumlah instance, dikurangi sedikit cadangan. 10 instance dengan batas 100 berarti max sekitar 10, bukan 100.
Laravel dan PHP-FPM
Laravel biasanya jalan di PHP-FPM: sekumpulan proses PHP (worker) yang jumlahnya diatur pm.max_children, dan setiap worker melayani satu request sampai selesai. Laravel membuka koneksi database saat query pertama di sebuah request, lalu koneksinya ditutup saat request selesai. Jadi Laravel di PHP-FPM gak punya pool: setiap request membayar handshake, dan jumlah koneksi sama dengan jumlah worker yang sedang sibuk. 3 server dengan pm.max_children = 50 bisa membuka 150 koneksi.
// config/database.php
'pgsql' => [
// ...
'options' => [
PDO::ATTR_PERSISTENT => true,
],
],
PDO::ATTR_PERSISTENT => truemembuat setiap worker menyimpan koneksinya setelah request selesai, lalu memakainya lagi di request berikutnya. Handshake-nya hilang, tapi ini bukan pool bersama: setiap worker memegang satu koneksi terus, termasuk saat nganggur. 150 worker tetap 150 koneksi.- Pengaturan yang menempel ke koneksi, misalnya
SETatau temporary table, ikut terbawa ke request berikutnya di worker yang sama. - Laravel Octane menjaga aplikasi tetap hidup di memory, jadi setiap worker Octane juga memakai ulang koneksinya.
Serverless: koneksi berlipat
Di serverless seperti AWS Lambda, setiap instance function melayani satu request pada satu waktu. 300 request bersamaan berarti 300 instance, dan setiap instance punya pool sendiri yang cuma pernah berisi 1 koneksi. Database tetap melihat 300 koneksi. Lebih parah lagi, instance yang sudah selesai tetap hidup sebentar (hangat) menunggu request berikutnya, dan koneksinya ikut tetap terbuka walau gak dipakai.
Solusinya pooler eksternal: server kecil di depan database yang memegang sedikit koneksi ke database dan meminjamkannya ke banyak client.
300 function ──┐
├──▶ PgBouncer :6432 ──▶ PostgreSQL :5432
Laravel, Node ─┘ 300 client 20 koneksi
- PgBouncer adalah pooler open source untuk PostgreSQL: aplikasi konek ke PgBouncer (biasanya port 6432), bukan langsung ke database. RDS Proxy adalah versi terkelola dari AWS, dan Supabase serta Neon juga menyediakan connection string khusus pooler.
- Di transaction mode, PgBouncer meminjamkan satu koneksi database hanya selama satu transaction, lalu langsung memberikannya ke client lain.
- Handshake dari function ke PgBouncer tetap ada. Yang dihemat adalah jumlah koneksi di database.
📌 Ukuran pool, mode PgBouncer, dan memantau koneksi
- Pool besar gak otomatis lebih cepat. Database dengan 4 core cuma bisa benar-benar mengerjakan beberapa query sekaligus. Titik awal dari wiki PostgreSQL:
(jumlah core × 2) + jumlah disk, misalnya4 × 2 + 1 = 9. Ukur, lalu sesuaikan. - Mode PgBouncer:
session(satu client memegang satu koneksi selama tersambung, hampir gak menghemat),transaction(paling umum untuk web),statement(per query, transaction berisi beberapa query gak bisa). Di transaction mode, fitur yang menempel ke koneksi sepertiSETdanLISTENgak bisa diandalkan. PgBouncer sebelum versi 1.21 juga gak mendukung prepared statement di mode ini; di Laravel, tambahkanPDO::ATTR_EMULATE_PREPARES => truedioptions. - Slot cadangan: PostgreSQL menyisakan 3 slot untuk superuser (
superuser_reserved_connections), jadi user aplikasi biasa sudah ditolak sebelum koneksi ke-100, dengan pesanremaining connection slots are reserved for .... Model di Lab gak memakai slot cadangan ini. - Pantau:
SELECT state, count(*) FROM pg_stat_activity GROUP BY state;menghitung koneksi per status. Banyakidle in transactionartinya ada kode yang membuka transaction tanpaCOMMIT. Setelanidle_in_transaction_session_timeout = '60s'memutus koneksi seperti itu otomatis.
⚠️ Jebakan yang sering ketemu
- Lupa
client.release().pool.connect()meminjam koneksi secara manual, dan kamu wajib mengembalikannya. Tanparelease(), koneksi itu gak pernah kembali (connection leak). Setelah semua koneksi bocor, request berikutnya antre sampai kena timeout. Taruhclient.release()di blokfinally, supaya selalu jalan:
const client = await pool.connect()
try {
await client.query('BEGIN')
await client.query('UPDATE orders SET total = 40000 WHERE id = 20')
await client.query('COMMIT')
} catch (err) {
await client.query('ROLLBACK')
throw err
} finally {
client.release() // jalan saat sukses maupun gagal
}
maxyang salah. Ini parameter pool yang paling sering bikin masalah di production. Terlalu kecil: request antre. Terlalu besar: jumlah instance dikalimaxmelewatimax_connections.- Di latihan, request yang antre belum aktif.
acquire()yang mengembalikan"queued"gak menaikkanactive. Setelahrelease(), antrean dipromosikan:activetetap 2,queuedjadi 0.
TL;DR: Membuka koneksi database butuh handshake (TCP, TLS, autentikasi), yang di Lab makan 50 ms sementara query-nya cuma 20 ms. Connection pool membuka koneksi sekali dan meminjamkannya bergantian: pinjam, query, kembalikan. Saat semua koneksi dipakai, request berikutnya antre sampai ada yang di-release, bukan error. Database punya max_connections (bawaan PostgreSQL 100); lewat dari itu, koneksi ditolak dengan FATAL: sorry, too many clients already. Pool hidup per proses, jadi total koneksi = jumlah instance × max. Laravel di PHP-FPM gak punya pool, dan serverless membuat satu koneksi per instance, jadi untuk banyak server atau serverless pasang PgBouncer atau RDS Proxy. Selalu client.release() di finally.