Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → Регулярные выражения: отладка и производительность

Регулярные выражения без катастроф: catastrophic backtracking и отладка паттернов

Почему регулярка «висит навсегда»: механизм backtracking, ReDoS, жадные и ленивые квантификаторы, якоря, атомарные группы и практическая отладка паттернов на примерах.

Прямой ответ

Регулярное выражение «зависает» не из-за длины текста, а из-за катастрофического backtracking: когда части паттерна перекрываются ((a+)+, (.*)*, вложенные квантификаторы), число вариантов разбора растёт экспоненциально с длиной входа — 30 символов считаются секунды, 50 — годы. Это не академическая страшилка, а вектор атаки ReDoS: регулярка, проверяющая пользовательский ввод, может положить сервис одним crafted-запросом. Лечение системное: устранять неоднозначность паттерна, использовать якоря и possessive/ленивые квантификаторы, тестировать на «злом» входе, а для проверки пользовательских данных предпочитать не регулярки, а специализированные парсеры и валидаторы.

Механика: почему (a+)+ убивает движок

Большинство движков (PCRE, Python re, JavaScript) — backtracking-машины: совпадение ищется перебором с откатами. Для паттерна ^(a+)+$ и строки aaaaaaaaaaaaaaaaX движок обязан перебрать все способы разбить последовательность a между вложенными группами, прежде чем признать поражение — это 2^n вариантов. Одна вложенность квантификаторов превращает линейную задачу в экспоненциальную. Признак проблемы в профилировщике: одна строка с re.match/Regex.test держит CPU минуты — порядок поиска такого места описан в статье про профилирование. Движки без backtracking (Go RE2, Rust regex) не подвержены катастрофе ценой отсутствия обратных ссылок и lookahead — это осознанный инженерный trade-off их авторов.

Паттерны-убийцы и их замена

Три классических семейства и безопасные эквиваленты. Вложенные квантификаторы (a+)+, ([a-zA-Z]+)* — почти всегда ошибка: заменяйте на один квантификатор a+. Чередования с перекрытием (a|ab|abc)* — делайте альтернативы непересекающимися или переписывайте без группы. «Жадный . между якорями»* .*@.*@ на длинной строке без совпадения даёт квадратичные откаты — заменяйте на [^@]*@[^@]* (символьный класс с исключением не может «перепрыгнуть» разделитель и не плодит вариантов). Общее правило: если два соседних элемента паттерна могут совпасть с одним и тем же символом более чем одним способом — паттерн подозрителен.

Инструменты ограничения: якоря, лень, атомы

Якоря ^...$ (или \A...\z) — первое средство: разбор начинается и заканчивается детерминированно, лишних путей меньше. Ленивые квантификаторы *?/+? останавливаются на первом совпадении — полезны для извлечения (<(.+?)>) и иногда снимают взрыв, но не являются лекарством от вложенности. Possessive-квантификаторы a*+ и атомарные группы (?>...) (PCRE/Java; в Python — с 3.11) запрещают откат внутрь — катастрофа исчезает ценой «всё или ничего». Ограничение длины входа перед проверкой — банальный, но действенный рубеж обороны от ReDoS: регулярка по 10-килобайтной строке из формы — уже подозрительный дизайн.

Отладка паттернов: практика

Порядок действий, когда регулярка ведёт себя не так. Первый шаг — интерактивная среда (regex101 с объяснением каждого шага и подсветкой групп, или repl вашего языка): паттерн отлаживается на коротких примерах, не на проде. Второй — набор тест-строк: минимум по паре «должно совпасть / не должно» на каждый смысловой случай, плюс граничные (пустая строка, очень длинная, спецсимволы). Третий — проверка на «злом» входе: строка из повторяющихся символов с неподходящим хвостом ('a'*40 + '!' для email-подобных паттернов) — если проверка не мгновенная, у вас backtracking-бомба. Четвёртый — декомпозиция: длинная регулярка разбивается на именованные фрагменты с комментариями (флаг re.VERBOSE в Python) — через месяц вы не прочитаете однострочник на 120 символов.

Когда регулярка — не тот инструмент

Честные границы: регулярки плохи для вложенных структур (HTML, скобочные выражения, JSON — «парсить HTML регулярками» канонический антипаттерн; берите парсер), для валидации сложных форматов (email по RFC, даты — специализированные библиотеки надёжнее), и там, где нужна понятная ошибка пользователю (парсер сообщает «где именно сломалось», регулярка — просто «нет»). Правило опыта: регулярка уместна для поиска и простых извлечений в текстах (логи — статья про grep/jq/awk), и сомнительна как единственный валидатор пользовательского ввода. В git-хуках и CI паттерны-фильтры обычно просты — риск живёт в рантайм-валидации (см. pre-commit про быстрые проверки).

Реальные кейсы: где это ломалось в жизни

Классический кейс ReDoS из практики веб-сервисов: «безобидная» регулярка валидации email вида ^([a-zA-Z0-9]+\.?)*@... проходит тесты на коротких адресах и ложится на входе aaaaaaaaaaaaaaaaaaaa! — экспоненциальный перебор способов раздать точки. Атакующему достаточно формы обратной связи: один запрос с 40-символьной строкой занимает воркер на минуты, десяток запросов — пул исчерпан. Лечение из этой статьи: убрать вложенный квантификатор (заменить на [a-zA-Z0-9.]+), добавить ограничение длины входа, и — радикально, но надёжно — валидировать email специализированной библиотекой, а не регуляркой.

Второй частый кейс не про атаку, а про производительность: разбор логов регуляркой с .* между якорями на гигабайтном файле идёт часами вместо минут — движок на каждой позиции пытается дотянуть жадный квантификатор до конца строки и откатывается. Замена .* на «класс с исключением разделителя» ускоряет разбор в десятки раз без изменения смысла — прием из статьи про обработку логов. Оба кейса объединяет урок: регулярка — исполняемый код со своей алгоритмической сложностью, и ревью она подлежит по тем же правилам, что и остальной код.

FAQ

Как найти уязвимую регулярку в проекте? Статические анализаторы (semgrep-правила на ReDoS, специализированные линтеры) плюс ревью всех паттернов, обрабатывающих внешний ввод. Первый признак — пользовательские регулярки валидации email/URL «из интернета».

Спасает ли таймаут на выполнение регулярки? Как аварийный рубеж — да (Python: отдельные потоки/сигналы, .NET: параметр timeout встроен). Но это лечение симптома: паттерн всё равно надо чинить, таймаут лишь не даёт положить процесс.

Почему в Go моя регулярка не «висит», а в Python висит? Go использует RE2 — движок без backtracking с гарантией линейного времени; Python re — backtracking-машина. Тот же паттерн в разных движках ведёт себя по-разному — переносить «проверенное» между языками на веру нельзя.

** lookahead/lookbehind опасны?** Сами по себе нет, но они добавляют движку путей перебора; короткий lookbehind безобиден, вложенные квантификаторы внутри lookahead — тот же путь к катастрофе. Правило про неоднозначность работает и для них.

Источники: regular-expressions.info (catastrophic backtracking), документация Python re, OWASP ReDoS (ссылки в шапке).