Python'da Redis Lock ile Dağıtık Kilit
Selamlar, bu yazımda Python tarafında redis-py'nin hazır gelen Lock sınıfı ile dağıtık kilit (distributed lock) kurarken nelere dikkat etmek gerekiyor, ona bakacağız. Konu basit gibi duruyor ama içine girince timeout seçimi, token tabanlı release, kilit kaybı ihtimali derken iş ciddileşiyor. Lafı çok uzatmadan başlayayım.
Neden dağıtık kilit?
Tek bir process içindeyseniz threading.Lock yeter. Ama birden fazla worker, birden fazla container aynı kaynağı paylaşıyorsa süreç içi kilitlerin kıymeti kalmıyor. Aynı kullanıcının cüzdanından iki instance aynı anda para çekmeye kalkarsa tatsız bir race condition çıkar. Burada Redis'in atomik SET NX PX komutu devreye giriyor: anahtar yoksa kuruluyor, varsa kurulmuyor, üstelik bir TTL ile. Process çökse bile kilit sonsuza kadar kalmıyor.
Temel kullanım
redis-py size Redis.lock(name, timeout=...) üzerinden hazır bir nesne veriyor. En sade hali şöyle:
import redis
r = redis.Redis(host='redis-cache', port=6379, decode_responses=True)
lock = r.lock('resource:wallet:42', timeout=10)
if lock.acquire(blocking=True, blocking_timeout=5):
try:
# kritik bolge
process_payment()
finally:
lock.release()
else:
print('Kilit alinamadi, baska bir worker calisiyor olabilir.')
Burada iki parametre kritik. timeout=10 kilidin Redis'te ne kadar yaşayacağını söylüyor; biz unutsak bile 10 saniye sonra kilit otomatik düşüyor. blocking_timeout=5 ise 'kilit boşalana kadar en fazla 5 saniye bekle' demek. Süre dolarsa acquire False döner. Açıkçası ben blocking_timeout'u her zaman veriyorum, sonsuz beklemek prod'da hiç hoş bir his değil.
Context manager ile daha temiz
try/finally yazmaktan sıkılırsanız (ki sıkılmalısınız) Lock nesnesi context manager'ı destekliyor. Kilit alınamazsa LockError fırlatıyor:
from redis.exceptions import LockError
try:
with r.lock('resource:report-job', timeout=30, blocking_timeout=2):
generate_report()
except LockError:
print('Rapor zaten baska bir instance tarafindan uretiliyor.')
Bence günlük kullanımda doğru olan da bu. Hem release etmeyi unutamazsınız, hem de exception akışı doğal duruyor.
Token tabanlı serbest bırakma
Burası genelde atlanan ama önemli bir nokta. Diyelim worker A kilidi 10 saniyeliğine aldı, ama işi 12 saniye sürdü. 10. saniyede TTL doldu, kilit Redis'ten silindi. Bu sırada worker B aynı kilidi kaptı. Worker A işini bitirince release çağırırsa ne olur? Naif bir implementasyonda worker B'nin kilidini siler. Sonuç: B hiç haberi olmadan kritik bölgenin dışına atılır.
redis-py bu sorunu token ile çözüyor. acquire her seferinde rastgele bir token üretip Redis'teki anahtarın değerine yazıyor. release ise Lua script ile 'değer hâlâ benim token'ım mı, öyleyse sil' kontrolü yapıyor. Yani başkasının kilidini yanlışlıkla düşürmüyorsunuz; sadece LockNotOwnedError yiyorsunuz, ki bu da tam istediğiniz sinyal.
Uzun isler icin extend
İşin ne kadar süreceğini kestiremiyorsanız iki yol var: timeout'u abartılı koymak ya da kilidi periyodik olarak uzatmak. Ben ikincisini tercih ediyorum, çünkü uzun timeout aslında 'crash olduğumda kaynak boş kalsın' garantinizi de uzatıyor.
import threading
lock = r.lock('long-job', timeout=30, thread_local=False)
with lock:
stop = threading.Event()
def keepalive():
while not stop.wait(10):
try:
lock.extend(additional_time=30, replace_ttl=True)
except Exception:
break
threading.Thread(target=keepalive, daemon=True).start()
try:
do_long_work()
finally:
stop.set()
thread_local=False burada bilinçli; extend'i farklı bir thread yapacaksa token'ın paylaşılması gerek.
Basit Lock vs Redlock
Eminim aklınıza Redlock takıldı. Redlock, kilidi tek master yerine N tane bağımsız Redis node'una yazıp çoğunluk almayı şart koşan bir algoritma. Teorik olarak failover anlarında daha güvenli, pratikte ise Martin Kleppmann'ın eleştirisinden beri tartışmalı; clock skew ve GC pause'lar Redlock'u da yanıltabiliyor.
Şahsi kanaatim: tek master + replica kurulumunda redis-py'nin Lock'u, makul TTL ve idempotent bir kritik bölge ile çoğu uygulama için yeterli. Sahiden katı tutarlılık gerekiyorsa kilit yerine veritabanı transaction'ı veya optimistic locking düşünmek bence daha sağlıklı.
Sık karşılaşılan tuzaklar
- Timeout vermemek: redis-py varsayılanı
None'dur, yani sonsuz. Worker çökerse kilit ölümsüz olur. Herlock()çağrısında bilinçli birtimeoutkoyun. - blocking_timeout'u atlamak:
blocking=True+blocking_timeout=Nonekombinasyonu pod'larınızı kilitlenmiş bir kuyrukta bekletir. Health check ezilir, restart başlar. - Aynı token'ı birden fazla thread'de kullanmak:
thread_local=True(varsayılan) çoğu zaman doğru; sadece kilidi başka thread'den release/extend edeceksenFalseyap. - Kilit kaybını yok saymak: TTL doldu ve siz hâlâ kritik bölgedesiniz - bu mümkündür. Bunu varsayım kabul edip kritik kodu mümkün olduğunca idempotent yazın.
- Çoklu kilit alırken sırayı karıştırmak: A->B alan biri ile B->A alan biri buluştuğunda klasik deadlock. Kilit isimlerini her yerde aynı sırada (mesela alfabetik) edinin.
Kapanış
Bu yazıda redis-py'nin Lock sınıfını, timeout/blocking_timeout ikilisini, token tabanlı release'in neden hayati olduğunu ve Redlock tartışmasını biraz kurcaladık. Bana sorarsanız tek bir with r.lock(...) ile çoğu işin altından kalkılır; önemli olan kilidin kaybolabileceğini baştan kabul edip kritik bölgeyi ona göre yazmak. Umarım faydalı olur, bir sonraki yazıda görüşmek üzere.
