Tudo o que você precisa saber sobre otimização de query no Django

13/08/2022 - 9 min de leitura

DJANGO

Ultimamente estou estudando mais sobre performance de código, melhoria de uso dos recursos do DB com Django, Python, Async e uma porrada de coisas na nossa área. Esse post é sobre um livro que comprei recentemente: The Temple of Django Database do Andrew Brookins. Um livro e um autor incrível, que sabe muito sobre Django, Banco de Dados e Devops. O Andrew tenta escrever sobre assuntos complexos (otimização, bd, queries, etc) de forma que instigue o leitor a querer avançar na leitura cada vez mais. Eu diria até que ele usou elementos de gamificação no seu livro, pois, o mesmo é voltado para o publico que gosta do RPG, fazendo o paralelo com criaturas, pegadinhas, artifatos (armadilhas e funcionalidades do django) e lugares escondidos (assuntos) que a gente vai desbravando. Tudo isso com ajuda de elementos visuais e artes incríveis espalhadas pelo livro.

Sendo assim, resolvi mastigar tudo e trazer o resumão aqui.

The Sacrificial Cliff of Profilling

Neste capítulo, o autor traz a coisa mais importante que podemos aprender do livro: 

"When you dabble in performance tuning, you must measure the results."

- Andrew Brookins

E isso faz total sentido, pois, sem mensurar os resultados e o problema, não podemos ter certeza que a solução é efetiva para o problema. Sem essa ténica, chamada profiling, muito provavelmente só perderemos tempo enviando fix após fix pra produção, esperando que o problema seja resolvido.

Beleza! Mas, como que a gente faz esse profiling do problema? Temos três níveis possíveis que podemos fazer no Django:

  • Com a aplicação em produção, utilzaremos algum sistema APM (New Relic, Sentry, etc);
  • Rodando localmente, podemos utilizar o Django Debug Toolbar ou Django Silk.
  • No nível do BD, podemos rodar um comando de query plan (cada BD tem seu comando específico)

Quero falar um pouco mais sobre o comando de query plan, pois é muito incrível. Veja abaixo a execução do comando, num dos meus projetos pessoais, utilizando o Postgres:

O comando EXPLAIN ANALYZE é específico do Postgres e é um pouco diferente do EXPLAIN (esse tem na maioria dos BD). O custo da query é dado da seguinte forma cost={custo inicial}..{custo total}. Esse número representa a estimativa de páginas de dados lidas no disco (alô arquitetura de computadores).

A principal métrica desse comando é a penúltima linha: Execution Time: 0.019 ms.

É através dessa métrica que podemos verificar se o novo fix na query que iremos subir foi efetivo e melhorou em relação ao antigo.

Pra isso, se você não sabe como pegar a query gerada no Django, É através do seguinte comando:

qs = MyModel.objects.filter(pk=1, type=MyModel.TYPE_ONE)
print(qs.query)

Então, basta mensurar o custo anterior e comparar com o novo custo da query refatorada.

O Django também pode fazer o Explain pra você.

The Labyrinth of Indexing

Enquanto no capítulo anterior o autor mostra as ferramentas que podemos utilizar para fazer o profiling, nesse capítulo ele já começa a adentrar em algumas técnicas, mais especificamente de index no BD, que podemos utilizar para melhorar as performances das nossas queries.

Aqui o autor explica que a maioria dos BD implementam arvores B-tree (alô Estruturas de Dados) para representar os seus indexes. Portanto, co uso dessa árvore balanceada,  é possível melhorar a consulta dos dados. No entanto, é importante dizer que, toda vez que inserimos um dados novo, o BD automaticamente vai re-balancear a árvore dos indexes. Isso implica dizer que, Index, feito da maneira certa e na coluna certa, pode melhorar a performance de uma query, porém, deixa as operações de insert e update mais lentas, no geral.

Mass.. como saber em qual coluna criar index? No geral, criamos indexes para colunas que fazemos muitos filtros. Por exmplo, se você faz muito busca por o campo "name", talvez ele seja um candidato a ter um index.

Adicionando um Index num model do Django

Podemos adicionar index no Django através da variável indexes, na classe Meta do model, como demonstrado a seguir:

class Event(models.Model): 
     user = models.ForeignKey('auth.User', on_delete=models.CASCADE)
     name = models.CharField(max_length=255)
     date = models.DateField()
     class Meta: 
         indexes = [
             models.Index(fields=['name'])
         ]

Aqui está o link da documentação para consulta.

Para verificar se foi criado o index no BD, podemos utilizar o segiunte comando no postgres:

\d table_name;

O resultado será algo parecido com isso:

É importante dizer que o index, nesse caso do exemplo do model Event, só vai funcionar se fizermos uma busca apenas pelo nome do evento. Se combinarmos o nome com a data (filter(name=x, date=y)), o index já não irá ser utilizado para a query. Se isso for o caso, você deve adicionar mais um item no array de indexes, contendo o fields name e date.

De toda forma, o BD quem vai decidir se vai utlizar o index na query ou não. Caso o BD não utilizar o index na consulta, o problema pode estar em diversos fatores. Um deles é ter muitos dados repetidos.

Adicionando Index em produção

Adicionar o index em desenvolvimento é tranquilo, pois, no geral, o banco de dados é bem menor do que o de produção. O comportamento padrão do Postgres é fazer lock na tabela que a gente ta adicionando o index. Dessa forma, fica bloqueada para escrita.

Dependendo do tamanho da tabela, adicionar um index pode ser bastante demorado. (informação aleatória: O MySQL com InnoDB não faz lock na tabela)

Por experiência própria, já vi um sistema inteiro "cair", porque estávamos subindo uma migration que adicionava um index numa tabela muito grande do banco Postgres. Isso provocou vários timeouts.

A gente pode amenizar esses problemas criando um index de forma concorrente no banco, com esse comando aqui:

CREATE INDEX CONCURRENTLY meu_index_bolado ON minha_tabela(meu_campo);

No Django, a gente pode fazer esses comandos "customizados" dentro do arquivo de migração do model. Podemos adicionar esse comando sql com ajuda do comando de RunSQL. O código fica mais ou menos assim:

class Migration(migrations.Migration): 
    atomic = False

    dependencies = [
        ('analytics', '0001_initial'),
    ]

    operations = [
        migrations.RunSQL("CREATE INDEX CONCURRENTLY" \
                          "analytics_event_name_idx " \
                          "ON analytics_event(name);")
    ]

Você pode perceber que eu declarei o atributo atomic=False. Tem uma explicação pra isso...

O Django, por padrão, executa as operações de migration dentro de uma transaction do BD. Porém.... o Postgres não suporta o comando de criar index concorrente dentro de uma transaction. Por isso, tem que ser feito esse ajuste técnico rsrs.

Testando se melhorou o custo da query

Pra testar se a inserção do index surtiu efeito na tabela do BD, primeiro você deve falar ao postgres atualizar as estatísticas que ele tem armazenados dos custos das queries. Isso é feito pelo comando VACUUM ANALIZE. Após isso, é só rodar o comando de EXPLAYN ANALIZE novamente.

No livro, o autor se aprofunda muito nos indexes e como que eles funcionam. Não irei fazer isso aqui. Porm fim deste tópico, quero deixar registrado os tipos de indexes que podemos criar no Django com Postgres:

  • Index GIN (para JSON Field do Postgres)
  • Index GIST (para coordenadas/spatial data do Postgres)
  • Index BRIN (index para tabelas muito grandes do Postgres)

The Crypt of Quering

Nesse capítulo a gente vê diversos recursos do Django que podemos utilizar para melhorar as queries.

select_related para evitar o problema de N+1 queries

No seguinte problema, Tenho uma tabela professor que faz relação com o usuário. Eu quero mostrar os nomes dos meus professores. Esse é um código extremamnete simples, mas veja quantas queries o django fez no BD:

O Django fez o select na tabela Teacher, depois fez o select na tabela User.

Esse é o problema do N + 1. Para cada Professor, o django vai fazer mais uma query, por conta que a informação do nome, está numa chave estrangeira com usuário.

Olhe como podemos resolver isso, com select_related:

