Mert Özen Her Satırda Daha İleri
Veritabanı Veritabanı Performansı ve Scaling — #02

Connection Pooling Derinlemesine: ADO.NET Pool'u, Pool Exhaustion, Npgsql ve PgBouncer

Mert Özen 14 Kas 2026 13 dk 9 görüntülenme
Connection Pooling Derinlemesine: ADO.NET Pool'u, Pool Exhaustion, Npgsql ve PgBouncer

"Max pool size was reached" hatası neredeyse hiçbir zaman pool'un küçüklüğünden değil, geri dönmeyen connection'lardan gelir; çözüm sızıntıyı bulmak ve instance × pool × Max Pool Size bütçesini hesaplamaktır. SqlClient pool key'i ve counter'ları, Application Name ile 50 pool'a bölünen fragmentation, PostgreSQL'de 53300 hatası, PgBouncer'ın 250 istemciyi 20 connection'la 5,5 kat throughput'la taşıması, transaction mode'da sızan SET ve max_prepared_statements.

Sürüm notu: Bu yazıdaki örnekler .NET 10, Microsoft.Data.SqlClient 7.1.2, Npgsql 10.0.3, SQL Server 2025 (CU10), PostgreSQL 18.7 ve PgBouncer 1.26.1 ile tek makinelik bir lab'da derlenip çalıştırılmıştır. Süreler bu lab'a aittir; oranlar ve hata mesajları önemlidir.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için yazılmış bir endpoint, bekleyen sipariş sayısını dönüyor. Kodu yazan kişi connection'ı açmış, sorguyu çalıştırmış ama using yazmayı unutmuş. Pool'u bilerek küçük tuttuk: en fazla 5 connection, bağlantı beklerken 3 saniye zaman aşımı.

SqlConnectionStringBuilder builder = new(Db.Env("BOOKSTORE_SQL"))
{
    MaxPoolSize = 5,
    ConnectTimeout = 3,
    ApplicationName = "Bookstore.Api"
};
string connectionString = builder.ConnectionString;

for (int request = 1; request <= 7; request++)
{
    // using yok: connection hiçbir zaman pool'a dönmüyor
    SqlConnection connection = new(connectionString);
    await connection.OpenAsync();
    await new SqlCommand("SELECT COUNT(*) FROM dbo.Orders WHERE Status = 'Pending'", connection).ExecuteScalarAsync();
}

Yedi istek art arda geliyor. Sunucu tarafında bir sorun yok, sorgu hızlı. Hangi istek, ne kadar sürede, hangi hatayla düşer? Ve hata anında SQL Server'da kaç session vardır?

request 1: ok in 174 ms
request 2: ok in 88 ms
request 3: ok in 86 ms
request 4: ok in 87 ms
request 5: ok in 87 ms
request 6: InvalidOperationException after 3004 ms
  Timeout expired.  The timeout period elapsed prior to obtaining a connection from the pool.  This may have occurred because all pooled connections were in use and max pool size was reached.
request 7: InvalidOperationException after 3000 ms
  Timeout expired.  The timeout period elapsed prior to obtaining a connection from the pool.  This may have occurred because all pooled connections were in use and max pool size was reached.
server: program=Bookstore.Api status=sleeping open_tran=0 sessions=5

Altıncı istek tam Connect Timeout kadar bekleyip InvalidOperationException fırlattı. Sunucuda 5 session var ve hepsi sleeping: veritabanı boşta, uygulama connection bekliyor. Bu, merdivenin ikinci basamağının en sık görülen arızası. Aynı kod await using ile çalıştığında yedi isteğin hepsi başarılı: ilki 167 ms, sonrakiler 10–12 ms sürüyor, çünkü ilk istekten sonra her istek pool'daki aynı fiziksel connection'ı kullanıyor. Aradaki ~75 ms fark, bu lab'da yeni bir fiziksel connection açmanın (TCP bağlantısı, TLS (şifreli bağlantı) kurulumu ve login) bedeli.

ADO.NET Pool'u Nasıl Çalışır?

SqlConnection.Open aslında bir pool'dan kullanılabilir bir connection ister; Close, Dispose ya da DisposeAsync onu pool'a geri verir. Pool bir connection'ı başka bir kullanıcıya vermeden önce session state'ini sıfırlar. Pooling varsayılan olarak açık; uygulamanın işi "geç aç, erken kapat" kuralına uymak.

