Lua Script ile Redis’te Atomik İşlemler

Redis ile ciddi işler yapıyorsanız, er ya da geç şu sorunla karşılaşırsınız: iki ayrı komut arasında başka bir istemci gelip durumu bozuyor. Bir değeri okuyorsunuz, işlem yapıyorsunuz, geri yazıyorsunuz – ama tam o esnada başka biri aynı anahtara dokunmuş. Race condition’ın klasik hali. İşte Lua scripting bu sorunu Redis’in kalbinde çözüyor.

Redis’te Atomiklik Neden Bu Kadar Önemli

Redis tek thread’li bir mimari üzerine kurulu, bu doğru. Ama uygulamanız onlarca, yüzlerce bağlantıyla Redis’e erişiyorsa, her komut ayrı ayrı işleniyor demektir. Yani şöyle bir senaryo düşünün:

İstemci A: GET stock_count -> 1 döndü
İstemci B: GET stock_count -> 1 döndü
İstemci A: SET stock_count 0 (stok düştü)
İstemci B: SET stock_count 0 (hata! ikisi de -1 gitmesi gerekirdi)

Her iki istemci de stoğu 1 gördü ve ikisi de 0 yazdı. Oysa biri 0, diğeri -1 yazmış olmalıydı. Tek ürün iki kez satıldı. Bu sadece e-ticaret için değil, rate limiting, oturum yönetimi, kilit mekanizmaları – her yerde bu sorun çıkabilir.

MULTI/EXEC ile transaction kullanabilirsiniz, ama bu yöntem optimistik locking gerektirir (WATCH komutuyla) ve retry mantığı yazmanız gerekir. Lua scripting ise farklı bir yaklaşım sunar: script Redis’e gönderilir ve Redis onu tek bir atomik işlem olarak çalıştırır. Script çalışırken hiçbir başka komut araya giremez.

Lua Script Temelleri

Redis, Lua 5.1 yorumlayıcısını gömülü olarak barındırır. Script içinde Redis komutlarını redis.call() ya da redis.pcall() fonksiyonuyla çağırırsınız.

# Basit bir Lua script örneği - değer oku ve döndür
redis-cli EVAL "return redis.call('GET', KEYS[1])" 1 mykey

EVAL komutunun yapısı şöyle:

  • EVAL script numkeys key [key…] arg [arg…]: Temel çağrı formatı
  • script: Lua kodu, string olarak
  • numkeys: Kaç tane anahtar geçtiğiniz
  • KEYS[1], KEYS[2]…: Geçilen anahtarlara erişim (1-indexed)
  • ARGV[1], ARGV[2]…: Anahtarlar dışındaki argümanlara erişim
# KEYS ve ARGV kullanımı
redis-cli EVAL "
  local current = redis.call('GET', KEYS[1])
  if current == false then
    redis.call('SET', KEYS[1], ARGV[1])
    return 1
  end
  return 0
" 1 mykey defaultvalue

Bu script şunu yapıyor: eğer anahtar yoksa verilen değeri set et ve 1 döndür, varsa dokunma ve 0 döndür. Klasik “set if not exists” mantığı ama biraz daha kontrollü.

redis.call ile redis.pcall Farkı

Bu ikisini doğru kullanmak kritik. redis.call() bir hata aldığında script çalışmayı durdurur ve hata fırlatır. redis.pcall() ise hatayı yakalar ve bir tablo olarak döndürür, script devam eder.

# redis.call - hata durumunda script durur
redis-cli EVAL "
  redis.call('SET', KEYS[1], 'test')
  redis.call('LPUSH', KEYS[1], 'item')  -- String'e list komutu, hata verir
  return 'bitti'
" 1 mykey

# redis.pcall - hata yakalanır, devam edilir
redis-cli EVAL "
  redis.call('SET', KEYS[1], 'test')
  local ok, err = pcall(redis.pcall, 'LPUSH', KEYS[1], 'item')
  if err then
    return 'hata yakalandi: ' .. tostring(err)
  end
  return 'bitti'
