quarta-feira, 6 de junho de 2012

Pare de usar a função `.exclude` do Django!

Atenção: Se você é DBA, pare de ler por aqui, esse post vai te magoar!

Ta bom, não esse tipo de DBA, mas mesmo assim...

Pra você que pode não saber o que é, a função queryset.exclude serve para você remover objetos da queryset que não lhe interessam, por exemplo, se você tem uma lista de noticias agrupadas por categoria, você pode usar o exclude para remover as noticias de uma determinada categoria. Mas isso tem um custo.

Quando você usa a função exclude, a query gerada pelo Django passa a usar uma condição negativa em algum ponto, seja usando um NOT, seja usando um <>, e isso tem um problema sério: o indice do banco de dados pode não ser usado corretamente, especialmente se você pega um código como o que eu já peguei assim: Model.objects.exclude(id__in=[1,2,3,...,20]).

Existem situações legitimas em que usar o exclude é aceitável (ao menos eu acho que deve existir, senão essa função nem existiria), mas no meu caso, onde uma das tabelas em que era feito o JOIN tinha mais de 1 milhão de registros, isso tava levando mais de 3 segundos pra executar uma query. UMA QUERY, ainda tinha o site todo pra carregar e ele já estava me ferrando ali.

O que fazer então?

No meu caso, existia uma variável que era uma lista de ID's dos objetos que eu não queria e fiz o que poderia ser impensável pra você DBA teimoso:

# Quero 5 objetos do banco de dados
limit = 5 

# Esses são os IDs dos objetos que eu não quero
excluded = [1,2,3,4,5,6] 

queryset = Model.objects.filter(foo=bar).order_by('-data')

objetos = []

# Pegamos uma quantidade de objetos que garanta 
# que eu va conseguir exatamente `limit` objetos
for objeto in queryset[:limit+len(excluded)]: 
    if objeto.id not in excluded:
        objetos.append(objeto)
    if len(objetos) >= limit:
        break

E pronto, tenho uma lista com apenas os objetos que eu quero exibir.

Existem (varias) situações em que essa solução não serve, por exemplo se você precisa usar o Paginator, mas ainda assim se é o caso que você precisa apenas de uma quantidade pequena de objetos, pode ser mais vantajoso adotar uma solução como essa.

Depois que fiz essa alteração no código, o carregamento inteiro da página caiu pra 0,7 segundos de execução de query, ainda tem muito o que melhorar porque o código ta f0d@, mas já é alguma coisa :)

PS: Esses tempos de execução de SQL eu peguei pelo django debug toolbar.

4 comentários:

  1. Respostas
    1. Bom, eu não queria apontar dedos, mas já que vc perguntou: MySQL

      Excluir
    2. Pois é, vi esses dias reclamações em relação ao mysql e o uso de índices, tentei achar aqui o link, mas não consegui.
      Seria interessante ver se o problema acontece em outros bancos.

      Excluir
    3. Desde que as consultas sejam feitas usando condições positivas (>, <, =, IN) os indices do MySQL vão ser usados sem problema.

      Mas quando você precisa que seja "diferente de 3", ele parece precisar checar cada registro pra saber se é igual a 3. Na melhor das hipoteses, acredito que ele utilize o indice pra encontrar o valor igual ao que você não quer, e pega todo o restante.

      No meu caso, o bicho pegou fortemente porque era feito um NOT IN com uma lista de 20~30 IDs em uma tabela com milhões de registros, isso levava (sem exagero) entre 3 e 7 segundos (depende do que o MySQL já tinha em memoria). Removendo o NOT IN e fazendo a exclusão via python me reduziu o tempo de carregamento do mesmo trecho de código e obtendo o mesmo resultado em no máximo 0,7ms (num cold start do MySQL)

      Excluir