Node.js-taustakehityksen bootcamp · Oppitunti

Cache stampede- ja thundering herd -ilmiöiden estäminen

Lievennä ruuhkapiikkejä pyyntöjen yhdistämisellä, satunnaistetuilla TTL-arvoilla ja todennäköisyyspohjaisella ennenaikaisella vanhentamisella.

Oppitunti 4/413 vaihetta

Cache stampede- ja thundering herd -ilmiöiden estäminen on ilmainen Node.js-taustakehityksen bootcamp-oppitunti CoddyKitissä. Tämä on oppitunti 4/4. Voit lukea koko oppitunnin alta ilmaiseksi ja harjoitella sen jälkeen käytännössä selaimessa sisäänrakennetulla koodieditorilla ja ympäri vuorokauden käytettävissä olevan tekoälytuutorin avulla. Oppitunti kuuluu Node.js-taustakehityksen bootcamp-oppimispolkuun, ja edistymisesi synkronoituu verkon ja CoddyKit-sovelluksen välillä. Node.js-taustakehityksen bootcamp-kurssilla on yhteensä 4 oppituntia.

Mikä on välimuistiryntäys?

Välimuistiryntäys (josta käytetään myös nimitystä thundering herd) tapahtuu, kun suosittu välimuistiarvo vanhenee ja monet samanaikaiset pyynnöt ohittavat välimuistin täsmälleen samalla hetkellä. Kaikki ryntäävät tietokantaan laskemaan saman arvon uudelleen.

  • Yksi avain vanhenee → 5 000 käynnissä olevaa pyyntöä havaitsee välimuistihudin
  • 5 000 identtistä kyselyä osuu tietokantaan samanaikaisesti
  • Tietokanta kuormittuu liikaa, viive kasvaa ja joskus koko järjestelmä kaatuu

Ironista on, että välimuistin tarkoitus on suojata tietokantaa, mutta vanhenemishetkestä tulee kaikista vaarallisin hetki.

Ongelman havaitseminen koodissa

Tässä on klassinen naiivi cache-aside-luku. Se toimii hyvin vähäisellä liikenteellä, mutta suuren samanaikaisuuden vallitessa jokainen huti käynnistää oman loadFromDb-kutsunsa.

Huomaattehan, ettei mikään estä 1 000:ta kutsujaa suorittamasta loadFromDb-kutsua samalle avaimelle yhtä aikaa.

async function getUser(redis, db, id) {
  const key = 'user:' + id;
  const cached = await redis.get(key);
  if (cached !== null) {
    return JSON.parse(cached);
  }
  // STAMPEDE RISK: every concurrent miss runs this
  const user = await db.loadUser(id);
  await redis.set(key, JSON.stringify(user), 'EX', 60);
  return user;
}

Strategia 1: pyyntöjen yhdistäminen

Pyyntöjen yhdistäminen (engl. single-flight tai in-flight de-duplication) tarkoittaa, että kun monet kutsujat haluavat saman avaimen arvon samanaikaisesti, vain yksi niistä laskee arvon oikeasti. Muut odottavat samaa promisea.

  • Ylläpidetään muistissa karttaa muodossa key → pending promise
  • Ensimmäinen kutsuja käynnistää työn ja tallentaa promisen
  • Samanaikaiset kutsujat löytävät odottavan promisen ja odottavat sitä
  • Kun promise valmistuu, merkintä poistetaan

Tämä supistaa tietokantakutsujen määrän prosessia kohden N:stä yhteen.

Single-Flightin toteuttaminen

Minimaalinen, riippuvuuksista vapaa single-flight-apufunktio. Kaikki saman avaimen samanaikaiset kutsujat jakavat yhden taustalla olevan promisen. finally-lohko tyhjentää merkinnän, joten seuraavalla kierroksella arvo lasketaan uudelleen.

Tämä koodikatkelma on täysin itsenäinen ja havainnollistaa yhdistämisen toimintaa simuloidulla hitaalla työllä.

function createSingleFlight() {
  const inFlight = new Map();
  return function run(key, fn) {
    if (inFlight.has(key)) {
      return inFlight.get(key);
    }
    const p = Promise.resolve()
      .then(fn)
      .finally(() => inFlight.delete(key));
    inFlight.set(key, p);
    return p;
  };
}

async function main() {
  const flight = createSingleFlight();
  let dbCalls = 0;
  const load = () => new Promise(res => {
    dbCalls++;
    setTimeout(() => res('value-' + dbCalls), 50);
  });

  // 5 concurrent callers, same key
  const results = await Promise.all(
    Array.from({ length: 5 }, () => flight('user:42', load))
  );
  console.log('results:', results);
  console.log('actual db calls:', dbCalls);
}

main();

Yhdistämisen rajoitus: se koskee vain yhtä prosessia

Muistissa toteutettu single-flight poistaa päällekkäisyydet vain yhden Node.js-prosessin sisällä. Jos ajatte 20 instanssia kuormantasaajan takana, voitte silti saada vanhentunutta avainta kohden enintään 20 samanaikaista tietokantakutsua — yhden prosessia kohden.

  • Erinomainen tapa vähentää N pyyntöä → 1:een kunkin instanssin sisällä
  • Ei yksin riitä suurille horisontaalisesti skaalatuille ympäristöille
  • Prosessien väliseen suojaukseen tarvitaan hajautettu lukko Redisissä

Yhdistäkää yhdistäminen (edullinen ja paikallinen) hajautettuun lukitukseen tai probabilistiseen vanhenemiseen, jotta suojaus kattaa koko klusterin.

Strategia 2: hajautettu lukko

Hajautettu lukko antaa yhdelle instanssille (koko ympäristössä) oikeuden laskea arvo uudelleen. Käyttäkää Redis-komentoa SET key value NX PX ttl: se asettaa avaimen vain, jos sitä ei ole olemassa, ja tekee tämän atomisesti.

  • Voittaja laskee arvon uudelleen ja täyttää välimuistin uudelleen
  • Häviäjät joko odottavat ja yrittävät lukea välimuistista uudelleen tai tarjoavat vanhentunutta dataa
  • Asettakaa lukolle aina TTL, jotta kaatunut voittaja ei voi lukita avainta pysyvästi

Tämä täydentää prosessin sisäistä yhdistämistä prosessien välisessä toiminnassa.

async function getWithLock(redis, db, id) {
  const key = 'user:' + id;
  const cached = await redis.get(key);
  if (cached !== null) return JSON.parse(cached);

  const lockKey = 'lock:' + key;
  const token = Math.random().toString(36).slice(2);
  // NX = only if absent, PX = lock TTL in ms
  const won = await redis.set(lockKey, token, 'NX', 'PX', 5000);

  if (won === 'OK') {
    try {
      const user = await db.loadUser(id);
      await redis.set(key, JSON.stringify(user), 'EX', 60);
      return user;
    } finally {
      // release only if we still own the lock
      if (await redis.get(lockKey) === token) await redis.del(lockKey);
    }
  }
  // Lost the race: briefly wait, then read the now-fresh cache
  await new Promise(r => setTimeout(r, 50));
  const retry = await redis.get(key);
  return retry !== null ? JSON.parse(retry) : db.loadUser(id);
}

Strategia 3: TTL-arvojen hajauttaminen

Jos lämmitätte 10 000 avainta silmukassa käyttäen samaa TTL-arvoa, ne kaikki vanhenevat samalla sekunnilla — seurauksena on useiden avainten samanaikainen synkronoitu välimuistiryntäys. TTL:n hajauttaminen levittää vanhenemishetkiä lisäämällä jokaiseen TTL-arvoon pienen satunnaisen poikkeaman.

  • Perus-TTL 300 s → todellinen TTL 270–330 s, satunnaistettuna avainten mukaan
  • Vanhenemishetket hajautuvat aikavälille sen sijaan, että ne keskittyisivät yhteen hetkeen
  • Edullinen, ei vaadi koordinointia ja toimii yhdessä kaikkien muiden strategioiden kanssa

Hajauttakaa TTL-arvot aina, kun täytätte tai päivitätte kerralla monia toisiinsa liittyviä avaimia.

// Add +/- jitterPct random spread around a base TTL
function jitteredTtl(baseSeconds, jitterPct = 0.1) {
  const spread = baseSeconds * jitterPct;
  const offset = (Math.random() * 2 - 1) * spread; // -spread..+spread
  return Math.max(1, Math.round(baseSeconds + offset));
}

// Demo: 5 keys warmed together get different lifetimes
for (let i = 0; i < 5; i++) {
  console.log('key' + i + ' ttl =', jitteredTtl(300));
}

Strategia 4: probabilistinen ennenaikainen vanheneminen

