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.
- Browser (Verwaltung)
- Nginx
- Gunicorn / Django
- 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.
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 againEin 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.
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.
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 überorder_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_statementsnach 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 ANALYZEundpg_stat_statementszeigen, 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_relatedundprefetch_relatedgehören zum Schreiben einer Liste, nicht zur späteren Optimierung. - Indizes auf laufenden Tabellen mit
CONCURRENTLYanlegen. - 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.
log_min_duration_statementeinschalten, damit langsame Abfragen sich selbst protokollieren. - 2.Einmal im Monat die teuersten Abfragen in
pg_stat_statementsansehen. - 3.Die übrigen Listen der Verwaltung auf dieselbe Weise prüfen.
- 4.Ein Werkzeug zur Leistungsmessung erwägen, wenn der Shop weiter wächst.
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.