Pool'u belirleyen şey yalnızca sunucu adresi değil. Microsoft.Data.SqlClient dokümanına göre pool key şunlardan oluşur: connection string'in metni birebir (anahtar sırası farklı iki string ayrı pool demek), Windows authentication'da kimlik, SqlCredential nesnesinin kendisi, AccessToken değeri ya da AccessTokenCallback delegate'inin instance'ı. Her istekte yeni bir callback lambda'sı ya da yeni bir SqlCredential oluşturan kod, her istekte yeni bir pool açar.

AyarVarsayılanNe yapar
Max Pool Size100Pool'daki en fazla fiziksel connection
Min Pool Size0Boşta da açık tutulan connection sayısı
Connect Timeout15 snPool doluysa Open'ın bekleme süresi
Load Balance Timeout0Pool'a dönen connection'ın en fazla yaşı
Connection Idle Timeout300 sn7.1 ile geldi; boştaki connection'ın atılma süresi (opt-in)

Varsayılan davranışta pool, Min Pool Size'ın üstündeki kullanılmayan connection'ları yaklaşık 4–8 dakikada kapatır; doküman bunu bir garanti değil, implementation davranışı olarak tarif ediyor. 7.1 iki opt-in yenilik getirdi: Connection Idle Timeout'un uygulanması için Switch.Microsoft.Data.SqlClient.UseLegacyIdleTimeoutBehavior switch'inin false yapılması gerekiyor; channel tabanlı yeni pool (V2) ise Switch.Microsoft.Data.SqlClient.UseConnectionPoolV2 ile açılıyor. İkisi de varsayılan olarak kapalı; açmadan önce kendi yük testinle dene.

Pool bir de kendiliğinden temizlenir: sunucuyla bağlantının koptuğunu gösteren fatal bir hata (failover gibi) gördüğünde ilgili pool'u temizler, boştaki connection'ları kapatır, kullanımdakileri de geri döndüklerinde atar. Failover'da bunun ne anlama geldiğini 05'te ölçeceğiz.

Pool Exhaustion'ı Teşhis Etmek

"Max pool size was reached" mesajı bir sonuçtur; sebebi neredeyse hiçbir zaman "pool küçük" değildir. Connection'lar pool'a geri dönmüyor ya da geç dönüyor demektir. Olası sebepler:

  • Dispose edilmeyen connection, command ya da reader (yukarıdaki örnek). SqlDataReader açık kaldıkça connection da meşgul kalır.
  • Uzun transaction'lar: özellikle ambient TransactionScope içinde açılan connection, transaction bitene kadar genel pool'a dönmez.
  • Yavaş sorgular ya da blocking: her istek connection'ı daha uzun tutar; trafik aynı kalsa bile ihtiyaç duyulan connection sayısı artar.
  • Gerçekten fazla eşzamanlılık: bir instance'a aynı anda 100'den fazla istek düşüyorsa pool değil, instance sayısı ya da sorgu süresi konuşulmalı.

Ayrımı yapmak için iki taraftan bak. Sunucuda sys.dm_exec_sessions uygulamanın session'larını gösterir. Hepsi sleeping ve open_transaction_count 0 ise sızıntı vardır (connection'lar uygulamada tutuluyor ama iş yapmıyor). open_transaction_count 0'dan büyük olan uyuyan session'lar commit edilmemiş transaction demektir, en tehlikelisi budur çünkü lock tutar. running ya da suspended session'lar yavaş sorgu ya da blocking'i gösterir; teşhisin devamı SQL Server Derinlemesine serisinin "Teşhis Metodolojisi" yazısında. Uygulama tarafında ise SqlClient'ın counter'ları var.

Bir not: EF Core'daki (Entity Framework Core; .NET'in veritabanı tablolarını nesnelere eşleyen kütüphanesi) AddDbContextPool, connection pooling değildir. DbContext instance'larını yeniden kullanarak nesne oluşturma maliyetini azaltır; altındaki connection'lar yine SqlClient ya da Npgsql pool'undan gelir. İkisinin farkı EF Core serisinin "ASP.NET Core'da EF Core: AddDbContext, Scoped Lifetime, Factory ve Pooling" yazısında.

