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

High Availability ve Failover: RPO/RTO, Listener, Patroni ve Uygulamanın Failover'a Dayanması

Mert Özen 14 Kas 2026 12 dk 13 görüntülenme
High Availability ve Failover: RPO/RTO, Listener, Patroni ve Uygulamanın Failover'a Dayanması

Uygulama failover'a ancak idempotent yazma ve failover'ı bekleyen bir retry ile dayanır; async replica ise kayıpsız değildir. Lab'da async replica ile 50 sipariş kayboldu; varsayılan EF Core retry'ı "No suitable host was found" hatasını tekrar denemediği için 98 sipariş düştü, genişletilmiş strategy ve Host Recheck Seconds=1 ile sıfır hata ve sıfır çift kayıt. RPO/RTO, HA ve DR, AG listener, Patroni ve SqlClient ConnectRetryCount.

Sürüm notu: Bu yazıdaki örnekler PostgreSQL 18.7 (iki container: primary ve streaming replica), SQL Server 2025 (CU10, 17.0.5035.5), .NET 10, Npgsql 10.0.3, Npgsql.EntityFrameworkCore.PostgreSQL 10.0.3, Microsoft.EntityFrameworkCore.SqlServer 10.0.15 ve Microsoft.Data.SqlClient 7.1.2 ile tek makinelik bir lab'da çalıştırılmıştır. Failover'ı elle (pg_promote()) tetikledik; Patroni, SQL Server availability group listener'ı ve managed servislerin yüksek erişilebilirlik seçenekleri lab'da kurulmadı, dokümana dayanarak anlatılıyor.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) PostgreSQL'i bir primary ve bir async streaming replica ile çalıştırıyor. Replica'nın ağ bağlantısı bir an kopuyor; bu sırada müşteriler 50 sipariş veriyor ve hepsi primary'de commit ediliyor. Hemen ardından primary'nin sunucusu çöküyor ve nöbetçi mühendis replica'yı primary yapıyor. Primary'de son durum:

SELECT max(order_id) AS last_order_on_primary FROM orders;
SELECT application_name, state FROM pg_stat_replication;
 last_order_on_primary
-----------------------
               1000600

 application_name |   state
------------------+-----------
 pg2              | streaming

Primary, replica'nın bağlantısının koptuğunu henüz fark etmemiş; pg_stat_replication hâlâ streaming gösteriyor. Tahmin et: replica'yı primary yaptıktan sonra bu 50 siparişten kaçı orada?

SELECT pg_promote();
SELECT max(order_id) AS last_order_on_new_primary FROM orders;
 last_order_on_new_primary
---------------------------
                   1000550

Hiçbiri. Müşterilere "siparişiniz alındı" denmiş 50 sipariş kayboldu. Async replica'nın kayıp riskini 04'te teoride görmüştük; bu yazıda onu ölçülebilir hedeflere çeviriyor, failover'ın parçalarını ayırıyor ve en önemlisi uygulamanın failover'a nasıl dayanacağını iki gerçek failover üzerinde ölçüyoruz.

RPO, RTO, HA ve DR

İki sayı her konuşmanın başında olmalı. RPO (Recovery Point Objective; bir arızada kaybedilmesi kabul edilen en fazla veri, zaman olarak) ve RTO (Recovery Time Objective; arızadan sonra servisin en geç ne kadar sürede yeniden yazabilir hâle gelmesi gerektiği). Yukarıdaki senaryoda RPO sıfır değildi: replica'ya ulaşmamış her commit gitti. RTO ise nöbetçinin uyanıp komutu çalıştırması kadardı. Bu hedeflere iki farklı araç ailesi hizmet eder: HA (High Availability; tek bir arızada servisin kısa bir kesintiyle devam etmesi) ve DR (Disaster Recovery; bölge kaybı ya da veri bozulması gibi büyük bir felaketten sonra veriyi ve servisi geri getirmek).

