Backend

Boost wydajności w Ruby on Rails dzięki technice batchingu

W tym poście chciałbym przybliżyć pojęcie batchingu i pokazać jak w prosty sposób wykorzystać batch loading w Railsowych aplikacjach, aby zmniejszyć liczbę zapytań do bazy danych.


Jan Bajena. Absolwent informatyki na Politechnice Warszawskiej. Od 2014 roku zakochany (z wzajemnością) w języku Ruby. Od 3 lat pracuje zdalnie jako fullstack developer w fińskiej firmie Leadfeeder, rozwijając oprogramowanie wspomagające sprzedaż B2B. Swoje prywatne projekty i przemyślenia na temat programowania publikuje na blogu MyCodeWorksIKnowWhy.wordpress.com. Natłok myśli po godzinach uspokaja ćwicząc CrossFit i gimnastykę sportową.


Problem N+1 zapytań

Ruby on Rails to framework, który zyskał ogromną popularność dzięki temu, że umożliwia bardzo szybkie prototypowanie aplikacji. Dosłownie w przeciągu kilku godzin możemy napisać MVP naszego produktu. Wszystko działa jak należy, klient jest zadowolony więc dostajemy zlecenia na kolejne ficzery i usprawnienia. Z zapałem dodajemy kolejne tabele do bazy danych, akcje w kontrolerach i widoki, jednak w pewnym momencie zaczynamy zauważać, że aplikacja zaczyna mulić przy każdym przeładowaniu strony.

Bardzo często okazuje się, że przyczyna problemu leży w nieoptymalnym sposobie, w jaki pobieramy dane z bazy danych. Przykładowo, załóżmy, że nasza aplikacja ma dwie tabele: users i posts i wykonamy następujący kod:

posts = Post.where(id: [1, 2, 3])
# SELECT * FROM posts WHERE id IN (1, 2, 3)

users = posts.map { |post| post.user }
# SELECT * FROM users WHERE id = 1
# SELECT * FROM users WHERE id = 2
# SELECT * FROM users WHERE id = 3

ActiveRecord wykonało jedno zapytanie do bazy danych, aby pobrać listę postów, a następnie kolejne 3 zapytania, aby dla każdego z postów pobrać autora. Baza danych została obciążona aż czterema zapytaniami, mimo że ten sam efekt można było osiągnąć za pomocą dwóch – jednego w celu pobrania postów i kolejnego po użytkowników, znając ich IDki. Ten problem jest nazywany problemem N+1 zapytań (N+1 queries problem).

Typowe metody rozwiązywania problemu N+1 zapytań w Rails

W Railsach do walki z problemem N+1 zapytań często wykorzystuje się następujące metody:

Metoda „includes”

Użycie metody includes spowoduje automatyczne załadowanie rekordów dla wskazanej w argumencie relacji (w tym wypadku jest to „user”).

To rozwiązanie sprawdza się bardzo dobrze w przypadku prostych akcji, jednak jej użycie może prowadzić do załadowania zbędnych danych (np. kiedy potrzebujemy użytkowników tylko dla postów z id 1,2). Z tego powodu warto pamiętać, że includes nie jest tzw. golden bullet i używać go rozsądnie. Poniżej przykład użycia metody:

posts = Post.where(id: [1, 2, 3]).includes(:user)
# SELECT * FROM posts WHERE id IN (1, 2, 3)
# SELECT * FROM users WHERE id IN (1, 2, 3)

users = posts.map { |post| post.user }

Metoda „joins”

Jeżeli rekordy relacji są nam potrzebne jedynie w celu przefiltrowania danych dobrym pomysłem może być użycie metody joins, będącej po prostu Railsowym odpowiednikiem SQL-owego JOIN-a.

Poniższy przykład pokazuje wykorzystanie rekordów z tabeli „users” bez konieczności ładowania danych do pamięci:

Post.joins(:users).where(users: { country_code: 'PL' }).map { |post| post.title }   

# SELECT  "posts".* FROM "posts" INNER JOIN "users" ON "posts"."user_id" = "users"."id" WHERE "users"."country_code" = $1

Wadą zarówno metody „joins”, jak „includes” jest to, że mogą one generować nieelastyczny kod odpowiedzialny za ładowanie danych, który uzależniony będzie od tego, jak wyglądają np. nasze widoki.

Przykładowo, chcąc wyświetlić listę użytkowników z postami i komentarzami do każdego z nich musimy zadeklarować chęć załadowania za jednym zamachem wszystkich relacji. W razie potrzeby zmiany widoków będziemy musieli przerabiać też kod odpowiedzialny za ładowanie danych.

Batching to the rescue!

Mimo że wyżej wymienione są użyteczne w niektórych scenariuszach, to często w „prawdziwym świecie” okazują się niewystarczające, z racji tego, że mogą powodować ładowanie zbędnych danych lub generowanie kodu spaghetti.

Alternatywą dla tych rozwiązań jest tytułowy „batching” – technika, która rozprzestrzeniła się w dużej mierze dzięki Facebookowi i ich standardzie GraphQL.

„Batching” można przedstawić jako proces składający się z trzech kroków:

1. Etap zbierania danych do załadowania – aplikacja gromadzi ID rekordów wymaganych np. do wyrenderowania widoku. Nawiązując do poprzednich przykładów mogą to być ID autorów poszczególnych postów.

2. Etap ładowania i cache’owania – po zgromadzeniu potrzebnych ID rekordów odpowiednie dane są ładowane za pomocą jednego zapytania i cache’owane w pamięci, w celu uniknięcia ponownego zapytania do bazy danych.