Pool Fragmentation ve SqlClient Counter'ları

Bookstore'un kurumsal müşterileri için bir ekip, hangi kurumun sorgusunun yavaş olduğunu SQL Server'da görmek istemiş ve Application Name'e kurum numarasını eklemiş. Masum görünen bu değişikliğin etkisini SqlClient'ın event counter'larıyla ölçüyoruz. Counter'lar Microsoft.Data.SqlClient.EventSource üzerinden yayınlanıyor; uygulama içinde bir EventListener ile okunabilir:

public sealed class SqlClientCounterListener : EventListener
{
    public ConcurrentDictionary<string, double> Latest { get; } = new();

    protected override void OnEventSourceCreated(EventSource eventSource)
    {
        if (eventSource.Name == "Microsoft.Data.SqlClient.EventSource")
        {
            // EventCounterIntervalSec verilmezse counter'lar yayınlanmaz
            EnableEvents(eventSource, EventLevel.Informational, EventKeywords.None,
                new Dictionary<string, string?> { ["EventCounterIntervalSec"] = "1" });
        }
    }

    protected override void OnEventWritten(EventWrittenEventArgs eventData)
    {
        if (eventData.EventName != "EventCounters" || eventData.Payload is null)
        {
            return;
        }
        foreach (object? item in eventData.Payload)
        {
            if (item is IDictionary<string, object> counter && counter.TryGetValue("Name", out object? name))
            {
                object value = counter.TryGetValue("Mean", out object? mean) ? mean : counter["Increment"];
                Latest[(string)name] = Convert.ToDouble(value);
            }
        }
    }
}

50 kurum için paralel olarak üçer tur connection açıp kapatıyoruz; önce kurum başına ayrı Application Name ile, sonra tek bir sabit değerle:

SqlConnectionStringBuilder builder = new(Db.Env("BOOKSTORE_SQL"))
{
    ApplicationName = canonical ? "Bookstore.Api" : $"Bookstore.Api-tenant-{tenant}"
};
number-of-active-connection-pool-groups  50
number-of-active-connection-pools        50
number-of-pooled-connections             50
number-of-free-connections               50

number-of-active-connection-pool-groups  1
number-of-active-connection-pools        1
number-of-pooled-connections             7
number-of-free-connections               7

Aynı iş için 50 fiziksel connection yerine 7. Fark kurum sayısıyla büyür: 2.000 kurumlu bir sistemde her instance 2.000 ayrı pool ve en az 2.000 fiziksel connection tutabilir; sunucunun session başına harcadığı bellek ve login maliyeti de onunla birlikte. Kurumun kimliğini sorguya taşımanın doğru yolu connection string değil, sp_set_session_context ya da OpenTelemetry trace'leri. Kurum başına ayrı veritabanı kullanılıyorsa (database-per-tenant) pool sayısı kaçınılmaz olarak veritabanı sayısı kadardır; bunu kapasite planına yazmak gerekir. Bu modelin diğer tarafları Çözüm Desenleri serisinin "Multi-Tenancy'de Veri İzolasyonu" yazısında.

Üretimde aynı counter'lara uygulamayı değiştirmeden dotnet-counters monitor --counters Microsoft.Data.SqlClient.EventSource -p <pid> ile bakabilirsin. İzlenecekler: hard-connects (saniyede açılan fiziksel connection; sürekli yüksekse pool işini yapmıyor), number-of-active-connection-pools (fragmentation), number-of-stasis-connections ve number-of-reclaimed-connections (dispose edilmeden garbage collector'ün (GC) topladığı connection; sızıntının kesin işareti).

Toplam Connection Bütçesi

Pool process'e özeldir; instance'lar arasında paylaşılmaz. Veritabanının göreceği en fazla connection sayısı basit bir çarpımdır: instance sayısı × instance başına pool sayısı × pool başına Max Pool Size. Bookstore.Api Kubernetes'te 3 pod ile çalışırken bu 300 eder. Kampanya günü HPA (Horizontal Pod Autoscaler; yüke göre pod sayısını artıran Kubernetes bileşeni) pod sayısını 12'ye çıkarırsa 1.200 olur. Arka plandaki job'lar, raporlama servisi ve migration aracı da aynı sunucuya bağlanıyorsa onları da ekle. HPA'nın çalışması Kubernetes serisinin "Kubernetes'te Scaling" yazısında; burada bilmemiz gereken, pod sayısının aynı zamanda bir connection çarpanı olduğu.

