Vissza a bloghoz

ProCat Solutions

Nagy terhelésű REST API-k tervezése: amit az első éles projektek tanítottak

Node.js és NestJS alapú API-k éles terhelés alatt: PostgreSQL indexelés, connection pooling, Redis cache, rate limiting, idempotencia és megfigyelhetőség.

ProCat Solutions rest-apinodejsnestjspostgresqlredisterheléses-teszt
Nagy terhelésű REST API-k tervezése: amit az első éles projektek tanítottak

Az elmúlt fél évben több olyan backend került ki a kezünk közül, amelynek másodpercenként több száz kérést kell kiszolgálnia, és amelynél a válaszidő közvetlenül befolyásolja az ügyfél üzletét. Ebben a bejegyzésben összeszedjük, mi az, ami ezekben a projektekben újra és újra előkerült. Semmi forradalmi nincs benne; inkább egy ellenőrzőlista, amit magunknak is jó lett volna hamarabb megírni.

Az adatbázis a szűk keresztmetszet, nem a Node.js

Az első tanulság: a Node.js eseményhurok szinte sosem az, ami elfogy. NestJS alatt egy jól megírt végpont ezres nagyságrendű kérést kezel másodpercenként egyetlen processzben, feltéve, hogy nem vár az adatbázisra. A gyakorlatban viszont majdnem mindig arra vár.

Amit ebből következően minden projektben megteszünk:

  • Indexek a valós lekérdezések alapján. A pg_stat_statements bekapcsolása az első lépés éles környezetben. A leglassabb és leggyakoribb lekérdezéseket EXPLAIN (ANALYZE, BUFFERS)-szel nézzük át, és csak azokra az oszlopkombinációkra teszünk indexet, amelyekre a terv ténylegesen szekvenciális olvasást mutat. A “minden idegen kulcsra index” szabály jó kiindulópont, de nem helyettesíti a mérést.
  • Connection pooling tudatosan. A Node.js-processzek száma szorozva a poolmérettel nem haladhatja meg a PostgreSQL max_connections értékét, és egy tartalékot is hagyni kell a karbantartáshoz. Több példány esetén PgBouncer kerül a processzek és az adatbázis közé tranzakciós módban; ilyenkor viszont a session-szintű beállításokról és a prepared statementekről le kell mondani, ezt érdemes előre tudni.
  • Rövid tranzakciók. Egy tranzakción belül nem hívunk külső szolgáltatást. Ha ez mégis kell, a hívás előtt lezárjuk, és az eredményt külön írjuk vissza.

Redis: cache, rate limit és zár egy helyen

A Redis nálunk három szerepet tölt be, és mindhárom másképp konfigurálandó.

Cache. Az olvasásintenzív végpontok (listák, konfigurációk, ritkán változó törzsadatok) válaszát kulcs alapján tároljuk, rövid TTL-lel. A cache-invalidálást nem próbáljuk tökéletesre tervezni: a legtöbb esetben egy néhány másodperces elavulás elfogadható, és sokkal egyszerűbb, mint az írásokhoz kötött, eseményvezérelt törlés. Ahol ez nem elfogadható, ott inkább nem cache-elünk.

Rate limiting. Csúszóablakos számlálót tartunk kliensenként (API-kulcs vagy IP alapján), és a válaszban visszaadjuk a X-RateLimit-* fejléceket. A limitet nem a végponton belül, hanem egy globális NestJS guardban ellenőrizzük, hogy ne lehessen elfelejteni.

Elosztott zár. Ha ugyanazt az erőforrást több példány is módosíthatja (például egy külső fizetési callback feldolgozása), rövid lejáratú SET NX zárat használunk. Nem Redlock, csak egyetlen példány; a hibatűrést nem a zárral, hanem az idempotens feldolgozással oldjuk meg.

Idempotencia és lapozás

Két olyan dolog, amit könnyű elhanyagolni, és nehéz utólag beilleszteni.

Az idempotencia-kulcs minden módosító végpontnál kötelező nálunk, amelyet külső rendszer vagy mobilalkalmazás hívhat. A kliens Idempotency-Key fejlécet küld, a szerver a kulcshoz tartozó választ egy ideig tárolja, és ismételt hívásra ugyanazt adja vissza. Ez menti meg a rendszert a hálózati újrapróbálkozásból származó duplikált rendelésektől és üzenetektől.

A lapozás esetében az OFFSET alapú megoldás tízezres nagyságrend felett érezhetően lassul, és mozgó adathalmaznál kihagy vagy megismétel elemeket. Kurzoralapú lapozásra váltottunk: a kliens az utolsó látott elem rendezőkulcsát kapja vissza, és azzal kéri a következő oldalt. Ez kevésbé kényelmes az “ugorj a 47. oldalra” jellegű felületeknél, de API-k esetében szinte mindig jobb.

Megfigyelhetőség: amit nem mérünk, azt nem tudjuk javítani

Az első éles hetek legfontosabb tanulsága, hogy a naplófájlok önmagukban nem elegendőek. Amit minden rendszerbe beépítünk:

  • strukturált (JSON) naplózás kérésazonosítóval, amely a bejövő kéréstől az adatbázis-lekérdezésig követhető,
  • Prometheus-metrikák végpontonként: kérésszám, hibaarány, válaszidő-percentilisek (p50, p95, p99),
  • az adatbázis-pool állapota (várakozók száma, foglalt kapcsolatok) mint metrika,
  • health check végpont, amely az adatbázist és a Redist is ellenőrzi, és amelyet a Docker és az Nginx is figyel.

A p99 válaszidő az, amire figyelni érdemes. Az átlag szinte mindig szép; a p99 mutatja meg, hogy a felhasználók egy százaléka mit él át.

Terheléses teszt, még az éles indulás előtt

Utolsóként, de nem utolsósorban: nem hisszük el a saját becsléseinket. Minden indulás előtt k6-tal vagy hasonló eszközzel futtatunk egy olyan forgatókönyvet, amely a várt csúcsterhelés többszörösét szimulálja, valós adatmennyiséggel feltöltött adatbázison. A tesztet a stagingen futtatjuk, amely Dockerben ugyanazzal a konfigurációval megy, mint az éles.

Amit ezek a tesztek jellemzően előhoznak: egy hiányzó index, egy túl kicsi pool, egy N+1 lekérdezés, amit az ORM elrejtett, vagy egy olyan végpont, amely egyetlen kérésre több megabájt JSON-t ad vissza. Mindegyik olcsóbb egy staging-teszten megtalálni, mint az első kampánynapon.

A következő bejegyzésben a többbérlős SaaS-architektúrákról írunk, ahol ezek a problémák egy újabb dimenzióval bővülnek: a bérlők egymásra hatásával.

Széchenyi Terv Plusz kedvezményezetti infoblokk
QR Code