Rate limiting implementálása a Redis segítségével

A modern webalkalmazások és mikroszolgáltatások világában az API-k jelentik a gerincet, amelyen keresztül a különböző rendszerek kommunikálnak. Azonban az API-k nyitott természete sebezhetővé teszi őket a túlterheléssel, a visszaélésekkel és a rosszindulatú támadásokkal szemben. Itt lép színre a rate limiting, azaz a kéréskorlátozás, amely alapvető védelmi mechanizmusként szolgál, biztosítva a rendszer stabilitását és méltányos használatát. De hogyan implementálhatjuk ezt hatékonyan, különösen, ha nagy teljesítményű, elosztott rendszerekről van szó? A válasz gyakran a Redis-ben rejlik.

Ez a cikk részletes útmutatót nyújt a rate limiting implementálásához a Redis segítségével. Megvizsgáljuk, miért ideális választás a Redis erre a célra, bemutatjuk a leggyakoribb algoritmusokat, és gyakorlati példákkal illusztráljuk azok megvalósítását, kitérve az atomikus műveletekre és a Lua scriptek szerepére is.

Mi az a Rate Limiting és Miért Fontos?

A rate limiting egy technika, amely korlátozza azt a sebességet, amellyel egy felhasználó, IP-cím vagy alkalmazás kéréseket küldhet egy szervernek vagy API-nak adott időegység alatt. Ennek bevezetése számos kritikus előnnyel jár:

  • DDoS és Brute-Force Támadások Kivédése: A túlzott számú kérés korlátozása megnehezíti a szolgáltatásmegtagadási (DDoS) támadásokat és a jelszófeltörési (brute-force) kísérleteket.
  • Erőforrásvédelem: Megakadályozza, hogy egyetlen felhasználó vagy alkalmazás monopolizálja a szerver erőforrásait, biztosítva a stabil működést mindenki számára.
  • Költséghatékonyság: Felhőalapú szolgáltatások esetén a kérések számának korlátozása csökkentheti az infrastruktúra költségeit.
  • API Visszaélések Megelőzése: Korlátozza az adatok túlzott lekaparását (scraping) vagy az API-val való visszaélést.
  • Rendszerstabilitás: Megvédi a háttérrendszert a váratlan forgalmi csúcsoktól, elősegítve a kiszámítható teljesítményt.

Miért Ideális Választás a Redis a Rate Limitingre?

A Redis (Remote Dictionary Server) egy nyílt forráskódú, memóriában tárolt adatszerkezet-tár, amelyet adatbázisként, gyorsítótárként és üzenetközvetítőként használnak. Számos tulajdonsága miatt kiválóan alkalmas a rate limiting megoldások implementálására:

  • Villámgyors Működés: Mivel az adatok memóriában tárolódnak, a Redis rendkívül gyorsan képes kéréseket kezelni, ami létfontosságú a nagy forgalmú rendszerekben.
  • Atomikus Műveletek: A Redis garantálja, hogy az egyes parancsok atomikusan, azaz megszakítás nélkül hajtódnak végre. Ez kulcsfontosságú a versenyhelyzetek (race conditions) elkerülésében, amikor több egyidejű kérés próbálja módosítani ugyanazt a számlálót.
  • Gazdag Adatszerkezetek: A Redis számos adatszerkezetet kínál (stringek, listák, halmazok, rendezett halmazok, hash-ek), amelyek rugalmasan használhatók különböző rate limiting algoritmusok megvalósításához.
  • Perzisztencia: Bár memóriában tárolódik, a Redis képes az adatok lemezre mentésére, így újraindítás esetén sem vesznek el.
  • Skálázhatóság: A Redis Cluster lehetővé teszi a horizontális skálázást, így elosztott rendszerekben is hatékonyan használható.

A Rate Limiting Alapvető Algoritmusai Redis Segítségével

