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. Her lock() çağrısında bilinçli bir timeout koyun.
  • blocking_timeout'u atlamak: blocking=True + blocking_timeout=None kombinasyonu 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 edeceksen False yap.
  • 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.