Ruby on Rails :include i ‘eager loading’ w akcji

eager loadingJako że ostatnie kilka tekstów było natury dywagacyjnej, a Ruby on Rails było poruszone tylko pobieżnie i niejako powierzchownie, tym razem będzie bardziej „technicznie”.

Nie czytam literatury dotyczącej RoR w języku innym, niż angielski, więc większość terminów będzie po angielsku, co mam nadzieję nie obrazi żadnych purystów językowych. Jako że nie znalazłem polskiego odpowiednika „eager loading„, będę się więc posługiwał taką właśnie formą. No chyba że bym się trochę wysilił z kreatywnością i przynajmniej spróbował to przetłumaczyć.
Może więc „chętne ładowanie”?
Albo – co brzmi dużo bardziej interesująco i ekstrawagancko – „skwapliwe ładowanie„?

Czym jest więc skwapliwe ładowanie? (Spodobał mi się ten termin, więc będę go stosował naprzemiennie z jego anglojęzycznym odpowiednikiem).

Otóż Rails daje nam proste i skuteczne narzędzia do tworzenia aplikacji internetowych. Jednakże prostota i skuteczność często pociąga za sobą mniejszą skłonność do myślenia i wręcz lenistwo.

Może więc na początek szybki quiz w stylu 1 z 10, kiedy na odpowiedź ma się około 5 sekund.
Sznuk: Kiedy stosuje się eager loading?
Programista RoR: Eager loading jest stosowany kiedy mamy do czynienia z powiązanymi modelami.

Sznuk: Do czego służy eager loading?
Programista RoR: Służy do zmniejszenia liczby zapytań do bazy danych i tym samym do usprawnienia i przyśpieszenia aplikacji. [ding!] Przynajmniej w większości przypadków. (Ostatnie zdanie, mimo iż prawdziwe, było już po „ding!”, sygnalizującym koniec czasu na odpowiedź, jednakże odpowiedź zaliczona).

Załóżmy więc, że mamy restaurację i w niej kilku dobrych kucharzy, którzy robą świetne dania i używają do tego swoich ulubionych składników

class Cook
  has_many :dishes
  has_many :ingredients, :through => :dishes

class Dish
  belongs_to :cook
  has_many :ingredients

class Ingredient
  belongs_to :dish

Jeśli więc chcielibyśmy sobie sprawdzić jakich to składników używa każdy z kucharzy (i dlaczego schodzi tak dużo rumu mimo iż nie jest składnikiem prawie żadnej potrawy), to będąc leniwymi programistami możemy sobie upichcić takie oto zapytanko:

@cooks = Cook.find(:all)

Później w widoku (view) pokażemy te składniki dla każdego z kucharzy:

<% for cook in @cooks %>
  <%= cook.name %>:<br />
  <% cook.ingredients.each do |i| %>
    <%= i.name %>
  <% end %>
<% end %>

I nic nie będzie w tym złego. Bo w końcu mamy tylko jedną restaurację, więc najwyżej kilku kucharzy. Nic więc strasznego, że dla każdej iteracji będzie generowane nowe zapytanie do bazy, żeby wydobyć listę składników. Jednakże jeśli byśmy mieli sieć restauracji w całej Europie i chcielibyśmy wiedzieć który kucharz spośród nich wszystkich używa więcej chili, niż przeciętny kucharz mąki, to już mielibyśmy problem, bo przy tak dużej ilości kucharzy nasza baza danych nie byłaby tak szybka w zaserwowaniu nam pysznych wyników.
Jednakże skwapliwe ładowanie stoi tuż za rogiem, machając nieśmiało ręką i próbując zwrócić na siebie uwagę. Przywołujemy więc je, pokazujemy mielącą dyskami i RAMem bazę danych i mówimy: „Zrób z tym coś”. Skwapliwe zabrawszy się za robotę, eager loading w końcu daje nam coś tak idealnie prostego, aczkolwiek skutecznego:

@cooks = Cook.find(:all, :include => :ingredients)
@cooks = Cook.find(:all, :include => [:dishes, :ingredients])

Skutkuje to tym, że do bazy danych zostaje wystosowane jedno jedyne, długie jak spagetti zapytanie, które wszystkie potrzebne nam dane wyciąga za jednym zamachem. Przykładowe zapytanie (z kategorii innej niż kuchenna, ale żeby zobrazować długość możliwego zapytania) może być np. takie jak poniżej.
Jest to zapytanie wygenerowane przez ten niewinny kod:

@user_categories = current_user.categories.find(:all,
                :include => [ :children => [ :children, :parent ]], :order => "categories.name")

SELECT `categories`.`id` AS t0_r0,
`categories`.`cat_type` AS t0_r1,
`categories`.`name` AS t0_r2,
`categories`.`description` AS t0_r3,
`categories`.`created_at` AS t0_r4,
`categories`.`updated_at` AS t0_r5,
`categories`.`parent_id` AS t0_r6,
`categories`.`user_id` AS t0_r7,
`childrens_categories`.`id` AS t1_r0,
`childrens_categories`.`cat_type` AS t1_r1,
`childrens_categories`.`name` AS t1_r2,
`childrens_categories`.`description` AS t1_r3,
`childrens_categories`.`created_at` AS t1_r4,
`childrens_categories`.`updated_at` AS t1_r5,
`childrens_categories`.`parent_id` AS t1_r6,
`childrens_categories`.`user_id` AS t1_r7,
`childrens_categories_2`.`id` AS t2_r0,
`childrens_categories_2`.`cat_type` AS t2_r1,
`childrens_categories_2`.`name` AS t2_r2,
`childrens_categories_2`.`description` AS t2_r3,
`childrens_categories_2`.`created_at` AS t2_r4,
`childrens_categories_2`.`updated_at` AS t2_r5,
`childrens_categories_2`.`parent_id` AS t2_r6,
`childrens_categories_2`.`user_id` AS t2_r7,
`parents_categories`.`id` AS t3_r0,
`parents_categories`.`cat_type` AS t3_r1,
`parents_categories`.`name` AS t3_r2,
`parents_categories`.`description` AS t3_r3,
`parents_categories`.`created_at` AS t3_r4,
`parents_categories`.`updated_at` AS t3_r5,
`parents_categories`.`parent_id` AS t3_r6,
`parents_categories`.`user_id` AS t3_r7
FROM `categories`
LEFT OUTER JOIN `categories` childrens_categories ON childrens_categories.parent_id = categories.id
LEFT OUTER JOIN `categories` childrens_categories_2 ON childrens_categories_2.parent_id = childrens_categories.id
LEFT OUTER JOIN `categories` parents_categories ON `parents_categories`.id = `childrens_categories`.parent_id
WHERE (categories.user_id = 3)
ORDER BY categories.name

Przy większej ilości danych średnia poprawa wydajności (nie według żadnych benchmarków, a według moich własnych i często subiektywnych obserwacji) jest co najmniej 2-3-krotna. Iterować po takich wynikach możemy dokładnie tak samo jak w poprzednim przypadku, tyle że tym razem już nie będą wysyłane żadne dodatkowe zapytania do bazy.

Sznuk: Pytanie finałowe dla Pana: czego nie potrafi eager loading?
Programista RoR: Eager loading nie potrafi gotować, prać, robić masażu i wybierać tylko poszczególne pola poprzez :select, bo tak bardzo jest skwapliwy, że [ding!] ładuje wszystko…
Sznuk: To jest poprawna odpowiedź! Gratuluję Panu wygranej w naszym programie. Wygrał Pan darmowe skwapliwe ładowanie trzech ciężarówek w malowniczym porcie w Gdyni. Pozdrawiam i do zobaczenia!

Trochę odbiegając już od tematu, jednak potwierdzę, że w przypadku eager loading opcja :select jest stanowczo i ostentacyjnie ignorowana. Jeśli nam jednak zależy na tej opcji, to możemy zrezygnować z :include na rzecz :joins, uzyskując pożądany wynik, aczkolwiek w trochę mniej elegancki sposób.

@posts = Post.find(:all,
:select => "posts.id, posts.title, posts.subject, users.username",
:joins => "left outer join users on users.id = posts.user_id")

Nie bądźmy więc leniwi bardziej niż to konieczne lub zdrowe i używajmy częściej skwapliwego ładowania, a baza danych będzie nam za to wdzięczna i w przyszłości, jak będziemy bardzo potrzebować jej dobrej pracy, spojrzy na nas łaskawszym okiem, westchnie głęboko i się zabierze za ładowanie.

Liczba komentarzy: 17 ↓

#1 oki 06.19.08 o 14:53

Jakiej wersji Railsów używasz?

#2 Sabon 06.19.08 o 14:57

Teraz się przesiadłem na 2.1 i w tym właśnie robię swój najnowszy serwis.
Poprzednie robiłem w 2.0.x, a jeszcze poprzednie w 1.2.5

#3 oki 06.19.08 o 15:00

A „eager loading” testowales na jakiej wersji?

#4 Sabon 06.19.08 o 15:01

A choćby na 2.1

#5 oki 06.19.08 o 15:14

Nie zawsze wszystko jest wyciagane za pomoca jednego selecta, np:

class User < ActiveRecord::Base
has_many :news
end

class News News.find(:all, :include => :user)
SQL (0.000148) SET NAMES ‘utf8′
SQL (0.000133) SET SQL_AUTO_IS_NULL=0
News Load (0.000302) SELECT * FROM `news`
News Columns (0.001854) SHOW FIELDS FROM `news`
User Load (0.000341) SELECT * FROM `users` WHERE (`users`.id IN (’1′))
User Columns (0.001748) SHOW FIELDS FROM `users`
=> [#]

Czasami Railsy uznaja ze szybciej bedzie zrobic np dwa selecty :)

#6 oki 06.19.08 o 15:14

uops, cos sie wywalilo z formatowaniem…

#7 Sabon 06.19.08 o 15:16

Dokładnie tak. Bodajże w wersji 2.1 zostało to dodane, że dla prostych i szybszych wyników są generowane 2 zapytania zamiast jednego.

Stąd też w tekście było zdanie: „Eager loading służy do zmniejszenia liczby zapytań do bazy danych i tym samym do usprawnienia i przyśpieszenia aplikacji. Przynajmniej w większości przypadków.”
To jest właśnie ten „niektóry przypadek” :)

#8 oki 06.19.08 o 15:22

Co do zapisu:
@cooks = Cook.find(:all)
bardziej zwiezle
@cooks = Cook.all

@cooks = Cook.find(:all, :include => :ingredients)
analogicznie:
@cooks = Cook.all(:include => :ingredients)

Duzo przykladow i zmian w Rails 2.1 zostalo opisanych w:
http://www.nomedojogo.com/2008/06/15/ruby-on-rails-21-whats-new-second-edition/

#9 Radarek 06.19.08 o 15:22

Dyskutowaliśmy o tym na ircu. Teraz przy eager loading zamiast jednego wielkiego zapytania jest bodajże N+1 zapytań gdzie N = liczba tabel, które łączymy. Ma to dosyć spore znaczenie zważywszy, że redundancja danych przy iloczynie kartezjańskim wielu tabel jest ogromna.

#10 Sabon 06.19.08 o 15:25

@oki
Dzięki za info. Mam ten pdf ściągnięty i część już przejrzałem, ale do tego jeszcze nie doszedłem. Małe, ale jednak udogodnienie. Zacznę stosować.

@radarek
N+1 przy N = liczba tabel to ciągle o wiele lepiej, niż N+1, gdzie N = liczba iteracji :)

#11 Radarek 06.19.08 o 15:30

@sabon: no tak, tutaj N+1 zapytań jest szybsze od tego jednego wielkiego, a co dopiero od M+1 zapytań (gdzie M = liczba iteracji). Nie zawsze mniejsza liczba zapytań wykona się szybciej.

#12 Sabon 06.19.08 o 15:34

Podsumowując:
* Nie zawsze mniejsza liczba zapytań jest szybsza i wydajniejsza
* Eager loading to nie zawsze jedno duże zapytanie
* Tak czy owak, warto jak najczęściej używać eager loading, bo ma to sens.
I już :)

#13 Mitsu 06.19.08 o 16:03

Przy wykorzystaniu tej mniej eleganckiej opcji :joins do połączenia np. 4 tabel, również dostajemy więcej zapytań czy jedno duże bez optymalizacji ?

#14 Sabon 06.19.08 o 17:13

Według mnie daje to tylko jedną tabelę.

#15 Seban 06.19.08 o 18:01

W 2.1 jeśli łączy się inlude z conditions lub jeśli mamy coś w stylu :include => { :operations => :group } (taki złożony include) to będzie jedno długie zapytanie tak jak pisał Sabon. Akita http://www.akitaonrails.com/2008/5/26/rolling-with-rails-2-1-the-first-full-tutorial-part-2 wyjaśnił dlaczego zachowanie eager loading zmienione zostało w 2.1.
Zawsze lubilem 1 z 10 :)

#16 Sabon 06.19.08 o 18:07

Seban, dzięki za linka. Jakimś trafem go wcześniej przeoczyłem.
Aż tak głęboko nie wchodziłem w temat eager loading, ale teraz już wiem kolejnych kilka nowych rzeczy z tematyki „dlaczego?” i „jak to działa?”

Też lubiłem 1 z 10. A teraz nie mam po prostu telewizora. Co zresztą bardzo sobie chwalę.

#17 Paweł Kondzior 06.19.08 o 19:06

Od kilku dni właśnie zastanawiałem się jak wygląda sprawa sortowania po join’owanych tabelach w Rails 2.1. Fajnie ze rozwaizali to w ten sposob i ze sie bez problemu da.

Zostaw swój komentarz