Optymalizacja Django: cache, query, middleware i typowe wąskie gardła

0
39
2.5/5 - (4 votes)

Nawigacja:

Jak rozpoznać, że Django jest za wolne: symptomy i źródła problemów

Wolna aplikacja vs wolne zapytania – co faktycznie mierzyć

Aplikacja napisana w Django może sprawiać wrażenie „wolnej” z wielu powodów. Czasem problemem są zapytania SQL, czasem middleware, a czasem zewnętrzne API albo sama sieć. Pierwszy krok to rozdzielenie pojęć: czy wolny jest pojedynczy request, czy raczej system nie wyrabia pod obciążeniem. To dwa różne typy kłopotów.

Pojedynczy request analizuje się, patrząc na czas odpowiedzi (response time) i jego rozbicie: ile zajmuje baza danych, ile logika biznesowa, ile renderowanie template. Problem z przepustowością widać, gdy rośnie średni czas odpowiedzi i pojawiają się błędy 502/504, choć pojedyncze zapytania lokalnie działają szybko. Wtedy problemem może być liczba workerów, blokujące operacje I/O czy brak cache na gorących ścieżkach kodu.

Zamiast ogólnego stwierdzenia „Django jest wolne”, kluczowe jest pytanie: które endpointy są wolne, w jakich godzinach i przy jakim ruchu. Bez tego optymalizacja przypomina strzelanie na oślep. Dobrą praktyką jest uruchomienie prostego APM (Application Performance Monitoring) lub chociaż logowanie czasu wykonania wybranych widoków.

Charakterystyczne symptomy słabej wydajności

Typowe sygnały, że aplikacja Django wymaga optymalizacji, pojawiają się zarówno po stronie użytkownika, jak i infrastruktury. Użytkownik zauważa głównie:

  • długi TTFB (Time To First Byte) – przeglądarka długo „czeka na odpowiedź”, zanim cokolwiek się pojawi,
  • czasem szybki TTFB, ale wolne ładowanie całej strony – problem raczej w front-endzie niż w Django,
  • błędy typu 504 Gateway Timeout przy bardziej złożonych operacjach (np. generowanie raportów).

Od strony serwera pojawiają się inne sygnały ostrzegawcze:

  • wysokie użycie CPU przez procesy uWSGI/Gunicorn,
  • wysokie użycie IO lub blokada na bazie danych (locki, długo trwające transakcje),
  • logi reverse proxy (Nginx, Traefik) pełne błędów 502/504 lub długich czasów upstream,
  • rosnąca liczba połączeń do bazy, a jednocześnie rosnące czasy odpowiedzi.

W przypadku aplikacji korzystających intensywnie z cache pojawia się jeszcze jedno zjawisko: nierówny rozkład czasów odpowiedzi. Część requestów jest bardzo szybka (cache hit), inne znacznie wolniejsze (cache miss). To naturalne, ale przy złej strategii cache może wyglądać jak losowy lag.

Kod, baza danych czy infrastruktura – gdzie szukać przyczyn

Coraz więcej aplikacji Django korzysta z całego łańcucha komponentów: reverse proxy, WAF, cache HTTP, bazy danych, kolejek (Celery), Redis/Memcached, zewnętrznych API. Spowolnienie może pojawić się w każdym z tych miejsc. Najprościej porównać kilka scenariuszy:

  • Problem w kodzie – lokalnie endpoint działa długo, zużycie CPU w workerach jest wysokie, a baza nie wygląda na przeciążoną; profilowanie Pythona pokazuje drogie pętle, serializację, konwersje dużych struktur.
  • Problem w bazie – lokalnie na małym zestawie danych wszystko działa, ale na produkcji czasy rosną wraz z ilością rekordów; logi slow queries, brak indeksów, skomplikowane joiny, zablokowane transakcje.
  • Problem infrastrukturalny – rosnące czasy odpowiedzi przy wzroście ruchu, mimo że pojedyncze zapytania są wciąż szybkie; limity CPU/RAM, zbyt mało workerów, brak connection poolingu.

Różne źródła wymagają odmiennych narzędzi. Dla bazy – EXPLAIN i logi slow queries. Dla kodu – profiler, Django Debug Toolbar, silk. Dla infrastruktury – metryki Prometheus/Grafana, logi Nginx, statystyki uWSGI/Gunicorn. Łączenie tych źródeł daje realny obraz problemu.

Liczby przed optymalizacją – konieczny punkt wyjścia

Optymalizacja bez pomiaru ma tę samą efektywność co wymiana wszystkich części w aucie tylko dlatego, że „coś stuka”. Zanim padnie decyzja o przepisywaniu widoków, włączaniu „wszędzie Redis” albo komplikowaniu architektury, potrzebne są:

  • metryki czasu odpowiedzi per endpoint (średnia, percentyle, np. p95, p99),
  • liczba zapytań do bazy na request i ich czas trwania,
  • podział czasu na: logikę aplikacji, bazę danych, zewnętrzne API, cache,
  • zasobożerne endpointy – ranking top N najwolniejszych i najczęściej wywoływanych.

Na tej podstawie widać, czy lepszy efekt przyniesie redukcja liczby zapytań SQL, użycie select_related/prefetch_related, czy wprowadzenie cache na poziomie widoków. Dopiero wtedy decyzje o optymalizacji Django mają sens i można mówić o konkretnym zysku.

Podstawy architektury Django pod wydajność – co wpływa na czas odpowiedzi

Przepływ requestu w Django – od WSGI/ASGI do ORM

Aby skutecznie optymalizować Django, warto znać jego ścieżkę wykonania. Typowy request HTTP przechodzi przez następujące punkty:

  1. Reverse proxy (np. Nginx) – terminacja TLS, cache HTTP, routing.
  2. Serwer aplikacyjny (uWSGI/Gunicorn/Daphne) – obsługa WSGI/ASGI, uruchomienie kodu Django.
  3. Middleware – łańcuch klas, które mogą modyfikować request/response.
  4. URL resolver – dopasowanie ścieżki do funkcji widoku.
  5. Widok (function-based lub class-based) – logika biznesowa.
  6. Django ORM – przygotowanie i wysłanie zapytań SQL do bazy.
  7. Renderowanie template (opcjonalnie) lub serializacja do JSON.
  8. Powrót przez middleware (część response) do serwera i reverse proxy.

