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.
- Ö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:
| Durum | Kilit gerekir mi? | Ne kullanmalı |
|---|---|---|
| Stok düşürme, bakiye güncelleme | Hayır | Atomik UPDATE ... WHERE (detay) |
| Tek kullanımlık kupon | Hayır | Unique index |
| Aynı kaydı iki kişi düzenliyor | Hayır | Sürüm alanı (optimistic lock) |
| Tekrar gelen ödeme bildirimi | Hayır | Idempotency anahtarı |
| Zamanlanmış iş, tek kopya çalışsın | Evet | Kilit veya lider seçimi |
| Tek seferlik veri taşıma | Evet | Kilit |
| Dış sistemle mutabakat | Evet | Kilit + 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)
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)
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)
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
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:
| Zaman | Kopya A | Kopya B | Kilit |
|---|---|---|---|
| 00:00 | kilidi aldı (30 sn) | A | |
| 00:05 | çalışıyor... (GC duraklaması) | A | |
| 00:30 | hâlâ duraklamada | süresi doldu | |
| 00:31 | kilidi aldı | B | |
| 00:33 | uyandı, işini bitirdi, DEL | çalışıyor | silindi! |
| 00:34 | çalışıyor | C de alabilir |
A, B’nin kilidini sildi. Şimdi B ile C aynı anda çalışıyor ve kimsenin haberi yok.
-- 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
-- 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.
-- 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.
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)
-- 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)
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:
-
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. - 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
- 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
DELise 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_lockdenendi 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.