Nézzük meg a leggyakoribb rate limiting algoritmusokat és azok Redis-alapú megvalósítását.

1. Fix Ablakos Számláló (Fixed Window Counter)

Ez a legegyszerűbb algoritmus. Lényege, hogy egy adott időablakon (pl. 60 másodperc) belül számlálja a kéréseket. Amikor egy kérés beérkezik, a számláló növekszik. Ha a számláló eléri a limitet, a további kéréseket elutasítja az időablak végéig. Az időablak lejártakor a számláló visszaáll nullára.

Redis Implementáció:

Egy Redis string kulcsot használunk a számláló tárolására. Az INCR parancs növeli az értékét, az EXPIRE pedig beállítja a kulcs élettartamát (TTL).

# Feltételezve, hogy a felhasználó IP-címe az "192.168.1.1", az API végpont pedig "/api/adatok"
# és a limit 10 kérés per 60 másodperc

# Egyedi kulcs generálása az aktuális időablakhoz (pl. "user:192.168.1.1:/api/adatok:1678886400")
# A kulcsot célszerű az aktuális időlap (pl. óra, perc) alapján generálni.
# Egyszerűség kedvéért most egy statikusabb kulcsot használunk, de az időablakhoz igazodó kulcs a jobb.

KEY = "rate_limit:ip:192.168.1.1:endpoint:/api/data"
LIMIT = 10
WINDOW_SECONDS = 60

# Ellenőrzés és számláló növelése egy Lua scripttel az atomicitás érdekében
-- A scriptben:
-- KEYS[1] a kulcs neve
-- ARGV[1] a limit
-- ARGV[2] az időablak másodpercben

local current_count = redis.call('INCR', KEYS[1])
if current_count == 1 then
    redis.call('EXPIRE', KEYS[1], ARGV[2])
end

if current_count > tonumber(ARGV[1]) then
    return 0 -- Limit túllépve
else
    return 1 -- Kérés engedélyezve
end

Ez a Lua script egyetlen atomikus műveletként kezeli a számláló növelését és az élettartam beállítását, megelőzve a versenyhelyzeteket. A redis-cli-ben futtatva:

EVAL "local current_count = redis.call('INCR', KEYS[1]); if current_count == 1 then redis.call('EXPIRE', KEYS[1], ARGV[2]); end; if current_count > tonumber(ARGV[1]) then return 0 else return 1 end" 1 rate_limit:ip:192.168.1.1:endpoint:/api/data 10 60

Hátrányok:

  • Burst probléma: Az ablak elején az összes limit felhasználható egyszerre, ami a rendszer túlterheléséhez vezethet. Például, ha a limit 100 kérés/perc, akkor az 0:00:00 és 0:00:01 között 100 kérés érkezhet, majd a következő percig egy sem. A 0:00:59 és 0:01:00 között ismét 100 kérés, ami összességében 200 kérés 2 másodperc alatt.

2. Csúszó Ablakos Napló (Sliding Window Log)

Ez az algoritmus pontosabb, mert figyelembe veszi az egyes kérések pontos időbélyegét. Amikor egy kérés beérkezik, annak időbélyegét eltárolja. Egy kérés engedélyezése előtt megnézi, hány kérés érkezett az elmúlt N másodpercben (vagy percben), és ha ez a szám a limiten belül van, engedélyezi, majd eltárolja az új kérés időbélyegét.

Redis Implementáció:

A Redis Sorted Set (rendezett halmaz) adatszerkezete tökéletesen alkalmas erre. A kérések időbélyegeit (score) tároljuk az elemenként (member) és kulcsérték (member) azonosítóként.

# Feltételezve, hogy a felhasználó ID-je "user_123", limit 10 kérés per 60 másodperc

KEY = "rate_limit:user:user_123"
LIMIT = 10
WINDOW_MILLISECONDS = 60 * 1000 # 60 másodperc milliszekundumban

