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

API Rate Limiting i warstwa cache - kontrola ruchu portowego

Port przyjmuje tyle statków, ile zdoła rozładować. Gdy przypłynie więcej, są dwa wyjścia: rozładowywać szybciej albo wpuszczać mniej. Aplikacja pod obciążeniem ma dokładnie te same dwa - cache zmniejsza pracę przypadającą na żądanie, a rate limiting ogranicza liczbę żądań, które w ogóle przyjmujesz.

Ta lekcja domyka obie strony: najpierw strażnik przy bramie, potem magazyn, z którego wydaje się towar bez schodzenia do składu.

Strażnik - ThrottlerModule

NestJS ma wbudowany mechanizm ograniczania ruchu, więc nie pisz własnego licznika:

1@Module({
2  imports: [
3    ThrottlerModule.forRoot([
4      {
5        ttl: 60000,
6        limit: 100,
7      },
8    ]),
9  ],
10  providers: [
11    { provide: APP_GUARD, useClass: ThrottlerGuard },
12  ],
13})
14export class AppModule {}

Dwie liczby opisują całą regułę.

ttl
to długość okna w milisekundach - tutaj minuta.
limit
to liczba żądań dozwolonych w tym oknie. Razem: sto żądań na minutę z jednego adresu.

Rejestracja przez

APP_GUARD
czyni z
ThrottlerGuard
guarda globalnego - działa na wszystkich trasach bez dopisywania
@UseGuards
w każdym kontrolerze. Po przekroczeniu limitu klient dostaje 429 Too Many Requests, a metoda kontrolera nie wykona się wcale.

Pojedyncze trasy można poluzować albo zaostrzyć dekoratorem:

1@Post('login')
2@Throttle({ default: { ttl: 60000, limit: 5 } })
3login(@Body() dto: LoginDto) { }

Pięć prób logowania na minutę zamiast stu. To jest właśnie miejsce, gdzie rate limiting przestaje być kwestią wydajności, a staje się zabezpieczeniem: bez niego nic nie powstrzyma zgadywania haseł metodą siłową.

Magazyn - serwis cache

Drugą stronę obsługuje cache. Opakowanie go we własny serwis daje jedno miejsce na klucze i czasy życia:

1@Injectable()
2export class CacheService {
3  constructor(@Inject(CACHE_MANAGER) private cache: Cache) {}
4
5  async get<T>(key: string): Promise<T | undefined> {
6    return this.cache.get<T>(key);
7  }
8
9  async set<T>(key: string, value: T, ttl = 300): Promise<void> {
10    await this.cache.set(key, value, ttl);
11  }
12
13  async del(key: string): Promise<void> {
14    await this.cache.del(key);
15  }
16}

Trzy metody wystarczają na wszystko.

get
czyta,
set
zapisuje z czasem życia,
del
usuwa. Domyślne
ttl = 300
można nadpisać przy każdym wywołaniu - krótkie dla danych zmiennych, długie dla słowników.

Zwróć uwagę na

del
- to metoda, o której najłatwiej zapomnieć przy pisaniu, a najtrudniej bez niej żyć. Bez możliwości usunięcia klucza jedynym sposobem na pozbycie się nieaktualnych danych jest czekanie, aż wygasną same.

Automatyczny cache dla endpointów

Dla zwykłych odczytów nie musisz pisać nawet tego.

CacheInterceptor
robi to za Ciebie:

1@Controller('provinces')
2export class ProvincesController {
3  @Get()
4  @UseInterceptors(CacheInterceptor)
5  @CacheTTL(600)
6  findAll() {
7    return this.provincesService.findAll();
8  }
9}

Przy każdym żądaniu przechodzi przez pięć kroków, zawsze w tej kolejności:

  1. Przechwycenie żądania HTTP.
  2. Sprawdzenie klucza w cache.
  3. Wykonanie handlera - tylko jeśli był MISS.
  4. Zapisanie wyniku do cache.
  5. Zwrócenie odpowiedzi klientowi.