3. Etap dystrybucji danych – odpowiednie rekordy są przekazane do miejsc w kodzie, które zgłosiły po nie potrzebę.

Dużą zaletą takiego podejścia jest to, że pozwala załadować dokładnie te dane, które są nam potrzebne w danym momencie i zwalnia nas z przymusu deklaracji wszystkich potrzebnych relacji w jednym miejscu.

Dodatkową korzyścią z poznania tej techniki jest fakt, że znajduje zastosowanie nie tylko w SQL-owych bazach danych, ale również bazach NO-SQL (np. Mongo, Cassandra). Co więcej, oprócz optymalizacji zapytań do bazy danych można jej też użyć np. do ograniczenia liczby zapytań HTTP do zewnętrznych serwisów.

Batching w Ruby

Osobom zainteresowanym wykorzystaniem tej techniki w aplikacjach napisanych w Rubym polecam zapoznanie się z gemem Batch Loader. Na początku koncept klasy BatchLoader może wydać się nieco nieintuicyjny, ale gwarantuję, że widok malejących logów zapytań do bazy danych będzie wart wysiłku.

Przyjrzyjmy się, w jaki sposób BatchLoader pozwala wyeliminować problem N+1 zapytań:

# app/models/post.rb
def user_lazy
  BatchLoader.for(user_id).batch do |user_ids|
    User.where(id: user_ids)
  end
end

posts = Post.where(id: [1, 2, 3]) # SELECT * FROM posts WHERE id IN (1, 2, 3)

users = posts.map { |post| post.user_lazy }

users.each { |user| puts "#{user.name}" } # SELECT * FROM users WHERE id IN (1, 2, 3)

Wywołanie metody „user_lazy” na każdym z postów pozwoliło Batch Loaderowi na zgromadzenie ID wszystkich potrzebnych użytkowników. Odpytanie bazy danych zostało odroczone, dzięki leniwej ewaluacji, aż do momentu, w którym atrybuty poszczególnych użytkowników są niezbędne do wyświetlenia. Dopiero wtedy BatchLoader pobiera za jednym zamachem trzy rekordy z tabeli „users”.

Oprócz zapobiegania N+1 zapytaniom BatchLoader posiada również cache zapobiegający ponownemu załadowaniu rekordów, które zostały załadowane wcześniej:

users = posts.map { |post| post.user_lazy }

users.each { |user| puts "#{user.name}" } # SELECT * FROM users WHERE id IN (1, 2, 3)
users.each { |user| puts "#{user.address}" } # Pobierze użytkowników z cache'u

Batching w Rails-owym REST API

BatchLoader wydał mi się bardzo użytecznym narzędziem, dlatego postanowiłem wykorzystać go w celu optymalizacji Railsowej aplikacji wystawiającej dość rozbudowane JSON API renderowane za pomocą popularnej biblioteki ActiveModel::Serializers.

Pomysł okazał się trafiony i aplikacja dostała wyraźnego kopa, jednak przy każdym kolejnym użyciu BatchLoadera zaczęła przeszkadzać mi ilość boilerplate code’u, czyli powtarzalnego kodu niezbędnego do dodania nowego batch loadera.

Wiele z serializerów powtarzało podobne schematy, jak na przykład ten:

class PostSerializer < ActiveModel::Serializer  
  attributes :id, :title

  belongs_to :user

  def self.lazy_user(post)
    BatchLoader.for(post.user_id).batch do |user_ids|
      User.where(id: user_ids)
    end
  end

  def author
    self.class.lazy_user(object)
  end
end

Eliminowanie powtarzającego się kodu doprowadziło do tego, że postanowiłem opakować całość w postaci gema i udostępnić społeczności jako ams_lazy_relationships.

Gem ten jest rozszerzeniem do ActiveModel::Serializers i umożliwia korzystanie z batchingu w serializerach za pomocą metod lazy_has_many/lazy_has_one/lazy_belongs_to.

Przykładowo, wcześniejszy serializer zmodyfikowany tak, by korzystać z ams_lazy_relationships będzie wyglądał następująco:

class BaseSerializer < ActiveModel::Serializer
  include AmsLazyRelationships::Core
end

class PostSerializer < BaseSerializer
  attributes :id, :title

  lazy_belongs_to :user
end

Tym prostym sposobem możemy uniknąć N+1 zapytań podczas serializacji nawet bardzo skomplikowanych obiektów.

Gem działa out-of-the-box z ActiveRecord-owymi SQL-owymi relacjami, ale można go użyć również do batch loadingu danych z różnych źródeł (ja np. korzystam do ładowania danych z Cassandry i MySQL-a).

Więcej przykładów i dokładniejsze objaśnienia można znaleźć w READMEgema albo w tym poście.

Podsumowanie

Mam nadzieję, że mój post zachęci kogoś z czytelników do zgłębienia idei batchingu. Myślę że warto ten koncept, znany głównie w kręgu ludzi zajmujących się technologią GraphQL, zaadaptować również w aplikacjach wykorzystujących stare dobre REST API, czy server-side rendering. Zapraszam do komentowania i zadawania pytań.


najwięcej ofert html

Artykuł został pierwotnie opubikowany na mycodeworksiknowwhy.wordpress.com.

 

Podobne artykuły

[wpdevart_facebook_comment curent_url="https://geek.justjoin.it/boost-wydajnosci-ruby-rails-dzieki-technice-batchingu/" order_type="social" width="100%" count_of_comments="8" ]