Redis ile Rate Limiting ve Throttling Uygulaması

Bir e-ticaret projesinde API gateway katmanını Redis ile güçlendirirken öğrendiğim şeyleri paylaşmak istiyorum. O dönemde sistemimiz günde yaklaşık 50 milyon istek alıyordu ve bazı müşteriler, kasıtlı ya da kasıtsız, API’yi adeta bir DDoS aracı gibi kullanıyordu. Çözüm olarak Redis tabanlı rate limiting uyguladık ve sonuçlar beklentimizin çok üzerinde oldu. Bu yazıda o süreçte öğrendiklerimi, hatalarımı ve doğru yaklaşımları aktaracağım.

Rate Limiting Nedir ve Neden Redis?

Rate limiting, bir istemcinin belirli bir zaman diliminde yapabileceği istek sayısını kısıtlama mekanizmasıdır. Throttling ise biraz farklı: istek sayısını keskin bir şekilde reddetmek yerine, hızı yavaşlatarak sistemi korumaya çalışır. İkisi de birbirini tamamlayan kavramlar.

Peki neden Redis? Çünkü rate limiting’in çalışabilmesi için birkaç kritik gereksinim var:

  • Atomik operasyonlar: Sayaç artırma işleminin thread-safe olması şart
  • Düşük gecikme: Her istek için milisaniyeler içinde karar verilmeli
  • TTL desteği: Zaman penceresi dolduğunda sayaçların otomatik sıfırlanması gerekiyor
  • Dağıtık mimari uyumu: Birden fazla uygulama sunucusu olduğunda merkezi bir state yönetimi lazım

Memcached de bazı bu özellikleri karşılıyor, fakat Redis’in Lua script desteği ve daha zengin veri yapıları rate limiting algoritmalarını çok daha temiz uygulamanızı sağlıyor.

Temel Algoritmaları Anlamak

Uygulamaya geçmeden önce hangi algoritmayı kullanacağınıza karar vermeniz gerekiyor. Her birinin trade-off’ları var.

Fixed Window (Sabit Pencere) Algoritması

En basit yaklaşım. Belirli bir zaman dilimi için sayaç tutuyorsunuz, dolunca reddediyorsunuz.

# Redis CLI ile Fixed Window örneği
# Kullanıcı başına dakikada 100 istek limiti

# İstek geldiğinde
redis-cli INCR "rate:user:12345:$(date +%Y%m%d%H%M)"
redis-cli EXPIRE "rate:user:12345:$(date +%Y%m%d%H%M)" 60

# Mevcut sayacı kontrol et
redis-cli GET "rate:user:12345:$(date +%Y%m%d%H%M)"

Bu yaklaşımın bir sorunu var: pencere sınırında abuse mümkün. Dakikanın son saniyesinde 100 istek, ardından yeni dakikanın ilk saniyesinde 100 istek daha gönderebilirsiniz. Yani 2 saniyede 200 istek. Bunu “boundary attack” olarak adlandırıyoruz.

Sliding Window Log Algoritması

Her isteğin timestamp’ini saklıyorsunuz ve her kontrol anında pencere dışındaki kayıtları temizliyorsunuz.

# Sliding Window Log - Redis Sorted Set ile

CURRENT_TIME=$(date +%s%3N)  # Milisaniye cinsinden
WINDOW_MS=60000               # 1 dakika
KEY="rate:sliding:user:12345"

# Eski kayıtları temizle
redis-cli ZREMRANGEBYSCORE $KEY 0 $((CURRENT_TIME - WINDOW_MS))

# Mevcut istek sayısını al
COUNT=$(redis-cli ZCARD $KEY)

if [ $COUNT -lt 100 ]; then
    # İsteği kaydet ve işle
    redis-cli ZADD $KEY $CURRENT_TIME $CURRENT_TIME
    redis-cli EXPIRE $KEY 70  # Pencere + biraz pay
    echo "İstek kabul edildi. Mevcut sayı: $((COUNT + 1))"
else
    echo "Rate limit aşıldı. Lütfen bekleyin."
fi

Daha doğru ama bellek maliyeti yüksek. Her istek için bir kayıt tutuyorsunuz. Milyonlarca kullanıcı için düşündüğünüzde sorunlu olabilir.

Token Bucket Algoritması

