Angular weather-intouch 2026-06-12 · 7 min čtení

Jak obsloužit tisíce uživatelů jedním voláním placeného API na lokalitu

Placené weather API se účtuje za volání. Naivní cache tě stejně zabije, když 500 lidí ve městě otevře appku ve stejnou vteřinu. Řešení: single-flight přes dvě vrstvy.

Jak obsloužit tisíce uživatelů jedním voláním placeného API na lokalitu

Náklad neškáluje s uživateli, ale s lokalitami × časem

Při návrhu klientských aplikací, jako je například předpověď počasí, vývojáři často narážejí na tvrdou ekonomickou realitu třetích stran. Poskytovatelé meteorologických dat si účtují poplatky za každé jednotlivé volání API. Pokud aplikaci navrhnete naivně, vaše provozní náklady porostou lineárně s počtem aktivních uživatelů. To je pro jakýkoli udržitelný projekt neakceptovatelné.

Typickým řešením bývá nasazení cache s časově omezenou platností (TTL). Samotná cache však při vyšší zátěži selhává v momentě, kdy platnost záznamu vyprší. Představte si situaci, kdy se blíží bouřka a pět set uživatelů ve stejném městě otevře aplikaci v tutéž vteřinu. Pokud cache entry právě expirovala, naivní systém propustí všech pět set požadavků přímo na placené upstream API. Výsledkem je zbytečný nárůst nákladů a potenciální zablokování ze strany poskytovatele kvůli překročení limitů (rate limiting).

Skutečným klíčem k minimalizaci nákladů je důsledná deduplikace volání API cache. Cílem je zajistit, aby nákladová křivka nekopírovala počet uživatelů, ale odpovídala vzorci:

Cena = počet sledovaných lokalit × časová granularita

Pokud máte v systému deset tisíc uživatelů, kteří se nacházejí v padesáti různých městech, systém by měl do externího API odeslat maximálně padesát požadavků za jedno aktualizační okno. Všechny ostatní dotazy musí být odbaveny z cache, případně musí počkat na dokončení již probíhajícího požadavku pro danou lokalitu. Tento princip se označuje jako single-flight a představuje základní stavební kámen efektivní backendové architektury.

Zaokrouhlení souřadnic: sousedi sdílí cache entry

Prvním krokem k úspěšné deduplikaci je sjednocení cache klíčů. Mobilní zařízení a moderní prohlížeče poskytují GPS souřadnice s vysokou přesností, často na šest nebo více desetinných míst. To v praxi znamená přesnost na milimetry až centimetry. Pokud by se do cache ukládaly surové souřadnice, každý uživatel by měl unikátní klíč a cache by zcela ztratila svůj význam. Dva sousedé ve stejném bytovém domě by generovali odlišné požadavky na API.

Řešením je záměrné snížení přesnosti souřadnic zaokrouhlením na čtyři desetinná místa. Tento krok vytvoří virtuální geografickou síť (grid) s rozlišením přibližně jedenáct metrů na rovníku. Pro účely předpovědi počasí je taková přesnost naprosto dostatečná, ale pro cache to znamená zásadní rozdíl. Lidé v jedné ulici, kancelářské budově nebo parku sdílejí identické cache klíče.

Cache klíč je konstruován jako řetězec obsahující verzi schématu a zaokrouhlené souřadnice:

function generateCacheKey(latitude: number, longitude: number, version: number): string {
  const lat = latitude.toFixed(4);
  const lon = longitude.toFixed(4);
  return `weather:v${version}:${lat}:${lon}`;
}

Zahrnutí verze schématu do samotného klíče je kritické pro bezvýpadkový deployment. Pokud se v nové verzi aplikace změní struktura datového bundlu (například se přidají nové metriky nebo se změní formát JSON), pouhým navýšením verze v konfiguraci dojde k okamžité invalidaci staré cache. Tím se předejde chybám při deserializaci dat na straně aplikace.

Vrstva 1: in-process single-flight

První obrannou linii tvoří deduplikace na úrovni jednotlivých instancí aplikačního serveru (in-process). Pokud v rámci jednoho Node.js procesu přijde několik požadavků na stejnou lokalitu ve stejný okamžik, nesmíme pro každý z nich zahájit asynchronní operaci směrem k databázi nebo Redis cache.

K tomu slouží sdílená mapa běžících slibů (Promises). Když přijde požadavek, aplikace nejprve zkontroluje, zda již neprobíhá načítání pro daný cache klíč. Pokud ano, vrátí existující Promise. Všichni souběžní klienti tak čekají na jeden jediný síťový request.

Následující ukázka ilustruje princip in-process deduplikace:

import { Injectable } from '@nestjs/common';

@Injectable()
export class SingleFlightService {
  private pendingRequests = new Map<string, Promise<unknown>>();

  async execute<T>(key: string, fetchFn: () => Promise<T>): Promise<T> {
    const existingPromise = this.pendingRequests.get(key);
    if (existingPromise) {
      return existingPromise as Promise<T>;
    }

    const promise = fetchFn().finally(() => {
      this.pendingRequests.delete(key);
    });

    this.pendingRequests.set(key, promise);
    return promise;
  }
}

Tímto způsobem se efektivně eliminují souběžné dotazy v rámci jedné repliky. Pokud však provozujete aplikaci ve vícereplikovém prostředí (například v Kubernetes nebo na serverless platformách), in-process paměť jednotlivých instancí o sobě navzájem neví. Zde nastupuje druhá vrstva.

Vrstva 2: distribuovaný zámek přes Redis

Abychom zabránili duplicitním požadavkům napříč různými replikami aplikačního serveru, musíme koordinovat přístup k externímu API pomocí distribuovaného zámku. Jako koordinační vrstva se ideálně hodí Redis.

