A armadilha da destruição em cascata do ActiveRecord

Publicado por Paulo Casaretto no dia dev

Ao analisar problemas de performance em um job cuja responsabilidade é a deleção de um lote de modelos, fiquei surpreso ao ver o número excessivo de queries SQL. Descobri que o culpado é o mecanismo de deleção em cascata do ActiveRecord, que pode ser muito útil mas que também pode virar um grande gargalo de performance.

O problema

O Lead é um modelo central no RD Station que representa um contato. Tudo que acontece no sistema, acontece com um Lead ou para ele, ou modifica-o de alguma forma. O modelo foi naturalmente acumulando muitos relacionamentos. Até o momento da escrita deste são 10 has_many e 2 has_one.

Esses relacionamentos perdem o sentido quando o Lead deixa de existir. A opção dependent: :destroy naturalmente apareceu para a maioria deles. Além disso, muitos desses modelos tem seus próprios has_many ou has_one com outros modelos.

Não é difícil entender porque o job estava com problemas de performance. Para cada Lead a ser deletado, precisamos deletar uma enorme quantidade de modelos relacionados e estávamos fazendo isso da maneira mais ingênua possível. Além disso, ainda havia mais um complicador.

O agravante

Usamos o mecanismo de expiração de cache do Rails no RD Station. O mecanismo expira o cache automaticamente a partir do timestamp do modelo, o que em baixa escala funciona muito bem. O problema é que com o aumento da escala ocorre uma degradação de performance muito significativa (mais sobre isso em posts futuros).

A maior parte das relações no nosso sistema usa essa mecanismo e o próprio Lead toca um modelo pai. Acontece que a propagação do evento se dá também na deleção então além da própria deleção, os modelos atualizam os timestamps de seus pais. É fácil ver como este problema agrava o anterior. O que é mais frustrante é que todos estes touches são inúteis, haja visto que o modelo está sendo destruído.

Vamos analisar passo a passo uma deleção sob o ponto de vista do banco de dados levando em consideração apenas uma das suas relações.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
SELECT lead 1
SELECT filho 1
SELECT neto 1
DELETE neto 1
UPDATE filho 1 set updated_at
UPDATE lead 1
UPDATE pai
...
SELECT neto N
DELETE neto N
UPDATE filho 1 set updated_at
UPDATE lead 1 set updated_at
UPDATE pai set updated_at
DELETE filho1
UPDATE lead 1 set updated_at
UPDATE pai set updated_at

Com isso, fica bem claro o quão problemático estava essa deleção.

Solução

A solução aqui é bem simples. Usar dependent: :delete_all que faz um DELETE direto no banco sem nem carregar os objetos filhos. Os filhos então, não tem a oportunidade de rodar nenhum callback pós/pré deleção incluindo o touch. Cuidado: Isso também significa que modelos que tenham de fato callbacks importantes não podem usar o delete_all.

Mas como deletar as relações de segunda ordem? Teoricamente seriamos forçados a abrir cada relação de primeira ordem e aí rodar os delete_all, o que não melhoraria muito no final das contas.

Para resolver isso, usamos o has_many :trough para adicionar relações auxiliares ao Lead que o ligam diretamente ao seus netos. Usamos o dependent: :delete_all para garantir que todos os netos sejam destruídos junto com os filhos.

Sob o ponto de vista do banco, agora temos:

1
2
3
SELECT lead 1
DESTROY netos where filho_id in (1..N)
DESTROY filhos where lead_id = 1

Muito melhor.

TL;DR

Cuidado com dependent: :destroy, evite callbacks e use dependent: :delete_all.

Paulo Casaretto

Paulo Casaretto

Full Stack Developer

Comentários