Gerçek dünyada en çok kullandığım yaklaşım bu. Bir kova var, içinde token’lar var. Her istek bir token tüketiyor, kova belirli hızda dolduruluyor. Kova boşsa istek reddediliyor.

# Token Bucket - Lua Script ile Atomik Implementasyon
cat > /tmp/token_bucket.lua << 'EOF'
local key = KEYS[1]
local now = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])      -- Saniyede eklenecek token
local capacity = tonumber(ARGV[3])  -- Maksimum token sayısı
local tokens_requested = tonumber(ARGV[4])

local last_tokens = tonumber(redis.call('HGET', key, 'tokens') or capacity)
local last_refreshed = tonumber(redis.call('HGET', key, 'last') or now)

-- Geçen süreye göre token ekle
local delta = math.max(0, now - last_refreshed)
local filled_tokens = math.min(capacity, last_tokens + (delta * rate / 1000))

local allowed = filled_tokens >= tokens_requested
local new_tokens = filled_tokens

if allowed then
    new_tokens = filled_tokens - tokens_requested
end

redis.call('HSET', key, 'tokens', new_tokens)
redis.call('HSET', key, 'last', now)
redis.call('EXPIRE', key, math.ceil(capacity / rate) + 1)

return { allowed and 1 or 0, math.floor(new_tokens) }
EOF

# Script'i test et
redis-cli EVAL "$(cat /tmp/token_bucket.lua)" 1 
    "bucket:user:12345" 
    $(date +%s%3N) 
    10 
    100 
    1

Sliding Window Counter (Hibrit Yaklaşım)

Production’da genellikle önerdiğim yaklaşım bu. Sliding Window Log’un bellek sorunu olmadan, Fixed Window’dan daha doğru sonuç veriyor.

# Sliding Window Counter - Python benzeri mantığı bash ile gösterelim
# Bu yaklaşım iki pencere arasında interpolasyon yapıyor

CURRENT_MS=$(date +%s%3N)
WINDOW_MS=60000
CURRENT_WINDOW=$((CURRENT_MS / WINDOW_MS))
PREV_WINDOW=$((CURRENT_WINDOW - 1))

CURRENT_KEY="rate:sw:user:12345:$CURRENT_WINDOW"
PREV_KEY="rate:sw:user:12345:$PREV_WINDOW"

CURRENT_COUNT=$(redis-cli GET $CURRENT_KEY || echo 0)
PREV_COUNT=$(redis-cli GET $PREV_KEY || echo 0)

# Önceki penceredeki ağırlık hesapla
ELAPSED=$((CURRENT_MS % WINDOW_MS))
PREV_WEIGHT=$(echo "scale=4; 1 - $ELAPSED / $WINDOW_MS" | bc)

# Tahmini toplam istek
ESTIMATED=$(echo "$PREV_COUNT * $PREV_WEIGHT + $CURRENT_COUNT" | bc | cut -d. -f1)

echo "Tahmini pencere içi istek: $ESTIMATED"

Production’a Hazır Implementasyon

Gerçek bir sistemde bash script yeterli değil. Aşağıda Redis ve Python ile production-grade bir rate limiter nasıl kurulur göstereyim. Önce Redis yapılandırmasına bakalım.

# Redis konfigürasyonu - rate limiting için optimize
cat >> /etc/redis/redis.conf << 'EOF'

# Rate limiting için özel ayarlar
maxmemory 2gb
maxmemory-policy allkeys-lru

# Latency için optimize
hz 100
dynamic-hz yes

# AOF persistence - rate limiting için genellikle gerekli değil
# ama audit gerekiyorsa açın
appendonly no

# TCP keepalive
tcp-keepalive 300
EOF

systemctl restart redis-server
# Redis Sentinel kurulumu - HA için kritik
# Ana Redis sunucusunda:
cat > /etc/redis/sentinel.conf << 'EOF'
port 26379
sentinel monitor mymaster 192.168.1.10 6379 2
sentinel down-after-milliseconds mymaster 5000
sentinel failover-timeout mymaster 60000
sentinel parallel-syncs mymaster 1

# Notification scripti
sentinel notification-script mymaster /usr/local/bin/redis-notify.sh
EOF