# Atomikus műveletek egy Lua scripttel:
-- KEYS[1] a Sorted Set kulcs neve
-- ARGV[1] a limit
-- ARGV[2] az időablak milliszekundumban
-- ARGV[3] az aktuális időbélyeg milliszekundumban (Unix epoch)

local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current_timestamp = tonumber(ARGV[3])
local min_timestamp = current_timestamp - window

-- 1. Eltávolítjuk az időablakon kívül eső régi kéréseket
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, min_timestamp)

-- 2. Megszámoljuk a fennmaradó kéréseket
local count = redis.call('ZCARD', KEYS[1])

-- 3. Ha a számláló túllépi a limitet, elutasítjuk a kérést
if count >= limit then
    return 0 -- Limit túllépve
else
    -- 4. Kérés engedélyezése, és az aktuális időbélyeg hozzáadása a halmazhoz
    redis.call('ZADD', KEYS[1], current_timestamp, current_timestamp)
    -- Opcionálisan beállíthatunk egy TTL-t a Sorted Set-nek, hogy ne gyűljön túl sok régi adat,
    -- de a ZREMRANGEBYSCORE folyamatosan takarít.
    -- redis.call('EXPIRE', KEYS[1], math.ceil(window / 1000) * 2) -- Dupla időablak a biztonság kedvéért

    return 1 -- Kérés engedélyezve
end

A Lua script futtatásához szükséges paraméterek generálása:

# current_timestamp_ms = $(date +%s%3N)
# redis-cli EVAL "..." 1 rate_limit:user:user_123 10 60000 $current_timestamp_ms

Előnyök:

  • Pontos: Nincs burst probléma, a limit egyenletesebben oszlik el az időablakban.
  • Méltányos: Minden kérés a saját „mini” ablakában értékelődik ki.

Hátrányok:

  • Memóriaigényes: Minden egyes kérés időbélyegét eltárolja, ami nagy forgalom esetén jelentős memóriahasználatot eredményezhet.

3. Csúszó Ablakos Számláló (Sliding Window Counter – Hibrid Megközelítés)

Ez az algoritmus egyfajta kompromisszum a fix ablakos számláló hatékonysága és a csúszó ablakos napló pontossága között. Két fix ablakos számlálót használ: egyet az aktuális ablakhoz, egyet pedig az előző ablakhoz. A korlátozás eldöntésekor súlyozott átlagot számol az aktuális és az előző ablakban lévő kérések számából, az aktuális ablakban eltelt idő arányában.

Redis Implementáció:

Két Redis string kulcsot használunk, mindegyik egy-egy fix ablak számlálója. Az atomicitás itt is kulcsfontosságú, ezért Lua scriptet használunk.

# Feltételezve: limit = 100 kérés, időablak = 60 másodperc (WINDOW_SECONDS)

# KEY_PREFIX = "rate_limit:user:user_123"
# LIMIT = 100
# WINDOW_SECONDS = 60
# current_timestamp = current_time_in_seconds (Unix epoch)

# Az aktuális ablak kulcsa
# current_window_start = floor(current_timestamp / WINDOW_SECONDS) * WINDOW_SECONDS
# current_key = KEY_PREFIX .. ":" .. current_window_start

# Az előző ablak kulcsa
# previous_window_start = current_window_start - WINDOW_SECONDS
# previous_key = KEY_PREFIX .. ":" .. previous_window_start

# Atomikus műveletek egy Lua scripttel:
-- KEYS[1] az aktuális ablak kulcsa
-- KEYS[2] az előző ablak kulcsa
-- ARGV[1] a limit
-- ARGV[2] az időablak másodpercben
-- ARGV[3] az aktuális időbélyeg másodpercben (Unix epoch)

local current_key = KEYS[1]
local previous_key = KEYS[2]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current_timestamp = tonumber(ARGV[3])

