Оптимизация регулярных выражений: избегаем взрыва возвратов

ТекстText processing tools

Производительность regex: одно выражение может положить весь сервер

Катастрофический возврат (Catastrophic Backtracking) может увеличить время сопоставления регулярного выражения с миллисекунд до часов.

Сценарий Regex Длина ввода Время
Нормальное сопоставление /^abc$/ 100 <1мс
Линейный возврат /a*b/ 100 ~1мс
Экспоненциальный возврат /(a+)+b/ 30 ~10с
Экспоненциальный возврат /(a*)*b/ 30 ~10с

I. Принцип механизма возвратов

Как работает движок NFA

JavaScript использует движок NFA (Недетерминированный Конечный Автомат) с алгоритмом возвратов:

Ввод: "aaaac"
Regex: /(a+)+c/

Процесс сопоставления:
1. (a+) совпадает с "aaaa" → пробует +c → не совпадает с 'c'
2. Возврат: (a+) совпадает с "aaa" → (a+) совпадает с "a" → пробует +c → не совпадает
3. Возврат: (a+) совпадает с "aa" → (a+) совпадает с "aa" → пробует +c → не совпадает
4. Возврат: (a+) совпадает с "aa" → (a+) совпадает с "a" → (a+) совпадает с "a" → ...
5. ... экспоненциальный возврат

Расчёт количества возвратов

Для /(a+)+b/ при сопоставлении с "aaa..." (n букв a):

Количество возвратов ≈ 2^n

n=10: ~1 024 раза
n=20: ~1 048 576 раз
n=30: ~1 073 741 824 раза

II. Распространённые паттерны катастрофических возвратов

Паттерн 1: Вложенные квантификаторы

// Катастрофический возврат
/(a+)+b/     // Вложенный + квантификатор
/(a*)*b/     // Вложенный * квантификатор
/(\w+)+@/    // Вложенный \w+

// Исправление: избегать вложенных квантификаторов
/a+b/        // Одноуровневый квантификатор
/(\w+)@/     // Одноуровневый квантификатор

Паттерн 2: Упорядоченная альтернатива

// Катастрофический возврат: варианты альтернативы с общим префиксом
/(a|ab)+c/

// При вводе "abababab..." без совпадения движок пробует все комбинации
// Количество возвратов ≈ O(2^n)

// Исправление: извлечь общий префикс
/a(b?)+c/

Паттерн 3: Перекрывающиеся квантификаторы

// Катастрофический возврат
/.*.*=/

// Оба .* могут совпадать с одним содержимым, создавая множество возвратов

// Исправление: объединить квантификаторы
/.*=/

Паттерн 4: Ловушка обратной ссылки

// Катастрофический возврат
/^(b+)(a+)\1$/

// При вводе "bbbbaaaa" \1 пробует все подразделения b+

// Исправление: по возможности избегать обратных ссылок или ограничивать диапазон квантификатора
/^(b+)(a+)\1$/.test(input) // Только для коротких вводов

III. Техники оптимизации

Техника 1: Конкретизация классов символов

// Медленно: . совпадает с любым символом, много возвратов
/.*foo/

// Быстро: [^\n]* не совпадает с переносами строк, уменьшает возвраты
/[^\n]*foo/

// Ещё быстрее: если известно, что перед foo только буквы
/[a-z]*foo/

Техника 2: Привязка для уменьшения пространства поиска

// Медленно: пытается сопоставить с каждой позиции
/foo/

// Быстро: если foo в начале строки
/^foo/

// Быстро: если foo в конце строки
/foo$/

// Быстро: привязка с обеих сторон
/^foo$/

Техника 3: Правильное использование ленивых квантификаторов

// Сценарий: извлечь содержимое тегов <b>

// Медленно: жадный квантификатор + возвраты
/<b>(.*)<\/b>/

// Быстро: ленивый квантификатор (но возвраты возможны)
/<b>(.*?)<\/b>/

// Быстрее всего: исключающий класс символов (ноль возвратов)
/<b>([^<]*)<\/b>/
Способ Совпадение <b>hello</b><b>world</b> Возвраты
(.*) hello</b><b>world Много
(.*?) hello Мало
([^<]*) hello Ноль

Техника 4: Атомарные группы (ES2024+)