# Sentinel'ı başlat
redis-sentinel /etc/redis/sentinel.conf --daemonize yes
# Nginx ile Redis rate limiting entegrasyonu
# nginx-plus veya openresty gerektirir, ama mantığı görmek için:

cat > /etc/nginx/lua/rate_limit.lua << 'EOF'
local redis = require "resty.redis"
local red = redis:new()
red:set_timeout(100)

local ok, err = red:connect("127.0.0.1", 6379)
if not ok then
    ngx.log(ngx.ERR, "Redis bağlantı hatası: ", err)
    return ngx.exit(500)
end

local key = "rate:" .. ngx.var.remote_addr
local limit = 100
local window = 60

local count, err = red:incr(key)
if count == 1 then
    red:expire(key, window)
end

-- Bağlantıyı havuza geri ver
local ok, err = red:set_keepalive(10000, 100)

if count > limit then
    ngx.header["X-RateLimit-Limit"] = limit
    ngx.header["X-RateLimit-Remaining"] = 0
    ngx.header["Retry-After"] = window
    return ngx.exit(429)
end

ngx.header["X-RateLimit-Limit"] = limit
ngx.header["X-RateLimit-Remaining"] = limit - count
EOF

Farklı Katmanlar İçin Rate Limiting Stratejileri

Tek tip rate limiting uygulamak genellikle yanlış. Sisteminizde birkaç farklı katman olmalı.

IP Tabanlı Genel Koruma

# Redis'te IP bazlı blacklist yönetimi
# Şüpheli IP'leri geçici olarak blokla

# IP'yi geçici blokla (1 saat)
redis-cli SET "blacklist:ip:192.168.1.100" 1 EX 3600

# IP kontrolü
check_ip() {
    local IP=$1
    local BLOCKED=$(redis-cli GET "blacklist:ip:$IP")
    
    if [ -n "$BLOCKED" ]; then
        echo "IP bloklu: $IP"
        return 1
    fi
    
    # Rate check
    local COUNT=$(redis-cli INCR "rate:ip:$IP")
    if [ "$COUNT" -eq 1 ]; then
        redis-cli EXPIRE "rate:ip:$IP" 60
    fi
    
    if [ "$COUNT" -gt 200 ]; then
        # Limit aşıldı, otomatik blokla
        redis-cli SET "blacklist:ip:$IP" 1 EX 3600
        echo "Rate limit aşıldı, IP bloklandı: $IP"
        return 1
    fi
    
    return 0
}

check_ip "192.168.1.50"

Kullanıcı Tiplerine Göre Farklı Limitler

# Kullanıcı tier'ına göre rate limit belirleme
# HSET ile kullanıcı metadata'sı sakla

# Kullanıcı tier'larını ayarla
redis-cli HSET "user:config:12345" tier "premium" rate_limit 1000 window 60
redis-cli HSET "user:config:67890" tier "free" rate_limit 100 window 60
redis-cli HSET "user:config:11111" tier "enterprise" rate_limit 10000 window 60

# Dinamik limit kontrolü
check_user_rate() {
    local USER_ID=$1
    
    # Kullanıcı konfigürasyonunu al
    local LIMIT=$(redis-cli HGET "user:config:$USER_ID" rate_limit)
    local WINDOW=$(redis-cli HGET "user:config:$USER_ID" window)
    local TIER=$(redis-cli HGET "user:config:$USER_ID" tier)
    
    # Default değerler
    LIMIT=${LIMIT:-100}
    WINDOW=${WINDOW:-60}
    
    local WINDOW_KEY=$(date +%Y%m%d%H%M)
    local COUNT_KEY="rate:user:$USER_ID:$WINDOW_KEY"
    
    local COUNT=$(redis-cli INCR $COUNT_KEY)
    if [ "$COUNT" -eq 1 ]; then
        redis-cli EXPIRE $COUNT_KEY $((WINDOW + 5))
    fi
    
    local REMAINING=$((LIMIT - COUNT))
    
    if [ $COUNT -gt $LIMIT ]; then
        echo "429 Too Many Requests - Tier: $TIER, Limit: $LIMIT"
        # Prometheus metric güncelle
        redis-cli INCR "metrics:rate_limit_hit:$TIER"
        return 1
    fi
    
    echo "200 OK - Remaining: $REMAINING, Tier: $TIER"
    return 0
}

