DjangoPythonWydajność

PostgreSQL performance w Django: indeksy, które naprawdę działają

Praktyczny przewodnik doboru indeksów w Django i PostgreSQL: które typy realnie obniżają latency, a które tylko zwiększają koszt zapisu.

PostgreSQL performance w Django: indeksy, które naprawdę działają

Krótka odpowiedź

W Django indeksy "naprawdę działają" wtedy, gdy rozwiązują konkretny problem zapytania: filtr, sortowanie albo lookup po funkcji.

Nie działają wtedy, gdy są dodawane hurtowo bez pomiaru, bo każdy indeks podnosi koszt INSERT, UPDATE i DELETE.

Dobra strategia to: najpierw pomiar, potem jeden celowany indeks, potem ponowny pomiar i decyzja keep/drop.

Co znaczy, że indeks działa

W tym artykule indeks uznajemy za skuteczny tylko wtedy, gdy spełnia jednocześnie trzy warunki:

  • obniża p95 latency dla konkretnego endpointu lub zapytania,
  • redukuje koszt planu i/lub odczyty buforów (BUFFERS) w EXPLAIN ANALYZE,
  • nie podnosi kosztu write path ponad uzgodniony budżet.

Jeśli poprawa dotyczy tylko benchmarku lokalnego, a produkcyjny write path zaczyna się dusić, indeks jest nietrafiony.

Flow doboru indeksu (od danych do decyzji)

flow doboru indeksu - od danych do decyzji

Ten proces wymusza decyzję na podstawie planu zapytania, a nie na podstawie listy "modnych indeksów".

1) Zacznij od realnych zapytań, nie od modeli

Użyj pg_stat_statements, żeby zobaczyć zapytania o najwyższym łącznym koszcie (total_exec_time) albo najwyższym czasie jednostkowym (mean_exec_time).

Dopiero potem przejdź do ORM i sprawdź plan przez QuerySet.explain(), najlepiej na danych zbliżonych do produkcyjnych.

python
qs = (
    Order.objects
    .filter(tenant_id=tenant_id, status="PAID")
    .order_by("-created_at")
)

print(qs.explain(analyze=True, buffers=True, verbose=True))

Wynik planu traktuj jako kontrakt wejściowy do decyzji o indeksie.

2) B-Tree: domyślny wybór dla większości API OLTP

W PostgreSQL B-Tree to domyślny i najczęściej właściwy typ dla porównań i sortowania.

Dla indeksów wielokolumnowych krytyczna jest kolejność: ograniczenia po lewej stronie (leftmost) najmocniej wpływają na skuteczność skanu.

Dlatego dla zapytania WHERE tenant_id = ? AND status = ? ORDER BY created_at DESC naturalnym kandydatem jest:

Index(fields=["tenant_id", "status", "-created_at"], name="ord_tenant_status_created_ix")

Trade-off: ten indeks przyspieszy read path, ale zwiększy koszt zapisu każdego rekordu Order.

3) Partial index: gdy "gorący" jest tylko fragment danych

Jeśli większość ruchu dotyczy małego podzbioru (np. tylko status='PAID'), partial index zwykle daje lepszy stosunek zysku do kosztu niż pełny indeks na całej tabeli.

W Django możesz to zapisać przez condition=Q(...).

Index(fields=["tenant_id", "-created_at"], condition=Q(status="PAID"), name="ord_paid_recent_ix")

Ważne: planner użyje partial index tylko wtedy, gdy warunek zapytania implikuje predykat indeksu. Przy zapytaniach parametryzowanych łatwo to zepsuć i wtedy indeks nie będzie używany.

4) Expression index: dla lookupów opartych o funkcję

Jeżeli filtrujesz po LOWER(email) albo innej funkcji, zwykły indeks na email może nie pomóc.

Wtedy potrzebny jest indeks na wyrażeniu:

Index(Lower("email"), name="ord_email_lower_ix")

To eliminuje konieczność obliczania funkcji podczas skanu indeksu i porównań, ale zwiększa koszt utrzymania indeksu przy zapisach i aktualizacjach.