// Атомарная группа (?>...) после совпадения не возвращается
const regex = /^(?>a+)+b$/;

// Эквивалентно: если a+ совпало, не возвращаться для повторной попытки внешним +
// Совпадает "aaab" → успех
// Совпадает "aaa" → немедленный провал (без экспоненциальных возвратов)

Техника 5: Сверхжадные квантификаторы (некоторые движки)

// Сверхжадный квантификатор a++ не освобождает для возвратов после совпадения
// JavaScript не поддерживает, но можно имитировать атомарными группами
const regex = /^(?>(a+))b$/;

Техника 6: Предкомпиляция + повторное использование

// Медленно: каждый раз создаётся новый RegExp
function validate(input) {
  return /^[\w.+-]+@[\w-]+\.[\w.]+$/.test(input);
}

// Быстро: предкомпиляция
const EMAIL_REGEX = /^[\w.+-]+@[\w-]+\.[\w.]+$/;
function validate(input) {
  return EMAIL_REGEX.test(input);
}

IV. Инструменты анализа производительности

Использование инструмента regex ToolsKu

  1. Открыть Инструмент регулярных выражений
  2. Ввести регулярное выражение и тестовый текст
  3. Посмотреть результаты сопоставления и время
  4. Скорректировать regex, сравнить производительность до и после оптимизации

Встроенное измерение в JS

function measureRegex(regex, input, iterations = 1000) {
  const start = performance.now();
  for (let i = 0; i < iterations; i++) {
    regex.test(input);
  }
  const end = performance.now();
  return {
    totalTime: end - start,
    avgTime: (end - start) / iterations,
    iterations,
  };
}

// Сравнение
const slow = measureRegex(/(a+)+b/, 'a'.repeat(25));
const fast = measureRegex(/a+b/, 'a'.repeat(25));
console.log(`Медленно: ${slow.avgTime}мс, Быстро: ${fast.avgTime}мс`);

Библиотека recheck для обнаружения опасных regex

npm install recheck
import { check } from 'recheck';

const result = check(/(a+)+b/);
// { safe: false, complexity: 'exponential' }

const result2 = check(/a+b/);
// { safe: true, complexity: 'linear' }

V. Сравнение производительности распространённых регулярных выражений

Regex Сложность Безопасно Оптимизированная версия
/(a+)+b/ Экспоненциальная ❌ /a+b/
/(a|ab)+c/ Экспоненциальная ❌ /a(b?)+c/
/.*.*=/ Экспоненциальная ❌ /.*=/
/(\d+){1,10}/ Полиномиальная ⚠️ /\d{1,10}/
/^[a-z]+$/ Линейная ✅ —
/^\d{4}-\d{2}-\d{2}$/ Константная ✅ —

VI. Серверная защита

Защита по таймауту

function safeRegexTest(regex, input, timeoutMs = 100) {
  const start = Date.now();
  const result = regex.test(input);
  if (Date.now() - start > timeoutMs) {
    throw new Error('Regex timeout: possible catastrophic backtracking');
  }
  return result;
}

Движок re2 (гарантия линейного времени)

import RE2 from 're2';

// RE2 гарантирует линейное время, без взрыва возвратов
const regex = new RE2('(a+)+b');
regex.test('a'.repeat(100)); // Безопасно, не зависнет

Ограничение длины ввода

function validateWithLimit(regex, input, maxLength = 1000) {
  if (input.length > maxLength) {
    throw new Error(`Input exceeds maximum length: ${maxLength}`);
  }
  return regex.test(input);
}

VII. Контрольный список лучших практик

  1. Избегать вложенных квантификаторов: (a+)+ → a+
  2. Конкретизировать классы символов: . → [^\n] или более конкретный класс
  3. Извлекать общие префиксы: (a|ab) → a(b?)
  4. Использовать исключающие классы вместо ленивых: (.*?) → ([^<]*)
  5. Привязка для уменьшения пространства поиска: ^ / $
  6. Предкомпиляция регулярных выражений: избегать повторного создания
  7. Использовать recheck для проверки: автоматически обнаруживать опасные regex в CI
  8. Серверная защита по таймауту: предотвращать regex DoS
  9. Ограничивать длину ввода: уменьшить влияние худшего случая
#正则表达式#性能优化#回溯#RegExp#文本处理