Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds

PROJEKT - system optymalizacji wydajności

Ten moduł pokazał kilka narzędzi: cache w pamięci procesu i w Redisie, indeksy w bazie, kompresję odpowiedzi, ograniczanie liczby żądań. Projekt jest miejscem, w którym trzeba zdecydować, którego użyć - a to zupełnie inna umiejętność niż znajomość składni.

Zbudujesz API zarządzania legionem, które radzi sobie z ruchem większym, niż wytrzymałaby wersja naiwna, i potrafi pokazać, dlaczego sobie radzi.

Jedna zasada przed wszystkimi innymi

Optymalizacja bez pomiaru to zgadywanie. Brzmi banalnie, a jest najczęściej łamaną regułą w tej dziedzinie: dodaje się cache tam, gdzie akurat przyszło do głowy, i odnotowuje poprawę, której nikt nie zmierzył.

Porządek pracy jest zawsze ten sam:

  1. Zmierz - ile trwa żądanie, ile zajmuje pamięć, ile zapytań idzie do bazy.
  2. Znajdź wąskie gardło - jedno, to najkosztowniejsze.
  3. Zastosuj najtańszą poprawkę, która je usuwa.
  4. Zmierz ponownie - i sprawdź, czy poprawka zadziałała, a nie tylko przesunęła problem.

Dlatego projekt zaczyna się od pomiaru, nie od cache'a.

Krok 1 - endpoint z metrykami

Pierwszą rzeczą, jaką wystawisz, jest podgląd stanu procesu:

1@Controller('metrics')
2export class MetricsController {
3  @Get()
4  getMetrics() {
5    const memory = process.memoryUsage();
6
7    return {
8      heapUsed: memory.heapUsed,
9      heapTotal: memory.heapTotal,
10      rss: memory.rss,
11      uptime: process.uptime(),
12    };
13  }
14}

process.memoryUsage()
zwraca kilka liczb i warto wiedzieć, czym się różnią.
heapUsed
to pamięć faktycznie zajęta przez obiekty twojej aplikacji - ta rośnie, gdy trzymasz w cache'u za dużo.
heapTotal
to pamięć, którą silnik V8 zarezerwował na stertę; jest zawsze większa i zmienia się skokowo.
rss
(Resident Set Size) to całość pamięci procesu widziana przez system operacyjny - sterta, stos, kod i bufory razem.

Najważniejsza jest różnica między

heapUsed
a
rss
. Gdy rośnie sam
heapUsed
, gromadzisz obiekty - zwykle w jakimś cache'u bez limitu. Gdy rośnie
rss
, a
heapUsed
stoi, wyciek jest poza stertą: bufory, otwarte połączenia, biblioteki natywne.

process.uptime()
podaje, ile sekund działa proces. Sam w sobie mało mówi, ale w zestawieniu z resztą owszem: zużycie pamięci rosnące liniowo z czasem działania to wyciek, a nie obciążenie.

Krok 2 - cache w Redisie

Cache w pamięci procesu, który poznałeś wcześniej, ma jedną wadę: każda instancja aplikacji ma własny. Przy dwóch instancjach ten sam odczyt trafia do bazy dwa razy, a unieważnienie w jednej nie dociera do drugiej. Redis to naprawia:

1CacheModule.register({
2  store: redisStore,
3  host: 'localhost',
4  port: 6379,
5  ttl: 300 })

Kolejność zapisu jest stała:

CacheModule.register({
otwiera konfigurację,
store: redisStore,
podmienia magazyn z pamięci procesu na Redisa,
host: 'localhost',
i
port: 6379,
wskazują, gdzie ten Redis stoi, a
ttl: 300 })
ustala domyślny czas życia wpisu i domyka.

Kluczowy jest

store
. Bez niego
CacheModule
działa dalej, tylko w pamięci procesu - i to jest podstępne, bo lokalnie wszystko wygląda dobrze, a problem pojawia się dopiero po uruchomieniu drugiej instancji na produkcji.

Zwróć uwagę, że

host
i
port
stoją tu bezpośrednio w obiekcie konfiguracji. W nowszych wersjach
cache-manager
zagnieżdża się je w osobnym polu połączenia - sprawdź wersję w swoim
package.json
, zanim skopiujesz cudzy przykład.

Krok 3 - baza danych

Cache przyspiesza powtarzalne odczyty. Zapytania, których nie da się zbuforować, trzeba naprawić u źródła:

1@Entity('legionaries')
2@Index(['cohortId', 'isActive'])
3export class Legionary {
4  @Column()
5  cohortId: number;
6
7  @Column({ default: true })
8  isActive: boolean;
9}
10
11// w repozytorium
12findActiveByCohort(cohortId: number) {
13  return this.repo.find({
14    where: { cohortId, isActive: true },
15    select: ['id', 'name', 'rank'],
16    take: 50,
17  });
18}

Trzy poprawki naraz, każda o innym działaniu. Indeks złożony na kolumnach, po których naprawdę filtrujesz, zamienia przegląd całej tabeli w skok do konkretnych wierszy.

select
pobiera tylko potrzebne kolumny - bez niego ściągasz z bazy pola, których nikt nie użyje, wraz z całą ich objętością.
take
ogranicza liczbę wierszy; endpoint bez limitu działa świetnie do dnia, w którym ktoś ma ich sto tysięcy.

Zanim dodasz indeks, sprawdź plan zapytania przez

EXPLAIN
. Indeks na kolumnie, po której nikt nie filtruje, nie przyspiesza odczytu, a spowalnia każdy zapis.

Krok 4 - warstwa wyjściowa

Na koniec dwie rzeczy działające na brzegu aplikacji:

1async function bootstrap() {
2  const app = await NestFactory.create(AppModule);
3
4  app.use(compression());
5
6  await app.listen(3000);
7}

compression()
pakuje odpowiedzi gzipem - przy listach w formacie JSON to zwykle kilkukrotne zmniejszenie transferu, kosztem odrobiny procesora. Rate limiting z
ThrottlerModule
działa z drugiej strony: nie przyspiesza obsługi, tylko ogranicza liczbę żądań, które w ogóle przyjmujesz.

Te dwa mechanizmy warto rozumieć jako parę przeciwieństw. Kompresja i cache sprawiają, że jedno żądanie kosztuje mniej. Ograniczanie liczby żądań sprawia, że żądań jest mniej. Gdy system się dławi, zawsze masz do wyboru te dwie drogi - i zwykle potrzebujesz obu.

Co oddajesz

Projekt jest gotowy, gdy zawiera:

  1. Endpoint
    /metrics
    z
    heapUsed
    ,
    heapTotal
    ,
    rss
    i
    uptime
    .
  2. Cache w Redisie przez
    CacheModule.register
    ze
    store: redisStore
    , z unieważnianiem przy zapisie.
  3. Indeksy na kolumnach filtrowanych oraz
    select
    i
    take
    w zapytaniach listujących.
  4. Kompresję odpowiedzi i ograniczanie liczby żądań na endpointach zapisu.
  5. Pomiar przed i po - liczby dla co najmniej jednego endpointu, z opisem, co się zmieniło.

Piąty punkt jest najważniejszy i najczęściej pomijany. Bez niego oddajesz zbiór technik, a nie optymalizację - bo optymalizacja to różnica między dwoma pomiarami, @name, a nie lista zastosowanych narzędzi.

Prześlij link do repozytorium, gdy skończysz.

Przejdź do CodeWorlds