Menu Zamknij
22 lutego 2023

Sposoby na optymalizację w Django

Sposoby na optymalizację w Django

 

Wstęp

Django to potężne narzędzie umożliwiające budowanie złożonych aplikacji webowych, jak i aplikacji REST API przy użyciu DRF (Django REST Framework). Wraz z wykorzystywaniem tych frameworków do coraz to bardziej złożonych potrzeb, należy zwiększać swoją świadomość związaną z potencjalnymi wąskimi gardłami i wadami omawianego rozwiązania.

Sam zresztą zauważam, że od programistów (również Juniorów) wymaga się na rynku IT coraz to większej świadomości w zakresie tego, jak budować optymalnie działające aplikacje.

W tym szkoleniu przyjrzymy się wadom i problemom wydajnościowym wynikającym z korzystania z Djangowego ORM (Object-Relational Mapper), jak i tworzenia samego kodu Pythonowego.

Temat jest na tyle szeroki, że poniżej omówię najważniejsze koncepty, a w przypadku zainteresowania, będę kontynuował tak rozpoczętą serię artykułów o optymalizacji.

 

Ale zanim zaczniemy... Czym jest ORM?

System ORM jest abstrakcją, która nakłada koncept klasowości na realizację zapytań SQL, przez co my - programiści - nie musimy ręcznie tworzyć złożonych zapytań SQL w kodzie, tylko posługujemy się konceptem OOP, aby tym wszystkim zarządzać.

Jednym słowem - ORM nie robi nic innego, jak tłumaczy zapytania wyższego poziomu na konkretne zapytania SQL. Ma to służyć głównie ułatwieniu pracy developerowi (no bo przecież łatwiej jest debuggować klasy niż zapytania SQL zawarte w stringach).  Jednak jest też druga strona medalu takiego rozwiązania - spadek szybkości realizowanych zapytań i nieefektywne realizowanie queries. Głównie nad omówieniem tego problemu skupiam się w tym artykule.

Tak więc dowiesz się, jak debuggować i analizować wolno działający kod, poprawiać performance wąskich gardeł aplikacji (znajdujących się głównie po stronie warstwy ORM). Co ciekawe - podobne dywagacje realizowaliśmy już jakiś czas temu, kiedy to jeden z naszych mentorów - Marcin prowadził konferencję, gdzie omawiał sposoby na zapewnienie wysokiej wydajności w Django (link do tego wystąpienia znajdziesz w ostatniej sekcji tego artykułu)

 

Kick off

Zanim przejdziemy do analizowania różnych sposobów i narzędzi umożliwiających optymalizację zapytań Django, przygotujmy wstępną strukturę modelu bazodanowego. W tym celu utworzę proste encje, które reprezentować będą studentów i przypisanych do nich mentorów. Zakładam, że mamy już odpowiednio skonfigurowany projekt Django, dlatego przejdę od razu do przedstawienia Ci kodu modeli.

from django.db import models


class Specializations(models.TextChoices):
    PYTHON = "Python"
    JAVA = "Java"
    DATA = "Data"
    Frontend = "Frontend"


class Mentor(models.Model):
    name = models.CharField(max_length=70)
    specialization = models.CharField(max_length=30, choices=Specializations.choices)


class Student(models.Model):
    name = models.CharField(max_length=70)
    bio = models.TextField()
    mentor = models.ForeignKey(to=Mentor, on_delete=models.SET_NULL, null=True)

Wyobraźmy sobie również, że do tak utworzonych modelów dodałem po kilkaset tysięcy rekordów. Pomoże to nam lepiej zwizualizować skutki potencjalnych spadków performance-u.

 

Narzędzia do analizy aplikacji

Do tego celu szczególny prym wśród programistów Pythona wiedzie Django Debug Toolbar. Rozwiązanie to dostarcza nam informacji o wszystkich wykonanych zapytaniach SQL (i nie tylko, bo analizować również możemy proces przetwarzania plików statycznych) oraz czasach ich wykonania, dzięki czemu możemy łatwo znajdować newralgiczne punkty w aplikacji. Opis tego, jak przeprowadzić instalację i jak efektywnie pracować z omawianym narzędziem znajdziesz w bardzo dobrze napisanej dokumentacji narzędzia.

Innym ciekawym narzędziem, które umożliwia analizowanie i debugowanie kodu jest Sentry. Jest to narzędzie stojące w hierarchii możliwości nieco wyżej od Django Debug Toolbar, ponieważ możemy w nim kompleksowo monitorować aplikację i odczytywać błędy. Dodatkowo, Sentry jest narzędziem, które może być bez problemu wykorzystywane produkcyjnie, w przypadku tego pierwszego natomiast pojawiają się często obiekcje przed upublicznianiem takiego rozwiązania.
Jeśli chodzi o analizę kodu, to nie zapominajmy również o najbardziej generycznym i niskopoziomowym rozwiązaniem, jakim jest CProfile. Można go używać zarówno w aplikacjach tworzonych w czystym Pythonie, jak i rozwiązaniach opartych o Django. Jest to jednak najbardziej prymitywne ze wszystkich dotąd wymienionych narzędzie, co oczywiście nie jest wadą, jednak wymaga zmian na poziomie kodu, aby móc wyprofiliować kod.

Czym jest profilowanie kodu?

Profilowanie kodu to proces analizowania, jak szybko (lub wolno) wykona się konkretny fragment kodu (bądź też zapytania SQL) oraz weryfikowania, jakie wywołania następują w procesie wykonania kodu.
 

Inne sposoby na analizę performance-u...

Oprócz zewnętrznych narzędzi, które możemy wykorzystywać do badania, jak optymalnie działa nasza aplikacja, nie zapominajmy również o wbudowanych metodach w Django, które również mogą skutecznie służyć do tego celu.

Jak metody ORM przekształcane są do SQL? Czyli użycie connection.queries.
Rozwiązaniem, z którego korzystam najczęściej, gdy nagli mnie czas, bo pozwala mi łatwo i szybko przejrzeć szczegóły realizowanego w SQL zapytania, jest wywołanie pola queries na obiekcie connection.

Przechowuje on zrealizowane w aktualnej sesji zapytania SQL oraz informację, ile czasu zajęło ich wykonanie. Zobaczmy, jaki efekt dostarczy nam omawiane rozwiązanie, gdy przyjrzymy się operacjom na tabeli Mentor.

>>> from core.models import Mentor
>>> Mentor.objects.all()

...

>>> from django.db import connection
>>> connection.queries
[{'sql': 'SELECT "core_mentor"."id", "core_mentor"."name", "core_mentor"."specialization" FROM "core_mentor" LIMIT 21', 'time': '0.000'}]

Jak widzisz, wykonany został prosty SELECT, a jego realizacja przebiegła naprawdę szybko (bardzo małe części sekundy), przez co widzimy wartość 0.000 reprezentującą czas potrzebny na wykonanie zapytania.

 

Shell plus

Jeżeli chcemy zapewnić sobie większą wygodę podczas testowania różnych zapytań ORM, możemy wykorzystać również shell_plus wywoływany z flagą --print-sql. Shell plus jest odmianą standardowej powłoki Django, z tą różnicą że są do niego automatycznie importowane modele (przez co nie musimy dbać o ich ręczne zarządzanie, jak to ma miejsce w zwykłym shellu) oraz po wykonaniu dowolnego zapytania na bazie danych, dostarcza nam on automatycznie SQL, który został pod spodem zrealizowany.


>>> Mentor.objects.all()
SELECT "core_mentor"."id",
"core_mentor"."name",
"core_mentor"."specialization"
FROM "core_mentor"
LIMIT 21
Execution time: 0.000973s [Database: default]

...

Powyżej efekt wykonania polecenia pobierającego wszystkich mentorów z bazy właśnie w shell_plus.
 

Konkretne problemy i optymalizacje w kodzie...

Wiedząc już, jak analizować szybkość realizacji poszczególnych operacji w kodzie, najwyższy czas, abyśmy przeszli do rozpatrzenia konkretnych obszarów w kodzie, które można napisać lepiej. Świadomość w tym całym procesie optymalizacji kodu jest najważniejsza. Z doświadczenia wiem, że wiele wąskich gardeł w aplikacjach występuje przez nieodpowiednie użycie istniejących w frameworkach mechanizów oraz nieświadomość, jak coś działa "pod spodem".

Dlatego w pierwszej kolejności przystąpimy do przeanalizowania, jak Django radzi sobie z potencjalnie wolnymi operacjami i jakie implikacje w związku z tym powinniśmy mieć na uwadze.
 

