Dağıtık Kilit Neden Yetmez: Kira Süresi, Çit Jetonu ve Redis, PostgreSQL, etcd Arasında Seçim
Süreli bir kilit, sahibi duraklayınca iki kişiye birden verilebilir. Verimlilik ve doğruluk kilidi ayrımı, çit jetonuyla eski sahibi reddetmek, Redis, PostgreSQL danışma kilitleri ve etcd arasında seçim ile kira ve yenileme tuzakları.
Birden fazla süreç aynı işi aynı anda yapmasın diye kilit koymak, dağıtık sistemlerde en sık başvurulan ve en sık yanlış kurulan çözümlerden biridir. Tek makinede bir mutex işletim sisteminin garantisine dayanır: süreç ölürse kilit çözülür, iki iş parçacığı aynı anda içeri giremez. Ağ üzerinden alınan bir kilitte bu garantilerin hiçbiri yoktur. Kilidi tutan süreç duraklayabilir, ağdan kopabilir, saati kayabilir ve tüm bunlar olurken kilidin süresi dolup başka birine verilebilir. İlk süreç uyandığında hâlâ kilidi tuttuğunu sanır. Bu yazı bu durumun neden kaçınılmaz olduğunu, hangi kilit altyapısının nerede doğru olduğunu ve kilidi gerçekten güvenli kılan tek mekanizma olan çit jetonunu (fencing token) anlatıyor.
Önce kilidin ne için olduğuna karar verin
İki tür kilit vardır ve aralarındaki fark tasarımın tamamını belirler.
Verimlilik kilidi, aynı işin iki kez yapılmasını önler ama iki kez yapılırsa felaket olmaz. Aynı raporu iki kez üretmek, aynı önbelleği iki kez ısıtmak, aynı e-postayı nadiren iki kez göndermek gibi. Burada amaç boşa iş yapmamaktır ve kilidin ara sıra iki sahibi olması kabul edilebilir.
Doğruluk kilidi ise iki sahibin aynı anda yazmasının veriyi bozacağı durumlar içindir: bir hesabın bakiyesini güncellemek, bir dosyayı yeniden yazmak, bir kaynağı tek bir kiracıya atamak. Burada kilidin bir an bile iki sahibi olursa veri bozulur.
Verimlilik kilidi için basit bir zaman aşımlı kilit yeterlidir. Doğruluk kilidi için hiçbir kilit tek başına yeterli değildir; korunan kaynağın kendisi de eski sahibi reddedebilmelidir. Bu ayrımı yapmadan altyapı seçmek, yanlış soruya cevap aramaktır.
Kilit neden süreli olmak zorunda ve bu neden sorun
Ağ üzerindeki bir kilit sonsuza kadar tutulamaz. Sahibi çökerse kilit hiç çözülmez ve sistem kilitlenir. Bu yüzden her dağıtık kilidin bir kira süresi (lease) vardır: sahibi süreyi düzenli olarak yenilemezse kilit kendiliğinden düşer.
Sorun şuradadır: kilidi tutan süreç, süresinin dolduğunu fark edemeyebilir. Şu senaryo her çöp toplayıcılı dilde, her sanal makinede ve her yüklü sunucuda olabilir:
- Süreç A kilidi 30 saniyelik kirayla alır.
- A, işin ortasında uzun bir çöp toplama duraklamasına girer ya da sanal makinesi askıya alınır.
- Kira dolar, süreç B kilidi alır ve yazmaya başlar.
- A uyanır, kilidi hâlâ tuttuğunu düşünür ve yazmaya devam eder.
A'nın "yazmadan önce kilidi kontrol et" demesi bunu çözmez, çünkü kontrol ile yazma arasında da duraklama olabilir. Kira süresini uzatmak olasılığı düşürür ama sıfırlamaz. Bu bir uygulama hatası değil, süreli kilidin doğasıdır.
Çit jetonu: kilidi kaynağa bağlamak
Çözüm, kilit her verildiğinde tekdüze artan bir sayı üretmektir. A kilidi aldığında 33 numaralı jetonu, B aldığında 34 numaralı jetonu alır. Her süreç yazarken jetonunu da gönderir. Korunan kaynak, gördüğü en büyük jetonu saklar ve ondan küçük bir jetonla gelen yazmayı reddeder. A uyanıp 33 ile yazmaya çalıştığında kaynak zaten 34 görmüştür ve isteği geri çevirir.
Kaynak bir veritabanı satırıysa bu kontrol tek bir koşullu güncellemedir:
UPDATE isler
SET durum = 'tamamlandi', son_jeton = 34
WHERE id = 1207 AND son_jeton < 34;
Etkilenen satır sayısı sıfırsa siz artık kilidin sahibi değilsinizdir ve işi bırakmalısınız. Bu kontrol, kontrol ile yazmanın ayrı adımlar olması sorununu ortadan kaldırır, çünkü ikisi tek bir atomik işlemdir.
Buradaki zorluk kilidin değil kaynağın tarafındadır. Jetonu kontrol edemeyen bir kaynağa (bir dış API, sıradan bir dosya sistemi, bir e-posta sunucusu) yazıyorsanız çit jetonu kuramazsınız. O durumda yapılabilecek en iyi şey işlemi idempotent yapmaktır: aynı işlem iki kez yapılsa bile sonuç bir kez yapılmış gibi olsun. Bunu da sağlayamıyorsanız elinizdeki şey bir doğruluk kilidi değil, bir verimlilik kilididir ve tasarımı buna göre yapmalısınız.
Altyapı seçimi
Redis ile tek düğüm kilit
En yaygın kurulumdur ve verimlilik kilidi için iyidir. Kilit, rastgele bir değerle ve süreyle atomik olarak alınır:
SET kilit:rapor <rastgele-deger> NX PX 30000
Çözerken anahtarı körlemesine silmek yanlıştır; kira dolmuş ve kilit başkasına geçmişse onun kilidini silersiniz. Silme işlemi değeri kontrol eden bir Lua betiğiyle yapılır:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
Redis'in sınırı replikasyonun asenkron olmasıdır. Birincil kilidi verip replikaya aktaramadan düşerse, terfi eden replikada kilit yoktur ve ikinci bir süreç onu tekrar alır. Birden fazla bağımsız Redis düğümünde çoğunluk alan Redlock algoritması bu riski azaltmayı hedefler, ama saat ve duraklama varsayımlarına dayandığı için doğruluk kilidi olarak kullanılması tartışmalıdır ve çit jetonu üretmez. Redis bir artırma komutuyla jeton üretebilir, ama yük devrinde o sayaç da geri gidebilir. Doğruluk gerekiyorsa Redis'i seçmeyin.
PostgreSQL danışma kilitleri
Zaten PostgreSQL kullanıyorsanız ek altyapı gerektirmeyen iyi bir seçenektir. pg_try_advisory_lock(anahtar) oturum düzeyinde kilit alır, pg_advisory_xact_lock(anahtar) ise kilidi işlemin sonuna bağlar. Kilit bağlantıya bağlı olduğu için sahip çökerse bağlantı kapanır ve kilit kendiliğinden çözülür; ayrı bir kira süresi yönetmezsiniz.
İki tuzağı vardır. Birincisi, oturum düzeyindeki kilitler işlem modunda çalışan bir bağlantı havuzlayıcısının arkasında güvenilmezdir: kilidi aldığınız sunucu bağlantısı sonraki sorgunuzda başka bir istemciye verilmiş olabilir. Bu durumda işlem düzeyindeki kilidi kullanın. İkincisi, bağlantının koptuğunu süreç ancak bir sonraki sorguda fark eder; arada geçen sürede kilit başkasına geçmiş olabilir. Korunan veri aynı veritabanındaysa en sağlam yol, işi kilitle aynı işlemin içinde yapmaktır. O zaman kilit düşerse işlem de geri alınır.
etcd, ZooKeeper ve Kubernetes Lease
Doğruluk kilidi için tasarlanmış altyapılar bunlardır. Konsensüs protokolüyle çalıştıkları için bir düğümün düşmesi kilidin iki kez verilmesine yol açmaz. etcd'de kilit bir kiraya bağlı bir anahtardır ve anahtarın revizyon numarası küme genelinde tekdüze artar, yani doğal bir çit jetonudur. ZooKeeper'da sıralı geçici düğümler aynı işi görür. Kubernetes'te çalışan bir denetleyici için coordination.k8s.io grubundaki Lease nesnesi lider seçimi için standart yoldur ve istemci kütüphaneleri bunu hazır sunar.
Bedeli işletimdir: konsensüs kümesi en az üç düğüm ister ve gecikmesi bir Redis çağrısından yüksektir. Saniyede binlerce kısa kilit alıyorsanız bu altyapılar doğru araç değildir; o noktada kilit yerine işi bölmeyi düşünün.
Tuzaklar
Kira süresini işin süresine göre seçmek. İşin genelde 10 saniye sürdüğünü görüp 15 saniyelik kira koyarsanız, yavaş bir günde iş 20 saniye sürer ve kilit işin ortasında düşer. Ya kirayı arka planda düzenli yenileyin ya da işi kiradan çok daha kısa parçalara bölün.
Yenileme başarısızlığını yok saymak. Kira yenilemesi başarısız olduğunda işi hemen durdurmayan kod, kilidin sahibi olmadığı hâlde çalışmaya devam eder. Yenileme hatası, işi iptal eden bir sinyal olmalıdır.
Duvar saatine güvenmek. Kiranın dolup dolmadığını yerel saatle hesaplamak, saat ayarlandığında ya da sanal makine askıdan döndüğünde yanlış sonuç verir. Süre ölçümü için tekdüze saat kullanın ve son sözü her zaman kilit sunucusuna bırakın.
Kilidi yanlış yerde tutmak. Kilidi alıp sonra dış bir API çağrısını beklemek, kilidin süresini kontrol edemediğiniz bir gecikmeye bağlar. Kilidin içinde yalnız korunan kısa işi yapın.
Kilidi kuyruk yerine kullanmak. Yüzlerce işçi aynı kilidi almak için sürekli deniyorsa elinizde bir kilit problemi değil, bir iş dağıtma problemi vardır. Her iş için ayrı bir kuyruk kaydı ve SELECT ... FOR UPDATE SKIP LOCKED gibi bir yöntem, tek bir sıcak kilitten çok daha iyi ölçeklenir.
Ne zaman kilit kullanmamalı
Dağıtık kilit, eşzamanlılık sorununu başka bir yere taşır ama çözmez. Sorunu veri modeliyle çözebiliyorsanız önce onu yapın: benzersiz kısıtlar, koşullu güncellemeler, sürüm sütunuyla iyimser eşzamanlılık ya da bir kaydı tek bir işçiye bölüştüren anahtarlama çoğu durumda kilitten daha basit ve daha doğrudur. Kilit gerekiyorsa da önce sorun: bu bir verimlilik kilidi mi, doğruluk kilidi mi? Verimlilikse basit bir Redis kilidi yeterlidir ve fazlası boşa karmaşıklıktır. Doğruluksa kilidin yanında kaynağın da eski sahibi reddedebildiğinden emin olun; bunu sağlayamıyorsanız hangi kilit altyapısını seçtiğiniz pek bir şey değiştirmez.