HADR
Neye karşıTek sunucu, disk ya da zone arızasıBölge kaybı, veri bozulması, yanlışlıkla silme
AraçReplica + otomatik failoverBackup, log arşivi, başka bölgede replica
Tipik RPOSıfır (sync) ya da saniyeler (async)Dakikalar–saatler
Tipik RTOSaniyeler–dakikalarDakikalar–saatler

İkisi birbirinin yerini tutmaz. Backup HA değildir: bir backup'tan dönmek saatler sürebilir. Replica da backup değildir: primary'de yanlışlıkla çalışan bir DELETE replica'ya da milisaniyeler içinde uygulanır; o anın öncesine dönmek için point-in-time recovery gerekir. Bookstore için ekip şu hedeflerde anlaştı: ödeme ve sipariş verisi için HA'da RPO sıfır, RTO bir dakikanın altı; DR'da RPO beş dakika, RTO bir saat.

Otomatik Failover'ın Parçaları

Elle failover'ı otomatik yapan şey dört parçadır. Sağlık kontrolü: primary'nin gerçekten çalışmadığını (yalnız yavaş olmadığını) anlamak. Karar: hangi replica'nın yeni primary olacağına bir quorum ile karar vermek; iki node kendi başına karar verirse ikisi de primary olabilir (split brain). Fencing: eski primary geri gelirse yazı kabul etmemesini garanti etmek. Yönlendirme: istemcilerin yeni primary'yi bulması (listener, DNS, proxy ya da istemcinin birden çok host denemesi). İlk üçünün teorisi Distributed Systems Temelleri serisinin "Consensus ve Raft" ve "Distributed Lock, Lease ve Fencing Token" yazılarında; burada motorların bunları nasıl çözdüğüne bakıyoruz.

