Pomalá administrácia e-shopu: N+1 dopyty a chýbajúci index
Zoznam objednávok sa s rastom e-shopu spomaľoval, až v sezóne padal na timeout. Stovky dopytov na jednu stránku, chýbajúci index a test, ktorý to ustráži.
- Firma
- E-shop s náhradnými dielmi, 12 zamestnancov
- Tím
- Jeden vývojár, externista na väčšie úpravy
- Technológie
- Django 5, PostgreSQL, Gunicorn za Nginx, jeden VPS
- Obmedzenia
- Žiadny nástroj na meranie výkonu aplikácie, iba logy servera
01Východiskový stav
Sklad pracuje v administrácii so zoznamom objednávok: 50 na stránku, filter podľa stavu, najnovšie hore. Pri každej objednávke je meno zákazníka, spôsob dopravy a počet položiek. Tabuľka objednávok má po niekoľkých rokoch stovky tisíc riadkov.
Šablóna prechádza objednávky v cykle a pri každej číta order.customer.name, order.shipping_method.name a order.items.count.
- Prehliadač (administrácia)
- Nginx
- Gunicorn / Django
- PostgreSQL
02Problém
Zoznam sa spomaľoval postupne, takže si to dlho nikto nevšímal. V predvianočnej sezóne sa stránka začala končiť chybou 502: Gunicorn po predvolených 30 sekundách ukončí pracovný proces, ktorý neodpovedá, a Nginx potom vráti 502. Sklad nemohol vybavovať objednávky inak než cez export.
Zároveň rástla záťaž databázy a spomaľoval sa aj samotný obchod, lebo zákazníci a administrácia zdieľajú tú istú databázu.
03Technická príčina
Prvá príčina je vzor N+1. Dopyt na objednávky načíta iba tabuľku objednávok. Každé order.customer a order.shipping_method v šablóne potom pošle do databázy vlastný dopyt a order.items.count ďalší. Pri 50 objednávkach je to 1 + 50 × 3 = 151 dopytov na jedno načítanie stránky. Každý je rýchly, ale 151 ciest do databázy a späť sa sčíta.
Druhá príčina je chýbajúci index. Filter podľa stavu a zoradenie podľa dátumu vytvorenia nemali vhodný index, takže PostgreSQL pri každom zobrazení prešiel celú tabuľku a výsledok zoradil, aby z neho vrátil prvých 50 riadkov.
Na vývojárskom počítači s pár desiatkami testovacích objednávok nebolo vidieť ani jedno.
04Vyšetrenie
Lokálne s kópiou produkčných dát, v ktorej boli osobné údaje nahradené vymyslenými: django-debug-toolbar ukázal 151 dopytov, z toho väčšinu opakujúcich sa s iným ID.
V produkcii rozšírenie pg_stat_statements, ktoré zbiera štatistiky o dopytoch. Treba ho mať v shared_preload_libraries, čo vyžaduje reštart PostgreSQL, preto sa zapínalo v noci. Dopyt zoznamu objednávok bol na prvom mieste podľa celkového času.
EXPLAIN (ANALYZE, BUFFERS) nad týmto dopytom ukázal sekvenčné prechádzanie celej tabuľky (Seq Scan) a triedenie všetkých riadkov so zvoleným stavom.
05Náprava
Súvisiace dáta sa načítajú naraz. select_related pripojí zákazníka a spôsob dopravy do toho istého dopytu cez JOIN, prefetch_related načíta položky pre všetkých 50 objednávok jedným ďalším dopytom.
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 againIndex na stav a dátum vytvorenia v zostupnom poradí. Tabuľka je veľká a obchod beží, preto sa index vytvára s CONCURRENTLY, ktorý neblokuje zápisy. Nesmie však bežať v transakcii, preto má migrácia atomic = False.
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"),
),
]Test, ktorý ustráži, že počet dopytov nerastie s počtom objednávok. Nekontroluje presné číslo, ktoré sa môže legitímne zmeniť, ale to, či 5 a 50 objednávok stojí rovnako.
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)06Prečo oprava funguje
Počet dopytov je teraz konštantný bez ohľadu na počet objednávok na stránke. Údaje, ktoré šablóna číta, sú už v pamäti, takže sa nepýta znova.
Index je zoradený presne tak, ako sa pýtame: najprv stav, potom dátum od najnovšieho. PostgreSQL nájde prvých 50 riadkov daného stavu priamo v indexe a ďalej nečíta. Nemusí prechádzať celú tabuľku ani nič triediť.
Test zlyhá pri prvom novom poli v šablóne, ktoré by vzor N+1 vrátilo, teda skôr, než to zistí sklad.
Čo to nerieši: stránkovanie potrebuje celkový počet objednávok v danom stave a ten COUNT(*) stále rastie s ich počtom, hoci s indexom je lacnejší. Ostatné stránky administrácie neboli preverené.
07Overenie
EXPLAIN (ANALYZE, BUFFERS)teraz ukazuje Index Scan cezorder_status_created_idxs obmedzením na 50 riadkov namiesto prechodu celou tabuľkou.- django-debug-toolbar ukazuje rovnaký malý počet dopytov pri 5 aj pri 50 objednávkach a test to overuje pri každej zmene.
pg_stat_statementspo vynulovaní štatistík a týždni prevádzky: dopyt zoznamu objednávok už nie je medzi najdrahšími.
08Výsledky
Opravené
- Počet dopytov na zoznam objednávok nerastie s počtom objednávok.
- Zoznam už neprechádza celú tabuľku a v sezóne nepadá na timeout.
Znížené riziko
- Administrácia v špičke nezaťažuje databázu, ktorú zdieľa s obchodom.
Ostáva otvorené
- Počet pre stránkovanie rastie s počtom objednávok.
- Ostatné zoznamy v administrácii čakajú na rovnakú kontrolu.
09Obmedzenia
- Každý index spomaľuje zápisy a zaberá miesto na disku. Pridávať ich treba podľa skutočných dopytov, nie do zásoby.
- Test stráži iba túto stránku. Nové stránky potrebujú vlastný.
- Kópia produkčných dát na vývoj musí mať osobné údaje nahradené, inak ide o spracúvanie, na ktoré treba právny základ a ochranu ako v produkcii.
10Poučenia
- Najprv merať, potom opravovať.
EXPLAIN ANALYZEapg_stat_statementspovedia, kde čas naozaj ide. - N+1 na vývojárskom počítači s desiatimi záznamami nevidno. Testujte na objeme, ktorý sa podobá produkcii.
- ORM pohodlne skrýva cesty do databázy.
select_relatedaprefetch_relatedpatria k písaniu zoznamov, nie k neskoršej optimalizácii. - Index na živej tabuľke vytvárajte s
CONCURRENTLY. - Test na počet dopytov je lacná poistka proti návratu chyby.
11Ďalšie kroky pre malú firmu
- 1.Zapnúť
log_min_duration_statement, aby sa pomalé dopyty zapisovali do logu samy. - 2.Raz za mesiac pozrieť najdrahšie dopyty v
pg_stat_statements. - 3.Preveriť rovnakým postupom ostatné zoznamy v administrácii.
- 4.Zvážiť nástroj na meranie výkonu aplikácie, ak sa obchod ďalej rozrastie.
Spoznávate v tom svoju firmu?
Napíšte nám, čoho sa to týka. Ozveme sa do jedného pracovného dňa a povieme, či je to práca pre nás, aj keď odpoveď bude nie.