Mert Özen Her Satırda Daha İleri
Yazılım Mimarisi Distributed Systems Temelleri — #10

Distributed Lock, Lease ve Fencing Token: Kilit Neden Yetmez?

Mert Özen 8 Kas 2026 11 dk 3 görüntülenme
Distributed Lock, Lease ve Fencing Token: Kilit Neden Yetmez?

Süreli bir kilit (lease) tek başına doğruluk sağlamaz: kilidi alan process GC duraklamasında kalırsa lease dolar, başkası kilidi alır ve uyanan ilk process yine yazar. Gerçek Redis ve SQLite ile yapılan denemede eski rapor yenisinin üstüne yazılıyor; her kilitle artan bir fencing token'ı veritabanında WHERE token > LastToken ile kontrol edince eski yazma reddediliyor. Redlock tartışmasının iki tarafı ve veritabanının kendi mekanizmaları da yazıda.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore'un API'si (Application Programming Interface) iki instance'ta çalışıyor ve her ikisinde de her gece günlük satış raporunu hesaplayıp DailySalesReport tablosuna yazan bir background job var. Raporun iki kez hesaplanmasını istemeyen ekip Redis'te bir kilit kullanıyor: job başlarken kilidi alıyor, bitince bırakıyor. Kilit bir süre sonra kendiliğinden düşüyor, böylece job'ı çalıştıran instance çökerse kilit sonsuza kadar asılı kalmıyor.

Bir gece instance-1 kilidi alıyor ve raporu hesaplamaya başlıyor. Tam o sırada uzun bir GC (garbage collector) duraklamasına giriyor; duraklama, kilidin süresinden uzun sürüyor. Kilit düşüyor, instance-2 kilidi alıyor, güncel veriyle raporu hesaplayıp yazıyor. Sonra instance-1 uyanıyor. Tabloda en sonunda hangi rapor kalır? Kilit burada neyi korudu?

Kilit Ne İçin: Efficiency mi, Correctness mi?

Martin Kleppmann 2016'daki "How to do distributed locking" yazısında distributed lock kullanım sebeplerini ikiye ayırır. Efficiency için kilit, aynı işin iki kez yapılmasını önler: raporun iki kez hesaplanması biraz CPU (Central Processing Unit) harcar, iki kez gönderilen bir bildirim e-postası biraz can sıkar. Kilit arada bir başarısız olursa bedeli küçüktür. Correctness için kilit ise iki process'in aynı veriyi eşzamanlı değiştirip bozmasını önler: aynı stoğun iki kez düşülmesi, bir dosyanın iki yazarı, bir hesabın iki kez kapatılması. Kilit bir kez bile başarısız olursa veri bozulur.

Bu ayrım hangi aracı seçeceğini belirler. Efficiency için tek bir Redis instance'ındaki basit bir kilit fazlasıyla yeterli ve ekipte herkesin "bu kilit yaklaşık" olduğunu bilmesi kâfi. Correctness için ise, birazdan göreceğimiz gibi, kilit tek başına hiçbir zaman yeterli değildir; korunan kaynağın kendisinin de katılması gerekir.

Lease: Süreli Kilit

Tek bir process içindeki lock ya da SemaphoreSlim, sahibi çökerse process'le birlikte yok olur. Dağıtık bir kilitte sahip çökebilir ve kilit sonsuza kadar asılı kalabilir. Bu yüzden dağıtık kilitler neredeyse her zaman lease'tir: belirli bir süre için verilen, süresi dolunca kendiliğinden düşen bir kilit. Gray ve Cheriton'un 1989 tarihli "Leases" makalesi bu fikri cache tutarlılığı için tanımlamıştı. Redis'te tek bir komutla alınır: SET key'i yalnız yoksa (NX) ve belirli bir süre için (PX, milisaniye) yazar. Bu deneme için kendi geçici Redis container'ımı açtım:

docker run -d --rm --name ds10-redis-tmp -p 127.0.0.1:16390:6379 redis:7-alpine
$ redis-cli SET lock:daily-sales instance-1 NX PX 30000
OK
$ redis-cli SET lock:daily-sales instance-2 NX PX 30000

$ redis-cli PTTL lock:daily-sales
29999
$ redis-cli GET lock:daily-sales
instance-1

İlk SET başarılı. İkincisi boş bir satır döndürüyor: interaktif olmayan redis-cli'da nil bu şekilde görünür, yani kilit alınamadı. PTTL kilidin yaklaşık 30 saniye daha yaşayacağını, GET sahibin instance-1 olduğunu söylüyor. Bırakırken dikkat gereken bir nokta var: kilidi körlemesine DEL ile silersen, sen duraklarken süresi dolup başkasına geçmiş bir kilidi silebilirsin. Doğrusu, değeri karşılaştırıp yalnız hâlâ senin ise silmek ve bunu tek bir atomik Lua script'iyle yapmak:

using StackExchange.Redis;

namespace Bookstore.Jobs;

public sealed record Lease(string Owner, long Token);

public sealed class RedisLease(IDatabase redis, string name)
{
    // SET name owner NX PX ttl, ardından bir sayaçtan fencing token
    public async Task<Lease?> TryAcquireAsync(string owner, TimeSpan ttl)
    {
        bool acquired = await redis.StringSetAsync(name, owner, ttl, When.NotExists);
        if (!acquired) return null;
        long token = await redis.StringIncrementAsync($"{name}:token");
        return new Lease(owner, token);
    }

    // Yalnız hâlâ bizimse sil (karşılaştır ve sil, tek script'te)
    public async Task<bool> ReleaseAsync(Lease lease)
    {
        const string script = "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end";
        RedisResult result = await redis.ScriptEvaluateAsync(script, [name], [lease.Owner]);
        return (long)result == 1;
    }
}

GC Duraklaması ve İki Sahip

Lease kendi sorununu getirir: kilidin süresi, sahibinin saatinden bağımsız olarak, kilit servisinin saatine göre dolar. Sahip duraklarsa, kilidin bittiğinden habersiz devam eder. Raporu bir SQLite tablosuna yazan küçük bir store:

using Microsoft.Data.Sqlite;

namespace Bookstore.Jobs;

public sealed class ReportStore(SqliteConnection db)
{
    public async Task InitAsync()
    {
        await using SqliteCommand cmd = db.CreateCommand();
        cmd.CommandText = """
            DROP TABLE IF EXISTS DailySalesReport;
            CREATE TABLE DailySalesReport (Id INTEGER PRIMARY KEY, Content TEXT NOT NULL, LastToken INTEGER NOT NULL);
            INSERT INTO DailySalesReport VALUES (1, 'empty', 0);
            """;
        await cmd.ExecuteNonQueryAsync();
    }

    public async Task<bool> SaveAsync(string content, long token, bool useFencing)
    {
        await using SqliteCommand cmd = db.CreateCommand();
        cmd.CommandText = useFencing
            ? "UPDATE DailySalesReport SET Content = $c, LastToken = $t WHERE Id = 1 AND $t > LastToken"
            : "UPDATE DailySalesReport SET Content = $c, LastToken = $t WHERE Id = 1";
        cmd.Parameters.AddWithValue("$c", content);
        cmd.Parameters.AddWithValue("$t", token);
        return await cmd.ExecuteNonQueryAsync() == 1;
    }

    public async Task<string> ReadAsync()
    {
        await using SqliteCommand cmd = db.CreateCommand();
        cmd.CommandText = "SELECT Content || ' (token ' || LastToken || ')' FROM DailySalesReport WHERE Id = 1";
        return (string)(await cmd.ExecuteScalarAsync())!;
    }
}

Senaryoyu birebir kuralım: lease 1 saniye, instance-1 kilidi aldıktan hemen sonra 1,5 saniyelik bir duraklamaya giriyor (Task.Delay ile taklit ediyoruz), instance-2 1,1 saniye sonra kilidi deniyor. Önce fencing olmadan:

using System.Diagnostics;
using Bookstore.Jobs;
using Microsoft.Data.Sqlite;
using StackExchange.Redis;