Probabilistinen ennenaikainen vanheneminen (XFetch-algoritmi) päivittää avaimen ennen sen todellista vanhenemista todennäköisyydellä, joka kasvaa vanhenemishetken lähestyessä. Näin yksi onnekas pyyntö laskee arvon uudelleen etuajassa, kun taas vanha arvo tarjotaan edelleen kaikille muille.

Klassisen säännön mukaan arvo lasketaan uudelleen, kun:

  • now - delta * beta * ln(random()) ≥ expiry

Tässä delta kertoo, kuinka kauan edellinen uudelleenlaskenta kesti, ja beta (oletusarvo 1) säätää aggressiivisuutta. Hitaasti laskettavat avaimet (suuri delta) alkavat päivittyä aiemmin, mikä on juuri toivottua.

XFetch koodissa

XFetchiä käytettäessä tallennetaan arvon rinnalle uudelleenlaskennan kesto (delta) ja absoluuttinen vanhenemisaika. Jokaisen luvun yhteydessä suoritetaan probabilistinen tarkistus. Useimmat kutsujat tarjoavat välimuistiarvon; ajoittain yksi kutsuja päivittää sen ennen vanhenemista.

Tämä itsenäinen esimerkki osoittaa, että vanhenemishetken lähestyessä ennenaikaisen päivityksen todennäköisyys kasvaa kohti arvoa 1.

function shouldRecompute(deltaMs, expiryMs, now, beta = 1) {
  // XFetch: earlier refresh as we near expiry, scaled by recompute cost
  const xfetch = now - deltaMs * beta * Math.log(Math.random());
  return xfetch >= expiryMs;
}

const now = Date.now();
const delta = 200;          // last recompute took 200ms
const expiry = now + 1000;  // value expires in 1s

let refreshes = 0;
for (let i = 0; i < 1000; i++) {
  // sample 'now' uniformly across the key's lifetime
  const t = now + Math.random() * 1000;
  if (shouldRecompute(delta, expiry, t)) refreshes++;
}
console.log('early refreshes out of 1000 reads:', refreshes);

Strategioiden yhdistäminen

Nämä tekniikat ovat toisiaan täydentäviä kerroksia, eivät kilpailijoita. Tuotantoympäristön välimuistiluvussa käytetään usein useita niistä:

  • Yhdistäminen — päällekkäinen työ supistetaan kussakin prosessissa
  • Hajautettu lukko — yksi uudelleenlaskenta koko ympäristössä
  • Hajautettu TTL — erävanhenemiset synkronoidaan erilleen toisistaan
  • Probabilistinen ennenaikainen vanheneminen — suositut avaimet päivitetään ennen kuin ne ehtivät aiheuttaa hutin

Aloittakaa yhdistämisestä ja TTL:n hajauttamisesta (edullista eikä vaadi koordinointia). Lisätkää lukko tai XFetch kaikkein suosituimmille ja kalleimmin laskettaville avaimille.

Stale-While-Revalidate

Käytännöllinen kaikki yhdistävä malli on stale-while-revalidate (SWR). Käyttäkää kahta elinaikaa — lyhyttä tuoretta ikkunaa ja pidempää vanhentunutta ikkunaa. Vanhentuneen ikkunan aikana vanha arvo tarjotaan heti ja käynnistetään taustalla päivitys, jonka single-flight poistaa päällekkäisyyksistä.

  • Käyttäjät joutuvat lähes koskaan odottamaan kylmää uudelleenlaskentaa
  • Päivitys suoritetaan kerran pyynnön kriittisen polun ulkopuolella
  • Yhdistäkää tämä TTL:n hajauttamiseen, jotta vanhentuneet ikkunat eivät pääty kaikki samaan aikaan

SWR muuttaa kovan hutin (kaikki odottavat) pehmeäksi hitiksi (yksi taustapäivitys, arvo tarjotaan heti kaikille).

async function swrGet(redis, db, id, freshSec = 60, staleSec = 600) {
  const key = 'user:' + id;
  const raw = await redis.get(key);
  if (raw) {
    const { value, storedAt } = JSON.parse(raw);
    const ageSec = (Date.now() - storedAt) / 1000;
    if (ageSec > freshSec) {
      // stale but usable: refresh in background, serve now
      refreshInBackground(redis, db, id, key, staleSec);
    }
    return value;
  }
  return refreshInBackground(redis, db, id, key, staleSec);
}

Pikatarkistus

Ajatte 30 Node.js-instanssia kuormantasaajan takana. Yksi erittäin suosittu avain vanhenee, ja teidän on taattava, että tietokantaan lähetetään sitä varten enintään yksi uudelleenlaskentakysely. Millä lähestymistavalla tämä onnistuu?