Caching w Django

Pierwszym sposobem na optymalizację dynamicznych operacji w Django jest wbudowany mechanizm cache-owania. Podsumowując w skrócie rolę tego feature-a - jeśli wykonamy złożone zapytanie (np. pobierające informacje o wielu rekordach w encji), a następnie ponowimy je, to czas odpowiedzi będzie znacznie krótszy, bo rezultaty zostaną automatycznie zapisane w cache. Ten sam mechanizm funkcjonuje również w kontekście wyświetlania dynamicznej zawartości strony. Jeśli użytkownik widział już kiedyś daną stronę serwowaną przez WSGI Django, to istnieje duże prawdopodobieństwo, że nie będzie musiał jej w całości na nowo załadowywać dzięki właśnie technikom cache-owania zawartości po stronie klienta i serwera Django.

Aby lepiej przedstawić omawiany koncept, przedstawię Ci w pseudokodzie, co jest realizowane "pod spodem", gdy użytkownik chce odpytać dany widok w Django:


given a URL, try finding that page in the cache
if the page is in the cache:
return the cached page
else:
generate the page
save the generated page in the cache (for next time)
return the generated page

Efekty i zalety płynące z wykorzystania cache-u, możemy doświadczyć, próbując odpytać kilkukrotnie bazę danych o wiele tych samych rekordów. Do tabeli mentorów dodałem kilkaset rekordów, po czym w shell plus zrealizowałem najprostsze zapytanie pobierające wszystkie ich rekordy z bazy.


>>> Mentor.objects.all()
SELECT "core_mentor"."id",
"core_mentor"."name",
"core_mentor"."specialization"
FROM "core_mentor"
LIMIT 21
Execution time: 0.000973s [Database: default]

...

Zwróć szczególną uwagę na czas, jaki zajęło silnikowi ORM pobranie wszystkich danych z bazy. Realizując natomiast drugi raz to samo query...


>>> Mentor.objects.all()
SELECT "core_mentor"."id",
"core_mentor"."name",
"core_mentor"."specialization"
FROM "core_mentor"
LIMIT 21
Execution time: 0.000000s [Database: default]

...

... widzimy, że Django ewidentnie wykorzystało cache-owane zależności, a czas potrzebny na ich pobranie był bliski zeru.

Zapisywnaie do cache-a w wielu przypadkach odbywa się samoczynnie, jednak my jako programiści również możemy mieć wpływ na to, co ma zostać umieszczone w pamięci podręcznej oraz kiedy i co chcemy wyłuskiwać z cache. Przykładowo własnoręczne cache-owanie mogę wykorzystywać, gdy potrzebuję cache-ować konkretne elementy z szablonu strony. Poniżej przykład dodawania do pamięci podręcznej konkretnej zawartości wyświetlanej strony (żeby przykładowo za każdym razem nie renderować w ten sam sposób widoku strony):

{% load cache %}
{% cache 300 mentors %}
{% for mentor in mentors %}

{{ mentor.name }}

{% for student in mentors.students_set.all %}
{{ student.name}}
{% endfor %}

{% endfor %}
{% endcache %}

Cache-owanie w Django pozwala na znacznie więcej niż opieranie się tylko na wbudowanych mechanizmach i operowanie na szablonach HTML. Możemy w ten sposób usprawniać również działanie backendu, zmieniać formę przechowywania danych w pamięci podręcznej. Moim celem w tym artykule jest zwiększyć Twoją świadomość, co w trawie (optymalizacji) piszczy. Chcąc w pełni opisać możliwości cache-a, musiałbym poswięcić na to zagadnienia osobny artykuł (co mogę z chęcią uczynić, jeśli pojawi się zainteresowanie ze strony Czytelników). Mając już świadomość, czym jest cache w Django, jesteś w stanie uzupełnić poruszone tutaj koncepty z tej części dokumentacji.

 

Nieoptymalny update w ORM

Zauważam, że stosunkowo mało osób jest świadoma problemu, który teraz omówię. Zacznijmy najpierw od małego wyzwania. Czy jesteś w stanie wskazać, co można by ulepszyć w poniższym widoku?

from django.shortcuts import render

@require_http_methods(["POST"])
def edit_profile_view(request):
    if not request.user or not request.user.is_authenticated:
        return HttpReposeForbidden()

    user = request.user

    user.first_name = request.POST.get("firstname", user.first_name)
    user.last_name = request.POST.get("last_name", user.last_name)
    user.email = request.POST.get("email", user.email)
    user.updated_at = datetime.now()
    user.save()

    return render(
        request,
        "templates/edit_profile.html",
        context=dict()
    )

Jest to jedno z zadań, które lubię omawiać z moimi kursantami, gdy jesteśmy na etapie przygotowywań do rekrutacji. Obszar do zmian jest dość szeroki, bo można choćby pokusić się o wdrożenie class based views, użycie odpowiedniego mixina/dekoratora @login_requred etc.

Są to jednak rzeczy będące częścią optymalizacji kodu, nie performance-u aplikacji. Dlatego też dla nas najważniejszym punktem jest sposób, w jaki aktualizujemy pola w modelu User. Sposób, w jaki Djangowy ORM buduje SQL-owe query UPDATE pozostawia wiele do życzenia. Pozwolę Ci samodzielnie odgadnąć, co tak mocno nieoptymalnie realizuje Django, oddając do analizy poniższy kod i output. Pamiętaj, że naszym celem jest zaktualizowanie tylko i wyłącznie pola name dla wybranego mentora spod modelu Mentor.


>>> mentor = Mentor.objects.get(id=1)
SELECT "core_mentor"."id",
"core_mentor"."name",
"core_mentor"."specialization"
FROM "core_mentor"
WHERE "core_mentor"."id" = 1
LIMIT 21
Execution time: 0.000000s [Database: default]
>>> mentor.name = "New Name"
>>> mentor.save()
UPDATE "core_mentor"
SET "name" = 'New Name',
"specialization" = 'TEST0'
WHERE "core_mentor"."id" = 1
Execution time: 0.000000s [Database: default]

Nie wiem, czy udało Ci się to dostrzec, ale problemem w powyższym zapytaniu SQL jest to, że w kodzie Django modyfikujemy wartość spod kolumny name, a ORM-owy dialekt tłumaczy kod do postaci, gdzie oprócz kolumny name, modyfikujemy również właściwość specialization.

Gdyby model Mentor posiadał również inne kolumny, to okazałoby się, że zbudowane zapytanie opiera się na każdej z nich i nadpisuje ich wartość. Oczywiście wartość tych kolumn pozostaje niezmienna (bo aktualizując je przypisujemy im aktualne wartości), jednak sam fakt operowania na tych kolumnach pod spodem, bez wskazywania tego jawnie w kodzie powinien zapalać nam w głowie lampkę ostrzegawczą. Tutaj jeszcze nie jest to dużym problemem, ale wyobraź sobie, jak bardzo złożone zapytanie może powstać, gdy aktualizować będziemy wartość pojedynczego pola w tabeli składającej się z kilkunastu kolumn!

Dlatego z pomocą przychodzi nam tutaj update_fields! Jeżeli bowiem w mentor.save() umieścimy keyword argument - update_fields, w którym wskażemy, które konkretnie pole aktualizujemy, SQL nie będzie redundantnie generował kodu, który nadpisze niezmieniające się atrybuty.

>>> mentor.save(update_fields=['name']) 

UPDATE "core_mentor"
   SET "name" = 'New Name'
 WHERE "core_mentor"."id" = 1

Zauważ, że dzięki takiemu rozwiązaniu wygenerowany SQL operuje już tylko i wyłącznie na tych polach, którym faktycznie zmieniamy wartość. Zatem dobrą praktyką jest, aby w metodzie save wskazywać referencje tylko do tych pól, których wartość zamierzamy zmienić. Oszczędzi to nam zbędnego overheadu w aplikacji.
Mniej oczywistą korzyścią płynącą z wykorzystywania update_fields i aktualizowania konkretnych pól jest zabezpieczenie przed race condition w wielowątkowej lub wieloprocesowej pracy z modelami. Dzięki ograniczaniu aktualizacji do konkretnych pól, zapewniamy atomowość realizowanych operacji.

Chcąc optymalizować proces aktualizowania pól, możemy również wykorzystać zapis .update(column_name=value), wtedy nie będzie potrzeby zmieniania metody save i dodawania do niej update_fields.

Chcąc zatem zrefaktoryzować przykład powyżej, zapiszemy:

Mentor.objects.filter(id=1).update(name=”New Name”)

I otrzymamy następujący rezultat:

>>> Mentor.objects.filter(id=1).update(name="New Name") 
UPDATE "core_mentor"
   SET "name" = 'New Name'
 WHERE "core_mentor"."id" = 1

Zauważ tylko, że update występuje wraz z filter(), niemożliwe by było wykorzystanie go z metodą .get().

 

Lazy loading

Wiesz już, że rezultaty z zapytań SQL mogą być cache-owane. Inną istotną cechą zapytań ORM w Django jest ich lazy loading. Oznacza to tyle, że dostęp do rekordu nie zostanie zainstancjonowany, dopóki nie wykorzystamy faktycznie pobranego rekordu. Przykładowo - jeśli pobierzemy z bazy wszystkie rekordy z konkretnej tabeli poprzez zapytanie Mentor.objects.all(), to nie zostanie ono wykonane dopóki faktycznie nie użyjemy pobranej listy danych.

Przeanalizujmy to w naszym shellu:


>>> mentors = Mentor.objects.all()
>>> print(mentors)
SELECT "core_mentor"."id",
"core_mentor"."name",
"core_mentor"."specialization"
FROM "core_mentor"
LIMIT 21

Jak widzisz, zapytanie SQL zrealizowało się dopiero przy zapisie print(mentors). I choć możesz zastanawiać się, czemu ma służyć taki zabieg w Django, to powinieneś być świadomy tego, że płynie z niego naprawdę wiele korzyści. Przede wszystkim lazy loading zapewnia chunkowanie danych w taki sposób, że nie muszą być wszystkie naraz wczytane do pamięci, gdy potrzebujemy spośród zestawu wielu rekordów odwołać się tylko do konkretnie wybranych.

Przykładowo - realizacja zapytania Mentor.objects.all()[0] dzięki leniwemu ładowaniu nie spowodowuje załadowania wszystkich mentorów do pamięci, wczyta tylko i wyłącznie pierwszy obiekt. Szczególnie duży trade-off takiego rozwiązania objawia się nam, gdy w bazie istnieje dużo obiektów mentorów. Wczytywanie każdego z nich, aby tylko móc odwołać się do pojedynczego byłoby kompletnie nieoptymalne pamięciowo. Nie muszę też chyba wspominać, że dzięki lazy loading możemy redukować czas ładowania się widoku, czy pobierać zawczasu i bez żadnych zobowiązań dane, aby później móc z nich korzystać.

 

Druga strona medalu lazy loadingu? Problem N+1.

Żeby jednak nie było tak kolorowo, należy mieć na uwadze fakt, że opóźniona inicjalizacja obiektów może również miejscami zapychać kanał dostępu do bazy danych. A to dlatego, że jak już wiesz - z powodu lazy loading-u, zapytania mogą indywidualnie odpytywać bazę danych w celu otrzymania konkretnych rekordów, zamiast jednorazowo załadować wszystko do pamięci. Taki zabieg staje się szczególnie dużym problemem w momencie, gdy operujemy na relacji między dwiema tabelami i pobieramy dany rekord przez jego klucz obcy.

Zanim przejdziemy do konkretów, przeanalizujmy przykład, gdzie faktycznie ma miejsce wspomniany problem. W bazie utworzyłem rekordy studentów, połączyłem ich z wybranymi mentorami. Teraz nadszedł czas na wyłuskanie pierwszych pięciu studentów i wyświetlenie imion przypisanych do nich mentorów.

Coś jak poniżej:

>>> students = Student.objects.all()[:5]
>>> for student in students:
...     student.mentor.name
...
SELECT "core_student"."id",
       "core_student"."name",
       "core_student"."bio",
       "core_student"."mentor_id"
  FROM "core_student"
 LIMIT 5
Execution time: 0.000990s [Database: default]
SELECT "core_mentor"."id",
       "core_mentor"."name",
       "core_mentor"."specialization"
  FROM "core_mentor"
 WHERE "core_mentor"."id" = 1
 LIMIT 21
Execution time: 0.000000s [Database: default]
'New Name'
SELECT "core_mentor"."id",
       "core_mentor"."name",
       "core_mentor"."specialization"
  FROM "core_mentor"
 WHERE "core_mentor"."id" = 2
 LIMIT 21
Execution time: 0.000000s [Database: default]
'TEST1'
SELECT "core_mentor"."id",
       "core_mentor"."name",
       "core_mentor"."specialization"
  FROM "core_mentor"
 WHERE "core_mentor"."id" = 3
 LIMIT 21
Execution time: 0.000000s [Database: default]
'TEST2'
SELECT "core_mentor"."id",
       "core_mentor"."name",
       "core_mentor"."specialization"
  FROM "core_mentor"
 WHERE "core_mentor"."id" = 4
 LIMIT 21
Execution time: 0.000000s [Database: default]
'TEST3'
SELECT "core_mentor"."id",
       "core_mentor"."name",
       "core_mentor"."specialization"
  FROM "core_mentor"
 WHERE "core_mentor"."id" = 5
 LIMIT 21
Execution time: 0.000000s [Database: default]
'TEST4'

Zadanie dla Ciebie - policz, ile zapytań w sumie wykonał ORM.

Jak się okazuje - aby zrealizować funkcjonalność - silnik musiał zrealizować 6 zapytań do bazy danych. Jedno, aby pobrać zestaw wszystkich studentów, a pozostałe pięć, aby dla każdego studenta znaleźć z tabeli mentorów przypisanego do niego nauczyciela, a następnie pobrać jego imię. Kompletnie nieoptymalne rozwiązanie! Jeżeli pracowałeś z czystym SQL, to powinieneś wiedzieć, że tak naprawdę te 6 zapytań moglibyśmy ograniczyć do jednego zapytania - choćby SQL-owego JOIN-a.

Problem, jaki tutaj występuje to tzw. N + 1, który pojawia się nie tylko w Djangowym ORM, ale również Javowi Hibernate-owcy muszą być świadomi jego istnienia.
Jego rozwiązanie i zażegnanie sprowadza się do zastąpienia mechanizmu lazy loadingu przy takich zapytaniach procesem eager loading. Umożliwiają nam to 2 funkcje w Django - prefetch_related oraz select_related. Zaczniemy od omówienia tej drugiej, która skutecznie pozwoli nam zredukować ilość potrzebnych zapytań do zrealizowania operacji powyżej. Do prefetch_related przejdziemy w następnej sekcji.
 

Select_related jako lek na naszą bolączkę.

Metoda select_related sprawdza się zawsze tam gdzie, chcemy rozwiązać problem N + 1 w relacji many-to-one (czyli przykładowo właśnie na znanej nam już płaszczyźnie Student - Mentor). Jej użycie jest bardzo proste. Wystarczy wywołać ją, gdy pobieramy queryset wszystkich rekordów z konkretnej tabeli. Dzięki temu wskazujemy, że nie chcemy lazy loadować rekordów z obcej tabeli, tylko chcemy wdrożyć eager loading dla wszystkich mentorów (czyli obcych obiektów), których referencje znajdują się w tabeli Student.

>>> students = Student.objects.select_related("mentor")[:5] 
>>> for student in students:                                
...     student.mentor.name              

SELECT "core_student"."id",
       "core_student"."name",
       "core_student"."bio",
       "core_student"."mentor_id",
       "core_mentor"."id",
       "core_mentor"."name",
       "core_mentor"."specialization"
  FROM "core_student"
  LEFT OUTER JOIN "core_mentor"
    ON ("core_student"."mentor_id" = "core_mentor"."id")
 LIMIT 5
Execution time: 0.000000s [Database: default]

Zauważ, że osiągnęliśmy optymalizację - teraz już nie wykonuje się 6 zapytań z osobna, tylko jedno, które umożliwia nam odpowiednie przygotowanie zestawu informacji o studentach i mentorach.
Jest to szczególnie ważna optymalizacja przy dużej ilości rekordów i tworzeniu efektywnych serializerów w DRF. Miej więc zawsze na uwadze select_related, gdy pracujesz z relacjami M-1 między tabelami.
 

Prefetch_related

Jak już jesteśmy na fali i omawiamy problem N + 1, to musimy również koniecznie rozważyć problem lazy loadingu, gdy mamy do czynienia z sytuacją odwrotną do wcześniejsze. Tj. pracujemy na relacji 1-M lub M-M. Czyli przykładowo chcemy wyświetlić wszystkich studentów przypisanych do pięciu pierwszych mentorów. Tak jak poniżej:

>>> mentors = Mentor.objects.all()[:5]
>>> for mentor in mentors:
...     mentor.student_set.all()
...
SELECT "core_mentor"."id",
       "core_mentor"."name",
       "core_mentor"."specialization"
  FROM "core_mentor"
 LIMIT 5
Execution time: 0.000000s [Database: default]
SELECT "core_student"."id",
       "core_student"."name",
       "core_student"."bio",
       "core_student"."mentor_id"
  FROM "core_student"
 WHERE "core_student"."mentor_id" = 1
 LIMIT 21
Execution time: 0.001097s [Database: default]
]>
SELECT "core_student"."id",
       "core_student"."name",
       "core_student"."bio",
       "core_student"."mentor_id"
  FROM "core_student"
 WHERE "core_student"."mentor_id" = 2
 LIMIT 21
Execution time: 0.000000s [Database: default]
]>
SELECT "core_student"."id",
       "core_student"."name",
       "core_student"."bio",
       "core_student"."mentor_id"
  FROM "core_student"
 WHERE "core_student"."mentor_id" = 3
 LIMIT 21
Execution time: 0.000000s [Database: default]
]>
SELECT "core_student"."id",
       "core_student"."name",
       "core_student"."bio",
       "core_student"."mentor_id"
  FROM "core_student"
 WHERE "core_student"."mentor_id" = 4
 LIMIT 21
Execution time: 0.000000s [Database: default]
]>
SELECT "core_student"."id",
       "core_student"."name",
       "core_student"."bio",
       "core_student"."mentor_id"
  FROM "core_student"
 WHERE "core_student"."mentor_id" = 5
 LIMIT 21
Execution time: 0.000000s [Database: default]

Uwaga!
Jeżeli nie kojarzysz, skąd się wzięła kolumna student_set w tabeli Mentor, to spieszę z wyjaśnieniem. Jest to tzw. backref relacja, która jest tworzona automatycznie, gdy ustawiamy w tabeli Student - foreign key do Mentora. Pole to przechowuje w sobie zbiór odwołań do wszystkich studentów przypisanych do konkretnego obiektu mentora.

Chcąc teraz ograniczyć ilość zapytań do dwóch (których ilość pozostanie stała, niezależnie, czy w pierwotnym przykładzie będziemy realizowali 6, czy 10, czy może 100 zapytań), możemy wprowadzić następujący zapis:

>>> mentors = Mentor.objects.prefetch_related("student_set")[:5] 
>>> for mentor in mentors:                                       
...     student.mentor.name            
...
SELECT "core_mentor"."id",
       "core_mentor"."name",
       "core_mentor"."specialization"
  FROM "core_mentor"
 LIMIT 5
Execution time: 0.000000s [Database: default]
SELECT "core_student"."id",
       "core_student"."name",
       "core_student"."bio",
       "core_student"."mentor_id"
  FROM "core_student"
 WHERE "core_student"."mentor_id" IN (1, 2, 3, 4, 5)
Execution time: 0.000000s [Database: default]

Zgodnie z tym, co wspominałem - otrzymaliśmy 2 zapytania - jedno, aby pobrać mentorów, a drugie, aby otrzymać wszystkich studentów przypisanych do mentorów. Tak samo prefetch_related sprawdziłoby się w przypadku, gdybyśmy operowali w podobny sposób na relacji wiele do wielu.

Zabieg bardzo prosty, a zobacz jak wiele dający. Wyobraź sobie, jak duża optymalizacja zachodzi w momencie, gdy operujesz na milionie rekordów. W przypadku select_related ograniczamy ilość potrzebny queries z milion + 1 do jednego, a przy prefetch_related do dwóch.
 

Stosuj pomocnicze metody w celu optymalizacji

Merytorykę tego artykułu chciałbym zwieńczyć opisując pomocnicze metody, których wdrożenie w kod wymaga naprawdę niskiego “progu wejścia”. Będą to bullety wiedzy, które będziesz mógł wdrożyć ad hoc do swoich rozwiązań.

1. Używaj count zamiast len.
Wyobraź sobie, że chcesz sprawdzić ilość elementów przypisanych do wybranego mentora. Jaki jest obszar do poprawy w poniższym kodzie?

