Skip to content
← Wszystkie wpisy
4 min czytania Dawid Skłodowski

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:

  • preload zawsze używa dwóch osobnych zapytań: jednego o posty, jednego WHERE author_id IN (...) o wszystkich autorów. Czyste i przewidywalne, ale nie możesz odnosić się do powiązanej tabeli w WHERE ani ORDER BY, bo nigdy nie była joinowana.
  • eager_load zawsze 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 }).
  • includes to mądry domyślny wybór: normalnie zachowuje się jak preload (dwa zapytania), ale jeśli wykryje, że odnosisz się do asocjacji w where lub order, automatycznie przełącza się na eager_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ć.