await using ConnectionMultiplexer mux = await ConnectionMultiplexer.ConnectAsync("127.0.0.1:16390");
IDatabase redis = mux.GetDatabase();
await using SqliteConnection db = new("Data Source=reports.db");
await db.OpenAsync();
ReportStore store = new(db);
Stopwatch clock = Stopwatch.StartNew();
void Log(string text) => Console.WriteLine($"{clock.ElapsedMilliseconds,5} ms  {text}");

foreach (bool useFencing in new[] { false, true })
{
    Console.WriteLine(useFencing ? "--- with fencing token ---" : "--- lease only ---");
    await redis.KeyDeleteAsync(["lock:daily-sales", "lock:daily-sales:token"]);
    await store.InitAsync();
    RedisLease lease = new(redis, "lock:daily-sales");
    clock.Restart();

    async Task Instance1()
    {
        Lease? mine = await lease.TryAcquireAsync("instance-1", TimeSpan.FromSeconds(1));
        Log($"instance-1 acquired lease, token {mine!.Token}");
        await Task.Delay(1500); // lease'ten uzun süren stop-the-world GC duraklaması
        bool saved = await store.SaveAsync("report from instance-1 (stale data)", mine.Token, useFencing);
        Log($"instance-1 wakes up and writes -> {(saved ? "ACCEPTED" : "REJECTED")}");
        Log($"instance-1 release -> {await lease.ReleaseAsync(mine)}");
    }

    async Task Instance2()
    {
        await Task.Delay(1100);
        Lease? mine = await lease.TryAcquireAsync("instance-2", TimeSpan.FromSeconds(1));
        Log($"instance-2 acquired lease, token {mine!.Token}");
        bool saved = await store.SaveAsync("report from instance-2", mine.Token, useFencing);
        Log($"instance-2 writes -> {(saved ? "ACCEPTED" : "REJECTED")}");
        await lease.ReleaseAsync(mine);
    }

    await Task.WhenAll(Instance1(), Instance2());
    Log($"final row: {await store.ReadAsync()}");
}
--- lease only ---
    2 ms  instance-1 acquired lease, token 1
 1105 ms  instance-2 acquired lease, token 2
 1107 ms  instance-2 writes -> ACCEPTED
 1506 ms  instance-1 wakes up and writes -> ACCEPTED
 1508 ms  instance-1 release -> False
 1511 ms  final row: report from instance-1 (stale data) (token 1)

Kilit görevini yaptı: instance-2 kilidi ancak lease dolduktan sonra aldı. Ama tabloda son kalan, instance-1'in duraklamadan önce hesapladığı eski rapor. instance-2'nin güncel yazması, ondan 400 milisaniye sonra gelen eski veriyle ezildi. instance-1'in ReleaseAsync'i False dönüyor; compare-and-delete doğru çalıştı, ama yazma çoktan olmuştu. Duraklamadan önce "kilit hâlâ bende mi?" diye kontrol etmek de işe yaramaz: duraklama tam kontrol ile yazma arasında olabilir.

Duraklamanın tek sebebi GC değil. Sanal makinenin canlı taşınması (live migration), belleğin swap'ten geri yüklenmesi, uzun bir senkron disk I/O (Input/Output), process'e gönderilen bir SIGSTOP ya da yalnız yazma isteğinin network'te uzun süre bekletilmesi aynı sonucu verir. Kleppmann HBase'in tam olarak bu sorunu yaşadığını ve GC duraklamalarının bazen dakikalarca sürebildiğini not eder. İkinci yazıdaki dille: lease, safety'yi zamanlamaya bağlıyor ve partially synchronous bir sistemde zamanlama varsayımları bazen bozulur.

Fencing Token

Çözüm, korunan kaynağı da işin içine katmak. Kilit servisi kilidi her verdiğinde monoton artan bir sayı (fencing token) verir. Kilidi tutan process her yazmada token'ı da gönderir. Kaynak, gördüğü en büyük token'ı saklar ve daha küçük token'lı yazmaları reddeder. Örnekte token, Redis'te INCR ile artan bir sayaç; kontrol ise tek bir SQL (Structured Query Language) koşulu: WHERE Id = 1 AND $t > LastToken. SQL Server'da aynı koşul @token > LastToken olarak yazılır. Aynı senaryo, fencing açıkken:

--- with fencing token ---
    0 ms  instance-1 acquired lease, token 1
 1104 ms  instance-2 acquired lease, token 2
 1105 ms  instance-2 writes -> ACCEPTED
 1503 ms  instance-1 wakes up and writes -> REJECTED
 1505 ms  instance-1 release -> False
 1505 ms  final row: report from instance-2 (token 2)

instance-1 yine uyandı ve yine yazmaya çalıştı, ama tablo ondan daha büyük bir token (2) görmüştü; UPDATE sıfır satır etkiledi ve yazma reddedildi. Son rapor instance-2'ninki. Fencing'in gücü, zamanlama varsayımına hiç ihtiyaç duymaması: duraklama ne kadar uzun sürerse sürsün, eski token'lı yazma geçmez.

İki şart var. Birincisi, token gerçekten monoton artmalı. Örnekteki INCR tek bir Redis node'unda çalışıyor; Redis asynchronous replication ile bir replica'ya failover yaparsa, son artırmalar kaybolup sayaç geri gidebilir ve aynı token iki kez verilebilir. Güvenilir token, consensus ile replike edilen bir kaynaktan gelir: ZooKeeper'da zxid ya da znode versiyonu, etcd'de revision, ya da korunan kaynakla aynı veritabanındaki bir sequence. İkincisi, kaynağın kendisi kontrolü yapabilmeli. Bir veritabanı tablosu, bir dosya sistemi ya da kendi API'n bunu yapabilir; token'ı kontrol edemeyen bir dış servis (ör. bir e-posta sağlayıcısı) için fencing mümkün değildir ve oraya idempotency key gerekir.

Redlock Tartışması

Redis'in yaratıcısı Salvatore Sanfilippo (antirez), tek Redis node'unun bir single point of failure olmasına karşı Redlock algoritmasını önerdi: beş bağımsız Redis master'ına sırayla kilit isteği gönder, çoğunluktan (en az üç) kısa süre içinde onay alırsan, kilidin geçerlilik süresinden harcanan zamanı düşerek kilidi aldığını kabul et.

Kleppmann'ın 2016'daki eleştirisinin iki ana argümanı var. Birincisi, Redlock fencing token üretmez; rastgele bir değer kullanır ve bu değer sıralanamaz. İkincisi, güvenliği zamanlama varsayımlarına dayanır: sınırlı network gecikmesi, sınırlı process duraklaması ve sınırlı saat hatası. Bir node'un saati ileri atlarsa kilit erken düşer ve başka bir client çoğunluğu toplayabilir; yukarıdaki duraklama senaryosu da aynen geçerlidir. Kleppmann'ın vardığı sonuç: efficiency için tek Redis yeterli ve Redlock gereksiz ağır; correctness için ise ZooKeeper gibi consensus tabanlı bir sistem ve fencing token gerekir.

Antirez "Is Redlock safe?" başlıklı cevabında farklı düşündü. Ona göre token'ı kontrol edebilecek kadar güçlü bir kaynak, rastgele kilit değerini de check-and-set için kullanabilir; kilidi aldıktan sonraki duraklama her kilit sistemi için geçerli bir sorundur, Redlock'a özgü değildir; saat sorunu da sistem saatini ileri geri atlatmayan bir configuration ve monotonic clock kullanımıyla yönetilebilir. Tartışmanın iki tarafını okumak öğretici, ama pratik ders ikisinin ortak noktasında: kilit, correctness'ı tek başına garanti etmez; korunan kaynak, eski bir kilit sahibinin yazmasını reddedebilmelidir.

Veritabanının Kendi Mekanizmalarıyla Koordinasyon