Każdy z tych kroków ma swój koszt. W małej aplikacji większość czasu spędza się w widoku i ORM. W rozbudowanej – może dominować komunikacja z innymi systemami (microservices, bramki płatności, external API). Optymalizacja Django oznacza świadome zarządzanie tym łańcuchem, a nie tylko „przyspieszanie bazy danych”.

Monolit Django vs aplikacja z wieloma usługami zewnętrznymi

Monolityczna aplikacja Django, która ma jedną bazę danych i nie korzysta z zewnętrznych API, jest względnie prosta w optymalizacji. Dominują w niej:

  • koszt zapytań ORM,
  • koszt renderowania szablonów,
  • koszt samej logiki Pythona (walidacje, pętle).

W aplikacjach korzystających z wielu zewnętrznych usług sytuacja wygląda inaczej. Pojawiają się dodatkowe źródła opóźnień:

  • połączenia HTTP do API (często powolne, podatne na timeouty),
  • komunikacja z kolejkami (Celery, RabbitMQ, Kafka) w trakcie requestu,
  • dodatkowe bazy (np. read replicas, search engine jak Elasticsearch).

W monolicie optymalizacja często polega na poprawieniu zapytań ORM i włączeniu cache. W rozwiązaniu z wieloma integrowanymi usługami kluczowe staje się asynchroniczne przetwarzanie, timeouty, retry, bulkowe zapytania do zewnętrznych serwisów oraz ich lokalny cache.

Koszt poszczególnych warstw: od sieci po logowanie

Z punktu widzenia czasu odpowiedzi każda warstwa dodaje pewien overhead. Porównanie kosztów (porządkowo, nie liczbowo) wygląda zwykle tak:

  • Sieć i reverse proxy – kilka milisekund, chyba że mowa o cross-region lub złożonych regułach WAF.
  • Middleware – od symbolicznego kosztu (proste dodanie nagłówków) po realny, jeśli w środku jest logika, np. odpytywanie bazy lub zewnętrznych systemów.
  • ORM + baza – bardzo zależne od zapytań; jedno dobrze zaindeksowane zapytanie bywa tańsze niż 100 prostych pętli w Pythonie, ale już jeden brak indeksu potrafi „zjeść” cały budżet czasu.
  • Renderowanie template – z reguły tanie, dopóki nie przetwarza się ogromnych list lub nie wykonuje logiki w samym szablonie.
  • Logowanie – nadmiernie szczegółowe logi, zwłaszcza do wolnych backendów (dyski sieciowe, zewnętrzne systemy logujące), potrafią znacząco wydłużać request.

Przy okazji profilowania dobrze rozdzielić, ile realnie czasu pochłania baza, ile Python, a ile logi i zewnętrzne systemy. Często okazuje się, że kilka linijek odpowiedzialnych za „niewinne” logowanie debug na produkcji generuje ogromny narzut.

Znaczenie konfiguracji: DEBUG, ALLOWED_HOSTS, LOGGING

Ustawienia Django wpływają na wydajność bardziej, niż mogłoby się wydawać. Kluczowe przykłady:

  • DEBUG = True – generuje dodatkowe sprawdzanie, rozbudowane błędy, często też włącza dodatkowe backendy (np. template loaders). Na produkcji powinno być bezwzględnie False.
  • ALLOWED_HOSTS – zbyt szerokie lub błędne ustawienia mogą spowodować niepotrzebne sprawdzanie hosta, choć zazwyczaj to marginalny koszt w porównaniu z innymi elementami.
  • LOGGING – poziom logów (DEBUG vs INFO vs WARNING), format, liczba handlerów, backend logowania (lokalny plik vs syslog vs zewnętrzny stack) – to wszystko wpływa na czas requestu, jeśli logowanie odbywa się synchronicznie.

Warto też rozważyć wyłączenie niektórych middleware’ów, które przydają się tylko w fazie developmentu (np. sprawdzające bezpieczeństwo w sposób nadmiarowy) oraz zmniejszenie szczegółowości logów zapytań SQL na produkcji, zostawiając pełną wersję tylko na środowiskach testowych.

Drewniane klocki SEO na klawiaturze laptopa symbolizujące optymalizację
Źródło: Pexels | Autor: Atlantic Ambience

Profilowanie i diagnoza: zanim dotkniesz kodu

Praktyczne narzędzia: Django Debug Toolbar, silk, slow queries

Najwygodniejszym narzędziem do badań lokalnych jest Django Debug Toolbar. Podczas otwierania strony pokazuje:

  • czas wykonania widoku i poszczególnych kroków,
  • liczbę i czas trwania zapytań SQL,
  • stack trace dla każdego zapytania (skąd zostało wywołane),
  • informacje o cache, ustawieniach, template’ach.

Inny popularny wybór, bardziej nastawiony na produkcję/staging, to silk (django-silk). Pozwala profilować requesty, mierzyć czas funkcji, analizować zapytania do bazy. W połączeniu z logowaniem w bazie danych (np. PostgreSQL log_min_duration_statement) można zidentyfikować wolne zapytania (slow queries) i sprawdzić, czy problem jest w brakującym indeksie, czy raczej w samym kodzie Pythona.

Do prostszych przypadków wystarczy proste logowanie w Django – ustawienie logera django.db.backends na poziom DEBUG na środowisku developerskim. Uzyskany log SQL pozwala policzyć liczbę zapytań i ich łączny czas. Tam zwykle widać klasę problemów n+1 queries i inne nadmiarowe odwołania do bazy.

Profilowanie lokalne kontra staging/produkcja