SQL Server'da Always On availability group (AG) bu parçaları Windows Server Failover Cluster ya da Linux'ta Pacemaker ile kurar. Otomatik failover için replica'nın synchronous commit ve automatic failover modunda olması gerekir; asynchronous replica'ya yalnız elle, veri kaybını kabul ederek (forced failover) geçilir. Yönlendirmeyi AG listener yapar: istemci bir DNS adına bağlanır, listener o an primary olan replica'yı gösterir. Microsoft, listener'a bağlanan istemcilerin connection string'ine MultiSubnetFailover=True koymasını öneriyor: SqlClient listener'ın bütün IP adreslerini paralel dener ve failover sonrası yeni adrese daha hızlı bağlanır. Azure SQL Database ve Azure SQL Managed Instance'ın business critical katmanları, Amazon RDS'in (Relational Database Service; Amazon'un managed veritabanı servisi) Multi-AZ seçeneği bu mekanizmayı servis olarak sunar. Bu paragrafı lab'da denemedik; dokümanlara dayanıyor.

PostgreSQL'in kendisinde otomatik failover yok; pg_promote() yalnızca bir replica'yı primary yapar. Kararı ve fencing'i dış bir araç verir. En yaygını Patroni (yazım gününde 4.1.5): her node'un yanında çalışır, etcd, Consul, ZooKeeper ya da Kubernetes gibi bir dağıtık konfigürasyon deposunda süreli bir leader kaydı tutar, kaydı yenileyemeyen primary'yi indirir ve yeni bir leader seçer. HTTP üzerinden çağrılan yönetim API'sindeki /primary endpoint'i yalnız leader'da 200 döner; HAProxy gibi bir load balancer bu endpoint'le yazma trafiğini doğru node'a yönlendirir. Azure Database for PostgreSQL'in zone-redundant HA'sı ve RDS Multi-AZ aynı işi servis olarak yapar. Bunları da lab'da kurmadık. Lab'da failover'ı Patroni'nin yapacağı işi elle taklit ederek tetikledik: primary container'ını docker kill ile öldürüp birkaç saniye sonra replica'da pg_promote() çalıştırdık.

Uygulama Failover'a Nasıl Dayanır? Birinci Deneme

Failover'ın kullanıcı tarafından görünen yüzü uygulamadır. Npgsql birden çok host'u tanıyan bir data source kurabiliyor; TargetSessionAttributes.Primary ile yalnız primary olan sunucudan connection verir:

Host=pg1:5432,pg2:5432;Database=bookstore;Username=bookstore_api;Password=<secret>;Timeout=3
NpgsqlDataSource dataSource = new NpgsqlDataSourceBuilder(Db.Env("BOOKSTORE_PG_HA")).BuildMultiHost()
    .WithTargetSession(TargetSessionAttributes.Primary);

DbContextOptions<CheckoutContext> contextOptions = new DbContextOptionsBuilder<CheckoutContext>()
    .UseNpgsql(dataSource, npgsql =>
        npgsql.EnableRetryOnFailure(maxRetryCount: 10, maxRetryDelay: TimeSpan.FromSeconds(2), errorCodesToAdd: null))
    .Options;

Checkout her 100 ms'de bir sipariş veriyor. Sipariş ve onunla birlikte yazılması gereken her şey bir transaction'da; EF Core'un execution strategy'si bu transaction'ı bir bütün olarak tekrar çalıştırabilsin diye CreateExecutionStrategy içinde:

Guid key = Guid.CreateVersion7();
await using CheckoutContext db = new(contextOptions);
IExecutionStrategy strategy = db.Database.CreateExecutionStrategy();
string server = await strategy.ExecuteAsync(async ct =>
{
    // bütün iş aynı idempotency key ile birlikte tekrar edilir
    await using IDbContextTransaction tx = await db.Database.BeginTransactionAsync(ct);
    await db.PlaceOrderAsync(key, 4242, 99.90m, ct);
    string host = await db.Database.SqlQueryRaw<string>("SELECT inet_server_addr()::text AS \"Value\"").SingleAsync(ct);
    await tx.CommitAsync(ct);
    return host;
}, CancellationToken.None);

8. saniyede primary'yi öldürdük, 14. saniyede replica'yı promote ettik. Uygulamanın gördüğü (tekrarlayan satırlar kısaltıldı; 172.22.0.2 pg1, 172.22.0.3 pg2):

[  0.6s] order 1 written on 172.22.0.2/32
[  8.1s] ef: info: 11/14/2026 21:50:41.335 CoreEventId.ExecutionStrategyRetrying[10404] (Microsoft.EntityFrameworkCore.Infrastructure)
[  8.2s] order failed after retries: NpgsqlException: No suitable host was found.
[  8.3s] order failed after retries: NpgsqlException: No suitable host was found.
[ 18.1s] order failed after retries: NpgsqlException: No suitable host was found.
[ 18.2s] order 72 written on 172.22.0.3/32
attempts=377 placed=279 failed=98

İki şey dikkat çekiyor. Birincisi, retry yalnızca bir kez devreye girdi. Kopan connection'ın hatası transient sayıldı, ama ardından gelen "No suitable host was found" (iki host'tan biri kapalı, diğeri henüz primary değil) Npgsql'in transient listesinde yok; EF Core onu retry etmeden kullanıcıya iletti. 98 sipariş hata döndü. İkincisi, replica 14. saniyede primary olduğu hâlde yazmalar 18,2. saniyede başladı. Npgsql host'ların rolünü Host Recheck Seconds (varsayılan 10) boyunca cache'liyor: pg2'yi bir kez "standby" olarak gördükten sonra 10 saniye boyunca tekrar sormadı. Kullanıcının gördüğü kesinti yaklaşık 10 saniye ve 98 hata.

İkinci Deneme: Failover'ı Bekleyen Bir Retry

İki ayar değiştirdik. Connection string'e Host Recheck Seconds=1 ekledik ve execution strategy'yi, sunucu hatası olmayan connection seviyesindeki Npgsql hatalarını da tekrar deneyecek şekilde genişlettik:

public sealed class FailoverAwareRetryStrategy(ExecutionStrategyDependencies dependencies)
    : NpgsqlRetryingExecutionStrategy(dependencies, maxRetryCount: 10, maxRetryDelay: TimeSpan.FromSeconds(2), errorCodesToAdd: null)
{
    protected override bool ShouldRetryOn(Exception exception) =>
        base.ShouldRetryOn(exception) || exception is NpgsqlException and not PostgresException;
}
npgsql.ExecutionStrategy(dependencies => new FailoverAwareRetryStrategy(dependencies));

PostgresException sunucunun döndürdüğü hatalardır (constraint ihlali, syntax hatası); onları tekrar denemek anlamsız. Geri kalan NpgsqlException'lar sunucuya ulaşamamakla ilgili. Bu kez roller tersti: pg2 primary, pg1 replica. 8. saniyede primary'yi öldürdük, 14. saniyede replica'yı promote ettik:

[  0.4s] order 1 written on 172.22.0.3/32
[  8.0s] retry: A transient exception occurred during execution. The operation will be retried after 0ms.
[  8.1s] retry: A transient exception occurred during execution. The operation will be retried after 1088ms.
[  9.2s] retry: A transient exception occurred during execution. The operation will be retried after 2000ms.
[ 11.2s] retry: A transient exception occurred during execution. The operation will be retried after 2000ms.
[ 13.2s] retry: A transient exception occurred during execution. The operation will be retried after 2000ms.
[ 15.2s] order 70 written on 172.22.0.2/32
attempts=211 placed=211 failed=0

Hiç hata yok: o anda verilen sipariş 7 saniye bekledi ve yeni primary'ye yazıldı. Kesinti promote'tan sonra en fazla bir retry aralığı kadar sürdü. Burada bir takas var: retry süresince HTTP isteği de bekler. 10 retry ve en fazla 2 saniyelik aralık, isteği 15 saniyeye kadar bekletebilir; bu, HTTP timeout'undan ve kullanıcının sabrından uzun olmamalı. Retry, backoff ve jitter'ın genel kuralları System Design serisinin "Reliability Pattern'leri" yazısında.

Retry Güvenli mi? Idempotent Yazma

Retry'ın tehlikesi şu: commit primary'de tamamlanmış, ama yanıt istemciye ulaşmadan connection kopmuş olabilir. 04'te bir sync commit'in iptalinde "transaction has already committed locally" uyarısını görmüştük. Böyle bir durumda körü körüne tekrar denenen bir INSERT aynı siparişi ikinci kez oluşturur. Çözüm, işin kendisini idempotent yapmak: her sipariş isteği istemcide üretilen bir idempotency key taşır, veritabanında bu key unique'tir ve tekrar gelen istek hiçbir şey yapmaz:

ALTER TABLE orders ADD COLUMN idempotency_key uuid;
CREATE UNIQUE INDEX ux_orders_idempotency_key ON orders (idempotency_key);
public Task<int> PlaceOrderAsync(Guid idempotencyKey, int customerId, decimal total, CancellationToken ct) =>
    Database.ExecuteSqlInterpolatedAsync($"""
        INSERT INTO orders (customer_id, status, total_amount, created_at, idempotency_key)
        VALUES ({customerId}, 'Pending', {total}, now(), {idempotencyKey})
        ON CONFLICT (idempotency_key) DO NOTHING
        """, ct);

Key retry'lar boyunca aynı kalır, çünkü execution strategy'nin dışında üretildi. İki failover'dan sonra yeni primary'de sayım:

SELECT pg_is_in_recovery(), count(*) AS orders_with_key, count(DISTINCT idempotency_key) AS distinct_keys
FROM orders WHERE idempotency_key IS NOT NULL;
 pg_is_in_recovery | orders_with_key | distinct_keys
-------------------+-----------------+---------------
 f                 |             491 |           491

