Каждый разработчик рано или поздно сталкивается с проблемой 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, которые помогут улучшить ваш код? Милости просим!
Спасибо за внимание!