Strona główna / Artykuły / Pamięć cache mapy źródłowej węzła stanowi cichą wyciek pamięci w trybie deweloperskim

Pamięć cache mapy źródłowej węzła stanowi cichą wyciek pamięci w trybie deweloperskim

Dowiedz się, dlaczego włączenie opcji --enable-source-maps lub NODE_V8_COVERAGE może powodować niekontrolowane rozrastanie się pamięci heap wskutek powtarzających się wywołań eval, oraz jak można to zdiagnozować i złagodzić już dziś.

1073 słów

Po uruchomieniu proces Node wydaje się w pełni sprawny, ale stopniowo zajmuje coraz więcej pamięci w miarę edycji kodu. Jeśli proces został uruchomiony z opcją --enable-source-maps (lub przy ustawionym NODE_V8_COVERAGE) i coś w Twoim kodzie ciągle wywołuje funkcję eval z nowym tagiem //# sourceURL za każdym razem, prawdopodobnie znalazłeś przyczynę problemu: silny cache map źródłowych, który gromadzi generowane wpisy i nigdy ich nie usuwa. Pamięć heap stale rośnie, więc restartujesz proces – on znów się powiększa.

Próbujesz zmusić do przeprowadzenia zbierania śmieci. Usuwasz wszystkie możliwe odniesienia, ale pamięć heap i tak nadal rośnie.

Wzorzec wzrostu

Załóżmy serwer deweloperski działający przez jakiś czas lub dowolny proces Node trwający długo, który regeneruje syntetyczne ślady stosu lub „ramki właściciela” oznaczone unikalnymi adresem źródłowymi. Zarówno wartość RSS, jak i heapUsed stale rosną. Ponowne uruchomienie procesu sformatuje tę krzywą, ale wykonywanie tego samego obciążenia bez flagi source-map utrzymuje pamięć na stałym poziomie.

