Чаму шаблоны рэгакса з адвярнутым працаваннем можу таямна зупініць ваш сервер
Дазвольце дазнацься, як жаднабесны падбір і катастрофічна перазворачванне можу прывести да таго, што на першы погляд правільны рэгулярны выраз стане прычыной блакання сервера, якое займае весь процэсар, і як выявіць такую апазычнасць.
Крыніцы: The Regex That Looked Fine in Testing and Hung the Server in Production
Шаблон адміністратыўскага імені праяшчыў перагляд коду, паспела ўсе тэсты, але чырвоце месцаў пасля запуску змусіў канцэнтр паўтарыць свою роботу ўпрацоўкі адзінаго запиту пра 11 секундаў. Не было жадных збоёў, жадных выклікаемых асаблівасцей — процэс проста перестаў адпавядаць, заставаючыся на 100% навантажэння CPU, пакуль обрабоўваў строку дужа короткага дыяпазона.
Ніякога бага ў звычным сэнсе не было. Сам регулярны выраз быў правільны і падходзіў абоўсумова таму, каму і патрабавалася. Але ён меў скрытую асоблівасць, якой большасць разработчыкаў ніколі не научаецца старацца, а ўсвядомленне яе значыць дывіцца за межы сімвалаў і разумець, што на самай працэ прадзейснюе регулярны выраз, што часта прыховваюць у навучальных матэрыялах, якія адгукваюцься толькі пра такія токены, як \d і +.
Regex — это мініатюрная схема праграмаведання, а не рядок симвалаў
Разглянем прыклад шаблону, які павінен пераканацься ў правамільнасці коду продукту, які складаецца з двух великаярных літер за якімі следуюць чатыры цифры:
const productCode = /^[A-Z]{2}\d{4}$/;
console.log(productCode.test("LM4821")); // true
console.log(productCode.test("LM482")); // false
Звычайна ўяваецца гэта як скрацаная апіс па форме рядка. Кращый ментальны модэль, які прыносіць плоды пазней, — гэта спрыяць гэту схему як мініатюрную схему праграмаведання: пачатак, трэба ровна две великаярныя літеры, потым ровна чатыры цифры, пасля чаго вхід завершаецца. Механізм праходзіць праз вхідны тэкст по аднаму симвалу, намагаючыся знайсці шлях праз гэту схему ад початковага вузла да завершальнага. Калі ён успешна дасягне канца, значы выпало падтверджэння. Калі ён натрапіць на пераканаце і за ягою ўсё щэ будзе непрабаваны галузь, ён вяртаецца назад і пробуе тую іншую галузь заместа таго.
Эты крок адступлення — вярнуцеся, каб перапрацаваць альтэрнатыўную гілку — называецца backtracking, і ён, можна сказаць, являецца найважлівейшым паняйом у рэгулярных выразах. Саме ён даём яным гнучкасць, і самэўсёле ён стаў прычыной вышэй описанага інцыденту ў працоўным серавере.
Групы ператвараюць „Чы рэгулярны выраз падходзіць?“ у „Што на самай працоўцы там?“
Выкліканне .test() вяртае толькі значэння true або false. Адгортаванне частак шаблона ў кранікі дазволяе отрымваць окремы фрагменты падходзячага тексту, і самэўсёле тут рэгулярныя выразы становяцца практычна корыстнымі для парсінгу, а не толькі для верыфікацыі:
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"
Кожны раздел узора, адакшы ў складках, становіцца самастойным элементам у масе рэзультата, аранжаваным па таму, дзе з’яўляецца яго ведаючы складак у узоре. Гэта ператварае простую перапачытку — параболь, чы рэчка мае форму запису логу — у ўсё бол корыстны элемент: рэвалюецца ранг серьёзнасці, час падзеі і тэкст прыемленае звесці як трохі адзінаковыя значэнні, гатовыя да вжывання. Саме згодна з гэтым регулярныя выразы так часта викорыстоўваюцца падчас обробкі логаў, парсінгу налашчэнняў і ў будзь-якіх ситуацыях, калі текст з лёгкаю структураю трэба ператварыць у ўсё, з чым програма можа працаваць безпосередна.
Агрэсіўная падборка захопляе больш, чым вы мелі на ўвазе
Наступная памылка — гэта та, яку практычна кожны разработчык дзеяў прынеймна адной раз. Уявіце сабе ситуацыю, калі трэба адчыніць лёгкія тагі HTML з поля тексту:
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
Другій строке ўскрываеяся якасць бага. По значэнню прызначэння .* ўжываецца алчна — яны намагаюцца прабрацаваць як магчыма больш симвалаў, вяртаючы іх толькі тады, калі для цього абавязкова патрабуе сам шаблон. Паколькі гэтая строка містіць два адзелённых тэгі, шаблон <.*> не зупыніцца на першам заканчальным </b>; яны продовжаюць працаваць з усім, што находзіцца пасля яго — за межамі ends, працягваючыся аж да самага последнага > у строке, які знаходзіцца ўнутры </i> — адтак як адзін алчны шаблон .*> ўжо задовольняецца, як толькі знаходзіць последній доступны > у тексте, незалежна ад таго, на якай вялікай адстані ён знаходзіцца.
console.log(html2.replace(/<.*?>/g, ""));
// "Sale ends Friday"
Размішчання знака ? ведучы за * перетварае яго з „жаднаго“ на „лінійны“: прыстрой тепер прагне знаходзіць як магчыма менш літер, розширюючыся толькі тады, калі няма іншага спосабу задовольніць наступны элемент. Завдяк гэтай змене кожная послідовнасць <...> заканчваецца бліжэйшым знакам >, а не останнім у всім рэштры, таму тагі выкалываюцца по аднам, як і планавалася. Гэта разліка ў одны знак — * проты *? — таямніча паводзіцься вялікай часткай багоў типу „рэгулярныя выразы выкалываюць значна больш тексту, чым прыпускалася“, з якімі сталкиваюцца разработчыкі.
Баг, які фактычна зупініў сервер
Разглянем спрощаную версію шаблона, які вызваў тыя 11 секунд зупінкі: пераконтроль імені паўтаральніка, які мяне выканаць пераконтроль аднаго чыста слова чыму-небудзь, можа роздзеленага, пасля чаго следуе знак @.
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
Проблема крыўцяецца ў вкладзенай павтарэнні: (\w+)+ — серыя з адного чы роўна большых симвалаў слова, якая сама знаходзіцца ўнутрь групы, якая павтараецца адна чы роўна больш разоў. Калі вхідны строк у рэальнасці мае символ @, прыстрой практычна адразу знаходзіць падпаданне. Але якщо даўаць строку без жаднага @, прыстрой не можа проста так здацца — ён должен абоўсюды пераканацца, што жаднага паспраўнага распаложэння няма. А колькасць распаложэнняў, якія трэба пераглядзець, є вельмі вялікай: внутршній + можа захапіць адны символ, пакалі зовнішняя група павтараецца над рэштай, а можа захапіць два символы і дазволіць зовнішней групе павтарацца над тым, што засталося, і так далей — працаваецца з кожным можлівым спосабам раздзелення. Прыстрой пераглядае кожную з гэтых комбінацыяў, прычаму перш чым паставіць дыягназ стошкавання, а кожны дадатковы символ прыбліжна удвое збільшае колькасць комбінацыяў.
const fixedPattern = /^\w+@/;
Калі вы разумеете асляпныя прычыны, спосаб рашэння выглядае практычна занадта проста: вялікія павтарэння з самага пачатку ніколи не былі неабяжны. \w+ сама па сабе означае абсолютна тое ж самае, што і (\w+)+ для будзь-якага вхіднага дання, якое насправе должна працаваць, але без рызыку экспансіўнага бэктракінгу, який хаваецца за ўсім гэтым. Гэтая проблема мае вядомую назву — катастрофічны бэктракінг, і трэба старацца уважваць такія форматы, калі які-небудзь оператор павтарэння застаўляецца ў групе, якая вже сама містіць свой оператор павтарэння; шаблоны на кшталт (x+)+, (x*)+ або (x+)* — усе гэта ў варіяцыях адной і той жа основнай пасткі.
Што насправе можа научыць вас уважваць
Вам не трэба запамятаваць кожную з існуючых апасных форматак патэранаў. Галоўнае — усваяць адну фундаментальную істыну: механізм regex не ацэнюе патэрана за момент, ён шукае падходжанне, штоўчы за раз адну дарогу, і вяртаецца назад, пробуючы іншую дарогу, калі тэкучая не паводзіцца. Звычныя патэраны маюць так мало дарог для адкрыція, што гэтае пошук завершаецца раней, чым вы зможаце заўважыць затрымку. Апошнія апаснасці выклікаюцца саме з вёсканымі, перакрытымі павтарэннямі, калі колькісць дарог для перагляду не толькі стабільна зрастае, але і множыцца з кожным дадатковым символам уводу, які не паводзіцца. Таму тэставайце вашы regex-експрэсы не толькі проты страк, якія, як вы спадзяваецеся, павінны падходзіць, але і проты дугих страк, якія спецыяльна створаны так, каб не паводзіліся. Будзь-які увод, які ваша сэтка тэстаў ніколи не думала прымерзці, — гэта саме той увод, які ў канцы канцоў з’являецца ў рэальных умовах.