Com ajuda do select_related do Django, só fizemos um HIT no BD. Isso acontece porque o django vai converter isso num INNER JOIN entre Teacher e User.

Ainda podemos ir além, adicionando um nested relationship. Dessa forma aqui:

User.objects.all().select_related('profile', 'profile__billing')

Essa query vai trazer as informações do profile e billing, tudo junto. Desta forma, quando eu acessar o user.profile.qtd_followers e user.profile.billing.amount, não será gerado nenhum filtro a mais.

O Django vai montar essa query com alguns LEFT OUTER JOINs.

No entanto, use com cuidado... As vezes é melhor fazer a seguinte query:

Profile.objects.all().select_related('user', 'billing')

Pois a query resultante será somente de INNER JOINs.

prefetch_related

Grosseiramente falando, o prefetch_related é o select_related para campos ManyToMany. No entanto, por debaixo dos panos, ele não utiliza o INNER JOIN, mas sim uma query adicional para trazer os dados que combinam com a queryset inicial. Algo mais ou menos asim:

SELECT "analytics_event"."id", "analytics_event"."user_id",
"analytics_event"."name",
"analytics_event"."data" FROM "analytics_event"
WHERE "analytics_event"."user_id" IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11)

Use o only() para retornar somente as colunas que você quer

O only() é um método da queryset que permite que djgamos ao django quais colunas queremos de um model. Veja no exemplo:

O Django sempre vai trazer o ID. Então, a diferença é que no primeiro exemplo ele buscou somente o nome e o ID. No segundo, ele pegou tudo. Dependendo da sua aplicação, a primeira query demanda muito menos da largura de banda, memória, etc.

Mas, veja. Se eu utilizar o only() e depois tentar acessar uma coluna que eu não especifiquei nele, o django irá gerar uma query adicional no banco, como demonstro no exemplo abaixo:

Neste exemplo anterior, o django gerou uma query adicional quando tentei buscar pela informação que diz se o usuário é superuser.

defer()

O defer() é o contrário do only(). Nessa função a gente pode especificar o que não queremos trazer de uma query. No entanto, tem o mesmo problema: se quisermos acessar um campo que não foi filtrado na queryset, o django vai gerar um HIT a mais no BD.

Reduzindo o uso de memória com values() e iterator()

O programador é responsável pelo código que gera. Devemos alocar os recursos e, se não tivermos utilizando mais, ou quisermos melhorar o uso de memória, temos que desalocar.

A função .values() da queryset do Django otimiza o recurso da memória, pois seu retorno não instancia objetos do tipo Model. Ao invés disso, ela retorna um dicionário.

Como você pode se beneficiar disso? Imagine que você têm 1 milhão de dados na query.... o Autor do livro fez o teste. Vou compartilhar com vocês:

(env) # python code/profile_values.py models
Running ORM query -- 1,000,000 records
71.258112 mb used
8.681967735290527 seconds elapsed

(env) # python code/profile_values.py values
Running values query -- 1,000,000 records
56.377343999999994 mb used
3.8750998973846436 seconds elapsed

Gosto bastante do values(). Ajuda muito.

Agora imagine que você seja obrigado a Instanciar o Model, porque tem que acessar o save() ou alguma outra dependência.. Como evitar o desperdício de memória nesse caso?

Podemos utilizar o iterator(), que têm como vantagem o seguinte: ele não vai instanciar todos os dados de uma vez só na memória, mas sim por partes.

"Just adding iterator() makes a dramatic difference when profiling this loop with 1.5 million Event objects. On my machine, without an iterator, the process consumed 12 GB before I killed it — and before it had even started looping over the results. With iterator() , the loop starts quickly and the process consumes a stable 60-70 MB while it runs."

- Andrew 

Inserts e Updates de forma rápida

Nesses casos podemos utilizar o bulk_update e o bulk_create.

O django têm diversas funções interssantes, como o annotate/aggregate, F Functions, SubQuery, Case When, etc. Você pode encontrar todas elas na documentação do Django.

Ainda tem diversos assuntos que podemos tratar com django, como por exemplo, cache. Estou pensando em fazer um post desse no futuro.

Foto de capa por Marc-Olivier Jodoin no Unsplash.

Compartilhe

Twitter