Krok trzeci jest sednem: przy trafieniu metoda kontrolera w ogóle się nie wykonuje, więc nie ma zapytania do bazy ani żadnej pracy. Klucz interceptor buduje domyślnie z adresu URL, więc

/provinces?page=2
i
/provinces?page=3
to dwa osobne wpisy.

Stąd też jego ograniczenie: interceptor obsługuje wyłącznie żądania

GET
. Nie ma sensu cache'ować
POST
, bo ten ma zmieniać stan, a nie go odczytywać.

Unieważnianie - najtrudniejsza część

Cache jest łatwy, dopóki dane się nie zmieniają. Gdy się zmieniają, trzeba stare wpisy usunąć - a proces ma cztery kroki:

  1. Wykrycie zmiany danych w źródle (DB).
  2. Identyfikacja kluczy cache do invalidacji.
  3. Usunięcie starych danych z cache (
    del
    ).
  4. Pobranie i zapisanie nowych danych do cache.
1async update(id: number, dto: UpdateProvinceDto) {
2  const province = await this.repo.save({ id, ...dto });
3
4  await this.cacheService.del(`province:${id}`);
5  await this.cacheService.del('provinces:all');
6
7  return province;
8}

Krok drugi jest tym, który sprawia najwięcej kłopotu, i widać to w powyższym kodzie. Zmiana jednej prowincji unieważnia dwa klucze: wpis tej prowincji i listę wszystkich, bo lista też zawiera jej dane. Przy trzeciej stronie wyników i piątym filtrze tych kluczy robi się kilkanaście - i wtedy zaczyna się pominięcia.

Dlatego, @name, przy projektowaniu kluczy zadaj sobie od razu pytanie odwrotne: nie „jak to zapisać", tylko „co będę musiał usunąć, gdy to się zmieni". Cache, którego nie umiesz unieważnić, jest gorszy od braku cache - bo pokazuje dane nieaktualne, a ty o tym nie wiesz.

Podsumowanie

Port kontroluje ruch i wydaje towar z magazynu:

  • pod obciążeniem masz dwie drogi: cache zmniejsza pracę na żądanie, rate limiting ogranicza liczbę żądań,
  • ThrottlerModule.forRoot
    opisują dwie liczby:
    ttl
    (okno w milisekundach) i
    limit
    (żądania w oknie),
  • rejestracja przez
    APP_GUARD
    czyni
    ThrottlerGuard
    globalnym; po przekroczeniu limitu klient dostaje 429,
  • @Throttle
    zaostrza limit dla pojedynczej trasy - przy logowaniu to zabezpieczenie, nie optymalizacja,
  • serwis cache opakowuje trzy metody:
    get
    ,
    set
    z konfigurowalnym TTL i
    del
    ,
  • CacheInterceptor
    działa w pięciu krokach
    : przechwycenie żądania → sprawdzenie klucza → wykonanie handlera (jeśli MISS) → zapisanie wyniku → zwrócenie odpowiedzi,
  • przy trafieniu metoda kontrolera nie wykonuje się wcale; klucz powstaje z adresu URL, więc różne parametry to różne wpisy,
  • interceptor obsługuje tylko
    GET
    ,
  • invalidacja w czterech krokach: wykrycie zmiany w bazie → identyfikacja kluczy → usunięcie starych (
    del
    ) → pobranie i zapisanie nowych,
  • jedna zmiana unieważnia zwykle kilka kluczy - projektuj klucze pytając, co będziesz musiał usunąć.

W następnej lekcji zajmiemy się kompresją i optymalizacją odpowiedzi - tym, co zmniejsza ruch już po stronie wyjścia. A na razie zapamiętaj: cache przyspiesza obsługę, limit chroni przed nadmiarem - a najtrudniejszy w cache nie jest zapis, tylko wiedza, kiedy go usunąć.

Przejdź do CodeWorlds