Evitando Catastrophic Backtracking em Expressões Regulares

Fala, pessoal! Hoje quero falar sobre um problema que pode transformar suas expressões regulares em verdadeiras bombas-relógio para a performance da sua aplicação: o Catastrophic Backtracking. Talvez você já tenha passado por isso sem perceber, especialmente se sua regex faz buscas muito complexas ou trava inesperadamente.

Então, se você quer evitar travamentos, loops infinitos e dores de cabeça ao trabalhar com regex, esse artigo é para você. Bora entender esse problema e como evitá-lo!

O que é Catastrophic Backtracking?

O backtracking é um mecanismo que a maioria dos motores de regex usa para encontrar combinações. Basicamente, quando uma regex falha em uma tentativa de correspondência, o motor volta alguns passos e tenta outra abordagem.

O problema acontece quando a regex é escrita de forma que crie muitas possibilidades de combinação antes de finalmente falhar. Isso faz com que o motor de regex passe por um número exponencial de tentativas antes de desistir, podendo travar sua aplicação.

Como o problema acontece na prática?

Vamos entender esse problema com um exemplo prático. Imagine que você quer validar uma string que contém apenas a letra "a" seguida da letra "b" e escreve a seguinte expressão:

^(a+)+b$

Agora testamos com uma string que deveria falhar:

const regex = /^(a+)+b$/;

console.log(regex.test("aaab")); // true (válido)
console.log(regex.test("aaaaaaaaaaaaaaaaaaaaa")); // Falha, mas não imediatamente!

O primeiro teste funciona corretamente, mas o segundo pode travar o motor de regex. Isso acontece porque a regex (a+)+ permite múltiplas maneiras de agrupar os caracteres "a", e o motor tenta todas essas combinações antes de perceber que a string não contém um "b" no final.

Vamos visualizar o que o motor está fazendo:

  1. Ele começa tentando agrupar todos os "a" dentro do primeiro (a+).
  2. Como há outro (a+) englobando esse grupo, ele tenta redistribuir os "a" de diversas formas.
  3. Quando chega ao final da string e não encontra "b", ele volta e tenta outras distribuições possíveis de "a".
  4. Como há muitas formas de distribuir os "a", o número de tentativas cresce exponencialmente, causando um travamento.

Esse comportamento é chamado de Catastrophic Backtracking porque o tempo de execução cresce de forma imprevisível, dependendo do tamanho da entrada.

Como evitar o Catastrophic Backtracking?

Agora que você entendeu o problema, vamos ver algumas soluções para evitá-lo:

1. Evite grupos desnecessários

Se você não precisa capturar algo, remova os parênteses. No exemplo anterior, ^(a+)+b$ pode ser simplificado para:

^a+b$

2. Use âncoras de limite

Sempre que possível, use ^ e $ para indicar o início e o fim da string, evitando tentativas desnecessárias de backtracking.

3. **Use quantificadores não gananciosos (*?, +?, ??)

Se você precisa usar quantificadores repetitivos, tente usar a versão lazy para reduzir o número de tentativas. Por exemplo:

^a+?b$

Isso faz com que o motor pare assim que encontrar o "b", sem testar todas as combinações possíveis de "a".

4. Use Atomic Groups ((?>...))

Se você está lidando com grupos repetitivos, você pode dizer ao motor para não fazer backtracking dentro do grupo com (?>...).

^(?>a+)+b$

Conclusão

Se você já sofreu com regex travando sua aplicação ou rodando mais devagar do que deveria, agora você sabe que o Catastrophic Backtracking pode ser o problema, e evitar esse problema é questão de boas práticas.

Até a próxima!

Muito bom!

Mas vale lembrar que, dependendo do caso, usar um quantificador lazy não garante que vc está livre do problema. Segue um exemplo retirado deste artigo:

