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

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

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


ссылка на оригинал статьи https://habr.com/ru/post/647141/

Добавить комментарий

Ваш адрес email не будет опубликован.