Проблема N+1

Rails, является одним из самых популярных фреймворков для созданияMVP ( Minimum Viable Products) — это именно тот продукт, который в кратчайшие сроки можно показать или продать заказчику ). Вы можете с предельной легкостью настроить, создать каркас и задеплоить приложение, поэтому наш любимый фреймворк и для MVP.

Обычно, разрабатывая подобные приложения(прототипы), большая часть разработчиков не задумывается об индексах производительности, потому что на стадии прототипирования это не столь важно. Но потом наступает время оптимизировать и масштабировать ваше приложения, и тут появляются проблемы . Разработчику нужно сосредоточится на том, как можно отрефакторить приложение, дабы повысить его скорость и производительность.

Одним из решений, можно рассматривать уменьшения количества запросов к БД. Уменьшение количества запросов неминуемо ведет к повышению производительности в целом.

ВRails приложениях, данные распределяются по множеству моделей, которые включают ассоциацию между собой, используя ORM для работы. ORM помогает решить проблему представления наших таблиц в виде классов, строк в виде объектов, а колонок в виде свойств объектов. И тут необходимо знать о подводных камнях, которые нас ожидают. Весьма популярная проблема тут — это проблема N + 1.

Что такое проблема N+1?

Эта проблема появляется при подгрузке дочерних обьектов, если мы используем ассоциацию ( many в one-to-many ). Множество ORM, по умолчанию, используют ленивую загрузку, т.е. делается запрос на выборку одной записи для родительского обьекта и запрос для КАЖДОЙ дочерней записи. Если коротко, то делая N+1 запрос вы в разы сильнее нагружаете базу данных, там, где этого можно избежать.

А теперь давайте рассмотрим тестовое приложение, в котором у нас есть 2 сущности: пользователи и их посты:

# Post model
class Post < ActiveRecord::Base
  belongs_to :user
end
# User model
class User < ActiveRecord::Base
  has_many :posts
end

Мы желаем вывести в сайдбаре пять постов, включая их названия и имя автора.

# Our controller
@recent_posts = Post.limit(5)
# Our view
@recent_posts.each do |post|
  Title: <%= post.title %>
  Author:<%= post.user.name %>
end

Данный код пошлет 6-ть ( 5 + 1 ) запросов к базе данных, 1 для выборки постов, и по одной для извлечения имени автора каждого поста. На стадии прототипирования, мы вряд ли заметим какую-то проблему с производительностью, но чем больше будет подобных вещей и чем масштабнее будут выборки, тем медленне будет вести себя наше приложение.

Решение: Eager Loading

Eager Loading — это механизм, позволяющий загружать ассоциации обьекта, используя минимальное количество запросов. В примере выше, мы могли бы сократить кол-во запросов до 2-х, в независимости от кол-ва постов, которое мы запрашиваем.

В ActiveRecord есть метод includes, который мы и будем использовать, чтобы загрузить данные ассоциации.

Теперь немного изменим наш код, применив ассоциативную загрузку:

# Our controller
# Using include(:user) will include user model.
@recent_posts = Post.includes(:user).limit(5)
#Our view
@recent_posts.each do |post|
  Title: <%= post.title %>
  Author:<%= post.user.name %>
end

Как Eager Loading может помочь с проблемой N+1?

Eager Loading является решением проблемы N+1, потому что вы можете быть уверены, что приложение не будет слать лишних запросов.

Если взглянуть на log до использования Eager Loading, то можно наблюдать такое:

Post Load (0.9ms) SELECT 'posts'.* FROM 'posts'
User Load (0.4ms) SELECT 'users'.* FROM 'users' WHERE 'users'.'id' = ? ORDER BY 'users'.'id' ASC LIMIT 1 [["id", 1]]
User Load (0.3ms) SELECT 'users'.* FROM 'users' WHERE 'users'.'id' = ? ORDER BY 'users'.'id' ASC LIMIT 1 [["id", 2]]
User Load (0.4ms) SELECT 'users'.* FROM 'users' WHERE 'users'.'id' = ? ORDER BY 'users'.'id' ASC LIMIT 1 [["id", 3]]
User Load (0.3ms) SELECT 'users'.* FROM 'users' WHERE 'users'.'id' = ? ORDER BY 'users'.'id' ASC LIMIT 1 [["id", 4]]
User Load (0.4ms) SELECT 'users'.* FROM 'users' WHERE 'users'.'id' = ? ORDER BY 'users'.'id' ASC LIMIT 1 [["id", 5]]

после:

Post Load (0.4ms) SELECT 'posts'.* FROM 'posts'
User Load (0.4ms) SELECT 'users'.* FROM 'users' WHERE 'users'.'id' IN (1,2,3,4,5)

Bullet:

Bullet — гем, который позволяет отследить случаи, в которых случается N+1 ошибка. Он был создан в 2009 году, но все еще актуален для улучшения производительности. К тому же, гем оповестит вас о каждой загруженной ассоциации, которая так и не была использована.

У Bullet несколько способов оповещения о данных проблемах: Уведомления Growl, JavaScript оповещения ( по умолчанию ), и даже при помощи XMPP. Помимо прочего, он позволяет сохранять информацию, о том, что послужило ошибкой в bullet.log . Можно менять настройки и делать запись логов в application.log.

Настройка и использование:

Добавьте bullet в ваш Gemfile и запустите bundle install.

Gemfile

group :development do
  gem 'bullet'
end

Данный гем должен работать только в development окружении, т.к. вряд ли вы захотите, что бы пользователи на вашем сайте, видели эти оповещения.

Следующее шаг — настройка Bullet оповещения:

Нужно задействовать Bullet в приложении, добавив следующую настройку:

config/environments/development.rb.

config.after_initialize do
  Bullet.enable = true
end

Оповещения с помощью JS alerts.

Bullet может быть сконфигурирован для вывода оповещения с помощью javascript alert. Alert будет всплывать во время загрузки страницы, на которой обнаружена N+1 проблема. Добавте следующую инструкцию в config/environments/development.rb .

Bullet.alert = true

Оповещение, с помощью консоли в браузере:

Bullet.console = true

Оповещение посредством Rails log:

Bullet также имеет возможность выводить уведомления в ваш rails log,

Bullet.rails_logger = true

Запись в файл:

Если вы желаете, что бы запись логов об ошибках производилась в файл, то можете использовать такой подход( после этого будет создан новый файл — bullet.log ):

Bullet.bullet_logger = true

Уведомления посредством Growl:

Если вы хотитеGrowl оповещений:

Bullet.growl = true

Ресурсы:

Original article: link

Rubygems : Bullet gem

Github : Source Code

RailsCast : Tutorial