check_user_rate "12345"
check_user_rate "67890"

Dağıtık Sistemlerde Dikkat Edilmesi Gerekenler

Birden fazla Redis node’u olduğunda işler karmaşıklaşıyor. Redis Cluster kullanıyorsanız, rate limit key’lerinin aynı slot’ta olduğundan emin olmanız gerekiyor.

# Redis Cluster için hash tag kullanımı
# {user:12345} şeklinde süslü parantez aynı slot'a yönlendirir

# Yanlış kullanım (farklı slot'lara dağılabilir):
redis-cli -c SET "rate:user:12345:window1" 0
redis-cli -c SET "rate:user:12345:window2" 0

# Doğru kullanım (aynı slot garantili):
redis-cli -c SET "rate:{user:12345}:window1" 0
redis-cli -c SET "rate:{user:12345}:window2" 0

# Cluster modunda pipeline ile atomik operasyon
redis-cli -c --pipe << 'EOF'
INCR rate:{user:12345}:$(date +%Y%m%d%H%M)
EXPIRE rate:{user:12345}:$(date +%Y%m%d%H%M) 65
EOF

# Cluster sağlık kontrolü
redis-cli --cluster check 192.168.1.10:6379
# Redis Lua script ile atomik sliding window - production versiyonu
cat > /tmp/sliding_window.lua << 'EOF'
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])

-- Pencere dışındaki kayıtları temizle
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

-- Mevcut sayıyı al
local count = redis.call('ZCARD', key)

if count < limit then
    -- Unique member için now + random küçük sayı
    redis.call('ZADD', key, now, now .. math.random(1000000))
    redis.call('PEXPIRE', key, window)
    return {1, limit - count - 1}
else
    -- En erken istek ne zaman dolacak?
    local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
    local retry_after = 0
    if oldest[2] then
        retry_after = math.ceil((tonumber(oldest[2]) + window - now) / 1000)
    end
    return {0, retry_after}
end
EOF

# Script SHA'sını al ve cache'le
SCRIPT_SHA=$(redis-cli SCRIPT LOAD "$(cat /tmp/sliding_window.lua)")
echo "Script SHA: $SCRIPT_SHA"

# Cache'lenmiş script ile çalıştır (daha hızlı)
redis-cli EVALSHA $SCRIPT_SHA 1 "rate:sliding:{user:99999}" 
    $(date +%s%3N) 
    60000 
    100

Monitoring ve Alerting

Rate limiting koyup unutmak olmaz. Sistemin nasıl davrandığını sürekli izlemeniz gerekiyor.

#!/bin/bash
# rate_limit_monitor.sh - Cron'a ekleyin, 5 dakikada bir çalıştırın

REDIS_HOST="localhost"
REDIS_PORT="6379"
ALERT_THRESHOLD=80  # %80 doluluk uyarısı

# Toplam rate limit istatistikleri
echo "=== Rate Limit Monitoring Raporu ==="
echo "Zaman: $(date)"
echo ""

# En çok hit alan IP'leri bul
echo "--- Top 10 Rate Limited IP ---"
redis-cli KEYS "rate:ip:*" | head -20 | while read KEY; do
    IP=$(echo $KEY | sed 's/rate:ip://')
    COUNT=$(redis-cli GET $KEY)
    echo "$COUNT - $IP"
done | sort -rn | head -10

# Tier bazlı istatistikler
echo ""
echo "--- Rate Limit Hit İstatistikleri ---"
for TIER in free premium enterprise; do
    COUNT=$(redis-cli GET "metrics:rate_limit_hit:$TIER" || echo 0)
    echo "$TIER: $COUNT hit"
done

# Redis bellek kullanımı
USED_MEMORY=$(redis-cli INFO memory | grep used_memory_human | cut -d: -f2 | tr -d 'r')
echo ""
echo "Redis Bellek Kullanımı: $USED_MEMORY"

# Uyarı kontrolü
REJECT_COUNT=$(redis-cli KEYS "blacklist:ip:*" | wc -l)
if [ $REJECT_COUNT -gt 50 ]; then
    echo "UYARI: $REJECT_COUNT IP şu an bloklu! DDoS saldırısı olabilir."
    # Buraya Slack webhook veya email gönderimi ekleyebilirsiniz