Kertaus

Opitte suojautumaan välimuistiryntäyksiltä ja thundering herd -ilmiöltä Node.js:ssä:

  • Pyyntöjen yhdistäminen supistaa samanaikaisen päällekkäisen työn yhteen promiseen — mutta vain prosessikohtaisesti.
  • Hajautetut lukot (SET NX PX) laajentavat takuun koko ympäristöön; antakaa lukolle aina TTL.
  • Hajautetut TTL-arvot poistavat erävanhenemisten synkronoinnin, joten monet avaimet eivät vanhene samalla hetkellä.
  • Probabilistinen ennenaikainen vanheneminen (XFetch) päivittää suositut ja kalliit avaimet ennen kuin ne ehtivät aiheuttaa hutin.
  • Stale-while-revalidate tarjoaa vanhan datan heti ja päivittää sen kerran taustalla.

Rakentakaa niistä kerroksia: TTL:n hajauttaminen ja yhdistäminen perustaksi, minkä jälkeen lukot tai XFetch kaikkein suosituimmille avaimille.

Aloita maksutta

Opi JavaScript tekoälytuutorin avulla — ilmaiseksi

Kirjoita ja suorita oikeaa koodia selaimessa, saa välitöntä apua tekoälytuutorilta ympäri vuorokauden ja jatka siitä, mihin jäit, verkossa tai sovelluksessa.

Kurssit
22
Oppitunnit
92

Usein kysytyt kysymykset

Onko oppitunti ”Cache stampede- ja thundering herd -ilmiöiden estäminen” ilmainen?

Kyllä – oppitunnin ”Cache stampede- ja thundering herd -ilmiöiden estäminen” koko tekstin voi lukea täällä verkossa ilmaiseksi. Jos haluat harjoitella interaktiivisesti sisäänrakennetulla koodieditorilla ja ympäri vuorokauden käytettävissä olevan tekoälytuutorin avulla sekä avata koko Node.js-taustakehityksen bootcamp-kurssin, päivitä CoddyKit PROhon. Node.js-taustakehityksen bootcamp-kurssilla on yhteensä 4 oppituntia.

Mitä opin oppitunnilla ”Cache stampede- ja thundering herd -ilmiöiden estäminen”?

Lievennä ruuhkapiikkejä pyyntöjen yhdistämisellä, satunnaistetuilla TTL-arvoilla ja todennäköisyyspohjaisella ennenaikaisella vanhentamisella. Harjoittelet Node.js-taustakehityksen bootcamp-aihetta koodilla, jonka suoritat suoraan selaimessa. Ympäri vuorokauden käytettävissä oleva tekoälytuutori vastaa kysymyksiisi oppitunnin aikana.

Tarvitsenko kokemusta aloittaakseni Node.js-taustakehityksen bootcamp-opiskelun?

Aiempi kokemus ei ole tarpeen. CoddyKitin Node.js-taustakehityksen bootcamp-oppimispolku sopii vasta-alkajista edistyneisiin, joten voit aloittaa tästä tai alusta ja edetä omaan tahtiisi. Tämä on oppitunti 4/4.

Kuinka kauan ”Cache stampede- ja thundering herd -ilmiöiden estäminen”-oppitunnin suorittaminen kestää?

Useimmat CoddyKitin oppitunnit kestävät noin 5–10 minuuttia. Jokainen oppitunti on lyhyt ja interaktiivinen, joten edistyt tasaisesti ja voit jatkaa siitä, mihin jäit – sekä verkossa että sovelluksessa.

Voinko kirjoittaa ja suorittaa koodia tällä Node.js-taustakehityksen bootcamp-oppitunnilla?

Kyllä. Jokainen Node.js-taustakehityksen bootcamp-oppitunti sisältää sisäänrakennetun koodieditorin, joten voit kirjoittaa ja suorittaa oikeaa koodia suoraan selaimessa ja saada välitöntä palautetta tekoälyltä – paikallista asennusta ei tarvita.

Kaikki tämän kurssin oppitunnit

  1. Cache-aside-, write-through- ja TTL-strategiat
  2. Hajautetut lukot ja Redlock-algoritmi
  3. Pub/Sub, Streams ja nopeusrajoitus Redisillä
  4. Cache stampede- ja thundering herd -ilmiöiden estäminen
← Takaisin: Node.js-taustakehityksen bootcamp