Dlaczego wzory regex z techniki backtracking mogą cicho sparaliżować twój serwer
Dowiedz się, jak chciwe dopasowywanie i katastrofalne cofanie może przekształcić pozornie poprawny regex w problem blokujący pracę procesora, oraz jak rozpoznać to ryzyko.
Źródła: The Regex That Looked Fine in Testing and Hung the Server in Production
Wzorzec walidacji nazwy użytkownika przeszedł przez przegląd kodu, z powodzeniem przebrnął przez cały zestaw testów, a kilka tygodni po wdrożeniu spowodował zawieszenie punktu końcowego API na jednej prośbie o dostęp przez jedenastu sekund. Nie doszło do awarii ani do rzucenia wyjątku — proces po prostu przestał odpowiadać, pracując na 100% wydajności procesora podczas przetwarzania ciągu znaków liczącego zaledwie około czterdziestu liter.
Nie wprowadzono żadnego błędu w zwykłym rozumieniu tego słowa. Sam wzorzec regularny był poprawny i dopasowywał się dokładnie do tego, co miał dopasować. Jednak posiadał ukrytą cechę, na którą większość programistów nigdy nie uczy się zwracać uwagi, a zrozumienie jej wymaga spojrzenia poza symbole i zrozumienia, co tak naprawdę robi wzorzec regularny podczas wykonywania, co większość tutoriali pomija, skupiając się na wyjaśnianiu takich elementów jak \d i +.
Regex to mały schemat przepływu, a nie ciąg symboli
Rozważmy wzorzec przeznaczony do weryfikacji kodu produktu składającego się z dwóch wielkich liter, a następnie czterech cyfr:
const productCode = /^[A-Z]{2}\d{4}$/;
console.log(productCode.test("LM4821")); // true
console.log(productCode.test("LM482")); // false
To naturalne, by postrzegać to jako skrócony opis kształtu ciągu znaków. Lepszym modelem myślowym, który przyniesie korzyści później, jest traktowanie go jako miniaturowego schematu przepływu: rozpoczęcie, wymaganie dokładnie dwóch wielkich liter, następnie dokładnie czterech cyfr, a potem zakończenie wprowadzanych danych. Silnik przegląda dane krok po kroku, próbując znaleźć trasę na tym schemacie od węzła początkowego do węzła końcowego. Gdy z powodzeniem dotrze na koniec, oznacza to dopasowanie. Gdy natrafi na przeszkodę, a gdzieś za nią wciąż istnieje niewypróbowana gałąź, cofa się i próbuje tej drugiej gałęzi zamiast tego.
To cofnięcie się – powrót w celu sprawdzenia innej gałęzi – nazywa się backtrackingiem i jest bez wątpienia najważniejszym pojęciem w wyrażeniach regularnych. To właśnie ono zapewnia im elastyczność, a jednocześnie było przyczyną opisanego powyżej incydentu w produkcji.
Grupy przekształcają „Czy pasuje?” w „Co tam faktycznie jest”
Wywołanie metody .test() zwraca jedynie wartość true lub false. Umieszczenie części wzorca w nawiasach pozwala na oddzielne przechwytywanie fragmentów tekstu, który pasuje do wzorca, co sprawia, że wyrażenia regularne stają się naprawdę przydatne do parsowania, a nie tylko walidacji:
const logPattern = /^\[(\w+)\] (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}) (.+)$/;
const line = "[ERROR] 2026-08-15T10:22:01 Failed to connect to database";
const match = logPattern.exec(line);
console.log(match[1]); // "ERROR"
console.log(match[2]); // "2026-08-15T10:22:01"
console.log(match[3]); // "Failed to connect to database"
Każda sekcja w nawiasach w wzorze staje się osobnym elementem tablicy wyników, ułożonym według pozycji otwierającego nawiasu w wzorze. Dzięki temu prosta kontrola powodzenia lub niepowodzenia — potwierdzenie, że wiersz ma kształt wpisu do dziennika — przekształca się w coś znacznie bardziej przydatnego: poziom powagi, data i godzina oraz treść wiadomości są dostępne jako trzy odrębne wartości gotowe do użycia. To właśnie ta możliwość sprawia, że wyrażenia regularne są tak często używane przy przetwarzaniu dzienników, analizie konfiguracji oraz w każdej sytuacji, gdy tekst o luźnej strukturze musi zostać przekształcony w coś, z czym program może bezpośrednio pracować.
Matchowanie chciwe pobiera więcej, niż zamierzano
Następny błąd to taki, który niemal każdy programista popełnia przynajmniej raz. Wyobraźmy sobie sytuację, w której konieczne jest usunięcie lekkich tagów HTML z pola tekstowego:
const html = "<b>Featured</b> item, <i>on sale</i> now";
console.log(html.replace(/<.*>/g, ""));
// "Featured item, on sale now" — wait, that actually looks right...
const html2 = "<b>Sale</b> ends <i>Friday</i>";
console.log(html2.replace(/<.*>/g, ""));
// "" — everything vanished
Druga strona ujawnia tę wadę. Domyślnie .* działa chciwie – stara się pochłonąć jak najwięcej znaków, zwracając je tylko wtedy, gdy reszta wzorca tego wymaga. Ponieważ ta strona zawiera dwa oddzielne tagi, wzorzec <.*> nie zatrzymuje się przy pierwszym zamknięciu </b>. Kontynuuje pobieranie wszystkiego, co znajduje się po nim – mimo sekcji ends, aż do samego ostatniego > w strunie, znajdującego się wewnątrz </i> – ponieważ jeden taki chciwy wzorzec .*> jest w pełni zaspokojony, jeśli trafi na ostatnie dostępne > w tekście, bez względu na to, jak daleko się ono znajduje.
console.log(html2.replace(/<.*?>/g, ""));
// "Sale ends Friday"
Umieszczenie ? tuż po * zmienia jego zachowanie z chciwego na leniwe: silnik teraz stara się dopasować jak najmniej znaków, rozszerzając pasmo dopiero wtedy, gdy nie ma innego sposobu na zaspokojenie wymagań następnego elementu. Dzięki tej zmianie każda sekwencja <...> zamyka się przy najbliższym >, zamiast biec do ostatniego w całej ciągu, dzięki czemu tagi są usuwane po jednym, zgodnie z zamysłem. Ta różnica wynikająca z jednego znaku, * w porównaniu z *?, w tle tłumaczy znaczną część błędów typu „regex usunęło o wiele więcej tekstu, niż się spodziewano”, z którymi borykają się programiści.
Błąd, który faktycznie sparaliżował serwer
Rozważmy uproszczoną wersję wzorca odpowiedzialnego za to jednastosekundowe zatrzymanie: sprawdzenie nazwy użytkownika, które ma wymagać jednego lub więcej fragmentów składających się z znaków słowowych, ewentualnie rozdzielonych, a następnie znaku @.
const badPattern = /^(\w+)+@/;
console.log(badPattern.test("valid_user@example.com")); // true, fast
console.log(badPattern.test("this_is_a_fairly_long_username_with_no_at_sign"));
// hangs, sometimes for a very long time, depending on length
Problem leży w nawarstwionej powtórce: (\w+)+, czyli serii jednego lub więcej znaków słowotwórczych, zamkniętej z kolei w grupie, która powtarza się jedno lub więcej razy. Gdy ciąg wejściowy rzeczywiście zawiera @, silnik znajduje dopasowanie niemal natychmiast. Jednak jeśli poda mu się ciąg bez żadnego @, silnik nie może tak po prostu się poddać – musi w pełni wykazać, że żadne możliwe ułożenie nie działa. A do sprawdzenia jest ogromna liczba układów: wewnętrzne + może pochłonąć jeden znak, podczas gdy zewnętrzna grupa powtarza się w odniesieniu do reszty, albo może pochłonąć dwa znaki i pozwolić zewnętrznej grupie powtórzyć się w odniesieniu do tego, co pozostało, i tak dalej – sprawdza się każde możliwe podzielenie. Silnik przegląda każdą z tych kombinacji, zanim zdecyduje się ogłosić niepowodzenie, a każdy dodatkowy znak mniej więcej podwaja liczbę kombinacji.
const fixedPattern = /^\w+@/;
Gdy już zrozumiesz przyczynę podstawową, rozwiązanie wydaje się niemal zbyt proste: nawarstwiona powtarzalność w ogóle nie była konieczna. \w+ samo w sobie oznacza dokładnie to samo co (\w+)+ dla każdych danych, które rzeczywiście powinny zostać przetworzone z sukcesem, ale bez ryzyka wykładniczego cofania się, które kryje się za tym rozwiązaniem. Ten błąd ma dobrze znane określenie – katastrofalne cofanie się – a do rozpoznania należy każdy operator powtarzalności zastosowany do grupy, która już zawiera swój własny operator powtarzalności; wzory takie jak (x+)+, (x*)+ czy (x+)* to wszystko warianty tego samego podstawowego problemu.
Czego to właściwie uczy nas, na co uważać
Nie musisz zapamiętywać wszystkich istniejących niebezpiecznych kształtów wzorów. Ważne jest zinternalizowanie jednej podstawowej prawdy: silnik regex nie ocenia wzoru natychmiast, lecz szuka dopasowania, ścieżkę po ścieżce, cofając się i próbując innej ścieżki za każdym razem, gdy obecna zawodzi. Zwykłe wzory mają tak mało ścieżek do przebadania, że to poszukiwanie kończy się zanim zdążysz zauważyć jakikolwiek opóźnienie. Prawdziwe zagrożenie pojawia się szczególnie w przypadku nawarstwionych, nakładających się powtórzeń, gdzie liczba ścieżek do sprawdzenia nie tylko stale rośnie, ale też mnoży się z każdą dodatkową literą wejścia, która nie zachowuje się zgodnie z oczekiwaniami. Dlatego testuj swoje regex-y nie tylko na ciągach, które spodziewasz się, że będą pasować, ale także na długich ciągach celowo stworzonych tak, by zawieść. Każde wejście, którego twój zestaw testów nigdy nie rozważył, to właśnie to wejście, które ostatecznie pojawi się w środowisku produkcyjnym.
Literatura pokrewna
- 20 patterni Node.js, które zapobiegają przerwom w serwerze produkcyjnym — Poznaj 20 praktycznych patterni Node.js – od obsługi błędów po stopniowe wyłączanie i zarządzanie zasobami połączeń – które zapobiegają awariom, zanim konieczne stanie się ponowne uruchomienie.
- Anty-patterny backendu: 7 drożnych błędów i ich praktyczne rozwiązania — Dowiedz się, jak wykrywać i naprawiać siedem powszechnych błędów w inżynierii backendu, od nadmiernie skomplikowanych kontrolerów po nierozwiązane błędy, zanim spowodują one problemy w produkcji.