Почему регулярные выражения с механизмом обратного поиска могут тихо заблокировать ваш сервер
Узнайте, как жадное совпадение и катастрофическое возвратное прослеживание могут превратить на первый взгляд корректный регулярный выражение в причину сбоев в производстве, истощающих мощности CPU, и как выявить такие риски.
Источники: The Regex That Looked Fine in Testing and Hung the Server in Production
Шаблон проверки имени пользователя прошел аудит кода, успешно пройдя весь набор тестов, но спустя несколько недель после развертывания привел к тому, что API-эндпоинт на один входящий запрос перестал отвечать в течение одиннадцати секунд. Краха или исключения не произошло — процесс просто перестал реагировать, оставаясь с загрузкой CPU на уровне 100%, пока обрабатывал строку длиной чуть более сорока символов.
В обычном понимании никаких багов не появилось. Сам регулярный выражение было корректным и совпадало точно с тем, что должно было совпадать. Однако в нем скрывалась особенность, на которую большинство разработчиков так и не учатся обращать внимание; чтобы ее понять, необходимо заглянуть за символы и осознать, что на самом деле делает регулярное выражение во время выполнения, чего большинство учебных материалов пропускают, ограничиваясь объяснением таких токенов, как \d и +.
Регулярное выражение — это миниатюрная диаграмма потока, а не строка символов
Рассмотрим шаблон для проверки кода продукта, состоящего из двух заглавных букв, за которыми следуют четыре цифры:
const productCode = /^[A-Z]{2}\d{4}$/;
console.log(productCode.test("LM4821")); // true
console.log(productCode.test("LM482")); // false
Естественно представлять это как краткое описание формы строки. Лучшая модель, которая принесет пользу позже, — рассматривать его как миниатюрную диаграмму потока: начало, требование ровно двух заглавных букв, затем ровно четырех цифр, после чего входные данные считаются завершенными. Механизм обрабатывает входные данные по одному символу, пытаясь найти путь по этой диаграмме от начального узла до конечного. Когда он успешно достигает конца, происходит совпадение. Если же он натыкается на препятствие, а позади все еще остается непроверенная ветвь, он отступает и пробует эту другую ветвь вместо нее.
Этот шаг отступления — возвращение для тестирования альтернативной ветви — называется обратным поиском, и это, пожалуй, самая важная концепция регулярных выражений. Именно она придает им гибкость, но также и стала причиной вышеописанного инцидента в производстве.
Группы превращают вопрос «Соответствует ли?» в вопрос «Что там на самом деле?»
Вызов метода .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"
Размещение символа ? сразу после * превращает режим поиска из жадного в ленивый: теперь движок пытается совпасть с как можно меньшим количеством символов, расширяя поиск только тогда, когда у него нет другого способа обработать следующий элемент. Благодаря этому изменению каждая последовательность <...> закрывается у ближайшего символа >, а не у последнего во всей строке, поэтому теги удаляются по одному, как и предусмотрено. Именно это различие в один символ — * против *? — стоит за большинством сбоев типа «регулярные выражения удаляют гораздо больше текста, чем ожидалось», с которыми сталкиваются разработчики.
Сбой, который фактически завис сервер
Рассмотрим упрощённую версию шаблона, ответственного за одиннадцатисекундную задержку: проверку имени пользователя, предполагающую наличие одного или нескольких фрагментов буквенных символов, возможно разделённых пробелами, за которыми следует знак @.
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+)* — это всего лишь вариации одной и той же основной ловушки.
Чему это на самом деле учит нас быть внимательными
Вам не нужно запоминать все существующие опасные формы шаблонов. Важно усвоить одну основную истину: движок регулярных выражений не оценивает шаблон мгновенно, он поиском находит соответствие, пробуя по одному пути за раз, отступая и пробуя другой путь каждый раз, когда текущий терпит неудачу. У обычных шаблонов настолько мало путей для исследования, что этот поиск завершается до того, как вы заметите задержку. Настоящая опасность возникает именно при вложенных, перекрывающихся повторениях, когда количество путей для проверки не только постепенно увеличивается, но и умножается с каждым дополнительным символом входных данных, который не соответствует шаблону. Поэтому тестируйте свои регулярные выражения не только на строках, которые, по вашим ожиданиям, должны соответствовать, но и на длинных строках, специально созданных для того, чтобы вызвать ошибку. Именно те данные, которые ваш набор тестов даже не рассматривал, в конечном итоге могут появиться в реальных условиях использования.