-- Számítsuk ki az aktuális ablak kezdőidejét és az ablakon belüli "előrehaladást"
local current_window_start = math.floor(current_timestamp / window) * window
local elapsed_in_current_window = current_timestamp - current_window_start
local weight_current = elapsed_in_current_window / window
local weight_previous = 1 - weight_current

-- Növeljük az aktuális ablak számlálóját
local current_count = redis.call('INCR', current_key)
if current_count == 1 then
    -- Állítsuk be az aktuális ablak TTL-jét. Két ablaknyi ideig éljen, hogy az előző ablak lefedéséhez is megmaradjon.
    redis.call('EXPIRE', current_key, window * 2)
end

-- Lekérdezzük az előző ablak számlálóját
local previous_count = redis.call('GET', previous_key)
if previous_count == false then
    previous_count = 0
else
    previous_count = tonumber(previous_count)
end

-- Kiszámítjuk a súlyozott összeget
local estimated_total = (previous_count * weight_previous) + current_count

-- Ellenőrizzük a limitet
if estimated_total > limit then
    return 0 -- Limit túllépve
else
    return 1 -- Kérés engedélyezve
end

Ez a Lua script elegáns módon implementálja a hibrid csúszó ablakos számlálót. Minden kérésnél frissíti az aktuális ablak számlálóját, lekérdezi az előző ablakét (ami lehet 0, ha már lejárt vagy még nem létezett), és a súlyozott átlag alapján dönti el, hogy a kérés engedélyezhető-e.

Előnyök:

  • Jó egyensúly: Kisebb memóriaigény, mint a csúszó ablakos naplónál, de sokkal pontosabb, mint a fix ablakos számláló.
  • Mérsékelt komplexitás: Bár Lua scriptet igényel, a logika kezelhető.

Hátrányok:

  • Kismértékű pontatlanság lehetséges az ablakváltásoknál, de elhanyagolható a fix ablakos számlálóhoz képest.

Atomikus Műveletek és Lua Scriptek Jelentősége

Ahogy a fenti példák is mutatják, a Redis Lua scriptek használata elengedhetetlen a rate limiting implementációjánál. Ennek oka az atomicitás:

  • A Redis parancsok egyenként atomikusak. Azonban egy összetett művelet, mint például a számláló növelése, majd a limit ellenőrzése, majd az TTL beállítása, több Redis parancsból áll.
  • Ha ezeket a parancsokat külön-külön küldjük el a Redis-nek, két párhuzamos kliens között versenyhelyzet alakulhat ki. Például, az első kliens növelheti a számlálót, de mielőtt ellenőrizné a limitet, a második kliens is növelheti, így mindkettő átjuthat a limiten, mielőtt bármelyik is értesülne a tényleges számláló értékéről.
  • A Lua script a Redis szerver oldalon hajtódik végre egyetlen atomikus egységként. Ez azt jelenti, hogy amíg egy Lua script fut, addig a Redis más parancsot nem hajt végre. Ez garantálja, hogy a rate limiting logika mindig konzisztensen és megbízhatóan működjön, elkerülve a versenyhelyzeteket.

Fejlett Megfontolások és Best Practice-ek

A rate limiting implementálása túlmutat az alapvető algoritmusokon. Íme néhány további fontos szempont:

  1. Elosztott Rate Limiting:

    Nagy, elosztott rendszerekben, ahol több alkalmazás példány fut, kulcsfontosságú, hogy a rate limiting globálisan működjön. A Redis Cluster természetes módon támogatja ezt, mivel az adatok szétoszthatók a klaszter csomópontjai között, de a kulcsoknak egy konzisztens hash-szabály szerint kell elhelyezkedniük. Bizonyos esetekben a Redlock algoritmusra is szükség lehet, bár ez ritkább, ha a Redis maga kezeli az atomicitást a scripteken keresztül.

  2. Granularitás:

    Döntse el, mi alapján szeretné korlátozni a kéréseket. Lehet:

    • IP-cím: Egyszerű, de problémás lehet NAT mögötti felhasználók esetén, vagy ha több felhasználó osztozik egy IP-n.
    • Felhasználói azonosító (User ID): Hitelesített felhasználók esetén pontosabb, de csak bejelentkezés után érvényes.
    • API kulcs: Alkalmazások azonosítására, lehetővé teszi a különböző alkalmazások eltérő limitekkel való kezelését.
    • Endpoint: Különböző API végpontokhoz eltérő limitek.

    Gyakran szükség van többrétegű rate limiting stratégiára (pl. IP-alapú korlátozás bejelentkezés előtt, felhasználói ID-alapú bejelentkezés után).

  3. Kliens Visszajelzés (HTTP Fejlécek):

    Fontos, hogy a kliensek értesüljenek a korlátozásokról és arról, mikor próbálkozhatnak újra. Az IETF javasolja a következő HTTP válaszfejlécek használatát (bár még nem szabványosak, széles körben elfogadottak):

    • X-RateLimit-Limit: A maximális kérések száma az időablakon belül.
    • X-RateLimit-Remaining: A még hátralévő kérések száma az aktuális időablakban.
    • X-RateLimit-Reset: Az az időpont (Unix timestamp másodpercben), amikor az aktuális limit visszaáll.

    Ha egy kérés limit túllépés miatt elutasításra kerül, a válasznak HTTP 429 Too Many Requests státuszkóddal kell jönnie, és tartalmaznia kell a Retry-After fejlécet, jelezve, mennyi idő múlva lehet újra próbálkozni.

  4. Hiba Kezelés és Graceful Degradation:

    Mi történik, ha a Redis nem elérhető? Fontos, hogy a rendszer ne álljon le teljesen. Lehetséges stratégiák:

    • Ideiglenes letiltás: Ha a Redis nem elérhető, ideiglenesen letiltja az API-t vagy korlátozza azt egy nagyon alacsony alapértelmezett limitre.
    • Fall-back cache: Egy helyi, memóriában tárolt cache használata egy rövid ideig.
    • Rendszerterhelés figyelése: Ha a Redis nem elérhető, de a háttérrendszer terhelése alacsony, engedélyezhetünk több kérést.
  5. Konfiguráció és Monitorozás:

    A limiteknek konfigurálhatónak kell lenniük, és ideális esetben dinamikusan módosíthatónak futásidőben. A rate limiting rendszer működését monitorozni kell, hogy azonosítani lehessen a visszaéléseket, a fals pozitív eseteket, és optimalizálni lehessen a limiteket.

Konklúzió

A rate limiting nem csupán egy opció, hanem egy alapvető szükséglet minden robusztus API és webalkalmazás számára. Védelmet nyújt a visszaélésekkel szemben, biztosítja az erőforrások méltányos elosztását és fenntartja a rendszer stabilitását. A Redis kivételes képességei – a sebesség, az atomikus műveletek és a rugalmas adatszerkezetek – ideális eszközzé teszik a komplex és nagy teljesítményű rate limiting megoldások megvalósításához.

A megfelelő algoritmus kiválasztása (legyen az a fix ablakos számláló az egyszerűségért, a csúszó ablakos napló a precizitásért, vagy a hibrid csúszó ablakos számláló a hatékonyságért), valamint az atomikus Lua scriptek alkalmazása biztosítja, hogy a rendszereink a legmostohább körülmények között is megállják a helyüket. Ne feledkezzünk meg a megfelelő klienskommunikációról és a monitorozásról sem, hogy a rate limiting ne csak védjen, hanem javítsa is a felhasználói élményt és a rendszer átláthatóságát. A Redis-alapú rate limiting segítségével magabiztosan építhetünk skálázható és ellenálló szolgáltatásokat.

Leave a Reply

Az e-mail címet nem tesszük közzé. A kötelező mezőket * karakterrel jelöltük