" 1 mykey

Üretim ortamında kritik işlemler için genellikle redis.call() tercih edilir çünkü hata aldığında direkt başarısız olmak, yarım kalmış bir işlemi tamamlamış gibi göstermekten daha güvenlidir.

Gerçek Dünya Senaryosu 1: Stok Yönetimi

E-ticaret projelerinde en sık karşılaşılan atomiklik ihtiyacı stok kontrolü. Hem stoğu kontrol et hem düş, hem de log tut – hepsini tek atomik işlemde yapmak istiyorsunuz.

# Stok düşme scripti - stok varsa düş, yoksa hata döndür
redis-cli EVAL "
  local stock_key = KEYS[1]
  local log_key = KEYS[2]
  local amount = tonumber(ARGV[1])
  local order_id = ARGV[2]
  
  local current_stock = tonumber(redis.call('GET', stock_key))
  
  if current_stock == nil then
    return redis.error_reply('STOCK_NOT_FOUND')
  end
  
  if current_stock < amount then
    return redis.error_reply('INSUFFICIENT_STOCK')
  end
  
  local new_stock = current_stock - amount
  redis.call('SET', stock_key, new_stock)
  
  -- Log kaydı
  local timestamp = redis.call('TIME')[1]
  local log_entry = order_id .. ':' .. amount .. ':' .. timestamp
  redis.call('LPUSH', log_key, log_entry)
  redis.call('LTRIM', log_key, 0, 99)  -- Son 100 log
  
  return new_stock
" 2 product:123:stock product:123:stock_log 3 ORDER-456

Bu scriptte birden fazla Redis komutu çalışıyor ama tamamı atomik. Script çalışırken product:123:stock anahtarına başka kimse dokunamaz.

Gerçek Dünya Senaryosu 2: Rate Limiting

Rate limiting için sliding window algoritması Lua ile çok temiz yazılır. Klasik yaklaşım: son X saniye içindeki istek sayısını tut.

# Sliding window rate limiter
redis-cli EVAL "
  local key = KEYS[1]
  local window = tonumber(ARGV[1])   -- saniye cinsinden pencere
  local limit = tonumber(ARGV[2])    -- max istek sayisi
  local now = tonumber(redis.call('TIME')[1])
  local window_start = now - window
  
  -- Pencere disindaki eski kayitlari temizle
  redis.call('ZREMRANGEBYSCORE', key, 0, window_start)
  
  -- Mevcut istek sayisini al
  local count = redis.call('ZCARD', key)
  
  if count >= limit then
    return 0  -- Rate limit asimdi
  end
  
  -- Bu istegi ekle (score = timestamp, member = timestamp + random suffix)
  local member = now .. ':' .. math.random(100000)
  redis.call('ZADD', key, now, member)
  redis.call('EXPIRE', key, window)
  
  return limit - count - 1  -- Kalan istek hakki
" 1 ratelimit:user:42 60 100

Bu script her çağrıda eski kayıtları temizliyor, mevcut sayıyı kontrol ediyor ve yeni kaydı ekliyor. Hepsi atomik. 0 dönerse rate limit aşılmış, pozitif değer dönerse kalan hak.

EVALSHA ile Script Yönetimi

Her seferinde script kodunu göndermek verimsiz. Redis, scriptleri SHA1 hash’iyle önbelleğe alır. SCRIPT LOAD ile scripti yükler, sonra EVALSHA ile çağırırsınız.

