Sertaç Yıldırımsaha notları

Ana sayfa → Teknik

Distributed Lock: Kilidi Doğru Kurmak

Gece 03:00’te çalışan fatura işi, sabah bakıldığında aynı faturayı iki kez kesmiş. Kod tek bir yerde. Sebep de tek: o gece uygulamanın iki kopyası çalışıyordu.

Özet
  • Önce “gerçekten gerekiyor mu” diye sor. Tek satır korunuyorsa kilit değil, atomik güncelleme lazım.
  • Kilit almak kolay, bırakmak zor. En sık hata: başkasının kilidini silmek.
  • Süre dolarsa iki sahip olur. Tazeleme yardımcı olur, garanti vermez; garanti isteyen fencing token kullanır.
  • Postgres kullanıyorsan muhtemelen zaten kilidin var ve Redis’e hiç gerek yok.

Önce dur: buna gerçekten ihtiyacın var mı?

Dağıtık kilit, elimizde çekiç olunca her şeyin çivi görünmesine iyi bir örnek. Çoğu vakada daha ucuz bir cevap var:

DurumKilit gerekir mi?Ne kullanmalı
Stok düşürme, bakiye güncellemeHayırAtomik UPDATE ... WHERE (detay)
Tek kullanımlık kuponHayırUnique index
Aynı kaydı iki kişi düzenliyorHayırSürüm alanı (optimistic lock)
Tekrar gelen ödeme bildirimiHayırIdempotency anahtarı
Zamanlanmış iş, tek kopya çalışsınEvetKilit veya lider seçimi
Tek seferlik veri taşımaEvetKilit
Dış sistemle mutabakatEvetKilit + fencing token

Kural şu: korunacak şey tek bir veritabanı satırıysa kilide gerek yok. Kilit, koruman gereken şey veritabanının dışındaysa anlamlı hale geliyor.

Adım adım: yanlıştan doğruya

Deneme 1 — en masum hali (bozuk)

Bunu yapma
if (redis.GET("kilit:fatura") == null) {
    redis.SET("kilit:fatura", "1");   // ← arada boşluk var
    isiYap();
    redis.DEL("kilit:fatura");
}

Bir önceki yazıdaki aynı kalıp: kontrol et, sonra yap. İki kopya GET’i aynı anda çalıştırırsa ikisi de null görür.

Deneme 2 — atomik alım, ama süresiz (bozuk)

Bunu da yapma
SETNX kilit:fatura 1      # atomik, guzel
# ... is yapiliyor ...
DEL kilit:fatura

Alım artık atomik. Ama süreç iş ortasında çökerse (deploy, OOM, node yeniden başlatma) DEL hiç çalışmaz ve kilit sonsuza kadar kalır. Ertesi gün kimse fatura kesemez ve sebebini bulmak saatler alır.

Deneme 3 — süre var ama iki komutta (hâlâ bozuk)

İnce tuzak
SETNX kilit:fatura 1
EXPIRE kilit:fatura 60     # ← ikinci komut

İkisinin arasında süreç ölürse süresiz kilit yine oluşur. Nadir ama olur; ve olduğunda “biz süre koymuştuk ya” diye saatlerce yanlış yerde ararsın.

Deneme 4 — doğru alım

Tek komut: atomik + süreli + sahipli
token = uuid()                                  # bu kopyaya ozel
SET kilit:fatura <token> NX PX 30000

# NX  -> yoksa yaz (atomik)
# PX  -> 30 saniye sonra kendiliginden silinsin
# token -> "bu kilit benim" diyebilmek icin

Buradaki token hayat kurtarıyor ve bir sonraki bölümün konusu.

Asıl tuzak: kilidi bırakmak

Kilit almayı çoğu ekip doğru yapıyor. Neredeyse hiçbiri bırakmayı doğru yapmıyor. Şu senaryoya bak:

ZamanKopya AKopya BKilit
00:00kilidi aldı (30 sn)A
00:05çalışıyor... (GC duraklaması)A
00:30hâlâ duraklamadasüresi doldu
00:31kilidi aldıB
00:33uyandı, işini bitirdi, DELçalışıyorsilindi!
00:34çalışıyorC de alabilir