mentor = Mentor.objects.get(id=1) 
no_students = len(mentor.student_set.all())

Ano taki, że można by zastąpić funkcję len wywołaniem metody count(), tak jak poniżej:

mentor.student_set.count()

Powód jest prosty - na bazie danych realizujemy tylko i wyłącznie funkcję agregującą - COUNT, zamiast wczytywać wszystkie rekordy i dopiero na pobranym zbiorze realizować Pythonową funkcję len(). Przyspieszamy w ten sposób naszą aplikację i kod staje się dużo czytelniejszy dzięki ograniczeniu zapytania do jednej linii.

2. Pierwszy i ostatni element pobieraj za pomocą first() i last().

Zmiana ta raczej nie będzie miała bezpośredniego wpływu na szybkość realizowanych operacji, jednak zabezpieczy nas przed ewentualnymi błędami możliwymi do pojawienia się w aplikacji. Rozważmy 2 sposoby na pobranie pierwszego elementu z listy:

vs

W momencie, gdyby lista mentorów byłaby pusta - pierwsze rozwiązanie zakończyłoby się rzuceniem wyjątku IndexError. Drugie rozmiązanie zabezpieczyłoby nas przed taką sytuacją i w przypadku nieistniejącego rekordu Mentora, zwróciłoby po prostu obiekt None.

3. Metoda exists

Nierzadko będziemy potrzebowali sprawdzić w naszej aplikacji, czy istnieje jakikolwiek obiekt, np. ze zbioru kluczy obcych. Wówczas warto użyć wbudowanej metody exists(), która poinformuje nas, czy istnieje co najmniej jeden obiekt we wskazanym QuerySet. Tak więc zapis:


if some_queryset.exists():
print("There is at least one object in some_queryset")

Zwróci rezultat znacznie szybciej niż:


if some_queryset:
print("There is at least one object in some_queryset")

4. Podejście deklaratywne zamiast imperatywnego

Ciekawym sposobem na optymalizację różnych operacji w Django może być zastąpienie fragmentów kodu imperatywnego odpowiednikami przygotowanymi w podejściu funkcyjnym. Pozwalają nam na to przede wszystkim funkcje wyższego rzędu (higher order functions) postaci filter() oraz map().

Rozpatrzmy prosty przykład kodu, w którym wyliczamy kwadraty liczb parzystych.

Podejście imperatywne:

import timeit

my_list = range(1,10)
for i in my_list:
    if i % 2 == 0:
        result = i**2 

Podejście funkcyjne (deklaratywne):

import timeit

my_list = range(1,10)
result = map(lambda x: x**2, filter(lambda x: x%2 ==0, my_list))

Czas realizacji pierwszego podejścia: około 0.01 sekundy, a drugiego 0.005 sekundy. Różnica dość spora, nieprawdaż?
Co ciekawe jest to optymalizacja, którą wdrażać możemy nie tylko w Djangowych projektach, ale ogólnie w kodzie Pythonowym. Dobrze widać w powyższych fragmentach kodu, jak duży potencjał tkwi w łączeniu kodu imperatywnego z funkcyjnym.
 

Zakończenie

To tyle, jeśli chodzi o to, co chciałem Ci zaprezentować w szkoleniu, które właśnie przeczytałeś. Sposobów na optymalizację kodu w Django i samym Pythonie jest naprawdę wiele, tak więc pozostawiam otwartam furtkę na stworzenie kolejnych artykułów, gdzie przedstawię Ci inne sposoby na zwiększenie performance-u swoich rozwiązań. Koniecznie daj znać, co sądzisz o takiej formie artykułów, a w razie pytań, czy potrzeby rozszerzenia danego zagadnienia - nasi mentorzy są do Twojej dyspozycji.
 

Dodatkowe źródła

Wystąpienie jednego z naszych mentorów: Filmik na naszym fanpage-u 

 

Wszystkiego optymalnego!

Sprawdź również nasz system mentorowania i outsourcowania specjalistów
  • Wyznaczona ścieżka od A do Z w kierunku przebranżowienia
  • Indywidualnie dostosowany materiał pod ucznia
  • Code Review ze strony mentora
  • Budowa portfolio
  • Intensywna praca z materiałami szkoleniowymi