# Scripti yükle
SCRIPT_SHA=$(redis-cli SCRIPT LOAD "
  local key = KEYS[1]
  local increment = tonumber(ARGV[1])
  local limit = tonumber(ARGV[2])
  local current = tonumber(redis.call('GET', key) or '0')
  
  if current + increment > limit then
    return redis.error_reply('LIMIT_EXCEEDED')
  end
  
  return redis.call('INCRBY', key, increment)
")

echo "Script SHA: $SCRIPT_SHA"

# Sonra her seferinde SHA ile çağır
redis-cli EVALSHA $SCRIPT_SHA 1 counter:daily 5 1000
redis-cli EVALSHA $SCRIPT_SHA 1 counter:daily 3 1000
# Script cache'i kontrol et
redis-cli SCRIPT EXISTS $SCRIPT_SHA

# Sonuç: 1 (var) veya 0 (yok)
# Redis restart olduysa script gitti, yeniden yüklemek gerekir

# Tüm script cache'ini temizle (dikkatli kullanın!)
redis-cli SCRIPT FLUSH

Üretim ortamında deployment scriptlerinizde önce SCRIPT LOAD, sonra SHA’yı uygulama config’ine yazma yaklaşımı yaygın kullanılır.

Gerçek Dünya Senaryosu 3: Dağıtık Kilit (Distributed Lock)

Microservice mimarilerinde aynı işin iki servis tarafından eş zamanlı yapılmaması için dağıtık kilit kullanılır. Lua ile basit ama güvenilir bir implementasyon:

# Kilit edinme scripti (Lock Acquire)
LOCK_ACQUIRE=$(redis-cli SCRIPT LOAD "
  local lock_key = KEYS[1]
  local lock_value = ARGV[1]   -- benzersiz token (UUID)
  local ttl = tonumber(ARGV[2])  -- saniye
  
  -- SET NX EX atomik ama biz burada ekstra kontrol istiyoruz
  local existing = redis.call('GET', lock_key)
  
  if existing == false then
    redis.call('SET', lock_key, lock_value, 'EX', ttl)
    return 1  -- Kilit alindi
  end
  
  if existing == lock_value then
    -- Bizim kilidimiz, TTL'i yenile (kilit yenileme)
    redis.call('EXPIRE', lock_key, ttl)
    return 2  -- Kilit yenilendi
  end
  
  return 0  -- Kilit baskasinda
")

# Kilit bırakma scripti (Lock Release) - sadece bizim kilidimizi silebiliriz
LOCK_RELEASE=$(redis-cli SCRIPT LOAD "
  local lock_key = KEYS[1]
  local lock_value = ARGV[1]
  
  local existing = redis.call('GET', lock_key)
  
  if existing == lock_value then
    redis.call('DEL', lock_key)
    return 1  -- Kilit birakildi
  end
  
  return 0  -- Kilit bizde degil, dokunma
")

echo "Acquire SHA: $LOCK_ACQUIRE"
echo "Release SHA: $LOCK_RELEASE"
# Kullanim ornegi
LOCK_TOKEN=$(cat /proc/sys/kernel/random/uuid)
LOCK_KEY="lock:email_processor"

# Kilit al
RESULT=$(redis-cli EVALSHA $LOCK_ACQUIRE 1 $LOCK_KEY $LOCK_TOKEN 30)

if [ "$RESULT" = "1" ] || [ "$RESULT" = "2" ]; then
  echo "Kilit alindi, is yapiliyor..."
  # ... asil is buraya ...
  sleep 2
  
  # Kilit birak
  redis-cli EVALSHA $LOCK_RELEASE 1 $LOCK_KEY $LOCK_TOKEN
  echo "Kilit birakildi"
else
  echo "Kilit baskasinda, atlanıyor..."
fi

Buradaki kritik nokta: kilit bırakma sırasında önce değeri kontrol edip sonra sil. Bu iki adım atomik olmadan yapılırsa, bizim TTL süremiz dolarken başkası kilidi alabilir ve biz onun kilidini silmiş oluruz. Lua bunu önlüyor.

Hata Yakalama ve Debug

Script geliştirirken redis.log() fonksiyonu hayat kurtarır. Redis log seviyelerine göre yazabilirsiniz.

redis-cli EVAL "
  redis.log(redis.LOG_WARNING, 'Script basliyor')
  
  local val = redis.call('GET', KEYS[1])
  redis.log(redis.LOG_DEBUG, 'Deger: ' .. tostring(val))
  
  if val == false then
    redis.log(redis.LOG_NOTICE, 'Anahtar bulunamadi: ' .. KEYS[1])
    return redis.status_reply('nil')
  end
  
  return val
" 1 testkey

Log seviyeleri:

  • redis.LOG_DEBUG: En detaylı, geliştirme için
  • redis.LOG_VERBOSE: Orta detay
  • redis.LOG_NOTICE: Önemli durumlar
  • redis.LOG_WARNING: Hatalar ve kritik durumlar

Redis config’de loglevel ayarınız debug değilse DEBUG loglar görünmez. Üretimde genellikle notice seviyesi kullanılır.

Zaman Aşımı ve Güvenlik Ayarları

Lua scriptlerin sonsuz döngüye girme ihtimali var. Redis bunu lua-time-limit ile kontrol eder.

# redis.conf ayarları
# Varsayılan 5000ms (5 saniye)
# Bu süre aşılırsa Redis BUSY hatası verir ama script çalışmaya devam eder
lua-time-limit 5000

# Çalışan scripti zorla durdur
redis-cli SCRIPT KILL

# Eğer script veri yazdıysa SCRIPT KILL çalışmaz, sunucuyu kapatmak gerekebilir
redis-cli DEBUG SLEEP 0  # Bazen durumu resetlemek için

Önemli uyarı: SCRIPT KILL sadece yazma yapmamış (read-only) scriptleri durdurabilir. Bir script veri yazdıktan sonra yarıda durdurulursa atomiklik bozulur. Bu yüzden Redis, yazma yapan scriptlerin bitmesini bekler.

# Script güvenlik ayarları - redis.conf
# Tehlikeli komutların script içinden çağrılmasını engelle
rename-command EVAL ""  # EVAL'i tamamen devre dışı bırak (aşırı kısıtlama)
rename-command SCRIPT "gizli_script_komutu"  # Yeniden adlandır

Cluster Ortamında Lua Scripting

Redis Cluster kullanıyorsanız, Lua scriptleri sadece aynı slot’taki anahtarlarla çalışabilir. Bu önemli bir kısıtlama.

# Cluster'da sorun çıkaran kullanım
redis-cli -c EVAL "
  redis.call('SET', KEYS[1], '1')  -- slot 5649
  redis.call('SET', KEYS[2], '2')  -- slot 8193 - HATA!
" 2 key1 key2

# Çözüm: Hash tag kullan
# {user:123} hash tag'i ile tüm anahtarlar aynı slot'a gider
redis-cli -c EVAL "
  redis.call('SET', KEYS[1], '1')
  redis.call('SET', KEYS[2], '2')
" 2 {user:123}:profile {user:123}:settings

Hash tag süslü parantez içindeki kısma göre slot hesaplar. {user:123}:profile ve {user:123}:settings her ikisi de user:123 üzerinden aynı slot’a düşer.

Performans Optimizasyonu

Lua scriptlerin Redis’te çalışması çok hızlı ama bazı noktalara dikkat etmek gerekiyor.

# Kötü: Döngüde çok fazla Redis çağrısı
redis-cli EVAL "
  for i=1,1000 do
    redis.call('SET', 'key:' .. i, i)
  end
  return 'ok'
" 0

# İyi: Pipeline mantığını script içinde uygula, işlemleri toplu yap
redis-cli EVAL "
  local keys = {}
  local values = {}
  
  -- Önce tüm değerleri oku
  for i=1,#KEYS do
    values[i] = redis.call('GET', KEYS[i])
  end
  
  -- Sonra toplu işle
  local total = 0
  for i=1,#values do
    if values[i] ~= false then
      total = total + tonumber(values[i])
    end
  end
  
  return total
" 5 counter1 counter2 counter3 counter4 counter5

Bir diğer performans ipucu: EVALSHA kullanmak, her seferinde script kodunu ağdan göndermekten çok daha hızlı. Özellikle büyük scriptlerde bu fark belirginleşir.

Python ile Entegrasyon

Gerçek projelerde Redis Lua scriptlerini uygulama kodundan çağırırsınız. Python örneği:

# requirements: redis-py
# pip install redis

python3 << 'EOF'
import redis
import uuid

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

# Script tanımı
RATE_LIMIT_SCRIPT = """
local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(redis.call('TIME')[1])
local window_start = now - window

redis.call('ZREMRANGEBYSCORE', key, 0, window_start)
local count = redis.call('ZCARD', key)

if count >= limit then
    return 0
end

local member = tostring(now) .. ':' .. tostring(math.random(999999))
redis.call('ZADD', key, now, member)
redis.call('EXPIRE', key, window)

return limit - count - 1
"""

# Script'i kaydet
rate_limit = r.register_script(RATE_LIMIT_SCRIPT)

# Kullan
user_id = "user:42"
for i in range(5):
    result = rate_limit(keys=[f"ratelimit:{user_id}"], args=[60, 3])
    if result > 0:
        print(f"İstek {i+1} kabul edildi. Kalan hak: {result}")
    else:
        print(f"İstek {i+1} REDDEDILDI - Rate limit aşıldı")

EOF

register_script() metodu sha’yı cache’ler ve her çağrıda EVALSHA kullanır, EVAL değil. Otomatik olarak script yönetimini halleder.

Yaygın Hatalar ve Çözümleri

Lua scripting yaparken sık karşılaşılan durumlar:

Tip dönüşümü sorunları: Redis her şeyi string döndürür, Lua’da aritmetik yapmadan önce tonumber() kullanın.

redis-cli EVAL "
  local val = redis.call('GET', KEYS[1])
  -- Yanlış:
  -- return val + 10  -- string + number = hata
  
  -- Doğru:
  return tonumber(val or '0') + 10
" 1 mycount

Nil değer kontrolü: Redis’te olmayan anahtar false döner, nil değil.

redis-cli EVAL "
  local val = redis.call('GET', KEYS[1])
  
  -- Yanlış:
  -- if val == nil then ...
  
  -- Doğru:
  if val == false then
    return 'anahtar yok'
  end
  
  return val
" 1 existingkey

Global değişken kirliliği: Lua’da local kullanmadan tanımlanan değişkenler global olur ve farklı script çağrıları arasında sorun yaratabilir. Her zaman local kullanın.

Sonuç

Lua scripting Redis’in gizli silahlarından biri. Çoğu sysadmin Redis’i basit bir key-value store olarak kullanıp geçiyor, ama gerçek gücü tam da bu tür atomik işlem kabiliyetlerinde yatıyor. Stok yönetiminden rate limiting’e, dağıtık kilitlerden karmaşık sayaç mantığına kadar birçok kritik senaryoyu güvenli ve tutarlı biçimde çözüyor.

Birkaç temel noktayı aklınızda tutun: EVALSHA kullanarak network trafiğini azaltın, cluster ortamında hash tag ile anahtarları aynı slot’a yönlendirin, lua-time-limit’in farkında olun ve local değişken disiplinine uyun. Bu dört kural çoğu üretim sorununu baştan engeller.

Özellikle yüksek eş zamanlılık gerektiren sistemlerde MULTI/EXEC+WATCH kombinasyonunu Lua script ile değiştirdiğinizde hem kod karmaşıklığı düşer hem de retry mantığından kurtulursunuz. Bir kere alışınca, “bunu başka nasıl yapardım” diye düşündüğünüz durumlar olacak.

Yorum yapın