Çoğu .NET uygulamasında korunan kaynak zaten bir veritabanıdır ve en sağlam koordinasyon onun içinde yapılır. Rapor örneği için en basit çözüm bir unique constraint olabilir: JobRuns tablosunda (JobName, RunDate) benzersizse, iki instance'tan yalnız biri o geceye ait satırı ekleyebilir, diğeri unique constraint ihlali alır ve çıkar. Veriyi değiştiren işlerde optimistic concurrency fencing'in ta kendisidir: SQL Server'ın rowversion kolonu her güncellemede artan bir token'dır ve WHERE RowVersion = @original koşulu eski okumaya dayanan yazmayı reddeder. EF (Entity Framework) Core serisinin "EF Core'da Concurrency: Token, rowversion ve DbUpdateConcurrencyException" yazısı bunu DbUpdateConcurrencyException ile nasıl yakaladığını gösteriyor.

Queue benzeri işler için satır kilitleri de bir seçenek: PostgreSQL'de SELECT ... FOR UPDATE SKIP LOCKED, SQL Server'da UPDLOCK, READPAST hint'leri, birden fazla worker'ın aynı işi almadan işleri paylaşmasını sağlar; kilit transaction'a bağlı olduğu için transaction bitince ya da bağlantı kopunca kendiliğinden açılır. Hangi yöntemi seçersen seç, yazmanın kendisi bir koşul taşıdığı sürece (unique key, versiyon, token) duraklama sorunu ortadan kalkar. İlerideki Backend › Background Job'lar ve Scheduled Job'lar serisi bu araçları job scheduler'lar üzerinden ele alacak.

Sık Düşülen Tuzaklar

Kilidi DEL ile bırakmak. Süresi dolup başkasına geçmiş bir kilidi silersin ve üçüncü bir process'in de kilidi almasına yol açarsın. Değeri karşılaştırarak, atomik olarak bırak.

Lease süresini işin süresine göre seçmek. "İş 20 saniye sürüyor, 30 saniye yeter" hesabı duraklamaları hesaba katmaz. Lease süresi ne olursa olsun, ondan uzun bir duraklama mümkündür; correctness gerekiyorsa fencing şart.

Kilidin süresini uzatmayı (renewal) çözüm sanmak. Arka planda lease'i yenileyen bir timer, process duraklarken o da durur. Yenileme, uzun işlerde lease'in gereksiz yere düşmesini önler, duraklama sorununu çözmez.

Fencing token'ı kontrol etmeyen bir kaynağa güvenmek. Token'ı gönderip kaynağın onu yok sayması, hiç göndermemekle aynıdır. Kontrol, yazmanın kendisiyle aynı atomik işlemde olmalı.

Efficiency kilidi ile correctness kilidini karıştırmak. Rapor örneğinde iki kez hesaplamak sorun değilse, kilit yeter ve fencing gereksiz karmaşıklıktır. Ama sonucu ezilen ya da iki kez işlenen bir veri varsa, artık correctness konuşuyorsun.

Küçük Bir Deneme

★☆☆ instance-1'in duraklamasını 1500 milisaniyeden 800 milisaniyeye indir. Fencing'siz çalıştırmada son satır ne oluyor? Neden?

★★☆ ReportStore'a JobRuns tablosu ve (JobName, RunDate) üzerinde bir unique constraint ekle. Redis kilidini tamamen kaldırıp iki instance'ı aynı anda çalıştır. Hangisi raporu yazıyor, diğeri hangi hatayı alıyor?

★★★ Token'ı Redis yerine SQLite'taki bir sayaç tablosundan al (UPDATE Counters SET Value = Value + 1 RETURNING Value). Bu değişiklik, Redis failover'ında token'ın geri gitmesi sorununu nasıl çözüyor? Hangi yeni varsayımı getiriyor?

Bu yazıda saatlere ve zamanlamaya güvenmenin bedelini bir kez daha gördük. Serinin son yazısında gerçek sistemlerin bu sorunlarla nasıl yaşadığına bakıyoruz: birleştirilmesi conflict üretmeyen CRDT'ler (Conflict-free Replicated Data Type), Spanner'ın TrueTime ile saat belirsizliğini nasıl beklediği, CockroachDB'nin HLC (hybrid logical clock) ve uncertainty interval'ı ve Jepsen testlerinin "dokümantasyon ile davranış aynı değildir" dersi.