5) Covering index (INCLUDE): mniej heap fetchów, ale bez gwarancji

INCLUDE pozwala dołożyć kolumny tylko do odczytu, bez udziału w kluczu wyszukiwania.

To bywa bardzo skuteczne dla endpointów listujących dane, bo planner może wybrać index-only scan.

Ale index-only scan zależy od visibility map. Jeśli strony nie są oznaczone jako all-visible, PostgreSQL i tak wykona heap lookup.

Przykład:

Index(fields=["tenant_id", "status"], include=["total_gross", "currency"], name="ord_cover_ix")

W Django include działa na PostgreSQL i jest ignorowane na innych backendach.

6) GIN i BRIN: narzędzia specjalistyczne, nie domyślne

GIN wybieraj dla danych złożonych: jsonb, array, hstore, tsvector.

W Django najczęściej będzie to GinIndex dla JSONField.

BRIN ma sens przy bardzo dużych tabelach append-only, gdzie wartości kolumny są skorelowane z fizyczną kolejnością danych (np. created_at).

W zwykłym workloadzie transactional API B-Tree będzie częściej trafionym wyborem niż BRIN.

Przykład Meta.indexes w Django

python
from django.contrib.postgres.indexes import GinIndex
from django.db import models
from django.db.models import Q
from django.db.models.functions import Lower

class Order(models.Model):
    tenant_id = models.UUIDField()
    status = models.CharField(max_length=20)
    created_at = models.DateTimeField()
    email = models.EmailField()
    total_gross = models.DecimalField(max_digits=12, decimal_places=2)
    currency = models.CharField(max_length=3)
    metadata = models.JSONField(default=dict)

    class Meta:
        indexes = [
            models.Index(
                fields=["tenant_id", "status", "-created_at"],
                name="ord_tenant_status_created_ix",
            ),
            models.Index(
                fields=["tenant_id", "-created_at"],
                condition=Q(status="PAID"),
                name="ord_paid_recent_ix",
            ),
            models.Index(
                Lower("email"),
                name="ord_email_lower_ix",
            ),
            models.Index(
                fields=["tenant_id", "status"],
                include=["total_gross", "currency"],
                name="ord_cover_ix",
            ),
            GinIndex(
                fields=["metadata"],
                name="ord_meta_gin_ix",
            ),
        ]

Ten zestaw ma sens tylko wtedy, gdy odpowiada konkretnym top-zapytaniom. Jeśli nie masz takich zapytań, to jest over-indexing.

Najczęstsze antywzorce

  • Dodawanie indeksu do każdego pola z WHERE bez sprawdzania częstotliwości zapytań.
  • Dublowanie indeksów o niemal identycznym prefiksie kolumn.
  • Ignorowanie kosztu write path po wdrożeniu.
  • Zakładanie, że partial index zadziała dla każdego wariantu zapytania.
  • Traktowanie INCLUDE jako gwarancji index-only scan.

Checklista rolloutu produkcyjnego

  1. Złap top 5 zapytań z pg_stat_statements dla ostatnich 24-72h.
  2. Dla każdego zapytania zapisz baseline: p95, plan, BUFFERS, liczba wywołań.
  3. Dodaj jeden indeks na iterację i wdrażaj poza peak traffic.
  4. Po wdrożeniu porównaj metryki read i write.
  5. Jeśli poprawa jest marginalna lub koszt zapisu rośnie zbyt mocno, usuń indeks.

To podejście zwykle daje mniejszy zestaw indeksów, ale o realnie wysokim ROI.

Finalny werdykt

W Django/PostgreSQL indeksy, które naprawdę działają, to nie "jak najwięcej indeksów", tylko "najmniejszy zestaw indeksów pasujących do najdroższych zapytań".

Zacznij od B-Tree i poprawnej kolejności kolumn, partial i expression dodawaj tam, gdzie masz twarde dane, a GIN/BRIN traktuj jako rozwiązania specjalistyczne.

Indeks to decyzja architektoniczna o koszcie utrzymania, nie jednorazowy trik na benchmark.