A, B’nin kilidini sildi. Şimdi B ile C aynı anda çalışıyor ve kimsenin haberi yok.

Doğrusu: karşılaştır ve sil (tek betikte)
-- Redis Lua: atomik calisir
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end

Uygulamada: EVAL <betik> 1 kilit:fatura <token>. Kilit senin değilse dokunmuyorsun. Bunu uygulamada iki komutla yapamazsın — GET ile DEL arasına yine biri girer.

Süre dolarsa ne olacak?

Yukarıdaki tablonun asıl problemi silme değil aslında: A hâlâ çalışıyorken B başladı. Compare-and-delete bunu çözmez, sadece zararı azaltır.

Kısmi çözüm: kilidi tazele

Watchdog
-- her 10 saniyede bir, sadece hala benimse suresini uzat
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("PEXPIRE", KEYS[1], ARGV[2])
else
    return 0
end

Arka planda bir zamanlayıcı bunu çağırır. Süreç ölürse tazeleme durur, kilit kendiliğinden düşer. İyi bir pratik — ama garanti değil: uzun bir duraklama sırasında tazeleme de duraklar.

Gerçek çözüm: fencing token

Bu, dağıtık kilit konusunun en önemli ve en az bilinen parçası. Fikir şu: kilit her verildiğinde artan bir numara da ver. Korunan kaynak, gördüğünden daha küçük numaralı bir yazmayı reddetsin.

Nasıl çalışıyor
-- Redis tarafinda: her kilit alisinda artan sayac
INCR kilit:fatura:sayac        -- A icin 41, B icin 42 doner

-- Yazarken numarayi da gonder:
UPDATE fatura_durumu
   SET son_islem = 'tamam', fence = 42
 WHERE id = 7
   AND fence < 42;             -- ← eski sahibi reddeder

-- A (fence=41) gec uyanip yazmaya kalkarsa:
-- fence < 41 kosulu tutmaz, 0 satir etkilenir, yazamaz.

Güzelliği şurada: A’nın uyanıp uyanmaması, ne kadar geciktiği, kilidin süresinin dolup dolmadığı artık önemli değil. Korunan kaynağın kendisi eski sahibi reddediyor. Kilidin güvenliği artık zamanlamaya bağlı değil.

Bedeli de var: korunan kaynağın böyle bir kontrol yapabiliyor olması gerekiyor. Veritabanında kolay, bir dosya sisteminde veya üçüncü parti bir API’de çoğu zaman mümkün değil. Mümkün değilse en azından bunu bilerek yaşıyorsundur.

Kilit, doğruluğu zamana emanet eder. Fencing token, doğruluğu sıraya emanet eder. Zaman dağıtık sistemlerde güvenilmez; sıra güvenilir.

Redlock meselesi

Birden fazla Redis düğümüne aynı anda kilit koymayı öneren bir algoritma var ve hakkında uzun bir tartışma yaşandı. Kısaca durum şu: tek Redis düğümü tek nokta arıza demek, ama çok düğüm de saat kaymaları ve duraklamalar nedeniyle tam garanti vermiyor.

Pratik yaklaşımım:

  • Verimlilik için kilitliyorsan (aynı işi iki kez yapmayalım, boşuna kaynak harcamayalım) tek Redis düğümü yeter. Nadir bir çakışma sadece israf demek.
  • Doğruluk için kilitliyorsan (iki kez olursa para kaybederiz) kilide tek başına güvenme. Fencing token ekle ya da işi idempotent yap.

Bu ayrımı yapmadan “kilit koyduk, güvendeyiz” demek, bu konuda düşülen en pahalı yanılgı.

Redis yerine daha basit üç yol

1. Postgres advisory lock (kullanıyorsan bedava)

Zaten elinde var
-- oturum boyunca tutulur, baglanti kopunca KENDILIGINDEN birakilir
SELECT pg_try_advisory_lock(834721);
-- true  -> kilit bende, ise basla
-- false -> baskasi calisiyor, cik

SELECT pg_advisory_unlock(834721);   -- isin bitince

En büyük avantajı: süreye ihtiyaç yok. Süreç ölünce bağlantı düşer, kilit anında serbest kalır. Redis kilidindeki “ya süre dolarsa” probleminin tamamı ortadan kalkar. Havuzlanmış bağlantı kullanıyorsan kilidi alan ve bırakan bağlantının aynı olduğundan emin ol — tek dikkat noktası bu.

2. Kilit tablosu (her veritabanında çalışır)

Basit ve okunabilir
CREATE TABLE kilitler (
  ad          text PRIMARY KEY,
  sahip       text NOT NULL,
  gecerlilik  timestamptz NOT NULL
);

-- almak: suresi dolmussa devral
INSERT INTO kilitler (ad, sahip, gecerlilik)
VALUES ('fatura', :ben, now() + interval '30 seconds')
ON CONFLICT (ad) DO UPDATE
   SET sahip = :ben, gecerlilik = now() + interval '30 seconds'
 WHERE kilitler.gecerlilik < now()
RETURNING sahip;

-- donen sahip ben isem kilit bende

Redis’ten yavaş ama gözle görülebilir: kim kilidi tutuyor, ne zamana kadar — hepsi bir SELECT uzağında. Teşhis kolaylığı hız kaybına çoğu zaman değer.

3. Hiç kilitleme: tek tüketicili kuyruk

Çoğu “aynı anda tek kişi yapsın” ihtiyacı aslında bir sıralama ihtiyacı. İşi bir kuyruğa koy ve o kuyruğu tek bir tüketici işlesin; kilide hiç gerek kalmaz. Kubernetes’te çalışıyorsan zamanlanmış işler için lease tabanlı lider seçimi de aynı işi yapar ve kendin yazmazsın.

Sahadan: fatura işine ne yaptık

Baştaki hikâyeye dönelim. İlk refleks Redis kilidi koymaktı. Koyduk, sorun bir süre görünmedi. Sonra bir gün iş beklenenden uzun sürdü, kilit düştü ve aynı hata döndü — bu sefer daha az sıklıkta, yani daha zor fark edilir şekilde.

Kalıcı çözüm iki parçalıydı ve ikisi de kilitle ilgili değildi:

  1. Fatura kesme işini idempotent yaptık. Fatura tablosuna (donem, musteri_id) üzerinde tekil kısıt. İkinci deneme veritabanı tarafından reddediliyor; kilit olsa da olmasa da çift fatura imkânsız.
  2. Kilidi verimlilik için tuttuk. Advisory lock’a geçtik; amacı doğruluk değil, iki kopyanın boşuna aynı işi yapmasını engellemek.

Buradaki ders bende kaldı: kilit, hatalı bir tasarımı güvenli yapmaz. İşin kendisi tekrar edilebilir olmalı; kilit sadece israfı azaltır.

Kontrol listesi

Kilit yazmadan önce
  • Bu gerçekten kilit mi istiyor, yoksa atomik UPDATE yeter mi?
  • Kilit doğruluk için mi, verimlilik için mi? (Cevabı yazılı olsun.)
  • Alım tek komutta mı? (SET ... NX PX)
  • Kilidin bir sahiplik değeri var mı?
  • Bırakma karşılaştır-ve-sil şeklinde mi? (Düz DEL ise hatalı.)
  • Süre, işin en kötü senaryodaki süresinden uzun mu?
  • Süre dolarsa ne oluyor? Cevabı “iki sahip olur” ise fencing token gerekiyor.
  • İşin kendisi idempotent mi? (Öyleyse kilidin yükü hafifler.)
  • Postgres var mı? Varsa pg_try_advisory_lock denendi mi?

Sonuç

Dağıtık kilit, dağıtık sistemlerdeki en kolay yanlış yapılan konulardan biri; çünkü yanlış hali de çoğu zaman çalışıyor. Hata ancak yük, gecikme ve şansın birleştiği anda ortaya çıkıyor — yani en kötü anda.

Akılda kalacak sıra şu: önce kilide ihtiyacın olmayacak şekilde tasarla, olmuyorsa veritabanının kilidini kullan, o da olmuyorsa Redis yaz — ama doğruluğu kilide değil fencing token’a ya da idempotency’ye emanet et.