Üretimde veritabanı incident’larının en sinsi olanı şudur: CPU düşük, veritabanı “ayakta”, ama uygulama donmuş gibi davranır. Kök neden çoğu zaman query’nin yavaş olması değil; connection pool’un doygunluğu ve bunun ürettiği kuyruklanma döngüsüdür.
Bu yazı; özellikle PostgreSQL + uygulama pool (Hikari/pgx/SQLAlchemy vb.) ve/veya PgBouncer kullanan yapılarda, pool doygunluğunu “teknik detay” değil operasyonel kontrol noktası olarak ele alır. Önce temeli kuralım — pool’un ne işe yaradığını ve neden bu kadar kritik olduğunu — sonra incident runbook’una geçeriz.
0) Temeller: connection pool ne çözer, neyi çözmez
Doygunluğu konuşmadan önce pool’un neden var olduğunu netleştirmek gerekir. Çünkü incident anında verilen kararların yarısı, “pool’un işi ne?” sorusunun yanlış cevaplanmasından doğar.
Her veritabanı bağlantısı kurmak pahalıdır: TCP handshake, kimlik doğrulama, yetkilendirme, sunucu tarafında bellek/CPU tahsisi. Saniyede yüzlerce-binlerce isteğin her biri için bağlantı açıp kapatmak, DB sunucusunu boğar ve yanıt sürelerini patlatır. Connection pool bu maliyetli adımları sıcak yoldan çıkarır: bağlantıları önceden açar, bir havuzda tutar, uygulama ödünç alır → kullanır → iade eder. Bağlantı fiziksel olarak kapanmaz, havuza geri döner ve bir sonraki istek için hazır bekler.
0.1 Rakamlarla etki: “daha hızlı” yetmez
Pool’un faydasını somut görmek, ileride pool boyutu kararlarını sakinleştirir. Bir bağlantı kurmak tipik olarak 20-100 ms sürer; saniyede 1000 istekte her istek için bağlantı açıp kapatırsanız, sadece bağlantı yönetimi için saniyede 20-100 saniyelik iş üretirsiniz — iş mantığına neredeyse zaman kalmaz. Pool’la bu maliyet milisaniyelerin altına iner.
Tipik kalıp şudur: operasyon ekranlarının her istek için ayrı bağlantı açtığı bir sistemde, yoğun saatlerde DB sunucusunda CPU tırmanır ve sorgu süreleri uzar. Pooling (ör. HikariCP) devreye alındığında aynı yük altında CPU yükü geriler, sorgu süreleri milisaniye bandına iner. Bu kalıp, yüksek eşzamanlılıkta yanıt veremeyen sistemlerde tekrarlanır: bağlantı yeniden kullanımı devreye girince, sabit bir pool boyutuyla çok daha fazla iş sorunsuz akabilir.
İşte tipik kazançlar:
- Bağlantı kurma maliyeti sıfırlanır: sorgu başlatma süresi milisaniyeye iner.
- Sunucu kaynağı verimli kullanılır: sürekli açıp-kapamak yerine yeniden kullanım, DB CPU/bellek yükünü düşürür.
- Eşzamanlılık artar, tail latency stabilize olur: daha az kaynak israfı, daha öngörülebilir p95/p99.
0.2 Pooler seçim matrisi
Pool’u nerede çalıştıracağın, doygunluk davranışını doğrudan belirler:
| Çözüm | Katman | Ne zaman | Not |
|---|---|---|---|
| HikariCP | Uygulama (JVM) | Modern Java / Spring Boot varsayılanı | Düşük gecikme, leakDetectionThreshold ile sızıntı tespiti |
| c3p0 | Uygulama (JVM) | Eski/legacy JVM yığınları | Hikari kadar hızlı değil ama esnek ve olgun |
| Apache Commons DBCP | Uygulama (JVM) | Yaygın ama performansta Hikari’nin gerisinde | Tomcat ekosisteminde sık |
SQLAlchemy QueuePool / pg.Pool |
Uygulama (Python / Node) | Dile gömülü, ORM’le gelir | Aşağıda konfigürasyonu var |
| PgBouncer | DB önünde (proxy) | Çok sayıda uygulama/instance aynı DB’ye bağlanıyorsa | DB’deki gerçek bağlantı sayısını tek elden yönetir |
Pratikte tercih çoğu Java işinde HikariCP; ama birden çok uygulamanın aynı PostgreSQL’e bağlandığı senaryoda PgBouncer ön plana geçer — DB tarafındaki bağlantı sayısını yönetilebilir tutmanın en temiz yolu odur. İkisi birlikte de kullanılır: uygulama → HikariCP → PgBouncer → PostgreSQL.
0.3 Pooling ne zaman gereksiz olur
Pooling neredeyse her yerde standarttır, ama körü körüne değil:
- Çok düşük trafikli işler: ayda bir çalışan rapor betiği, birkaç kullanıcılı iç araç. Havuz yönetiminin getirdiği ek karmaşıklık, sağladığı faydayı gölgeler.
- Tekil, kısa ömürlü süreçler: açılıp tek sorgu yapıp kapanan CLI aracı. Bağlantıyı aç-kullan-kapat, havuz kurmaktan daha verimli.
- Bazı serverless mimariler: fonksiyon her çağrıda yeni ortamda kalkabilir; klasik pooling’in verimi düşer (warm start’ta yeniden kullanım var, cold start’ta yok). Burada dışsal bir pooler (ör. PgBouncer / serverless-uyumlu proxy) genelde daha doğru cevaptır.
Bu istisnalar dışında, sürekli aktif ve yüksek istekli sistemlerde (web, mikroservis, API) pooling olmadan performans kabul edilemez seviyeye düşer. Kural basit: performans önemliyse, pool kullan.
1) Mental model: problem veritabanında değil, kuyruğun önünde başlar
Basit zincir:
- Trafik artar → daha çok istek DB’ye ulaşmak ister
- Pool dolar → thread/worker bekler
- Bekleyen istekler timeout olur → retry devreye girer
- Retry, DB’ye yeni baskı yaratır → latency daha da artar
Sonuç: latency geri besleme döngüsü (kuyruk büyürken sistem “daha çok deniyor”).
2) Triage: 10 dakikada pool doygun mu?
2.1 Uygulama metrikleri (en değerli sinyal)
Aradığın metrik sınıfları:
- Active connections (kullanım)
- Pending / wait queue (bekleyen)
- Acquire time / wait time (bağlantı alma süresi)
- Timeout count
Bu metrikler yoksa, incident anında ilk aksiyonlardan biri şudur: pool metriklerini standartlaştırmak.
2.2 PostgreSQL tarafı: oturum ve bekleme
-- aktif oturumların genel görünümü
select
state,
count(*) as sessions
from pg_stat_activity
group by 1
order by 2 desc;
Triage yorumu:
- Çok sayıda
activeoturum: DB gerçekten çalışıyor olabilir ama doygun - Çok sayıda
idle in transaction: uygulama transaction’ı açık bırakıyor (en pahalı hata)
Bekleme sınıflarını okumak için:
select
wait_event_type,
wait_event,
count(*) as sessions
from pg_stat_activity
where wait_event is not null
group by 1,2
order by 3 desc;
2.3 PgBouncer varsa: “asıl kuyruk burada olabilir”
PgBouncer admin DB üzerinden:
show pools;
show stats;
Aradığın sinyaller:
- Waiting client sayısı
- Server connection sayısı (DB’ye giden gerçek bağlantı)
3) Pool parametre sözlüğü ve dile göre konfigürasyon
Triage’da gördüğün doygunluğun kalıcı cevabı, çoğu zaman birkaç parametrenin doğru ayarıdır. Kütüphaneler isimleri farklı koysa da temel knob’lar aynıdır:
maximumPoolSize— havuzun aynı anda tutabileceği maksimum aktif bağlantı. DB’ninmax_connectionsdeğerinin %70-80’ini aşma; uygulamanın thread/işlem sayısı ve CPU çekirdeğiyle ilişkilendir. Çok yüksek → DB’yi döver; çok düşük → contention.minimumIdle— havuzda her zaman hazır bekleyen minimum bağlantı. Ani yük artışında yeni bağlantı açma gecikmesini önler. GeneldemaximumPoolSize’dan çok küçük ama steady-state ihtiyacı karşılayacak kadar.connectionTimeout— havuzdan bağlantı almak için beklenecek maksimum süre (ms). Dolarsa hata fırlar. Bu, runbook’taki pool acquire timeout’un ta kendisidir.idleTimeout— bir bağlantının boşta ne kadar kalıp tahliye edileceği.minimumIdleüzerindeki bağlantılar için geçerli; boşta kaynağı temizler.maxLifetime— bir bağlantının havuzda kalabileceği en uzun süre; dolunca boşta olsa bile emekliye ayrılır. Uzun ömürlü bağlantı sorunlarını (sunucunun server-side kapatması, bellek) önler.idleTimeout’tan kısa olmamalı.validationQuery/connectionTestQuery— bağlantının canlı olduğunu doğrulayan sorgu (SELECT 1). Tahsis öncesi veya periyodik çalışır; sunucunun sessizce öldürdüğü bağlantının dağıtılmasını engeller.
3.1 Java / Spring Boot — HikariCP
# DataSource
spring.datasource.url=jdbc:postgresql://localhost:5432/mydb
spring.datasource.username=user
spring.datasource.password=password
spring.datasource.driver-class-name=org.postgresql.Driver
# HikariCP
spring.datasource.hikari.maximumPoolSize=10
spring.datasource.hikari.minimumIdle=2
spring.datasource.hikari.connectionTimeout=30000
spring.datasource.hikari.idleTimeout=600000
spring.datasource.hikari.maxLifetime=1800000
spring.datasource.hikari.poolName=MyHikariPool
spring.datasource.hikari.validationTimeout=5000
spring.datasource.hikari.leakDetectionThreshold=30000
maximumPoolSize=10 ile zirvede en fazla 10 aktif bağlantı tutulur. Spring Data JPA gibi bir ORM kullanıyorsan havuz yönetimi otomatiktir; bağlantıyı elle alıp iade etmen gerekmez.
3.2 Python — SQLAlchemy
from sqlalchemy import create_engine, text
DATABASE_URL = "postgresql://user:password@localhost/mydb"
engine = create_engine(
DATABASE_URL,
pool_size=10, # maximumPoolSize (varsayılan: 5)
max_overflow=5, # havuz dolunca geçici ek bağlantı
pool_timeout=30, # connectionTimeout (saniye)
pool_recycle=1800, # maxLifetime (saniye, 30 dk)
pool_pre_ping=True, # validationQuery eşdeğeri — bağlantı canlı mı?
)
def fetch_data():
# 'with' bloğu bağlantıyı otomatik havuza iade eder
with engine.connect() as connection:
result = connection.execute(text("SELECT id, name FROM users LIMIT 5"))
for row in result:
print(f"ID: {row.id}, Name: {row.name}")
if __name__ == "__main__":
fetch_data()
print("Veri başarıyla çekildi.")
pool_size, HikariCP’deki maximumPoolSize’a karşılık gelir; max_overflow kısa yük artışlarında havuzu esnetir; pool_pre_ping=True her tahsisde küçük bir sorguyla bağlantının canlılığını doğrular.
3.3 Node.js — pg (node-postgres)
const { Pool } = require('pg');
const pool = new Pool({
user: 'user',
host: 'localhost',
database: 'mydb',
password: 'password',
port: 5432,
max: 10, // maximumPoolSize
idleTimeoutMillis: 30000, // idleTimeout (ms)
connectionTimeoutMillis: 2000, // connectionTimeout (ms)
statement_timeout: 5000, // DB statement timeout (ms) — kuyruk hizalaması için kritik
});
async function getUsers() {
let client;
try {
client = await pool.connect(); // havuzdan bağlantı al
const res = await client.query('SELECT id, name FROM users LIMIT 5');
console.log(res.rows);
} catch (err) {
console.error('Veritabanı hatası', err.stack);
} finally {
if (client) {
client.release(); // bağlantıyı havuza geri bırak
}
}
}
getUsers();
finally içindeki client.release(), hata durumunda bile bağlantının havuza dönmesini garanti eder — bu olmazsa sızıntı başlar (bkz. 5.3). statement_timeout’u açıkça verdim: runbook’taki “uygulama timeout < pool acquire < DB statement” hiyerarşisini kodda görünür kılmak için.
4) Hızlı mitigasyon: kuyruğu küçült, retry’ı kes
Hedef: DB’yi “daha güçlü” yapmak değil; önce sistemi stabil yapmak.
4.1 Trafiği kontrollü azalt (shed load)
Uygulanabilir seçenekler:
- Rate limit / concurrency limit (gateway katmanında)
- Cache ile DB read baskısını düşür (kısa süreli bile olsa)
- İş kritik olmayan endpoint’leri degrade et
4.2 Retry budget uygula (kritik)
Incident sırasında:
- Retry sayısını düşür (1-2)
- Exponential backoff + jitter zorunlu
- Idempotent olmayan işlemlerde retry’ı kapat
4.3 Timeout’ları “kuyruk”a göre hizala
Sık hata: uygulama timeout’u 30s, DB statement timeout yok.
Pratik kural:
- Uygulama request timeout < pool acquire timeout < DB statement timeout
Örnek (yaklaşım):
- Request: 10s
- Pool acquire: 3s
- Statement: 2s (kritik sorgular için daha kısa)
4.4 Backpressure: akışı kaynağında yönet
Timeout bir isteğin ne kadar bekleyeceğini belirler; backpressure ise gelen istek akışını kaynağında kısarak havuzun hiç doygunlaşmamasını hedefler. Üretici (uygulama), tüketicinin (havuz) hızını aşıyorsa yavaşlamalıdır. Üç temel strateji:
- Kuyruklama (queueing): Bağlantı taleplerini sınırlı bir kuyrukta beklet; havuz boşaldıkça sıradakine ver. Geçici sıçramaları yutar — ama kuyruk sınırsızsa veya bekleme şişerse yine timeout üretir. Kuyruğu bounded tut.
- Kısma (throttling): Birim zamanda yapılabilecek DB isteği sayısını sınırla (rate limit). Aşırı yükte genel istikrarı korur; bazı istekleri geciktirir/reddeder.
- Yük atma (load shedding): Sistem doygunken daha az kritik istekleri bilerek reddet. Son çare — en kritik işlevleri ayakta tutmak için.
5) Kalıcı tasarım: pool boyutu bir “kapasite kontratı”dır
5.1 Pool’u büyütmek çoğu zaman çözüm değildir
Pool’u büyütmek, DB’ye daha fazla concurrency iter. Eğer DB’nin CPU/IO kapasitesi sabitse:
- Latency artar
- Lock contention artar
- Tail latency patlar
Sonuçta “daha çok connection” yerine “daha az concurrency ama daha stabil throughput” daha iyi olur.
5.2 Transaction sınırını netleştir
En sık kök neden:
- Uygulama, transaction’ı gereksiz uzun tutar
- IO/HTTP çağrısı transaction içinde kalır
- “idle in transaction” birikir
Operasyonel kontrol:
- Transaction içinde network çağrısı yasak
- ORM lazy-load sürprizleri gözlenebilir olmalı
5.3 Bağlantı sızıntısını kökten kes
Doygunluğun en sinsi sebebi yük değil, sızıntıdır: alınan bir bağlantı havuza iade edilmezse, “kullanımda” olarak kilitli kalır ve havuz yavaşça tükenir. Çözüm dile gömülü kaynak yönetimidir — bağlantıyı kapatmayı “unutma” ihtimalini ortadan kaldır:
- Java:
try-with-resources— blok bitince bağlantı/statement/result set otomatik kapanıp havuza döner. - Python:
withstatement (yukarıdaki SQLAlchemy örneğindeki gibi). - Node.js:
finallyiçindeclient.release()— istisnasız. - Tespit: HikariCP’de
leakDetectionThreshold(ör. 30000 ms) iade edilmeyen bağlantıyı stack trace ile loglar.
// Java'da try-with-resources ile güvenli bağlantı kullanımı
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement("SELECT * FROM users WHERE id = ?")) {
stmt.setLong(1, userId);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
// sonuçları işle
System.out.println(rs.getString("name"));
}
}
} catch (SQLException e) {
// hata yönetimi: logla, gerekirse yukarı fırlat
log.error("Sorgu başarısız", e);
}
// conn, stmt ve rs otomatik kapanır ve bağlantı havuza döner
5.4 PgBouncer mod seçimi
PgBouncer kullanıyorsan:
transactionmode: en verimli, çoğu web iş yükü için iyisessionmode: bazı özellikler için gerekli ama kapasiteyi daha hızlı tüketir
Yanlış seçim, “pool var ama hiçbir şey akmıyor” incident’ına dönüşebilir.
6) Runbook kapanış: doğrulama ve iz
Stabilite doğrulaması:
- Pool wait queue düşüyor mu?
- Timeout sayısı düşüyor mu?
- DB wait event’leri normalleşiyor mu?
Postmortem için delil:
- Peak concurrency (app + pgbouncer + db)
- En pahalı sorgu sınıfları (p95/p99)
- Retry davranışı (hangi katman retry etti?)
Connection pool, uygulamanın DB’ye verdiği sözleşmedir. Bu sözleşme görünür değilse incident’lar “DB yavaş” diye başlar, ama asıl sorun kuyruk yönetimidir. Sağlam sistemler, kuyruğu saklamaz; ölçer, sınırlar ve runbook’a bağlar.