Birinci denemede 279, ikincide 211 başarılı sipariş ve öncesinde elle eklenen 1 satır: 491. Hiçbir sipariş iki kez yazılmadı, başarılı denen hiçbir sipariş kaybolmadı (iki denemede de replica failover anında yetişmiş durumdaydı). Gerçek bir checkout'ta key, istemcinin (tarayıcı ya da mobil uygulama) her "Siparişi tamamla" denemesinde ürettiği ve API'ye header ile gönderdiği değerdir; böylece kullanıcının çift tıklaması da aynı kurala takılır. Ayrıntılar Çözüm Desenleri serisinin "Idempotency Key" yazısında.

SQL Server Tarafı: Kopan Connection ve User Transaction

Microsoft.Data.SqlClient'ın idle connection resiliency özelliği, boştaki bir connection'ın sunucu tarafında kopmasını fark edip bir sonraki komutta sessizce yeniden bağlanır. ConnectRetryCount varsayılan olarak 1. Bir connection açıp session'ını başka bir session'dan KILL ile sonlandırdık ve aynı SqlConnection üzerinden bir sorgu daha çalıştırdık:

ConnectRetryCount=1 before: spid=57 connection=751ba813-030c-4b98-9ee7-a530b28c6ae9
KILL 57 from another session
after:  spid=57 connection=650b9538-6fe2-4cda-bfbf-9f17cd04df78
ConnectRetryCount=0 before: spid=57 connection=b954e88c-4937-40f0-962c-a646da98c28c
KILL 57 from another session
SqlException 0: A transport-level error has occurred when receiving results from the server. (provider: TCP Provider, error: 2 - Connection was terminated)