SQL Server'ın sınırı yüksek (SELECT @@MAX_CONNECTIONS bu lab'da 32767 döndü), ama her session bellek ve worker thread kullanır; asıl sınır genelde bu kaynaklardır. PostgreSQL'de ise max_connections düşük tutulur, çünkü her connection ayrı bir backend process'tir. Lab'daki PostgreSQL max_connections = 200 ile çalışıyor. Tek bir process'ten 250 eşzamanlı istemciyle, Npgsql pool'unu 300'e açarak yükleniyoruz:

db=pg concurrency=250 requests=91981 errors=327
throughput=6062.1 req/s p50=7.6 ms p95=227.1 ms p99=349.7 ms
first error: PostgresException: 53300: sorry, too many clients already

327 istek connection açamadan düştü, p95 227 ms'ye çıktı. Bu, 12 pod'luk kampanya gününün küçük bir provası. Aynı yükü Npgsql pool'unu 20 ile sınırlayarak çalıştırınca tablo değişiyor:

db=pg concurrency=250 requests=413559 errors=0
throughput=27552.9 req/s p50=8.6 ms p95=11.5 ms p99=15.6 ms

Daha az connection, hatasız ve 4,5 kat fazla throughput. Sunucuda 250 process'in aynı CPU'lar ve aynı shared buffers için yarışması yerine 20 process sırayla iş yapıyor; bekleme, ucuz olan istemci tarafına kayıyor. Ama bu sınır tek process için çalışır. 12 pod'un her biri 20 connection açarsa yine 240 eder.

PgBouncer: Connection'ları Sunucunun Önünde Toplamak

02-connection-budget-tr.png

PgBouncer, PostgreSQL'in önünde duran hafif bir connection pooler. İstemciler PgBouncer'a bağlanır, PgBouncer da sunucuya sınırlı sayıda connection açar ve bunları istemciler arasında paylaştırır. Paylaşımın ne zaman yapılacağını pool_mode belirler:

pool_modeServer connection ne zaman geri dönerKullanım
session (varsayılan)İstemci bağlantıyı kapatıncaSession özelliklerinin hepsi çalışır; kazanç sınırlı
transactionTransaction bitinceEn yaygın seçim; session state kullanılamaz
statementHer sorgudan sonraÇok statement'lı transaction yasak

Lab'daki yapılandırma:

[databases]
bookstore = host=pg1 port=5432 dbname=bookstore

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
admin_users = postgres
pool_mode = transaction
default_pool_size = 20
max_client_conn = 2000
max_prepared_statements = 200

Aynı 250 istemciyi, Npgsql pool'u yine 300 iken, bu kez PgBouncer'ın 6432 portuna yönlendiriyoruz. Yük sürerken PgBouncer'ın admin konsolunda SHOW POOLS (sütunların bir kısmı):

 database  |   user    | cl_active | cl_waiting | sv_active | sv_idle |  pool_mode
-----------+-----------+-----------+------------+-----------+---------+-------------
 bookstore | postgres  |        66 |        184 |        20 |       0 | transaction
db=pgb concurrency=250 requests=502027 errors=0
throughput=33450.3 req/s p50=7.2 ms p95=12.0 ms p99=13.8 ms

250 istemci connection'ı (cl_active + cl_waiting), sunucuda 20 connection (sv_active). Hata yok ve throughput doğrudan bağlantının 5,5 katı. Burada sınırı tek bir uygulamanın pool ayarı değil, sunucunun önündeki tek nokta koyuyor; pod sayısı 3'ten 12'ye çıksa da PostgreSQL 20 connection görmeye devam eder. Bedeli: bir network hop'u daha, işletilmesi ve izlenmesi gereken bir bileşen daha ve PgBouncer'ın kendisi bir single point of failure (tek başına düştüğünde bütün sistemi durduran bileşen) olmasın diye en az iki instance. Managed servislerde (Azure Database for PostgreSQL'in yerleşik PgBouncer'ı gibi) işletim kısmı sağlayıcıda; bunu dokümana dayanarak söylüyoruz, lab'da denemedik.

Transaction Mode'un Tuzakları: Session State

Transaction pooling'de art arda iki transaction'ın aynı server connection'da çalışacağının garantisi yoktur; tersine, farklı istemcilerin transaction'ları aynı server connection'ı paylaşır. Session'a bağlı her şey bu yüzden ya kaybolur ya da daha kötüsü, başka bir istemciye sızar. Sızıntıyı görmek için pool'u bir connection'a indiren ayrı bir database tanımı ekliyoruz (bookstore_one = host=pg1 port=5432 dbname=bookstore pool_size=1). Birinci istemci kendi işi için bir timeout ayarlayıp çıkıyor:

SET statement_timeout = '50ms';
SELECT pg_backend_pid();
 pg_backend_pid
----------------
          34701

Arkasından bambaşka bir istemci bağlanıp rapor sorgusunu çalıştırıyor:

SELECT pg_backend_pid();
SHOW statement_timeout;
SELECT count(*) FROM order_items;
 pg_backend_pid
----------------
          34701

 statement_timeout
-------------------
 50ms

ERROR:  canceling statement due to statement timeout

Aynı backend process, aynı ayar, başkasının sorgusu iptal. Transaction mode'da server_reset_query (DISCARD ALL) çalışmaz; doküman bu modda istemcilerin session özelliklerine güvenmemesi gerektiğini açıkça yazıyor. Bu modda çalışmayanlar: SET/RESET, LISTEN, WITH HOLD cursor'lar, SQL seviyesinde PREPARE, session seviyesi advisory lock'lar, ON COMMIT DROP olmayan temp table'lar ve LOAD. Bir ayar gerekiyorsa transaction içinde SET LOCAL kullan ya da rolün/veritabanının varsayılanı yap. 1.26 ile PgBouncer bazı parametreleri istemci başına takip ediyor (application_name, TimeZone, PostgreSQL 18 ile search_path…); ama statement_timeout bu listede yok, gördüğümüz gibi sızıyor. Ek parametreler track_extra_parameters ile listeye eklenebilir. Advisory lock ile yapılan distributed lock'lar (Background Job'lar serisinin "Çok Instance'lı Ortamda Job'lar" yazısı) ve Npgsql'in LISTEN/NOTIFY'ı PgBouncer'ı atlayıp doğrudan sunucuya bağlanmalı.

Prepared Statement'lar, Npgsql ve PgBouncer

Protokol seviyesindeki prepared statement'lar uzun süre transaction pooling'le çalışmıyordu: istemci statement'ı bir server connection'da hazırlıyor, bir sonraki transaction'da başka bir connection'da çalıştırmaya çalışıyordu. PgBouncer 1.21 bunu çözdü: max_prepared_statements sıfırdan büyükse PgBouncer istemcinin statement'larını takip edip gerektiğinde ilgili server connection'da yeniden hazırlıyor. 1.24'ten beri varsayılan değer 200. Npgsql'in auto-prepare özelliğini açıp (Max Auto Prepare=20;Auto Prepare Min Usages=2) 100 istemciyle deniyoruz:

db=pgb concurrency=100 requests=321431 errors=0
throughput=32130.3 req/s p50=2.9 ms p95=5.2 ms p99=6.3 ms

Aynı testi max_prepared_statements = 0 ile, yani eski davranışla tekrarlayınca:

db=pgb concurrency=100 requests=151663 errors=98926
throughput=15162.6 req/s p50=3.6 ms p95=6.3 ms p99=8.1 ms
first error: PostgresException: 26000: prepared statement "_auto0" does not exist

İsteklerin üçte ikisi düştü. Eski bir PgBouncer sürümü ya da bu ayarı sıfıra çeken eski bir konfigürasyonla çalışıyorsan auto-prepare'i kapalı tut. Npgsql tarafında bir ayar daha var: Npgsql, pool'a dönen connection'ı bir sonraki kullanımda DISCARD ALL ile sıfırlar (01'deki pg_stat_statements çıktısında 10.435 kez gördük). Npgsql dokümanı, kendi pool'u açıkken PgBouncer transaction ya da statement mode'da kullanılıyorsa No Reset On Close=true verilmesi gerektiğini söylüyor. Böylece her connection kullanımında fazladan bir komut gitmez. Lab'daki PgBouncer bağlantı string'i:

Host=pgbouncer;Port=6432;Database=bookstore;Username=bookstore_api;Password=<secret>;No Reset On Close=true;Max Auto Prepare=20;Auto Prepare Min Usages=2

Üretimde Dikkat

Bütçeyi yaz. Her veritabanı için "kim, kaç instance, pool başına kaç connection" tablosunu tut ve HPA'nın en fazla pod sayısıyla hesapla. Toplam, SQL Server'da kaynakların, PostgreSQL'de max_connections eksi superuser_reserved_connections (lab'da 3) değerinin altında kalmalı; bakım için de pay bırak.

Pool metric'lerini alarm yap. SqlClient'ta hard-connects ve number-of-reclaimed-connections, Npgsql'de pool kullanım metric'leri, PgBouncer'da SHOW POOLS'taki cl_waiting ve maxwait. cl_waiting'in sürekli sıfırdan büyük olması, sunucu tarafındaki pool'un dar ya da sorguların yavaş olduğunu gösterir.

Timeout'ları katmanlı düşün. Connect Timeout pool'dan connection bekleme süresini de kapsar; HTTP isteğinin kendi timeout'undan kısa olmalı ki kullanıcıya anlamlı bir hata dönebilesin. PgBouncer'da query_wait_timeout istemcinin server connection beklediği süreyi sınırlar.

Failover ve deploy anlarını hesaba kat. Yeni sürüm dağıtılırken eski ve yeni pod'lar bir süre birlikte çalışır; connection sayısı o anda tepe yapar. Failover sonrasında ise bütün pool'lar aynı anda yeniden bağlanmaya çalışır (05).

Sık Düşülen Tuzaklar

Max Pool Size'ı ya da Connect Timeout'u büyütüp sızıntıyı gizlemek: hata seyrekleşir ama kaybolmaz; sızıntı connection'ları yine tüketir, üstelik sunucuda daha çok uyuyan session birikir.

Her tenant ya da her istek için farklı bir connection string üretmek: Application Name'e istek kimliği koymak, her seferinde yeni SqlCredential ya da token callback'i oluşturmak pool'u parçalar. Connection string'i SqlConnectionStringBuilder ile bir kez üret ve tekrar kullan.

Transaction pooling'de session state'e güvenmek: SET, advisory lock, LISTEN ve temp table'lar başka istemcilere sızar ya da kaybolur. Bu özelliklere ihtiyaç duyan iş yükünü ayrı bir session-mode pool'una ya da doğrudan sunucuya yönlendir.

DbContext pooling'i connection pooling sanmak: AddDbContextPool açmak connection sayısını değiştirmez; Max Pool Size ve bütçe hesabı yine gerekir.

Min Pool Size'ı yüksek tutmak: her instance başlarken bu kadar connection açar ve boşta da tutar. Scale-out anında bütün yeni pod'ların aynı anda bunu yapması, sunucuda bir login fırtınası yaratır.

Küçük Bir Deneme

★☆☆ Sızıntı örneğini BEGIN TRANSACTION içeren bir sorguyla değiştir ve commit etmeden bırak. sys.dm_exec_sessions'ta open_transaction_count değerinin nasıl değiştiğini ve başka bir session'dan aynı satırları güncellemeye çalışınca ne olduğunu gözle.

★★☆ Fragmentation örneğini dotnet-counters monitor ile dışarıdan izle ve hard-connects ile soft-connects oranını iki senaryoda karşılaştır.

★★★ PgBouncer'ın önüne ikinci bir uygulama process'i ekle ve default_pool_size'ı 10, 20 ve 40 yaparak 250 istemcilik yükü tekrarla. Throughput'un en yüksek olduğu değeri bul ve bunu PostgreSQL'in wait event dağılımıyla açıkla.

Sonraki yazıda tek dev bir DELETE'in transaction log, lock escalation ve replica üzerindeki etkisini ölçüp, Bookstore'un dört yıllık siparişlerini canlı sistemi durdurmadan batch'ler hâlinde arşive taşıyacağız; online index ve online şema değişikliği de aynı yazıda.