Tropienie i zabijanie zapytań N+1
Zapytanie N+1 to najczęstszy błąd wydajnościowy w aplikacjach Rails i ukrywa się idealnie w developmencie. Deep-dive o tym, co je powoduje, różnicy między preload/eager_load/includes, counter cache i jak łapać je automatycznie.
Jeśli sprofilujesz wolną stronę Rails, szanse są przytłaczające, że winowajcą jest zapytanie N+1. To najczęstszy błąd wydajnościowy w Railsach i najokrutniejszy, bo jest niewidoczny w developmencie — z dwudziestoma rekordami na laptopie strona wydaje się natychmiastowa, a dopiero na produkcji, z dwudziestoma tysiącami, pełza. Oto co go powoduje, jak go naprawić porządnie i jak upewnić się, że nigdy się nie wkradnie z powrotem.
Czym N+1 właściwie jest
Błąd to pętla, która wyzwala zapytanie do bazy na każdej iteracji. Rozważ renderowanie listy postów z nazwiskiem autora każdego:
@posts = Post.all # 1 zapytanie: SELECT * FROM posts
@posts.each do |post|
puts post.author.name # 1 zapytanie KAŻDORAZOWO: SELECT * FROM authors WHERE id = ?
end
To 1 zapytanie o posty plus N kolejnych — po jednym na post — by pobrać
każdego autora. Stąd „N+1”. Z 20 postami to 21 zapytań, dość szybko, by zignorować. Z
2000 postów to 2001 zapytań, każde z własną podróżą do bazy, a twoja strona zajmuje
sekundy. Leniwe ładowanie, które czyni ActiveRecord tak wygodnym — post.author po
prostu działa — jest dokładnie tym, co ukrywa koszt: każde z tych niewinnie
wyglądających wywołań to osobne zapytanie, którego nie widzisz w kodzie.
Poprawka: eager loading
Rozwiązaniem jest powiedzenie ActiveRecord z góry, których asocjacji będziesz
potrzebować, by załadował je hurtem zamiast po jednej. Sztandarowym narzędziem jest
includes:
@posts = Post.includes(:author) # ładuje wszystkie posty I wszystkich ich autorów wydajnie
@posts.each do |post|
puts post.author.name # tu brak zapytania — już załadowane
end
Teraz nie ma żadnego zapytania per iteracja. includes(:author) pobiera autorów w
jednym dodatkowym zapytaniu (albo joinie), a post.author czyta z pamięci.
Dwadzieścia jeden zapytań staje się dwoma, a strona jest szybka przy dowolnej skali.
Dla zagnieżdżonych asocjacji przekazujesz hash:
Post.includes(author: :profile, comments: :user)
preload vs eager_load vs includes
Tu opłaca się zrozumieć, co ActiveRecord faktycznie robi, bo są trzy powiązane metody i zachowują się różnie:
preloadzawsze używa dwóch osobnych zapytań: jednego o posty, jednegoWHERE author_id IN (...)o wszystkich autorów. Czyste i przewidywalne, ale nie możesz odnosić się do powiązanej tabeli wWHEREaniORDER BY, bo nigdy nie była joinowana.eager_loadzawsze używa pojedynczego LEFT OUTER JOIN, ciągnąc wszystko w jednym zapytaniu. To czego potrzebujesz, gdy chcesz filtrować lub sortować po powiązanej tabeli —Post.eager_load(:author).where(authors: { active: true }).includesto mądry domyślny wybór: normalnie zachowuje się jakpreload(dwa zapytania), ale jeśli wykryje, że odnosisz się do asocjacji wwhereluborder, automatycznie przełącza się naeager_load(join).
Przez większość czasu includes robi właściwą rzecz i to po niego powinieneś sięgać.
Zejdź do preload lub eager_load jawnie, gdy musisz kontrolować strategię — na
przykład, gdy join kosztownie zwielokrotniłby wiersze i wolisz dwa zapytania, albo gdy
includes źle zgadnie, czy joinować.
Nie każde N+1 potrzebuje eager loading
Licznik to szczególny przypadek warty wymienienia. Jeśli potrzebujesz tylko liczby asocjacji — „23 komentarze” — eager-ładowanie każdego komentarza, by policzyć je w Ruby, jest marnotrawstwem. Counter cache przechowuje liczbę na wierszu rodzica i trzyma ją zaktualizowaną automatycznie:
class Comment < ActiveRecord::Base
belongs_to :post, counter_cache: true # utrzymuje post.comments_count
end
Teraz post.comments_count to odczyt kolumny, bez zapytania i bez załadowanych
asocjacji. Counter cache to właściwa poprawka, gdy ładujesz rekordy tylko, by je
policzyć.
Warto też powiedzieć: nie każde N+1 jest warte naprawy. Jeśli strona ładuje dokładnie jeden rekord i dotyka jednej asocjacji, „N” wynosi 1 i nie ma czego optymalizować. Przedwczesne eager-ładowanie asocjacji, których nie używasz, po prostu marnuje pamięć, ładując dane, które wyrzucasz. Naprawiaj te N+1, które są faktycznie na gorącej ścieżce z realnym N — najpierw mierz.
Łapanie tego automatycznie
Powodem, dla którego N+1 są tak uporczywe, jest to, że przechodzą każdy ręczny test na
małych danych. Poprawką jest wykrywanie ich mechanicznie. Gem bullet obserwuje
twoje zapytania w developmencie i wyskakuje z ostrzeżeniem w chwili, gdy zobaczy N+1
(i, pomocnie, gdy eager-ładujesz coś, czego nigdy nie używasz):
# config/environments/development.rb
config.after_initialize do
Bullet.enable = true
Bullet.alert = true # alert przeglądarki
Bullet.bullet_logger = true # i plik logu
end
Jeszcze lepiej, niech zawiedzie twoje testy. Uruchom Bullet w środowisku testowym i każ mu podnosić błąd na N+1, by nowe psuło CI zamiast trafiać po cichu na produkcję:
Bullet.raise = true # w konfiguracji testów — N+1 teraz zawodzi zestaw
Jeszcze lepiej, miej oko na log developmentu: ściana niemal identycznych linii
SELECT ... WHERE id = ? przewijających się przy jednym załadowaniu strony to
nieomylny odcisk palca N+1. Czytanie własnego logu zapytań to najtańsze narzędzie
wykrywania, jakie istnieje.
Werdykt
Zapytanie N+1 to błąd wydajnościowy, który będzie w twojej aplikacji — nie dlatego,
że ktoś jest nieostrożny, ale dlatego, że leniwe ładowanie ActiveRecord jest na tyle
wygodne, by ukryć go idealnie aż do momentu, gdy skala produkcji go obnaży. Lekarstwo
nie jest trudne: eager-ładuj asocjacje, po których iterujesz, przez includes,
zrozum preload vs eager_load na przypadki, gdzie strategia ma znaczenie, użyj
counter cache, gdy potrzebujesz tylko liczby, i pomiń nadgorliwe ładowanie danych,
których nie używasz. Prawdziwą wygraną jest jednak mechaniczne wykrywanie — uruchom
Bullet, zawodź testy na N+1 i obserwuj log zapytań — by błąd, który ukrywa się w
developmencie, był łapany w developmencie, gdzie jest tani, zamiast na produkcji,
gdzie jest stroną, której nikt nie może załadować.