Pull to refresh

N+1 больше не будет проблемой

Ruby *Ruby on Rails *

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

Спасибо за внимание!

Tags:
Hubs:
Total votes 10: ↑8 and ↓2 +6
Views 20K
Comments Comments 14