Skip to content

perf(admin): cztery N+1 na changelistach — mierzone na kopii produkcji, bez Django 6.1 - #738

Merged
mpasternak merged 2 commits into
devfrom
perf/admin-select-related
Aug 7, 2026
Merged

perf(admin): cztery N+1 na changelistach — mierzone na kopii produkcji, bez Django 6.1#738
mpasternak merged 2 commits into
devfrom
perf/admin-select-related

Conversation

@mpasternak

@mpasternak mpasternak commented Aug 7, 2026

Copy link
Copy Markdown
Member

Cztery N+1 w adminie, znalezione pomiarem na kopii bazy produkcyjnej
(68 tys. autorów, 491 jednostek), nie lekturą kodu. Wszystkie to czysty
select_relateddziałają na Django 5.2 i nie czekają na upgrade do 6.1.

Wynik

Mierzone przez bench/bench_orm.py --pomiar w wariancie bench_prod
(produkcyjne reguły CACHEOPS), Django 5.2.16:

changelist przed po dla porównania: 6.1 + FETCH_PEERS
jednostki 66 16 17
wyd. zwarte 70 35 36
autorzy 102* 45* 102*
źródła (KONTROLER, nietknięty) 16 16 16

* changelista autorów mierzona bez reguł cacheops — z nimi ma 27 zapytań
przed i po, bo bpp.uczelnia jest cache'owana i pobrania idą do Redisa
zamiast do PostgreSQL. Licznik SQL ich nie widzi, zegar widzi.

Warto odnotować: na 5.2 wychodzimy o jedno zapytanie lepiej niż 6.1
z FETCH_PEERS. FETCH_PEERS musi dorzucić jedno zapytanie hurtowe na
relację, a select_related załatwia to JOIN-em.

Co konkretnie

  1. AutorAdminlist_select_related dla kolumny aktualna_jednostka
    ciągnął jednostkę i jej wydział, ale nie uczelnię, a Jednostka.__str__
    czyta self.uczelnia.uzywaj_wydzialow. 50 zapytań na stronę 50 autorów.
    Kolumna jest opcjonalna (list_display_allowed), a get_list_select_related
    dokłada JOIN-y tylko dla kolumn aktualnie widocznych — więc poprawka nic
    nie kosztuje, gdy kolumna jest schowana.

  2. WydzialAutoraFilter.lookups — buduje listę rozwijaną przez str(j)
    w pętli, bez select_related("uczelnia"). Jedno zapytanie na pozycję.

  3. JednostkaAdmin (z Bramka regresyjna na N+1 w adminie (FETCH_RAISE) + dwa realne N+1 #736)ChangeList.get_queryset aplikuje
    list_select_related tylko gdy queryset bazowy nie ma jeszcze żadnego
    select_related, a JednostkaManager dokłada select_related("wydzial")
    — więc cała deklaracja przepadała.

  4. Wydawnictwo_ZwarteAdmin (z Bramka regresyjna na N+1 w adminie (FETCH_RAISE) + dwa realne N+1 #736) — kolumna wydawnictwo
    (domyślnie widoczna) czyta self.wydawca.nazwa przez property, a wydawca
    był zmapowany wyłącznie na jawną kolumnę wydawca.

Relacja do #736 (zmergowany do django-6.1)

Pozycje 3 i 4 pochodzą z #736, który celował w django-6.1 i został tam
w międzyczasie zmergowany
(5635e0394). Same poprawki nie wymagają 6.1,
więc trafiają tu, żeby użytkownicy 5.2 nie czekali na scalenie całego
upgrade'u. Bramka FETCH_RAISE z #736 zostaje na django-6.1 — ona
faktycznie wymaga fetch modes; tu zastępują ją bramki działające na 5.2.

Skutek uboczny: te dwa pliki są chwilowo w obu gałęziach. Treść jest
identyczna poza jednym docstringiem w jednostka.py (przekierowany na test
działający na 5.2), więc przy scalaniu django-6.1dev będzie co
najwyżej drobny konflikt w komentarzu.

Testy

src/bpp/tests/test_admin/test_admin_select_related.py — cztery bramki
przybijające niezmiennik „liczba zapytań nie rośnie z liczbą wierszy",
a nie konkretną liczbę zapytań (taka asercja pękałaby przy każdej
niezwiązanej zmianie). Każda zweryfikowana pod kątem tego, że bez
poprawki faktycznie płonie
: Uczelnia 2→6, RodzajJednostki 2→7,
Wydawca 1→6.

src/bpp/tests/test_admin/ + src/bpp/tests/test_views/: 1016 passed,
1 skipped (łącznie z testami Playwright).

🤖 Generated with Claude Code

https://claude.ai/code/session_01PjoMtF6KAaJydhGEGibgV7

mpasternak and others added 2 commits August 7, 2026 16:32
Znalezione pomiarem na kopii bazy produkcyjnej (68 tys. autorow),
nie lektura kodu. Oba sprowadzaja sie do relacji dotykanej per wiersz,
ktorej nikt nie zadeklarowal w select_related.

1. AutorAdmin.list_select_related dla kolumny "aktualna_jednostka"
   ciagnal sama jednostke i jej wydzial, ale NIE uczelnie. Jednostka.__str__
   czyta self.uczelnia.uzywaj_wydzialow, zeby zdecydowac, czy dokleic nazwe
   wydzialu -- czyli jeden SELECT na kazdy wiersz. Zmierzone: 50 zapytan
   po bpp_uczelnia na stronie 50 autorow.

2. WydzialAutoraFilter.lookups buduje liste rozwijana przez str(j) w petli,
   bez select_related("uczelnia") -- jedno zapytanie na kazda pozycje.

Changelista autorow: 102 -> 45 zapytan (mierzone bez regul CACHEOPS).

Dlaczego licznik zapytan tego NIE pokazywal: produkcyjne reguly CACHEOPS
cache'uja bpp.uczelnia, wiec te pobrania nie ida do PostgreSQL, tylko
zamieniaja sie w round-tripy do Redisa. W wariancie bench_prod changelista
autorow ma 27 zapytan przed i po -- caly koszt widac dopiero na zegarze.
Dlatego testy mierza wariant bez cacheops, gdzie N+1 jest widoczne wprost.

Kolumna "aktualna_jednostka" jest opcjonalna (list_display_allowed,
wlaczana per uzytkownik przez dynamic_admin_columns), a
get_list_select_related dokłada JOIN-y tylko dla kolumn aktualnie
widocznych -- wiec poprawka nic nie kosztuje, gdy kolumna jest schowana.

Testy przybijaja niezmiennik "liczba zapytan nie rosnie z liczba wierszy",
a nie konkretna liczbe zapytan -- taka asercja pekalaby przy kazdej
niezwiazanej zmianie. Zweryfikowane, ze bez poprawek oba padaja.

Zmiany sa czystym ORM-em i nie wymagaja Django 6.1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjoMtF6KAaJydhGEGibgV7
Przeniesione z PR #736 (gałąź feat/fetch-raise-gate), ktory celowal
w django-6.1. Same poprawki to czysty select_related i NIE wymagaja
Django 6.1 -- trzymanie ich na gałęzi 6.1 opoznialoby je do czasu
scalenia calego upgrade'u. Bramka FETCH_RAISE z tamtego PR-a zostaje
na django-6.1, bo ona faktycznie wymaga fetch modes.

1. JednostkaAdmin: ChangeList.get_queryset aplikuje list_select_related
   TYLKO gdy queryset bazowy nie ma jeszcze zadnego select_related.
   JednostkaManager.get_queryset() dokłada select_related("wydzial"),
   wiec warunek byl falszywy i CALA deklaracja przepadala -- rodzaj
   i uczelnia dociagaly sie per wiersz. Wymuszamy je wprost.

2. Wydawnictwo_ZwarteAdmin: kolumna "wydawnictwo" (domyslnie widoczna)
   to property czytajace self.wydawca.nazwa, ale list_select_related
   mapowalo wydawce wylacznie na jawna kolumne "wydawca" -- czyli JOIN
   praktycznie nigdy nie wchodzil.

Zmierzone na kopii bazy produkcyjnej (Django 5.2, produkcyjne CACHEOPS):

  changelist jednostek      66 -> 16 zapytan
  changelist wyd. zwartych  70 -> 35 zapytan
  changelist zrodel (kontroler, nietkniety)  16 -> 16

Dla porownania: te same changelisty na django-6.1 z FETCH_PEERS daja
17 i 36 zapytan, czyli o jedno wiecej. FETCH_PEERS musi dorzucic jedno
zapytanie hurtowe na relacje, a select_related zalatwia to JOIN-em.

Dolozone bramki regresyjne dzialajace na 5.2 (test_admin_select_related):
przybijaja niezmiennik "liczba zapytan nie rosnie z liczba wierszy",
a nie konkretna liczbe. Zweryfikowane, ze bez poprawek padaja
(RodzajJednostki 2->7, Wydawca 1->6).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjoMtF6KAaJydhGEGibgV7
mpasternak added a commit that referenced this pull request Aug 7, 2026
Pomiar wykonany na cichym hoscie, na kopii bazy produkcyjnej. Kryterium
z handoffu spelnione: kontroler "admin: zrodlo (changelist)" ma 16 zapytan
we wszystkich czterech przebiegach, rozrzut 3,2 ms = 1,1 % mediany, zero
flag SZUM. Na mac-mini ten sam kontroler skakal 320 -> 420 ms, przez co
tamten przebieg byl do wyrzucenia.

Docstring BaseBppAdminMixin.get_queryset podawal 40 i 38 zapytan oraz
czasy opisane jako rzad wielkosci -- podmienione na zmierzone (36 i 34).
Dopisane dwie rzeczy, ktore pomiar ujawnil, a ktore zmieniaja uzasadnienie
FETCH_PEERS:

* changelist autorow ma 27 zapytan przed i po, a czas spada o 68 % --
  bo dotykane relacje sa cache'owane przez CACHEOPS i koszt to round-tripy
  do Redisa, niewidoczne dla licznika SQL. Sam ten spadek pochodzi zreszta
  glownie z poprawek filtrow na dev (5ffc83f), nie z FETCH_PEERS.
* PR #738 dokłada na dev jawne select_related dla tych samych relacji
  i schodzi do 16 i 35 zapytan -- o JEDNO mniej niz FETCH_PEERS, bo ten
  musi dorzucic zapytanie hurtowe na relacje, a JOIN nie. Po scaleniu te
  pozycje przestana byc argumentem za FETCH_PEERS; zostaje wlasciwy:
  siatka bezpieczenstwa na relacje, ktorych nikt nie zadeklarowal.

Handoff przestawiony na ZAMKNIETY, z tabela czasow, rozbiciem 5.2 vs 6.1
i nowa pulapka: bench.py ustawia porty przez os.environ.setdefault(), co
NIE nadpisuje zmiennej juz wyeksportowanej przez profil shella -- benchmark
po cichu uderza wtedy w dev-owe kontenery zamiast w odtworzony dump.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjoMtF6KAaJydhGEGibgV7
@mpasternak
mpasternak merged commit a343561 into dev Aug 7, 2026
22 checks passed
@mpasternak
mpasternak deleted the perf/admin-select-related branch August 7, 2026 14:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant