Eine langsame Shop-Verwaltung: N+1-Abfragen und ein fehlender Index

Die Bestellliste wurde mit dem Shop langsamer, bis sie in der Saison in den Timeout lief. Hunderte Abfragen pro Seite, ein fehlender Index, ein Test als Wache.

Firma
Onlineshop für Ersatzteile, 12 Beschäftigte
Team
Ein Entwickler, ein Freelancer für größere Änderungen
Technik
Django 5, PostgreSQL, Gunicorn hinter Nginx, ein VPS
Rahmen
Kein Werkzeug zur Leistungsmessung der Anwendung, nur Serverlogs

01Ausgangslage

Das Lager arbeitet in der Verwaltung mit der Bestellliste: 50 pro Seite, gefiltert nach Status, die neuesten oben. Zu jeder Bestellung stehen Kundenname, Versandart und Anzahl der Positionen. Nach einigen Jahren hat die Bestelltabelle Hunderttausende Zeilen.

Das Template durchläuft die Bestellungen in einer Schleife und liest bei jeder order.customer.name, order.shipping_method.name und order.items.count.

  1. Browser (Verwaltung)
  2. Nginx
  3. Gunicorn / Django
  4. PostgreSQL

02Das Problem

Die Liste wurde allmählich langsamer, deshalb fiel es lange niemandem auf. Im Weihnachtsgeschäft endete die Seite mit einem 502: Gunicorn beendet einen Worker, der nicht innerhalb der standardmäßigen 30 Sekunden antwortet, und Nginx liefert dann 502. Das Lager konnte Bestellungen nur noch über einen Export bearbeiten.

Gleichzeitig stieg die Datenbanklast und auch der Shop selbst wurde langsamer, weil Kunden und Verwaltung dieselbe Datenbank nutzen.

03Technische Ursache

Die erste Ursache ist das N+1-Muster. Die Bestellabfrage lädt nur die Bestelltabelle. Jedes order.customer und order.shipping_method im Template schickt dann eine eigene Abfrage, order.items.count eine weitere. Bei 50 Bestellungen sind das 1 + 50 × 3 = 151 Abfragen pro Seitenaufruf. Jede ist schnell, aber 151 Wege zur Datenbank und zurück summieren sich.

Die zweite Ursache ist ein fehlender Index. Für den Filter nach Status und die Sortierung nach Erstellungsdatum gab es keinen passenden Index, also las PostgreSQL bei jedem Aufruf die ganze Tabelle und sortierte das Ergebnis, nur um die ersten 50 Zeilen zu liefern.

Auf einem Entwicklerrechner mit ein paar Dutzend Testbestellungen war beides nicht zu sehen.

04Untersuchung

Lokal, mit einer Kopie der Produktionsdaten, in der personenbezogene Daten durch erfundene ersetzt waren: django-debug-toolbar zeigte 151 Abfragen, die meisten Wiederholungen mit anderer ID.

In der Produktion die Erweiterung pg_stat_statements, die Statistiken zu Abfragen sammelt. Sie muss in shared_preload_libraries stehen, was einen Neustart von PostgreSQL erfordert, deshalb wurde sie nachts aktiviert. Die Abfrage der Bestellliste stand nach Gesamtzeit ganz oben.

EXPLAIN (ANALYZE, BUFFERS) zu dieser Abfrage zeigte einen sequenziellen Durchlauf der ganzen Tabelle (Seq Scan) und das Sortieren aller Zeilen mit dem gewählten Status.

05Behebung

Zugehörige Daten werden auf einmal geladen. select_related holt Kunde und Versandart per JOIN in dieselbe Abfrage, prefetch_related lädt die Positionen für alle 50 Bestellungen mit einer einzigen weiteren Abfrage.

shop/admin_views.py
orders = (
    Order.objects
    .filter(status=status)
    .select_related("customer", "shipping_method")  # joined into the same query
    .prefetch_related("items")                       # one extra query for the whole page
    .order_by("-created_at")
)
page = Paginator(orders, 50).get_page(request.GET.get("page"))

# template: {{ order.customer.name }}, {{ order.items.all|length }}
# both now read what is already loaded instead of asking the database again

Ein Index auf Status und Erstellungsdatum in absteigender Reihenfolge. Die Tabelle ist groß und der Shop läuft, deshalb wird der Index mit CONCURRENTLY angelegt, das Schreibzugriffe nicht blockiert. Das darf aber nicht in einer Transaktion laufen, daher atomic = False in der Migration.

shop/migrations/0043_order_status_created_idx.py
from django.contrib.postgres.operations import AddIndexConcurrently
from django.db import migrations, models


class Migration(migrations.Migration):
    # CREATE INDEX CONCURRENTLY cannot run inside a transaction
    atomic = False

    dependencies = [("shop", "0042_order_note")]

    operations = [
        AddIndexConcurrently(
            "order",
            models.Index(fields=["status", "-created_at"], name="order_status_created_idx"),
        ),
    ]

