Entendendo o problema de N+1 query usando o Django

wesleymorais

Wesley de Morais

Posted on February 13, 2023

Entendendo o problema de N+1 query usando o Django

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:

der

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Instalando dependências:

pip install django djangorestframework
Enter fullscreen mode Exit fullscreen mode

Criar o projeto e a aplicação:

django-admin startproject video_plataform .
django-admin startapp core
Enter fullscreen mode Exit fullscreen mode

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
]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
python manage.py makemigrations
python manage.py migrate
Enter fullscreen mode Exit fullscreen mode

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]
        }}]
Enter fullscreen mode Exit fullscreen mode

No terminal execute o comando:

python manage.py loaddata data.json
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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
]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'}
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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'}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'}
Enter fullscreen mode Exit fullscreen mode

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

Django Virtual Model
Django Rest Framework
Django official

💖 💪 🙅 🚩
wesleymorais
Wesley de Morais

Posted on February 13, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related