Suponha que tem um arquivo CSV, e quero fazer uma regex que pegue o décimo segundo campo, desde que o primeiro caractere seja "P". Pra isso, uso a regex ^(.*?,){11}P, que a princípio parece ok. O .*?, pega zero ou mais caracteres seguido de vírgula, mas de maneira não-gananciosa para que o ponto não pegue uma vírgula. O quantificador {11} garante que faço isso 11 vezes, e que o P esteja no décimo segundo campo.

O problema acontece quando o décimo segundo campo não começa com "P". Supondo que a linha seja 1,2,3,4,5,6,7,8,9,10,11,12,13: depois que o trecho ^(.*?,){11} consumiu os 11 primeiros campos, a regex falha ao encontrar o "P" (pois o décimo segundo campo começa com o caractere 1). Então ela faz o backtracking e o .*? consome a vírgula depois do 11.

Sim, afinal o ponto corresponde a qualquer caractere, inclusive a vírgula. O quantificador lazy não garante que o ponto nunca vai pegar uma vírgula, ele só garante que não vai pegar mais que o necessário. E neste caso, pegar zero vírgulas não foi o suficiente, então ele tenta de novo pegando uma.

Então agora a décima primeira ocorrência de (.*?,) vai consumir todo o trecho 11,12, e verificar se depois tem um "P", mas como não tem, ela faz mais um backtracking. E agora a décima primeira ocorrência passa a ser o trecho 11,12,13, mas na verdade ela nem chega a ser completada, pois não tem uma vírgula no final. Lembre-se que 11,12,13 corresponde agora a .*?, e depois teria que ter uma vírgula, mas como chegou ao final da string, a regex não encontra um match.

Só que ela não desiste! Agora ela faz o backtracking para a décima ocorrência de (.*?,), fazendo com que ela seja o trecho 10,11,, e aí a décima primeira ocorrência é 12,, para ver se depois tem um P. E como não tem, faz mais um backtracking: a décima ocorrência passa a ser 10,11,12, e assim vai até chegar ao final da string e não encontrar nenhum match.

E ela para pro aí? Claro que não, tem mais um backtracking: agora para a nona ocorrência, que será 9,10,, depois 9,10,11, e depois 9,10,11,12,. Mas entre cada uma dessas tentativas, há mais possibilidades a serem testadas. Por exemplo, ao tentar 9,10,, a décima ocorrência pode ser 11, e 11,12,. E mais uma vez ela chegará ao final da string sem encontrar um match e fará o backtracking para a oitava ocorrência (que abrirá mais possibilidades para a nona, décima, etc), depois novo backtracking para a sétima, e assim por diante.

Fiz um teste no Regex101, e neste link vc pode ver que todo esse backtracking gerou 32240 passos!

A solução, neste caso, foi mudar a expressão. O problema desta regex é que o ponto também pode incluir a própria vírgula, então o melhor é trocá-lo por uma expressão que não pegue a vírgula. Por exemplo, ^([^,\r\n]*,){11}P. Troquei o ponto por [^,\r\n], que é "qualquer caractere que não seja vírgula ou quebra de linha". Repare que assim nem preciso usar o quantificador lazy, pois agora não existe o risco dele pegar uma vírgula por engano.

Aqui podemos ver que agora a regex só precisa de 65 passos para detectar que não existe um match.

Aproveitando, existe também um mito de que o quantificador lazy sempre é "melhor" (ou "pior", já ouvi as duas coisas), ou "mais rápido" (ou "mais lento", também já ouvi ambos), mas isso também não é verdade. Dependendo da regex e das strings sendo buscadas, ele pode ou não ser a melhor opção, conforme eu demonstro aqui.


O mesmo vale para as demais dicas, cada um pode ter casos específicos em que eles podem ser a causa do backtracking excessivo. Usar um ou outro nem sempre garante que vc está livre do problema, cada caso é um caso.

Perfeito exemplo, muito obrigado por compartilhar!!

Meus 2 cents:

Expressoes regulares ajudam um bocado, mas trabalhar com elas as vezes pode ser... trabalhoso !

Para quem nao conhece, um material legal para regex eh o:

Parabens pelo artigo, obrigado por compartilhar conosco !