DEV Community

Wesley de Morais
Wesley de Morais

Posted on • Edited on

2

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

Image of Timescale

🚀 pgai Vectorizer: SQLAlchemy and LiteLLM Make Vector Search Simple

We built pgai Vectorizer to simplify embedding management for AI applications—without needing a separate database or complex infrastructure. Since launch, developers have created over 3,000 vectorizers on Timescale Cloud, with many more self-hosted.

Read full post →

Top comments (0)

Postmark Image

Speedy emails, satisfied customers

Are delayed transactional emails costing you user satisfaction? Postmark delivers your emails almost instantly, keeping your customers happy and connected.

Sign up

Retry later