Birinci koşuda ClientConnectionId değişti: SqlClient arka planda yeni bir fiziksel connection açtı (spid'in aynı çıkması sunucunun numarayı yeniden kullanmasından). İkinci koşuda aynı durum uygulamaya hata olarak geldi. Bu özellik yalnız boştaki connection'lar içindir: bir transaction'ın ortasında kopan connection kurtarılamaz, onun için uygulama seviyesinde retry gerekir. Pool da fatal bir hata gördüğünde (failover gibi) ilgili pool'u temizler, 02'de anlattığımız gibi.

EF Core'un SQL Server için retry'lı execution strategy'sinde de aynı kural geçerli, ve EF Core bunu bir hatayla hatırlatıyor. EnableRetryOnFailure açıkken transaction'ı strategy'nin dışında başlatınca:

await using IDbContextTransaction tx = await db.Database.BeginTransactionAsync();
OrderStatus order = await db.Orders.SingleAsync(o => o.OrderId == 1);
order.Status = "Paid";
await db.SaveChangesAsync();
await tx.CommitAsync();
InvalidOperationException: The configured execution strategy 'SqlServerRetryingExecutionStrategy' does not support user-initiated transactions. Use the execution strategy returned by 'DbContext.Database.CreateExecutionStrategy()' to execute all the operations in the transaction as a retriable unit.

Strategy transaction'ın yarısını tekrar çalıştıramaz; ya hepsini ya hiçbirini. Aynı kod strategy.ExecuteAsync içine alındığında çalıştı (with CreateExecutionStrategy: updated=1). PostgreSQL örneğinde de kalıp aynıydı.

Failover'ı Test Etmek

Test edilmemiş bir failover çalışmayan bir failover'dır. Yukarıdaki iki deneme, birkaç dakikada kurulup tekrar edilebilen bir game day'in (planlı arıza tatbikatı) küçük bir örneği. Üretim benzeri bir ortamda düzenli olarak cevaplanması gereken sorular: primary öldüğünde yazmalar kaç saniye duruyor (ölçülmüş RTO), hangi istekler hata döndü ve kullanıcıya ne gösterildi, kaç işlem kayboldu (ölçülmüş RPO), eski primary geri geldiğinde ne oluyor (replica olarak mı katılıyor, yoksa ikinci bir primary mi), alarmlar çaldı mı. Bookstore ekibi bunu her çeyrek, bir kampanya döneminden önce yapmayı karara bağladı. 10. yazıda bu tatbikatı uçtan uca senaryonun parçası yapacağız.

Üretimde Dikkat

RPO'yu replica lag'iyle birlikte izle. Async bir replica'da olası kayıp, failover anındaki lag kadardır; lag alarmı aslında bir RPO alarmıdır.

Eski primary'yi doğrudan geri açma. Kendi başına geri gelen eski primary, kopukken aldığı yazmalarla yeni primary'den ayrışmış olabilir. PostgreSQL'de pg_rewind ya da yeniden pg_basebackup ile replica olarak katılmalı; Patroni bunu kendisi yapar. Lab'da her failover'dan sonra eski primary'yi silip yeni primary'den baştan kurduk.

Timeout'ları zincir olarak düşün. Connection timeout, retry sayısı ve aralığı, HTTP isteğinin timeout'u ve load balancer'ın timeout'u birbirini tutmalı. Retry'lar bitmeden HTTP isteği düşüyorsa kullanıcı hata görür, arkada iş yine de tamamlanabilir; idempotency key bu yüzden şart.

Failover sonrası connection fırtınasını hesaba kat. Bütün pod'ların pool'ları aynı anda yeni primary'ye bağlanmaya çalışır. 02'deki connection bütçesi ve PgBouncer burada da geçerli; PgBouncer kullanıyorsan onun da yeni primary'yi bulması gerekir.

Sık Düşülen Tuzaklar

Retry'ı idempotent olmayan işlemde açmak: retry, "olmadı" sanılan ama aslında commit edilmiş işi ikinci kez yapar. Önce işi idempotent yap, sonra retry'ı aç.

Varsayılan transient listesine güvenmek: lab'da "No suitable host was found" hatası retry edilmedi ve 98 sipariş hata döndü. Failover sırasında gerçekten hangi exception'ların geldiğini ölç ve strategy'yi ona göre ayarla.

Transaction'ı strategy'nin dışında başlatmak: EF Core bunu SQL Server'da hata ile yakalar, ama elle yazılmış retry döngülerinde transaction'ın yarısı tekrar edilebilir.

Async replica ile RPO sıfır sanmak: yazının başındaki 50 sipariş böyle kayboldu. Sıfır RPO gerekiyorsa sync replication ve en az iki replica gerekir (04).

Failover'ı hiç denememek: listener'ı yanlış yapılandırılmış bir connection string, cache'lenmiş bir DNS kaydı ya da Host Recheck Seconds gibi bir varsayılan, ancak gerçek bir failover'da ortaya çıkar.

Küçük Bir Deneme

★☆☆ Birinci denemeyi Host Recheck Seconds=1 ile ama varsayılan execution strategy ile tekrarla. Kesinti süresi ve hata sayısı nasıl değişiyor?

★★☆ Async kayıp deneyini synchronous_standby_names = 'pg2' ile tekrarla. Replica'nın ağı kopukken verilen siparişlere ne oluyor, primary'yi öldürüp replica'yı promote edince kaç sipariş kayboluyor?

★★★ Checkout'u bir ASP.NET Core endpoint'ine taşı: idempotency key'i Idempotency-Key header'ından al, aynı key ile gelen ikinci istek ilk siparişin id'sini dönsün. Failover sırasında aynı isteği iki kez gönderen bir istemciyle sonucu doğrula.

Sonraki yazıda replica'yı okuma kapasitesi için kullanıyoruz: .NET'te okumaları replica'ya, yazmaları primary'ye yönlendirmek, SQL Server'ın read-only routing'i ve Npgsql'in prefer-standby'ı, ve kullanıcının az önce verdiği siparişi listede görememesiyle sonuçlanan read-your-writes sorunu.