W publicznym systemie śledzenia problemów Node.js dotyczącym tego zachowania (nodejs/node#65760) minimalny przykład reprodukcji obejmuje około 90 bajtów kodu źródłowego przetwarzanych przy użyciu jednego zewnętrznego mapowania źródłowego, przy czym przy każdej iteracji zmieniana jest tylko wartość sourceURL. Po przeprowadzeniu przymusowej kolekcji śmieci:

Evals | with --enable-source-maps | no flag
0     | 5 MB                       | 5 MB
400   | 170 MB                     | 5 MB
800   | 334 MB                     | 6 MB
1200  | 499 MB                     | 6 MB

Oznacza to w przybliżeniu 415 KB pamięci zachowanej na każde wezwanie eval, przy czym nie widać żadnego górnego ograniczenia.

Jeśli pracujesz z Next.js App Router, problem ten często objawia się w postaci tego, że next dev zyskuje dziesiątki megabajtów przy każdej edycji pliku. Mechanizm owner-stack w React Server Components uruchamia eval raz na każdą ramę stosu, oznaczając każde wywołanie czymś w rodzaju //# sourceURL=about://React/…?<counter++> wraz z dużym mapowaniem źródłowym wplecionym bezpośrednio. Ponieważ ten licznik wzrasta przy każdym wywołaniu, każdy klucz cache’a jest unikalny, więc nic nie jest ponownie wykorzystywane. Odpowiednia dyskusja na Next.js znajduje się pod adresem vercel/next.js#98221 – należy ją traktować jako miejsce, w którym pojawia się ten objaw, a nie jako odrębną przyczynę.

Dlaczego cache nie chce zwolnić

Flaga --enable-source-maps instruuje Node do przechowywania w pamięci cache map źródłowych, aby ślady stosu z czasu wykonywania mogły zostać przetłumaczone z powrotem na oryginalne pliki źródłowe (zobacz dokumentację CLI). Zwykłe źródła modułów przechodzą przez cache o słabych kluczach, dzięki czemu kolektor śmieci może je zwolnić, gdy przestaną być referencjonowane. Natomiast źródła wygenerowane — te obsługiwane przez gałąź isGeneratedSource — trafiają do generatedSourceMapCache, czyli zwykłego, silnie referencjonowanego obiektu Map zdefiniowanego w pliku lib/internal/source_map/source_map_cache.js, który nigdy nie jest oczyszczany.

Komentarz w kodzie dotyczący tego cache zakłada, że w ciągu trwania procesu będzie istniało zaledwie kilka wygenerowanych źródeł. Funkcje Hot Module Replacement oraz regeneracja stosu właściciela całkowicie obalają to założenie. Każdy unikalny sourceURL staje się trwałym kluczem, stare wpisy nigdy nie są usuwane, a pełna mapa zanalizowana dla każdego z nich pozostaje w pamięci.

Ustawienie NODE_V8_COVERAGE kieruje żądania tą samą ścieżką cache, więc zobaczysz ten sam nieograniczony wzrost, gdy klucze wygenerowane przez eval będą się ciągle zmieniać.

Istnieje już otwarty pull request w głównym repozytorium (#65761), który ogranicza bufor generated-sources za pomocą strategii LRU z wykorzystaniem limitu bajtów — 32 MiB w najnowszej wersji — oraz odświeża wpisy podczas odczytu, aby mapy funkcji nadal aktywnych nie były wyrzucane przedwcześnie. Na chwilę obecną ten PR pozostaje otwarty i oznaczony jako needs-ci. Nie został połączony i nie jest częścią żadnej wydanej wersji Node, więc nie zakładaj, że twoja obecna wersja LTS już zawiera to rozwiązanie.

Potwierdzenie diagnozy

  1. Sprawdź, czy twój proces działający przez długi czas został uruchomiony z parametrem --enable-source-maps lub ma ustawione NODE_V8_COVERAGE, oraz czy coś powtarzalnie wykonywa operację eval na kodzie z nowym tagiem //# sourceURL za każdym razem — może to być HMR, stosy właścicieli RSC lub niestandardowe generatory kodu.
  • Przykład wartości process.memoryUsage().heapUsed po uruchomieniu pętli z przymusowym GC, zarówno w oddzielnej sesji reprodukcji rozpoczętej za pomocą node --expose-gc, jak i poprzez zrobienie zdjęcia pamięci heap za pomocą inspektora w działającym procesie.
  • Zrób to samo z zadaniem, ale bez użycia tej flagi. Charakterystycznym objawem z #65760 jest to, że pamięć pozostaje na tym samym poziomie bez flagi, natomiast stale rośnie, gdy jest ona włączona.
  • Opcjonalnie zrób zdjęcie pamięci heap i poszukaj w nim wartości generatedSourceMapCache lub context:generatedSourceMapCache. Osoby badające ten problem znalazły tysiące zapisów, które przechowywały łącznie ponad gigabajt danych sourcesContent i mappings podczas rzeczywistej sesji next dev.
  • Zwiększenie wartości --max-old-space-size nie jest rozwiązaniem — jedynie opóźnia ostateczny awarię spowodowaną brakiem pamięci.

    Co robić natychmiast

    Wybierz metodę odpowiednią dla Twojego środowiska:

    1. Dla projektów Next.js, działaj natychmiast: uruchom next dev --disable-source-maps. Osoby sprawdzające problem na #65760 zauważyły znaczne zmniejszenie wzrostu rozmiaru plików — około +6 MB przy każdej modyfikacji w porównaniu z około +89 MB przy włączonych mapach źródłowych, według ich pomiarów. Tracisz nieco czytelność ścieżek wywołań, ale komputer przestaje mieć problemy z pamięcią.
    2. Dla innych narzędzi Node działających długo: usuń --enable-source-maps lub wyłącz NODE_V8_COVERAGE na dowolnym serwerze hot-reloading, dopóki mapowanie ścieżek wywołań nie stanie się faktycznie konieczne. Zamiast tego przeznacz mapowanie źródłowe na krótkotrwałe sesje debugowania.
  • Sledź poprawkę w źródle: obserwuj zarówno nodejs/node#65760, jak i pull request #65761. Gdy wersja Node wyraźnie wspomni o ograniczonej pamięci cache dla generowanych źródeł, można bezpiecznie dokonać aktualizacji. Do tego czasu traktuj wszelkie dane od innych użytkowników jako wyniki pomiarów w ich konkretnym środowisku, a nie gwarancję, że twoje aplikacje będą zachowywać się identycznie.
  • Pomijaj radę „po prostu zwiększ limit pamięci heap”. Podstawowa pamięć cache jest wytrzymała i nieograniczona przy dokładnie tej kombinacji flag oraz wzorca użycia. Większy limit pamięci heap daje jedynie nieco więcej czasu przed awarią.
  • Wniosek

    Twój serwer deweloperski nie zużywa pamięci losowo bez powodu. Połączenie flagi --enable-source-maps z wielokrotnymi operacjami eval wykorzystującymi unikalne wartości sourceURL powoduje wypełnienie silnie referencowanego bufora generatedSourceMapCache, który nigdy nie uwalnia swoich wpisów. Zwykłe mapy źródłowe modułów mogą zostać poddane procesowi zbierania śmieci; te wygenerowane nie mogą, przynajmniej dopóki numer #65761 nie trafi do kolejnej wersji oprogramowania. Wyłącz mapy źródłowe podczas procesów hot-reload już dziś i planuj aktualizację, gdy zostanie wdrożony ograniczony bufor.

    Zatem następnym razem, gdy zauważysz wzrost wartości RSS podczas prostego edytowania plików, a przymusowe zbieranie śmieci nic nie daje efektu, sprawdź, czy to właśnie ta flaga jest przyczyną.

    Literatura pokrewna