Testy lokalne są wygodne, ale nie zawsze reprezentatywne. Różnice pojawiają się m.in. w:

  • rozmiarze bazy (na produkcji wielokrotnie większa liczba rekordów),
  • sprzęcie i konfiguracji (serwery produkcyjne vs laptop developera),
  • ruchu (na lokalnym środowisku zwykle pojedynczy użytkownik).

Dlatego ważne jest zestawienie wyników: co dzieje się lokalnie (czy kod jest w ogóle poprawnie zoptymalizowany) oraz jak aplikacja zachowuje się pod realnym obciążeniem. Na stagingu można:

  • zastosować mniejszy próg logowania slow queries w bazie,
  • włączyć lekkie APM lub logowanie czasu wybranych widoków,
  • przeprowadzić testy obciążeniowe (np. Locust, k6), aby zobaczyć skalowanie przy rosnącym ruchu.

Na produkcji pełne profilowanie krok po kroku bywa zbyt kosztowne. Lepiej ograniczyć się do pomiaru wybranych fragmentów (critical path), bo narzędzia takie jak silk czy Debug Toolbar dodają narzut i mogą zmienić zachowanie aplikacji.

Interpretacja wykresów i metryk czasu

Metryki wydajności można łatwo źle zinterpretować. Sam średni czas odpowiedzi niewiele mówi. Liczy się też:

  • percentyle – p95/p99 pokazują, jak zachowuje się system dla najwolniejszych 5%/1% requestów,
  • liczba zapytań na request – 2 szybkie zapytania zwykle są lepsze niż 30 bardzo szybkich,
  • czas bazy vs czas Pythona – jeśli baza zajmuje 10% czasu, a Python 80%, optymalizacja SQL niewiele pomoże.

Mierzenie czasu na poziomie widoków i funkcji

Profilery pełnoekranowe są dobre na start, ale przy powtarzalnej pracy przy optymalizacji wygodniejsze bywa punktowe mierzenie czasu. Dwie popularne techniki to:

  • ręczne pomiary z użyciem time.monotonic() lub podobnych narzędzi,
  • dekora­tory/profile ry funkcji (np. cProfile, dedykowane biblioteki).

Ręczny pomiar sprawdza się przy wąskich gardłach, które już podejrzewasz. Przykład minimalnego logowania czasu widoku:


import time
import logging

logger = logging.getLogger(__name__)

def my_view(request):
    start = time.monotonic()
    try:
        # logika widoku
        ...
    finally:
        duration = time.monotonic() - start
        logger.info("my_view took %.3f s", duration)

Dekorator daje wygodniejsze użycie i wymusza spójny format logów. Można nim objąć nie tylko widoki, ale też funkcje usługowe (np. integracje z zewnętrznym API), co pozwala rozdzielić, ile czasu „zjada” sama baza, a ile np. płatności.

APM i tracing rozproszony: kiedy lokalne profilery nie wystarczą

Przy większej architekturze (kilka usług, kolejki, cache, kilka baz) lokalne narzędzia szybko okazują się zbyt wąskie. Wtedy zwykle wchodzą do gry APM-y (Application Performance Monitoring) i tracing rozproszony:

  • narzędzia komercyjne (np. Datadog APM, New Relic, Sentry Performance),
  • rozwiązania oparte na OpenTelemetry (Jaeger, Tempo, Zipkin).

Różnica jest podobna jak między mierzeniem czasu jednego widoku, a oglądaniem całej ścieżki requestu przechodzącego przez kilka serwisów. APM pokazuje wykres „flamegraph” – ile czasu poszło na:

  • zapytania SQL po stronie Django,
  • wywołania HTTP do innych serwisów,
  • kolejki (czas oczekiwania, czas przetwarzania),
  • cache (trafienia i pudła).

Dla małej aplikacji APM bywa „armata na muchę”. Przy systemie z kilkoma mikroserwisami i kolejkami to często jedyna sensowna metoda, żeby znaleźć nietypowe opóźnienia (np. łańcuch retry w jednym z serwisów albo niestabilną replikę bazy).

Optymalizacja zapytań w Django ORM: podstawowe zasady

Unikanie problemu N+1 queries

N+1 queries to klasyk w Django. Pojawia się zwykle tam, gdzie po pobraniu listy obiektów odwołujesz się w pętli do relacji, np.:


posts = Post.objects.all()[:50]
for post in posts:
    print(post.author.username)

Bez optymalizacji każde użycie post.author generuje osobne zapytanie, więc łącznie powstaje 1 (lista postów) + N (autor każdego posta). Dwa podstawowe narzędzia do walki z tym zjawiskiem to:

  • select_related() – dla relacji jeden-do-jednego i wiele-do-jednego,
  • prefetch_related() – dla relacji wiele-do-wielu i odwrotnych ForeignKey.

Przykład poprawionej wersji:


posts = Post.objects.select_related("author")[:50]
for post in posts:
    print(post.author.username)

Różnica w liczbie zapytań bywa dramatyczna. Dlatego porównując dwie wersje widoku, sensowne jest zestawienie właśnie liczby zapytań i ich łącznego czasu zamiast tylko patrzenia na sumaryczny czas widoku.

Filtrowanie po stronie bazy zamiast w Pythonie

Częsty wzorzec: pobranie „dużo za dużo” rekordów z bazy, a potem odfiltrowanie w Pythonie. Przy małej liczbie danych jest to niezauważalne, ale przy dużych tabelach szybko staje się problemem. Przykład nieoptymalny:


orders = Order.objects.all()
paid_orders = [o for o in orders if o.is_paid]

Znacznie lepiej:


paid_orders = Order.objects.filter(is_paid=True)

Podobnie z sortowaniem czy limitowaniem wyników. order_by() i [:N] na QuerySet wykonują się w bazie, więc mogą skorzystać z indeksów. Sortowanie listy obiektów już po stronie Pythona skaluje się dużo gorzej.

