Каждый разработчик рано или поздно сталкивается с проблемой N+1. ActiveRecord (Rails default ORM) поддерживает подгрузку ассоциаций с помощью includes для обхода N+1.
К сожалению, зачастую, не все данные, что нам нужны можно задекларировать в виде стандартных ассоциаций. Рассмотрим несколько примеров.
Пример 1. Количество заказов у пользователя
Предположим у нас есть модели:
class User < ActiveRecord::Base
has_many :orders
end
class Order < ActiveRecord::Base
belongs_to :user
end
Наша задача отобразить на странице список пользователей и общее количество заказов для каждого пользователя. Нашим первым решением можем быть.
<table>
<tr>
<td> User ID </td>
<td> Orders count </td>
</tr>
<%- User.all.each do |user| %>
<tr>
<td> <%= user.id %> </td>
<td> <%= user.orders.count </td>
</tr>
<% end %>
</table>
Не трудно заметить, что сейчас у нас есть N+1 проблема, так как для каждого пользователя мы подгружаем количество его заказов.
Данную проблему можно исправить с помощью встроенных средств следующим образом:
<table>
<tr>
<td> User ID </td>
<td> Orders count </td>
</tr>
<%- User.includes(:orders).all.each do |user| %>
<tr>
<td> <%= user.id %> </td>
<td> <%= user.orders.size </td>
</tr>
<% end %>
</table>
Мы использовали includes
, чтобы подгрузить каждому пользователю все заказы одним общим запросом, а также заменили count
на size
, чтобы избежать исполнения запроса COUNT(*)
.
Несмотря на то, что данное решение избавляет от N+1, но к сожалению, оно не самое стоящее, так как мы подгружаем все объекты Order
, а затем в памяти считаем их количество. В идеале, мы бы хотели подгружать только количество для достижения наилучшего результата.
Пример 2. Последний заказ пользователя
Пусть, у нас все те же модели пользователя и заказа.
class User < ActiveRecord::Base
has_many :orders
end
class Order < ActiveRecord::Base
belongs_to :user
end
Наша задача отобразить информацию по каждому пользователю и по их последнему заказу. К сожалению, ActiveRecord не предоставляет возможности для создания ассоциации нужного нам формата, поэтому нашим решением мог быть следующий код:
users = User.all
recent_order_per_user =
Order.where(id: Order.where(user: users).group(:user_id).maximum(:id))
.index_by(&:user_id)
users.each do |user|
p "User ID = #{user.id}"
p "Last order ID = #{recent_order_per_user[user.id]}"
end
Данное решение, несмотря на оптимальную загрузку данных, не самое удобное, так как отдельно предзагруженные данные приходиться "тянуть" до места их использования. Имея сложную цепочку вызовов методов данный подход сильно ухудшает читаемость и поддерживаемость кода.
Решение
Gem N1Loader был создан для того, чтобы заполнить недостающую возможность в поддержке сложных ассоциаций для избежания N+1 проблем.
Рассмотрим, как с помощью N1Loader мы могли бы решить проблему N+1 в примерах выше.
Решение для примера 1.
class User < ActiveRecord::Base
include N1Loader::Loadable
n1_loader :orders_count do |users|
orders_count_per_user = Order.where(user: users).group(:user_id).count
users.each { |user| fulfill(user, orders_count_per_user[user.id])
end
end
class Order < ActiveRecord::Base
belongs_to :user
end
С данной реализацией, предзагрузка orders_count
для множества пользователей не составляет труда:
<table>
<tr>
<td> User ID </td>
<td> Orders count </td>
</tr>
<%- User.includes(:orders_count).all.each do |user| %>
<tr>
<td> <%= user.id %> </td>
<td> <%= user.orders_count </td>
</tr>
<% end %>
</table>
Решение для примера 2
class User < ActiveRecord::Base
include N1Loader::Loadable
n1_loader :recent_order do |users|
recent_order_per_user =
Order
.where(id: Order.where(user: users).group(:user_id).maximum(:id))
.index_by(&:user_id)
users.each { |user| fulfill(user, recent_order_per_user[user.id]) }
end
end
class Order < ActiveRecord::Base
belongs_to :user
end
и не посредственно использование данного метода:
User.all.includes(:recent_order).each do |user|
p "User ID = #{user.id}"
p "Last order ID = #{user.recent_order}"
end
Итого
N1Loader помогает избежать N+1 проблемы как никогда легко. Гем имеет много крутых фич, таких как поддержка аргументов, интеграция с ArLazyPreload и много другого.
Я рекомендую ознакомиться и попробовать у себя в проектах. Признателен за любой отзыв и вклад!
Незнакомы с гемами Database Consistency и Factory Trace, которые помогут улучшить ваш код? Милости просим!
Спасибо за внимание!
Комментарии (14)
solovey_tani
23.01.2022 00:44+1Большое спасибо за разбор данной проблемы. Будет интересно применить у себя в работе. Буду иметь ввиду данный гем и попробую вообще решение!
mSnus
23.01.2022 07:48+7Извините, это всё вместо left join?
djezzzl Автор
23.01.2022 12:23Я понимаю о чем вы говорите, но, согласитесь, что left join не лучшее решение, например, если мы хотим очень много подобных вещей подгрузить. Сомневаюсь, что вы захотите, чтобы у вас был запрос с 20 left join и что это будет соизмеримо также быстро работать. Здесь же, запросы в сути своей остаются простыми.
Наверное, примеры не самые лучшие, так как очень простые, изначально, я таковые и взял, чтобы показать, что ActiveRecord такого простого не может, а здесь это возможно по аналогии с ассоциациями. Предположим, вы хотите обращаться в сторонний сервис по API за данными о заказах и API поддерживает batch запросы, тогда данное решение может пригодится.
На самом деле случае куда больше, но я согласен, иногда, в зависимости от требований, все может обойтись обычным left join. С другой стороны, не всегда это будет самым оптимальным по производительности.
motoroller95
24.01.2022 01:26это вместо counter_cache мне кажется. Ну и ИМХО пример 2 также решается денормализацией
djezzzl Автор
24.01.2022 11:43Да, вы правы, решается, у всего есть несколько решений, здесь представлено одно из них без необходимости поддержки денормализированных данных.
Mayurifag
23.01.2022 23:23Как вариант, можно ещё для graphql добавить примеры.
Но уже выше отметили, семантически смотрится, извините, пугающе. Ещё и в модели.
Вообще по заголовку я верил, но не надеялся, что вы продолжили идеи гема ar_lazy_preload, чтобы теперь в active record вообще не приходилось учитывать N+1 или что-нибудь типа того. Но кликбейтный заголовок с реальностью не сошёлся, N+1 всё так же нужно иметь в виду, всё так же он является проблемой.
В любом случае, спасибо вам! Для себя, увы, не нахожу где и как применить.djezzzl Автор
24.01.2022 11:50Как вариант, можно ещё для graphql добавить примеры.
Вы правы, данные методы отлично смотрятся в использованием GraphQL, особенно в совокупности с ArLazyPreload. Это позволяет полностью избежать необходимости в написании Loader (исключением будут аргументы).
Но уже выше отметили, семантически смотрится, извините, пугающе. Ещё и в модели.
Если вы не используете ассоциации в моделях, тогда я бы и вправду не рекоммендовал добавлять внутрь моделей. К сожалению, по умолчанию, ActiveRecord способствует сильной связности между моделями, и данный гем продолжает эту идею решая проблему "сложных ассоциаций".
Вообще по заголовку я верил, но не надеялся, что вы продолжили идеи гема
ar_lazy_preload, чтобы теперь в active record вообще не приходилось
учитывать N+1 или что-нибудь типа того.Так и есть, почему вы думаете что нет? Просто примеры изложены простые, и как я уже понял, очень плохие. В документации сказано про интеграцию с ArLazyPreload лучше. Но документация тоже плохая и будет переделана в ближайшее время.
Если вы же о чем-то конкретно другом, прошу, поделитесь, я открыт к улучшениям/переработкам.
N+1 всё так же нужно иметь в виду, всё так же он является проблемой.
Проблемой, которая теперь решается добавлением простого лоудера без необходимости тащить сторонние агрегаты наподобие ViewModels и прочее.
В любом случае, спасибо вам! Для себя, увы, не нахожу где и как применить.
Спасибо, сожалею, что я так плохо изложил суть и применение гема. Надеюсь вы сможете пересмотреть свою позицию. Я всегда готов ответить на вопросы/предложения.
Alex_ME
24.01.2022 10:43В мире RoR проблема N+1 действительно является проблемой? Зачем тянуть модель базы данных непосредственно во View и там работать? Казалось, что более чистым и гибким решением будет в "логике" (в контроллере или в том, что вызывается из контроллера) выполнить необходимые запросы, а ответ представить в виде отдельного класса ViewModel.
djezzzl Автор
24.01.2022 11:52Вы правы, такое решение тоже может быть, но оно не гибкое, так как данные агрегаты нужно будет "протаскивать" до места их использования.
Насчет ViewModel, а есть если вам нужны данные в Background Job? Данное же решение является универсальным и может быть использовано в любой части кода.
artjoju
Очень элегантное решение, спасибо!
djezzzl Автор
Спасибо за отзыв!