Ein Test, der darüber wacht, dass die Zahl der Abfragen nicht mit der Zahl der Bestellungen wächst. Er prüft keine genaue Zahl, die sich legitim ändern kann, sondern ob 5 und 50 Bestellungen gleich viel kosten.

shop/tests/test_admin_orders.py
from django.db import connection
from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from django.urls import reverse


def count_queries(client, url):
    with CaptureQueriesContext(connection) as ctx:
        client.get(url)
    return len(ctx.captured_queries)


class OrderListTests(TestCase):
    def test_query_count_does_not_grow_with_orders(self):
        self.client.force_login(make_staff())
        url = reverse("admin_orders")

        make_orders(5)
        few = count_queries(self.client, url)
        make_orders(45)
        many = count_queries(self.client, url)

        self.assertEqual(few, many)

06Warum die Lösung wirkt

Die Zahl der Abfragen ist jetzt konstant, egal wie viele Bestellungen auf der Seite stehen. Die Daten, die das Template liest, sind schon im Speicher, es fragt nicht erneut.

Der Index ist genau so sortiert, wie wir fragen: erst Status, dann Datum ab dem neuesten. PostgreSQL findet die ersten 50 Zeilen dieses Status direkt im Index und liest nicht weiter. Kein Durchlauf der ganzen Tabelle, kein Sortieren.

Der Test schlägt beim ersten neuen Feld im Template fehl, das N+1 zurückbringen würde, also bevor das Lager es merkt.

Was das nicht löst: Die Seitennavigation braucht die Gesamtzahl der Bestellungen im Status, und dieses COUNT(*) wächst weiter mit ihrer Zahl, wenn auch mit Index günstiger. Andere Seiten der Verwaltung wurden nicht geprüft.

07Überprüfung

  • EXPLAIN (ANALYZE, BUFFERS) zeigt jetzt einen Index Scan über order_status_created_idx, begrenzt auf 50 Zeilen, statt eines Durchlaufs der ganzen Tabelle.
  • django-debug-toolbar zeigt bei 5 und bei 50 Bestellungen dieselbe kleine Zahl von Abfragen, und der Test prüft das bei jeder Änderung.
  • pg_stat_statements nach dem Zurücksetzen der Statistik und einer Woche Betrieb: Die Abfrage der Bestellliste gehört nicht mehr zu den teuersten.

08Ergebnisse

Behoben

  • Die Zahl der Abfragen für die Bestellliste wächst nicht mit der Zahl der Bestellungen.
  • Die Liste liest nicht mehr die ganze Tabelle und läuft in der Saison nicht mehr in den Timeout.

Risiko gesenkt

  • Zu Spitzenzeiten belastet die Verwaltung die Datenbank, die sie mit dem Shop teilt, nicht mehr.

Weiter offen

  • Die Zählung für die Seitennavigation wächst mit der Zahl der Bestellungen.
  • Die übrigen Listen der Verwaltung warten auf dieselbe Prüfung.

09Grenzen

  • Jeder Index verlangsamt Schreibzugriffe und belegt Platz. Man legt sie für echte Abfragen an, nicht auf Vorrat.
  • Der Test bewacht nur diese Seite. Neue Seiten brauchen einen eigenen.
  • Eine Kopie der Produktionsdaten für die Entwicklung muss personenbezogene Daten ersetzt haben, sonst ist es eine Verarbeitung, die eine Rechtsgrundlage und denselben Schutz wie die Produktion braucht.

10Erkenntnisse

  • Erst messen, dann beheben. EXPLAIN ANALYZE und pg_stat_statements zeigen, wo die Zeit wirklich hingeht.
  • N+1 ist auf einem Entwicklerrechner mit zehn Zeilen unsichtbar. Testen Sie mit einem Volumen, das der Produktion ähnelt.
  • Ein ORM verbirgt die Wege zur Datenbank. select_related und prefetch_related gehören zum Schreiben einer Liste, nicht zur späteren Optimierung.
  • Indizes auf laufenden Tabellen mit CONCURRENTLY anlegen.
  • Ein Test auf die Zahl der Abfragen ist eine günstige Versicherung gegen die Rückkehr des Fehlers.

11Nächste Schritte für eine kleine Firma

  1. 1.log_min_duration_statement einschalten, damit langsame Abfragen sich selbst protokollieren.
  2. 2.Einmal im Monat die teuersten Abfragen in pg_stat_statements ansehen.
  3. 3.Die übrigen Listen der Verwaltung auf dieselbe Weise prüfen.
  4. 4.Ein Werkzeug zur Leistungsmessung erwägen, wenn der Shop weiter wächst.
Kontakt aufnehmen

Erkennen Sie Ihre eigene Firma darin?

Schreiben Sie uns, worum es geht. Wir antworten innerhalb eines Werktags und sagen Ihnen, ob es Arbeit für uns ist, auch wenn die Antwort nein lautet.