Ograniczanie zakresu pobieranych danych

QuerySet w Django jest leniwy – samo stworzenie go nie generuje zapytania. Zapytanie pojawi się dopiero przy ewaluacji (iteracja, list(), len(), itp.). I tu pojawia się kilka pułapek:

  • bezmyślne użycie .all() tam, gdzie potrzebujesz kilku rekordów,
  • brak .only() / .defer() przy bardzo „szerokich” modelach,
  • pobieranie dużych kolekcji bez paginacji.

Porównanie dwóch podejść:

  • Pełne obiekty: wygodniejsze w użyciu, ale przy kilkudziesięciu polach i setkach tysięcy wierszy mocno obciążają pamięć i sieć.
  • Ograniczenie pól (only, values, values_list): mniej danych w transferze, ale nieco większa złożoność kodu (trzeba pamiętać, co zostało pobrane).

Dla widoku API zwracającego dużą listę ID i prostych metadanych często bardziej opłaca się użycie .values() niż pełnych instancji modeli. Dla logiki biznesowej, gdzie pracujesz intensywnie na obiekcie, wygodę zazwyczaj wygrywa pełny model.

Pagination i stronicowanie po kluczu

Przy dużych listach prosty .all()[:100] jest w porządku, ale już tradycyjne stronicowanie z offset i limit (np. .all()[10000:10100]) bywa problematyczne przy rosnącej liczbie rekordów, bo baza musi przeskanować i odrzucić poprzednie wiersze.

Dwa popularne style:

  • offset-based – łatwy w użyciu, prosty dla użytkownika („strona 5”), gorzej skaluje przy bardzo dużych tabelach,
  • cursor/keyset-based – zamiast numeru strony przechowujesz ostatni klucz (np. ID, datę) i filtrujesz WHERE id > last_id; lepiej skaluje się dla długich list, ale trudniej o klasyczną nawigację po stronach.

W Django REST Framework dobrze widać ten wybór: klasyczny PageNumberPagination kontra CursorPagination. Pierwszy jest bardziej „user friendly”, drugi zwykle wydajniejszy dla długich historycznych list (logi, transakcje).

Zaawansowane techniki ORM: indeksy, transakcje, agregacje i surowe SQL

Dobór i analiza indeksów w bazie danych

Optymalizując ORM prędzej czy później dociera się do poziomu indeksów. Różnice między kilkoma podejściami dobrze ilustruje typowe filtrowanie:

  • model bez indeksów – każde złożone filter() skutkuje skanem dużej części tabeli,
  • indywidualne indeksy na kolumnach – przy prostych filtrach (po jednym polu) jest już dobrze,
  • indeksy złożone (wielokolumnowe) – lepsze dla często powtarzających się zapytań z kilkoma warunkami i sortowaniem.

W Django indeksy definiuje się w Meta modelu, np.:


class Order(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    status = models.CharField(max_length=20)
    created_at = models.DateTimeField()

    class Meta:
        indexes = [
            models.Index(fields=["user", "status"]),
            models.Index(fields=["-created_at"]),
        ]

Różnica między indeksem pojedynczym a złożonym jest istotna. Zapytanie .filter(user=u, status="paid") skorzysta z indeksu złożonego, podczas gdy dwa osobne indeksy na user i status niekoniecznie dadzą taki sam efekt przy rosnącej tabeli.

Używanie EXPLAIN i narzędzi bazodanowych

Gdy dane zapytanie staje się podejrzanie wolne, same logi z Django to za mało. Trzeba zajrzeć do planu wykonania w bazie (EXPLAIN / EXPLAIN ANALYZE w PostgreSQL). Typowy cykl pracy:

  1. Skopiowanie zapytania z logów Django (pełnego SQL z parametrami).
  2. Uruchomienie EXPLAIN (ANALYZE, BUFFERS) w narzędziu typu psql, DBeaver, PgAdmin.
  3. Sprawdzenie, czy używany jest właściwy indeks, czy występuje sekwencyjny skan całej tabeli.

Czasem wychodzi, że problemem jest nie tylko brak indeksu, lecz także np. zbyt agresywna filtracja po funkcji (np. WHERE date(created_at) = ...) zamiast po surowym polu (created_at >= ... AND created_at < ...). Wtedy lepszym rozwiązaniem jest zmiana kodu w ORM niż dorabianie kolejnego indeksu.

Transakcje: atomic, izolacja i blokady

Transakcje pomagają utrzymać spójność danych, ale przy złym użyciu potrafią mocno spowolnić system. Dwa skrajne podejścia:

  • Brak jawnych transakcji – krótsze blokady, prostsze zachowanie, ale ryzyko stanów pośrednich przy złożonych operacjach.
  • Jedna gigantyczna transakcja wokół całej logiki – maksymalna spójność, ale dużo dłuższe blokady i rosnące ryzyko konfliktów.

Django daje wygodne narzędzia, np. @transaction.atomic. Kluczem jest trzymanie transakcji możliwie krótkich. Nie ma sensu otwierać transakcji na cały request HTTP, jeśli część pracy (np. wysyłka emaila, integracja z API) może zostać wykonana po commicie lub w tle (Celery).

W bardziej wrażliwych fragmentach (np. aktualizacja stanu magazynowego) czasem opłaca się świadome użycie select_for_update() i dopuszczenie blokady rekordu, zamiast budowania skomplikowanych mechanizmów „optimistic locking” w Pythonie.

Agregacje i obliczenia po stronie bazy

ORM ma spory zestaw narzędzi do zliczania, sumowania i innych operacji agregujących: annotate(), aggregate(), F(), Case, When. Porównanie dwóch stylów liczenia raportu:

  • Po stronie Pythona: pobierasz dużą liczbę wierszy i iterujesz, sumując wartości; łatwe do napisania, ale nie skaluje,
  • Po stronie bazy: jedno większe zapytanie z agregacjami; wymaga większej ostrożności, ale bazodanowy silnik potrafi zrobić to dużo szybciej.

Przykład z użyciem annotate:


from django.db.models import Count

users = User.objects.annotate(
    orders_count=Count("order")
).filter(orders_count__gt=0)

W efekcie dostajesz listę użytkowników z wyliczonymi agregatami jednym zapytaniem, zamiast n osobnych sumowań.

Surowy SQL i raw(): kiedy ORM przeszkadza

Czasem ORM nie wystarcza: zbyt złożony JOIN, specyficzna funkcja bazodanowa, niestandardowy indeks czy zapytanie typu UNION. Wtedy pojawia się pytanie: trzymać się na siłę ORM-u czy użyć surowego SQL?

Dwa kompromisy:

  • .raw() – mapuje wynik SQL na instancje modelu; dobre, gdy chcesz wrócić do świata ORM po nieco bardziej zaawansowanym zapytaniu.
  • connection.cursor() – pełna swoboda SQL, rezultat jako „gołe” wiersze; bardziej niskopoziomowe, ale też bardziej elastyczne (np. przy masowych insertach/ update’ach).

Dla pojedynczych „krytycznych” zapytań (raporty, skomplikowane statystyki) surowy SQL jest często uzasadniony. Dla standardowych operacji CRUD i relacyjnych joinów zazwyczaj lepiej pozostać przy ORM, bo łatwiej wtedy utrzymać spójność logiki i migracji.

Zbliżenie ekranu laptopa z kodem JavaScript podczas programowania
Źródło: Pexels | Autor: Markus Winkler

Cache w Django: podejścia, backendy i strategia

Rodzaje cache: lokalny, procesowy, sieciowy

Istnieje kilka poziomów cache, które można połączyć lub porównać między sobą:

  • cache lokalny (per proces) – np. LocMemCache, trzymany w pamięci procesu; bardzo szybki, ale nie współdzielony między workerami i hostami,
  • cache sieciowy – Redis, Memcached; wolniejszy niż cache lokalny, ale współdzielony,
  • cache HTTP (reverse proxy) – Nginx/Varnish; przechowuje całe odpowiedzi HTTP poza Django.

Dla małych projektów z jednym serwerem LocMemCache potrafi znacząco przyspieszyć powtarzalne odczyty. W środowisku z kilkoma instancjami aplikacji kluczowe staje się przejście na backend sieciowy, żeby cache był spójny między workerami.

Porównanie backendów cache Django

Porównanie backendów cache Django w praktyce

W konfiguracji Django często spotykają się przynajmniej trzy backendy cache. Różnią się nie tylko wydajnością, ale i zachowaniem pod obciążeniem, sposobem zarządzania pamięcią czy odpornością na awarie.

  • LocMemCache – wbudowany cache w pamięci procesu:

    • najszybszy dostęp (brak sieci),
    • stan rozjeżdża się między workerami/gunicorn workers,
    • odświeżenie danych w jednym procesie nie jest widoczne w innych,
    • dobry do cache pomocniczego (np. krótkie, 1–5 sekund TTL dla drogich obliczeń szablonu).
  • Memcached (np. django.core.cache.backends.memcached.MemcachedCache):

    • od lat klasyk do cache’owania stron i fragmentów,
    • prosty, bardzo szybki, ale bez trwałości danych,
    • lepszy przy bardzo wysokich wolumenach losowych odczytów niż Redis w trybie ogólnym.
  • Redis (np. z django-redis):

    • nieco wolniejszy niż Memcached przy prostym GET/SET, ale za to bogatszy – struktury danych, pub/sub, blokujące operacje,
    • może służyć jako cache, kolejka zadań i storage dla sesji,
    • daje trwałość (snapshoty / AOF), co czasem jest plusem, a czasem przeszkodą (migawki mogą chwilowo spowalniać).

Prosta, jednowarstwowa aplikacja na jednym serwerze poradzi sobie z LocMemCache. Natomiast przy kilku instancjach, auto-scalingach i osobnej bazie danych dużo bezpieczniejsze jest od razu wejście w Redis/Memcached, żeby unikać trudnych do odtworzenia bugów ze „starymi” danymi w lokalnym cache’u.

Strategie cache: co, gdzie i na jak długo

Sam wybór backendu nie rozwiązuje problemu – trzeba jeszcze ustalić strategię: co trafia do cache, na jaki czas, jak wygląda odświeżanie. Dwa dominujące sposoby podejścia:

  • cache-aside (lazy cache) – aplikacja najpierw próbuje odczytać z cache, jeśli klucza nie ma, pobiera dane z bazy/API i zapisuje do cache:

    • łatwy do zaimplementowania,
    • przy awarii cache aplikacja działa „jak bez cache”,
    • trzeba ręcznie (lub przez TTL) dbać o wygaszenie/odświeżanie.
  • write-through / write-behind – zapis do bazy automatycznie wiąże się z aktualizacją cache:

    • świeższe dane w cache, mniejsza szansa na odczyt „starego” wpisu,
    • logika aktualizacji rozlewa się po kodzie zapisu (lub wymaga wzorca repozytorium / serwisu),
    • write-behind (opóźniony zapis do bazy) raczej spotykany poza klasycznym Django, ale w niektórych zastosowaniach (logi zdarzeń) bywa użyteczny.

Krótki TTL (np. 30–120 sekund) bywa dobrym kompromisem dla list i rankingów: nie trzeba idealnej świeżości, a jednocześnie nie trzeba budować rozbudowanego systemu invalidacji przy każdej zmianie danych.

Projektowanie kluczy cache

Im bardziej przewidywalne klucze cache, tym łatwiej debugować i unikać kolizji. Można porównać dwa skrajne style:

  • ręczne klucze typu string – np. "user:%s:profile" % user_id:

    • pełna kontrola nad formatem,
    • możliwość prostego grupowania (prefiksy user:, post:),
    • ryzyko konfliktów przy refaktoryzacjach, jeśli prefiksy nie są konsekwentne.
  • funkcje pomocnicze / bibliotekowe generatory kluczy – np. django-redis z własnym KEY_FUNCTION:

    • mniej powtarzalnego kodu,
    • spójne przestrzenie nazw,
    • czasem trudniejszy debug (klucze hashowane / nieintuicyjne).

Dodatkowe pole manewru dają wersje cache (VERSION, cache.set(..., version=2)). Zamiast usuwać dziesiątki kluczy po dużej zmianie logiki, można podbić globalną wersję namespace’u (np. CACHE_VERSION = 3) i pozwolić, by stare wpisy same wygasły.

Cache na poziomie widoków, fragmentów i danych

Cache całych widoków: kiedy wystarczy „zamrozić” odpowiedź

Cache na poziomie widoku (django.views.decorators.cache.cache_page) to najprostszy sposób na przyspieszenie wolnego endpointu, zwłaszcza publicznych stron niepersonalizowanych (landing, listy produktów bez loginu, blog).


from django.views.decorators.cache import cache_page

@cache_page(60 * 5)  # 5 minut
def product_list(request):
    ...

Zalety są dość wyraźne:

  • jedna linia kodu,
  • cache’uje pełną odpowiedź HTTP łącznie z nagłówkami,
  • ignoruje wewnętrzne szczegóły zapytań – nieważne, czy w środku robi się 10, czy 100 query.

Problem zaczyna się przy personalizacji (cookies, język, lokalizacja). Można wtedy:

  • ignorować część nagłówków w Varnish/Nginx i mieć oddzielne cache per język / device,
  • przerzucić personalizację do frontendu (JS) lub do „mikro-widżetów” bez cache’,
  • przejść na cache fragmentów szablonu zamiast całego widoku.

Cache fragmentów szablonu: kompromis między elastycznością a złożonością

Gdy cały widok jest zbyt dynamiczny, ale część strony jest w miarę statyczna (np. panel „najpopularniejsze artykuły”, ranking, stopka z liczbą aktywnych użytkowników), dobrym rozwiązaniem jest cache fragmentu szablonu z tagiem {% cache %}.


{% load cache %}

<h1>Aktualności</h1>

{% cache 300 "popular_posts" language %}
  {% include "partials/popular_posts.html" %}
{% endcache %}

Dwa istotne elementy:

  • czas (TTL) – ile sekund fragment ma się utrzymywać,
  • klucz + zmienne – np. "popular_posts" i language jako parametr – różne wersje dla różnych języków.

Z jednej strony łatwiej tu kontrolować, co jest cache’owane, z drugiej – rośnie liczba potencjalnych kluczy (różne warianty językowe, rozmiary list itd.). Przy często zmieniających się danych fragmenty z długim TTL mogą wprowadzać dezorientująco „przedawniony” wygląd strony, więc przy dynamicznych sekcjach lepiej skrócić czas lub przejść na cache na poziomie danych.

Cache danych aplikacyjnych: poza szablonem i HTTP

Cache obiektów i wyników zapytań na poziomie logiki biznesowej daje najwięcej kontroli. Zwykle stosuje się prosty wzorzec:


from django.core.cache import cache

def get_user_profile(user_id):
    key = f"user:{user_id}:profile"
    profile = cache.get(key)
    if profile is None:
        profile = UserProfile.objects.select_related("user").get(user_id=user_id)
        cache.set(key, profile, timeout=300)
    return profile

W porównaniu do cache widoku:

  • można wielokrotnie wykorzystać ten sam wynik w różnych endpointach lub zadaniach Celery,
  • łatwiej precyzyjnie unieważnić wpis po zmianie danych (np. w sygnale post_save),
  • pełna odpowiedź HTTP nadal może być generowana dynamicznie (np. inny layout, inny format JSON), a ciężkie części danych przychodzą z cache.

Invalidacja i odświeżanie cache: problemy spójności

Najtrudniejszym elementem cache, niezależnie od poziomu, jest jego odświeżanie. Zazwyczaj wybiera się pomiędzy:

  • TTL + okazjonalna „stara” wartość – proste, ale przez kilka-kilkanaście sekund dane mogą być nieaktualne,
  • precyzyjna invalidacja – usuwanie/aktualizowanie kluczy po zapisach, z ryzykiem pominięcia któregoś miejsca w kodzie.

Przy powiadomieniach, licznikach lajków czy statystykach biznesowych małe opóźnienie zwykle nie przeszkadza, więc TTL + ewentualny „lazy recalc” bywa wystarczający. Natomiast przy stanach krytycznych (saldo, limit kredytowy, dostępność biletu) cache często służy tylko jako warstwa odczytowa, a sama operacja modyfikacji odbywa się zawsze na bazie pod kontrolą transakcji.

Czasem stosuje się hybrydę:

  • ustawienie dość długiego TTL (np. 5–10 minut) dla drogich raportów,
  • ręczne cache.delete() w krytycznych ścieżkach (np. zapis nowej transakcji usuwa cache raportu finansowego).

Przy większych systemach dochodzą bardziej wyszukane podejścia – „cache stampede” (wiele procesów odświeża ten sam klucz naraz) łagodzi się np. przez krótszy TTL + „soft TTL” (klucz ważny od strony aplikacji nieco dłużej niż od strony cache) albo przez blokowanie odświeżenia (Redis z SETNX jako prosty lock).

Cache per użytkownik vs cache globalny

W serwisach z logowaniem pojawia się pytanie, jak łączyć cache ze stanem użytkownika. Dwa skrajne modele:

  • cache globalny – jedna wersja danych dla wszystkich:

    • prosty, mało kluczy, duża trafność cache (hit rate),
    • zwykle tylko dla publicznych / niepersonalizowanych fragmentów.
  • cache per user/session – różne wersje dla różnych użytkowników:

    • rośnie liczba kluczy,
    • kłopotliwa invalidacja (np. po zmianie uprawnień),
    • stosowany głównie dla ciężkich wyliczeń na konto użytkownika (dashboardy, spersonalizowane rekomendacje).

Dobry kompromis to cache częściowo globalny (np. lista dostępnych planów cenowych, globalne ustawienia) i niewielkie, precyzyjne cache per user dla rzeczy naprawdę drogich. Drobne dane personalizacyjne (imię w nagłówku, avatar) zwykle szybciej jest pobrać z bazy niż komplikować system cache.

Middleware a wydajność: pomiary, kolejność i pułapki

Koszt jednego middleware vs wiele prostych warstw

Middleware w Django działa na każdy request i każdą odpowiedź, więc każdy dodatkowy element w stosie ma swój koszt. Do wyboru są dwa intuicyjne podejścia:

  • kilka prostych middleware – np. osobno logowanie, osobno identyfikacja użytkownika, osobno pomiar czasu:

    • klarowna odpowiedzialność, łatwiej wyłączyć pojedynczą funkcję,
    • więcej wywołań funkcji przy każdym żądaniu.
  • jedno „łączone” middleware – kilka funkcji w jednej klasie:

    • mniejszy overhead funkcji oraz potencjalnie mniej powtarzalnych odczytów z requestu,
    • większe i bardziej skomplikowane pliki, trudniejsze do testowania jednostkami.

W praktyce dopiero kilkanaście–kilkadziesiąt middleware z ciężką logiką robi realną różnicę. Przy kilku lekkich warstwach (log, tracing, security headers) nie ma sensu przedwcześnie łączyć wszystkiego „dla szybkości”, o ile nie pokazuje tego profilowanie.

Middleware a kolejność wykonywania

Kolejność wpisów w MIDDLEWARE decyduje o tym, w jakiej sekwencji kod jest wykonywany. Przesunięcie jednego elementu potrafi zmienić zarówno funkcjonalność, jak i wydajność:

  • middleware autoryzacji/przekierowań umieszczone przed ciężkimi middleware (np. debug toolbar, obsługa sesji plikowych) skraca drogę dla odrzuconych żądań,
  • middleware kompresji GZip na samej górze może skompresować nawet odpowiedzi debugowe, co niepotrzebnie obciąża CPU w środowiskach deweloperskich.

Warto mieć na uwadze dwa kierunki przepływu: request (od góry listy do dołu) i response (od dołu do góry). Middleware, które „dokleja coś do odpowiedzi” (np. nagłówki bezpieczeństwa, kompresja) powinno znajdować się bliżej dołu, natomiast te, które mają szansę szybko zakończyć cykl (np. blokada IP, wczesne 403) – wyżej.

Cache middleware HTTP vs cache aplikacyjny

Najczęściej zadawane pytania (FAQ)

Jak sprawdzić, czy moja aplikacja Django jest za wolna przez bazę danych, czy przez kod Pythona?

Najprostsze rozróżnienie to porównanie czasu spędzanego w bazie z czasem wykonania samego kodu. Narzędzia typu Django Debug Toolbar, silk czy APM (np. Sentry Performance, New Relic) pokazują statystyki: ile zapytań SQL wykonał request, ile one trwały oraz jaki był całkowity czas odpowiedzi.

Jeżeli widzisz kilka ciężkich zapytań, rosnącą liczbę joinów i brak indeksów – wąskim gardłem jest baza. Gdy natomiast czas w bazie jest mały, a request nadal trwa długo, winne bywają pętle w Pythonie, parsowanie dużych struktur, skomplikowana serializacja albo blokujące operacje I/O (np. wywołania API w środku widoku).

Jakie są typowe objawy tego, że aplikacja Django nie wyrabia pod obciążeniem?

Przy problemach z przepustowością pojedyncze requesty lokalnie działają szybko, ale pod ruchem pojawiają się skoki czasów odpowiedzi i błędy 502/504. Do tego dochodzą takie sygnały jak wyraźny wzrost średniego czasu odpowiedzi przy większym ruchu, a także kolejki requestów w serwerze aplikacyjnym (np. Gunicorn) mimo tego, że sama logika nie jest specjalnie ciężka.

Od strony serwera zwykle widać wysokie użycie CPU lub blokujące operacje I/O, rosnącą liczbę połączeń do bazy, ale bez proporcjonalnego wzrostu RPS (requests per second). W logach reverse proxy (Nginx, Traefik) zaczynają się pojawiać timeouty upstream oraz nietypowo długie czasy obsługi niektórych ścieżek.

Jak mierzyć wydajność endpointów w Django, żeby optymalizacja miała sens?

Zamiast patrzeć tylko na „średni czas odpowiedzi aplikacji”, lepiej mierzyć wydajność per endpoint. Przydatne są przede wszystkim: średni czas odpowiedzi, percentyle (p95, p99), liczba zapytań SQL na request, udział czasu bazy w całkowitym czasie oraz to, jak te parametry zmieniają się przy rosnącym ruchu.

W praktyce oznacza to włączenie APM lub przynajmniej własnego logowania czasu wykonania widoków. Dobrze jest też zbudować prosty ranking: top N najwolniejszych i jednocześnie najczęściej wywoływanych endpointów. To one przynoszą największy zwrot z inwestycji przy każdej optymalizacji.

Kiedy przyspieszać Django cache’owaniem, a kiedy optymalizować zapytania ORM?

Cache ma sens wtedy, gdy te same dane są odczytywane wielokrotnie, a ich generowanie jest kosztowne. Dobrym kandydatem są popularne, ale rzadko zmieniające się widoki (np. strony główne, listy produktów). W takich przypadkach cache na poziomie widoku lub fragmentu szablonu daje duży zysk niewielkim kosztem, szczególnie przy dużym ruchu.

Optymalizacja ORM jest niezbędna, gdy pojedynczy request robi wiele podobnych zapytań (N+1 queries), gdy czasy rosną wraz z ilością rekordów lub gdy w logach bazy pojawia się dużo slow queries. Wtedy lepsze indeksy, select_related/prefetch_related i redukcja liczby zapytań są skuteczniejsze niż „doklejanie” kolejnych warstw cache, które tylko maskują problem.

Jak odróżnić problem z samym Django od problemów infrastrukturalnych (Nginx, uWSGI, sieć)?

Dobrym testem jest porównanie zachowania aplikacji lokalnie i na produkcji. Jeżeli lokalnie endpoint jest szybki, a na produkcji nagle „puchnie”, warto zajrzeć w metryki infrastruktury: CPU/RAM na serwerach, liczbę workerów uWSGI/Gunicorn, obciążenie reverse proxy oraz opóźnienia sieciowe. Problemy infrastrukturalne często ujawniają się dopiero powyżej pewnego poziomu ruchu.

Z kolei jeśli i lokalnie, i na produkcji endpoint jest wolny, przy podobnym zużyciu bazy i CPU, to raczej kwestia kodu lub samego zapytania SQL. Dodatkowym rozróżnikiem jest to, czy czasy rosną liniowo z ruchem (infrastruktura), czy z liczbą danych w tabelach (baza/ORM).

Jak middleware w Django wpływa na wydajność i kiedy może stać się wąskim gardłem?

Każdy middleware dodaje narzut do obsługi requestu, ale przy prostych operacjach (nagłówki, proste logowanie) jest on znikomy. Problem zaczyna się wtedy, gdy middleware wykonuje kosztowne operacje: zapytania do bazy, wywołania zewnętrznych API, skomplikowane przetwarzanie danych albo synchroniczne logowanie do wolnego backendu.

Jeśli profilowanie pokazuje, że duża część czasu requestu znika w middleware, lepszym podejściem jest przeniesienie ciężkiej logiki do widoków, zadań asynchronicznych (Celery) lub osobnych usług. Zazwyczaj middleware powinno robić jak najmniej: decyzje dotyczące kontroli dostępu, proste modyfikacje request/response i ewentualnie lekkie metryki.

Czy monolit Django jest wolniejszy od architektury opartej na wielu mikroserwisach?

Sam w sobie monolit nie jest ani szybszy, ani wolniejszy – inne są tylko źródła opóźnień. W klasycznym monolicie głównym kosztem jest baza, ORM i renderowanie szablonów. Łatwiej jest też profilować całą ścieżkę, bo wszystko dzieje się w jednym procesie i nad jedną bazą danych.

W środowisku z wieloma zewnętrznymi usługami dochodzi koszt komunikacji sieciowej: opóźnienia HTTP, timeouty, retry, kolejki, dodatkowe bazy czy silniki wyszukiwania. Zyskujesz elastyczność skalowania poszczególnych komponentów, ale płacisz większą złożonością i nowymi punktami awarii. Z punktu widzenia wydajności nie ma jednej „zwycięskiej” opcji – kluczowe jest to, czy wąskie gardła są lepiej kontrolowane w jednym procesie, czy w rozproszonym systemie.

Najważniejsze punkty

  • Zamiast ogólnego „Django jest wolne” trzeba precyzyjnie wskazać, które endpointy zwalniają, przy jakim ruchu i w jakich godzinach, bo pojedynczy wolny request to inny problem niż aplikacja, która nie wyrabia pod obciążeniem.
  • Symptomy po stronie użytkownika (długi TTFB, timeouty 504, wolne ładowanie pełnej strony) trzeba zestawić z sygnałami z infrastruktury (wysokie CPU, I/O, locki w bazie, błędy 502/504), aby odróżnić problemy backendu od tych w front-endzie czy sieci.
  • Źródła spowolnienia można podzielić na trzy główne kategorie – kod (droga logika Pythona), baza danych (brak indeksów, wolne zapytania, złożone joiny) i infrastruktura (za mało workerów, limity CPU/RAM) – i dla każdej grupy używać innych narzędzi diagnostycznych.
  • Nieregularne czasy odpowiedzi, gdzie część requestów jest bardzo szybka, a inne wyraźnie wolniejsze, często wynikają z nieprzemyślanej strategii cache (duży rozrzut między cache hit a miss), a nie z „magicznej losowej niestabilności” Django.
  • Skuteczna optymalizacja wymaga twardych danych: czasu odpowiedzi per endpoint (średnia i percentyle), liczby i czasu zapytań SQL na request, podziału czasu na aplikację, bazę, cache i zewnętrzne API oraz listy najbardziej kosztownych endpointów.
  • Decyzje typu „dodać cache”, „użyć select_related/prefetch_related” czy „przepisać widok” mają sens dopiero po analizie metryk; w małej aplikacji zwykle dominuje ORM i logika widoku, a w rozbudowanych systemach coraz częściej największy koszt generują zewnętrzne usługi i I/O.
  • Źródła informacji

  • Django Documentation: Performance and optimization. Django Software Foundation – Oficjalne wskazówki dot. wydajności, cache, ORM i architektury Django
  • Django Documentation: Caching. Django Software Foundation – Opis mechanizmów cache w Django: per‑view, per‑site, low‑level, backendy
  • Gunicorn Documentation. Benoit Chesneau and contributors – Konfiguracja workerów, model współbieżności i wpływ na przepustowość aplikacji
  • uWSGI Documentation. Unbit – Parametry procesów/workerów, zarządzanie obciążeniem i integracja z Django
  • Nginx HTTP Load Balancing and Reverse Proxy. F5 NGINX – Reverse proxy, upstream, timeouty 502/504 i ich wpływ na wydajność
  • PostgreSQL 16 Documentation: Performance Tips. PostgreSQL Global Development Group – Indeksy, slow queries, EXPLAIN i optymalizacja zapytań SQL
  • Prometheus: Monitoring System and Time Series Database. Prometheus Authors – Zbieranie metryk czasu odpowiedzi, obciążenia CPU, liczby requestów
  • Grafana Documentation. Grafana Labs – Wizualizacja metryk APM, dashboardy p95/p99 i analiza wąskich gardeł