Entendendo o problema de N+1 query usando o Django
Wesley de Morais
Posted on February 13, 2023
Sumário
- Introdução do que é o problema n+1 query
- Entendendo a situação problema em Django
- Criação da API com DRF
- Resolvendo usando o que o Django dispõe por padrão
- Entendendo o Django Virtual Model
- Referências
Introdução do que é o problema n+1 query
O problema N+1 query é um problema que ocorre quando existem determinadas associações entre entidades na aplicação, desta forma quando é necessário acessar informações de A em relação a B ocorre muitas requisições ao banco de dados para cada entidade de A, tornando a aplicação lenta e possivelmente gastando mais do que o necessário quando a aplicação está em produção.
Entendendo a situação problema em Django
Para podermos entender o problema de n+1 query, vamos a um exemplo, imagine uma aplicação na qual usuários de uma plataforma postam vídeos, e esses vídeos tem determinadas categorias que o próprio usuário pode adicionar para que o vídeo chegue em pessoas que gostam dessas categorias. Desta forma podemos ter o seguinte diagrama entidade relacionamento:
Podemos pensar um pouco em relação ao primeiro relacionamento, um usuário publica muitos vídeos, e um vídeo é de um usuário, temos assim um relacionamento 1 para muitos. Imagine que temos 3 usuários na plataforma inteira(Começou agora), e cada um postou 1 vídeo(Uma máquina de postagem), e temos uma rota na nossa API que nos mostra informações dos usuários e o título do vídeo que ele postou, então vai ser necessário 1 query para pegar todos os Users e N queries para pegar as informações dos vídeos relacionados a cada User. Assim temos:
SELECT * FROM User ...
SELECT title FROM Video WHERE userID = 1
SELECT title FROM Video WHERE userID = 2
SELECT title FROM Video WHERE userID = 3
O grande problema ocorre quando aumentamos o número de usuários. Em relação a segunda associação as ideias são as mesmas. Então já podemos imaginar qual a solução para problema, eu vou precisar fazer uma query para pegar todos os usuários e uma outra para pegar os vídeos de todos os usuários
SELECT * FROM User ...
SELECT title FROM Video WHERE userID in (1,2,3)
Mas surge a pergunta, como faço isso usando o ORM do django?
Criação da API com DRF
Pré-requisitos
- Entendimento básico sobre o funcionamento do Django
- Entendimento básico sobre o funcionamento do Django Rest Framework
Mão na massa
Crie uma pasta chamada video_plataform e instale um ambiente virtual para não poluir seu ambiente.
python -m venv venv
Instalando dependências:
pip install django djangorestframework
Criar o projeto e a aplicação:
django-admin startproject video_plataform .
django-admin startapp core
Settings
Posteriormente, vamos adicionar no arquivo settings.py as duas aplicações
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
"core.apps.CoreConfig", #<- new
"rest_framework" #<- new
]
Models
Para fazer o mapeamento podemos adicionar o seguinte código no arquivo models.py e fazer a migração ao banco de dados:
from django.db import models
class User(models.Model):
name = models.CharField(max_length=30)
def __str__(self) -> str:
return self.name
class Video(models.Model):
title = models.CharField(max_length=30)
url = models.CharField(max_length=255)
visualizations = models.IntegerField(default=0)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="videos")
def __str__(self) -> str:
return self.title
class Category(models.Model):
title = models.CharField(max_length=30)
videos = models.ManyToManyField(Video, related_name="categories")
def __str__(self) -> str:
return self.title
python manage.py makemigrations
python manage.py migrate
Povoando o banco de dados
Crie um diretório chamado fixtures e dentro dele crie um arquivo chamado data.json com o seguinte conteúdo:
[ {
"model": "core.user",
"pk": 1,
"fields": {
"name": "Paul"
}
},{
"model": "core.video",
"pk": 1,
"fields": {
"title": "Dancinha do loirinho",
"url":"www.google.com.br",
"visualizations": 0,
"user": 1
}
},{
"model": "core.user",
"pk": 2,
"fields": {
"name": "Marie"
}
},{
"model": "core.video",
"pk": 2,
"fields": {
"title": "Dancinha do forró",
"url":"www.google.com.br",
"visualizations": 0,
"user": 2
}
},{
"model": "core.user",
"pk": 3,
"fields": {
"name": "Mary"
}
},{
"model": "core.video",
"pk": 3,
"fields": {
"title": "The office",
"url":"www.google.com.br",
"visualizations": 0,
"user": 3
}
},{
"model": "core.category",
"pk": 1,
"fields": {
"title": "Entretenimento",
"videos":[1,2,3]
}
},{
"model": "core.category",
"pk": 2,
"fields": {
"title": "Forró",
"videos":[2]
}
},{
"model": "core.category",
"pk": 3,
"fields": {
"title": "Funk",
"videos":[1]
}}]
No terminal execute o comando:
python manage.py loaddata data.json
Serializer
Crie o arquivo serializers.py e coloque o conteúdo:
from rest_framework import serializers
from core.models import User, Video, Category
class VideoSerializer(serializers.ModelSerializer):
class Meta:
model = Video
fields = ("title","url","visualizations")
class UserSerializer(serializers.ModelSerializer):
videos = VideoSerializer(many=True)
class Meta:
model = User
fields = ("name", "videos")
class CategorySerializer(serializers.ModelSerializer):
videos = VideoSerializer(many=True)
class Meta:
model = Category
fields = ("title")
URL
No arquivo urls.py na pasta do projeto adicione o seguinte comando:
from django.contrib import admin
from django.urls import path
from core.views import UserListAPIView #<- new
urlpatterns = [
path('admin/', admin.site.urls),
path('users/', UserListAPIView.as_view()) #<- new
]
View
Afim de podermos fazer a listagem de usuários da nossa aplicação vamos criar a nossa view UserListAPIView
from rest_framework.generics import ListAPIView
from core.serializers import UserSerializer
from core.models import User
from django.db import connection, reset_queries
class UserListAPIView(ListAPIView):
serializer_class = UserSerializer
def get_queryset(self):
users = User.objects.all()
for query in connection.queries:
print("Query:", query)
reset_queries()
return users
O código é um pouco curioso, temos uma classe que herda de ListAPIView, pois eu quero que ela faça a funcionalidade de listagem de todos as instâncias de um determinado recurso de uma forma serializada, assim eu faço o comando User.objects.all(), mas o django nos possibilita saber quais foram as queries feitas ao banco usando o atributo connection.queries, assim caso você acesse a rota pelo browser e for ao seu terminal vai encontra algo semelhante a isso:
Query: {'sql': 'SELECT "core_user"."id", "core_user"."name" FROM "core_user"', 'time': '0.004'}
Query: {'sql': 'SELECT "core_video"."id", "core_video"."title", "core_video"."url", "core_video"."visualizations", "core_video"."user_id" FROM "core_video" WHERE "core_video"."user_id" = 1', 'time': '0.003'}
Query: {'sql': 'SELECT "core_video"."id", "core_video"."title", "core_video"."url", "core_video"."visualizations", "core_video"."user_id" FROM "core_video" WHERE "core_video"."user_id" = 2', 'time': '0.000'}
Query: {'sql': 'SELECT "core_video"."id", "core_video"."title", "core_video"."url", "core_video"."visualizations", "core_video"."user_id" FROM "core_video" WHERE "core_video"."user_id" = 3', 'time': '0.000'}
Se você lembrar são, praticamente, as mesmas queries que mostrei anteriormente, ou seja, podemos ver o problema aqui, caso o número de usuários aumente, então maior vai ser o número de queries feitas para pegar os vídeos deles para essa view.
Resolvendo usando o que o Django dispõe por padrão
Para resolver esse problema vamos modificar o modo como usamos o ORM, assim modifique:
#users = User.objects.all()
#=>
users = User.objects.prefetch_related("videos")
Decorrente que estamos fazer um acesso inverso, pois quem tem a chave estrangeira na relação é o modelo Video e não User, temos que usar o a função prefetch_related() que faz 2 queries e é feito um join por meio do python, também temos o select_related() que faz a mesma coisa, mas deve ser usado em modelos que tenha a chave estrangeira e também ocorre o join por meio do banco de dados, assim fazendo apenas 1 query. Portanto, se acessarmos a rota e formos no terminal vamos ver algo parecido com:
Query: {'sql': 'SELECT "core_user"."id", "core_user"."name" FROM "core_user"', 'time': '0.004'}
Query: {'sql': 'SELECT "core_video"."id", "core_video"."title", "core_video"."url", "core_video"."visualizations", "core_video"."user_id" FROM "core_video" WHERE "core_video"."user_id" IN (1, 2, 3)', 'time': '0.003'}
Como podemos ver na query, mesmo que o sistema tenha 1 milhão de usuários, apenas será feita 2 requisições ao banco de dados, e não 1 milhão de requisições mais 1.
Indo Além
Vamos adicionar um dado novo aos dados do usuário que são mostrados, podemos querer mostrar a quantidade de Vídeos que cada usuário tem, podemos adicionar o seguinte código no arquivo serializer.py:
class UserSerializer(serializers.ModelSerializer):
videos = VideoSerializer(many=True)
amount_videos = serializers.SerializerMethodField() #<-new
class Meta:
model = User
fields = ("name", "videos", "amount_videos") #<-new
def get_amount_videos(self, obj): #<-new
return Video.objects.filter(user=obj).count() #<-new
O código acima vai criar uma nova chave nos dados que são mandados para o cliente com a quantidade de vídeos de cada usuário, porém podemos visualizar as queries feitas pelo terminal:
Query: {'sql': 'SELECT "core_user"."id", "core_user"."name" FROM "core_user"', 'time': '0.002'}
Query: {'sql': 'SELECT "core_video"."id", "core_video"."title", "core_video"."url", "core_video"."visualizations", "core_video"."user_id" FROM "core_video" WHERE "core_video"."user_id" IN (1, 2, 3)', 'time': '0.001'}
Query: {'sql': 'SELECT COUNT(*) AS "__count" FROM "core_video" WHERE "core_video"."user_id" = 1', 'time': '0.001'}
Query: {'sql': 'SELECT COUNT(*) AS "__count" FROM "core_video" WHERE "core_video"."user_id" = 2', 'time': '0.000'}
Query: {'sql': 'SELECT COUNT(*) AS "__count" FROM "core_video" WHERE "core_video"."user_id" = 3', 'time': '0.000'}
Veja, as nossas duas queries ainda estão lá(TUDO OK!), evitamos o problema de n+1 query, mas para saber a quantidade de vídeos de cada usuário o problema ainda persiste.
Entendendo o Django Virtual Model
Para podermos resolver o problema proposto, uma das formar é usar uma biblioteca chamada django virtual model, esta biblioteca nos oferece um meio de resolver o problema de N+1 query, mas ainda criar um código com uma alta mantenabilidade.
Instale a biblioteca:
pip install django-virtual-model
Crie um arquivo chamado virtual_models.py na aplicação com o seguinte código:
import django_virtual_models as v
from core.models import User, Video
from django.db.models import Count
class VirtualVideo(v.VirtualModel):
class Meta:
model = Video
class VirtualUser(v.VirtualModel):
videos = VirtualVideo(manager=Video.objects)
amount_videos = v.Annotation(
lambda qs, **kwargs: qs.annotate(
amount_videos=Count("videos")
).distinct()
)
class Meta:
model = User
O código acima vai criar modelos virtuais que nos auxiliaram na processo de pré-processamento tanto dos vídeos quanto da quantidade deles, podemos ver que estamos usando uma anotação afim de adicionar no atributo amount_videos a quantidade de vídeos de um usuário.
No nosso arquivo serializers.py podemos modificar o serializer de usuário para o seguinte:
import django_virtual_models as dvm
class UserSerializer(dvm.VirtualModelSerializer):
videos = VideoSerializer(many=True)
amount_videos = serializers.IntegerField(read_only=True)
class Meta:
model = User
virtual_model = VirtualUser
fields = ("name", "videos","amount_videos")
E a nossa view ficará a seguinte:
class UserListAPIView(dvm.VirtualModelListAPIView):
serializer_class = UserSerializer
queryset = User.objects.all()
def get_queryset(self):
queryset = super().get_queryset()
for query in connection.queries:
print("Query:", query)
reset_queries()
return queryset
No código acima estamos já usando um queryset geral, pois a nossa biblioteca já cuida de resolver o nosso problema de vídeos e também da quantidade de vídeos. Assim, quando visitarmos a rota e visualizar o terminal podemos ver algo como:
Query: {'sql': 'SELECT DISTINCT "core_user"."id", "core_user"."name", COUNT("core_video"."id") AS "amount_videos" FROM "core_user" LEFT OUTER JOIN "core_video" ON ("core_user"."id" = "core_video"."user_id") GROUP BY "core_user"."id", "core_user"."name"', 'time': '0.001'}
Query: {'sql': 'SELECT "core_video"."id", "core_video"."title", "core_video"."url", "core_video"."visualizations", "core_video"."user_id" FROM "core_video" WHERE "core_video"."user_id" IN (1, 2, 3)', 'time': '0.000'}
Podemos ver que voltamos para a quantidade de 2 queries, pois na query de pegar os dados já é feita a contagem dos vídeos. Legal em!?
Código desenvolvido: Repositório
Referências
Posted on February 13, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.