Vissza a bloghoz

ProCat Solutions

Másodpercenként több száz kérés: amit az első nagy terhelésű API-ink 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
Megosztás
Másodpercenként több száz kérés: amit az első nagy terhelésű API-ink tanítottak

Másodpercenként több száz kérés, és a válaszidő közvetlenül az ügyfél üzletét érinti. Az elmúlt fél évben több ilyen backendet adtunk át. Ugyanazok a problémák jöttek elő újra és újra. Forradalmi megoldás nincs köztük; ez inkább az az ellenőrzőlista, amit magunknak is jó lett volna hamarabb megírni.

Fejlesztő éjszaka a terminálkimenetet figyeli

Az adatbázis fogy el először

A Node.js eseményhurok szinte sosem fogy el. NestJS alatt egy jól megírt végpont ezres nagyságrendű kérést kezel másodpercenként egyetlen processzben, ha nem vár az adatbázisra. A gyakorlatban szinte mindig arra vár.

Ezért minden projektben három dolgot csinálunk:

  • Indexek a valós lekérdezések alapján. Éles környezetben az első lépés a pg_stat_statements bekapcsolása. 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, ahol a terv ténylegesen szekvenciális olvasást mutat. A „minden idegen kulcsra index” szabály jó kiindulópont, a mérést viszont nem pótolja.
  • Connection pooling tudatosan. A Node.js-processzek száma szorozva a poolmérettel nem lépheti túl a PostgreSQL max_connections értékét, és karbantartásra is kell tartalék. Több példánynál PgBouncer kerül a processzek és az adatbázis közé, tranzakciós módban. Ilyenkor le kell mondani a session-szintű beállításokról és a prepared statementekről. Ezt jobb előre tudni.
  • Rövid tranzakciók. Tranzakción belül nem hívunk külső szolgáltatást. Ha mégis kell, a hívás előtt lezárjuk, és az eredményt külön írjuk vissza.

Redis, három szerepben

Nálunk a Redis cache, rate limiter és zár egyszerre, és mindhárom szerep 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. Tökéletes cache-invalidálást nem tervezünk. A legtöbb esetben néhány másodperces elavulás belefér, és ez sokkal egyszerűbb, mint az írásokhoz kötött, eseményvezérelt törlés. Ahol nem fér bele, ott nem cache-elünk.

Rate limiting. Kliensenként (API-kulcs vagy IP alapján) csúszóablakos számlálót tartunk, és a válaszban visszaadjuk az X-RateLimit-* fejléceket. A limitet egy globális NestJS guard ellenőrzi, az egyes végpontok nem foglalkoznak vele. Így nem lehet 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ásakor, rövid lejáratú SET NX zárat használunk. Redlock helyett egyetlen Redis-példányt. A hibatűrést az idempotens feldolgozás adja, a zár ebben nem játszik szerepet.

Szerverrackek egy kis adatközpontban

Idempotencia és lapozás

Mindkettőt könnyű elhanyagolni, és nehéz utólag beilleszteni.

Az idempotencia-kulcs nálunk kötelező minden módosító végponton, 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 védi meg a rendszert a hálózati újrapróbálkozásokból eredő duplikált rendelésektől és üzenetektől.

A lapozásnál az OFFSET alapú megoldás tízezres nagyságrend felett érezhetően lassul, mozgó adathalmaznál pedig kihagy vagy megismétel elemeket. Kurzoralapú lapozásra váltottunk: a kliens visszakapja az utolsó látott elem rendezőkulcsát, és azzal kéri a következő oldalt. Az „ugorj a 47. oldalra” típusú felületeken ez kényelmetlenebb, API-knál szinte mindig jobb.

A p99 mondja meg az igazat

Az első éles hetek legfontosabb tanulsága: a naplófájlok önmagukban kevesek. Minden rendszerbe beépítjük:

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

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

Terheléses teszt indulás előtt

A saját becsléseinknek nem hiszünk. Minden indulás előtt k6-tal vagy hasonló eszközzel lefuttatunk egy 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 teszt a stagingen fut, amely Dockerben ugyanazzal a konfigurációval megy, mint az éles.

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

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

Nagy terhelésű backendet SaaS-projektek részeként is építünk: SaaS fejlesztés és üzemeltetés.

Megosztás
Széchenyi Terv Plusz kedvezményezetti infoblokk
QR Code