Při pokusu o stažení nových dat se replika pokusí získat zámek pro daný cache klíč. Používá se k tomu příkaz SET s parametry NX (nastav pouze pokud klíč neexistuje) a PX (exspirace v milisekundách). Exspirace je klíčová pojistka pro případ, že by proces, který zámek získal, neočekávaně havaroval. Zámek nesmí zůstat viset navždy.

const lockAcquired = await redis.set(lockKey, uniqueToken, 'NX', 'PX', lockTimeoutMs);

Uvolnění zámku je stejně důležité jako jeho získání a musí probíhat bezpečně. Nemůžeme jednoduše smazat klíč zámku, protože by mohlo dojít k situaci, kdy proces A držel zámek příliš dlouho, zámek exspiroval, získal ho proces B a následně proces A dokončil svou práci a smazal zámek, který už ale patřil procesu B.

K bezpečnému uvolnění se používá Lua skript, který zaručuje atomicitu operace. Skript nejprve ověří, zda hodnota zámku odpovídá unikátnímu tokenu, který proces vygeneroval při jeho získání. Teprve při shodě zámek smaže.

if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

Lock loser nečeká naprázdno — pollne cache

Co se stane s požadavkem na replice, která souboj o zámek prohrála? Tento proces (lock loser) nesmí selhat ani okamžitě volat placené API. Ví, že jiná replika právě data stahuje a brzy je uloží do sdílené cache.

Místo generování další zátěže přejde lock loser do režimu krátkého dotazování (polling). V pravidelných intervalech se dotazuje Redisu, zda už jsou nová data k dispozici.

async function waitForData(cacheKey: string, maxAttempts = 5, delayMs = 200): Promise<unknown> {
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    const data = await redis.get(cacheKey);
    if (data) {
      return JSON.parse(data);
    }
    await new Promise(resolve => setTimeout(resolve, delayMs));
  }
  throw new Error('Timeout waiting for data replication');
}

Pokud data v limitu do cache dorazí, proces je vrátí klientovi. V extrémním případě, kdy by polling selhal (například pokud vítězná replika zkolabovala uprostřed zpracování a zámek exspiroval), se lock loser pokusí získat zámek sám, provede záložní volání na placené API a uloží výsledek pro ostatní.

Stale-while-revalidate: nikdo nečeká na síť

Uživatelé očekávají okamžitou odezvu aplikace. I když deduplikace šetří peníze, čekání na dokončení síťového požadavku k externímu poskytovateli počasí zvyšuje latenci. K vyřešení tohoto problému slouží vzor Stale-While-Revalidate (SWR).

Cache v tomto režimu rozlišuje dva stavy expirace:

  1. Měkká expirace (stale limit): Data jsou považována za zastaralá, ale stále jsou okamžitě vrácena uživateli.
  2. Tvrdá expirace (TTL limit): Data jsou příliš stará a nelze je použít.

Pokud uživatel požádá o data, která překročila měkkou expiraci, ale jsou stále před tvrdou expirací, systém okamžitě vrátí tato "stale" data z cache. Na pozadí se asynchronně spustí proces revalidace. Tento proces získá zámek, stáhne čerstvá data z API, aktualizuje cache a klient při příštím zobrazení uvidí nejnovější informace. Uživatel tak v drtivé většině případů nikdy nečeká na odezvu externí sítě.

Tento přístup je plně integrován do architektury, kterou využívá celý projekt Weather InTouch. Díky tomu je uživatelské rozhraní extrémně rychlé a plynulé, bez ohledu na aktuální latenci meteorologických služeb.

Bez Redisu: vědomá degradace do paměti

Robustní architektura musí počítat s chybami. Pokud dojde k výpadku Redis clusteru nebo pokud aplikaci spouštíte v lokálním vývojovém prostředí bez kompletní infrastruktury, systém nesmí selhat. V takovém případě dochází k vědomé degradaci služeb (graceful degradation).

Pokud aplikace detekuje nedostupnost Redis serveru, automaticky přepne cachovací vrstvu do in-process paměti. K tomu se využívá paměťová cache s omezenou velikostí (bounded LRU cache), aby nedošlo k přetečení paměti procesu při velkém množství dotazovaných lokalit.

V tomto režimu sice ztrácíme možnost distribuované koordinace a deduplikace mezi replikami, ale aplikace zůstává plně funkční. Na jedné replice in-process single-flight stále funguje perfektně. Provoz se pouze stane o něco dražším z pohledu volání API, což je přijatelná daň za vysokou dostupnost celého systému.

Podrobnější pohled na to, jak je tato odolná infrastruktura navržena a jak spolu jednotlivé komponenty komunikují, popisuje článek o tom, jak je celá platforma poskládaná.

Závěr

Efektivní správa nákladů u aplikací závislých na externích datech vyžaduje více než jen jednoduché ukládání do paměti. Kombinace zaokrouhlování geografických souřadnic, in-process deduplikace, distribuovaných zámků a strategie Stale-While-Revalidate umožňuje škálovat aplikaci pro tisíce uživatelů při zachování minimálních nákladů na infrastrukturu. Kompletní implementaci těchto principů v reálném prostředí si můžete prohlédnout v detailu projektu Weather InTouch.

Související články

Kontaktní informace

Klidně se mi ozvěte ohledně nového projektu, konzultace nebo dlouhodobější spolupráce. Rád proberu smysluplné produktové zadání i zajímavé frontendové příležitosti.

Napište mi

© 2026 Martin Hubálek. Všechna práva vyhrazena.

Martin Hubálek IČO: 08827303 Sídlo: Dolní Štěpanice 29, 514 01 Benecko, Česká republika Zapsán v živnostenském rejstříku Nejsem plátce DPH