fi

Gerçek Dünya Senaryosu: E-Ticaret Kampanya Koruması

Bir kampanya günü yaşadığımız sorunu ve çözümünü paylaşayım. Flash sale anında saniyede 10.000 istek geliyordu ve bazı botlar kampanya ürünlerini otomatik olarak sepete eklemeye çalışıyordu.

#!/bin/bash
# Kampanya endpoint koruması
# /api/campaign/add-to-cart için özel throttling

protect_campaign_endpoint() {
    local USER_ID=$1
    local PRODUCT_ID=$2
    local CAMPAIGN_ID=$3
    
    # 1. Global kampanya rate limit (tüm kullanıcılar)
    local GLOBAL_KEY="campaign:global:$CAMPAIGN_ID:$(date +%Y%m%d%H%M%S)"
    local GLOBAL_COUNT=$(redis-cli INCR $GLOBAL_KEY)
    if [ "$GLOBAL_COUNT" -eq 1 ]; then
        redis-cli EXPIRE $GLOBAL_KEY 2
    fi
    
    if [ $GLOBAL_COUNT -gt 5000 ]; then
        echo "503 Service Overloaded - Queue'ya al"
        redis-cli LPUSH "campaign:queue:$CAMPAIGN_ID" "$USER_ID:$PRODUCT_ID"
        return 2
    fi
    
    # 2. Kullanıcı bazlı ürün limiti
    local USER_PRODUCT_KEY="campaign:user:$USER_ID:product:$PRODUCT_ID"
    local EXISTING=$(redis-cli EXISTS $USER_PRODUCT_KEY)
    
    if [ "$EXISTING" -eq 1 ]; then
        echo "429 - Bu ürünü zaten talep ettiniz"
        return 1
    fi
    
    # 3. Kullanıcı genel rate limit
    local USER_KEY="campaign:rate:user:$USER_ID:$(date +%Y%m%d%H%M)"
    local USER_COUNT=$(redis-cli INCR $USER_KEY)
    if [ "$USER_COUNT" -eq 1 ]; then
        redis-cli EXPIRE $USER_KEY 65
    fi
    
    if [ $USER_COUNT -gt 10 ]; then
        echo "429 - Çok fazla istek gönderdiniz"
        # Şüpheli kullanıcıyı işaretle
        redis-cli ZADD "suspicious:users" $(date +%s) $USER_ID
        return 1
    fi
    
    # Tüm kontroller geçildi, işlemi yap
    redis-cli SET $USER_PRODUCT_KEY 1 EX 3600
    echo "200 OK - Talep kabul edildi"
    return 0
}

# Test
protect_campaign_endpoint "user123" "product456" "flash-sale-2024"

Sonuç

Redis tabanlı rate limiting ve throttling, basit görünse de üzerinde ciddi düşünülmesi gereken bir konu. Yanlış algoritma seçimi sisteminizi hem aşırı kısıtlayabilir hem de saldırılara açık bırakabilir.

Benim önerilerim şu şekilde özetlenebilir:

  • Algoritma seçimi: Basit senaryolar için Fixed Window yeterli. Production sistemlerde Sliding Window Counter veya Token Bucket tercih edin
  • Atomiklik: Her zaman Lua script kullanın, INCR + EXPIRE kombinasyonu race condition yaratabilir
  • Katmanlı yaklaşım: IP, kullanıcı ve endpoint bazlı limitleri ayrı ayrı uygulayın
  • Monitoring: Limit aşımlarını metrik olarak saklayın, anomalilere alert kurun
  • Hash tag: Redis Cluster kullanıyorsanız ilgili key’lerin aynı slot’ta olmasına dikkat edin
  • Graceful degradation: Rate limit aşıldığında sadece 429 dönmek yerine queue mekanizması düşünün
  • Test etmek: Yük testlerini production’a benzer bir ortamda mutlaka yapın

Bu konuda en büyük hatam, başlangıçta monitoring’i ihmal etmemdi. Rate limiting koydum, çalışıyor diye geçtim. Sonradan fark ettim ki bazı meşru kullanıcılar da etkileniyordu. İstatistikleri takip etmek, limitleri doğru kalibre etmek için kritik.

Herhangi bir konuda sorunuz olursa yorumlarda buluşalım.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir