Каждый разработчик рано или поздно сталкивается с проблемой 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)


  1. artjoju
    22.01.2022 16:31
    +1

    Очень элегантное решение, спасибо!


    1. djezzzl Автор
      22.01.2022 16:31

      Спасибо за отзыв!


  1. aelaa
    22.01.2022 16:39

    решение технически хорошее, но семантически...


    1. djezzzl Автор
      22.01.2022 18:16

      Спасибо за отзыв, а можете, пожалуйста, немного подробнее объяснить по семантической части решения? Что не так? Можно ли что-либо исправить/улучшить?


  1. solovey_tani
    23.01.2022 00:44
    +1

    Большое спасибо за разбор данной проблемы. Будет интересно применить у себя в работе. Буду иметь ввиду данный гем и попробую вообще решение!


    1. djezzzl Автор
      23.01.2022 00:44

      Спасибо за отзыв! Приятно!


  1. mSnus
    23.01.2022 07:48
    +7

    Извините, это всё вместо left join?


    1. djezzzl Автор
      23.01.2022 12:23

      Я понимаю о чем вы говорите, но, согласитесь, что left join не лучшее решение, например, если мы хотим очень много подобных вещей подгрузить. Сомневаюсь, что вы захотите, чтобы у вас был запрос с 20 left join и что это будет соизмеримо также быстро работать. Здесь же, запросы в сути своей остаются простыми.

      Наверное, примеры не самые лучшие, так как очень простые, изначально, я таковые и взял, чтобы показать, что ActiveRecord такого простого не может, а здесь это возможно по аналогии с ассоциациями. Предположим, вы хотите обращаться в сторонний сервис по API за данными о заказах и API поддерживает batch запросы, тогда данное решение может пригодится.

      На самом деле случае куда больше, но я согласен, иногда, в зависимости от требований, все может обойтись обычным left join. С другой стороны, не всегда это будет самым оптимальным по производительности.


    1. motoroller95
      24.01.2022 01:26

      это вместо counter_cache мне кажется. Ну и ИМХО пример 2 также решается денормализацией


      1. djezzzl Автор
        24.01.2022 11:43

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


  1. Mayurifag
    23.01.2022 23:23

    Как вариант, можно ещё для graphql добавить примеры.

    Но уже выше отметили, семантически смотрится, извините, пугающе. Ещё и в модели.

    Вообще по заголовку я верил, но не надеялся, что вы продолжили идеи гема ar_lazy_preload, чтобы теперь в active record вообще не приходилось учитывать N+1 или что-нибудь типа того. Но кликбейтный заголовок с реальностью не сошёлся, N+1 всё так же нужно иметь в виду, всё так же он является проблемой.

    В любом случае, спасибо вам! Для себя, увы, не нахожу где и как применить.


    1. djezzzl Автор
      24.01.2022 11:50

      Как вариант, можно ещё для graphql добавить примеры.

      Вы правы, данные методы отлично смотрятся в использованием GraphQL, особенно в совокупности с ArLazyPreload. Это позволяет полностью избежать необходимости в написании Loader (исключением будут аргументы).

      Но уже выше отметили, семантически смотрится, извините, пугающе. Ещё и в модели.

      Если вы не используете ассоциации в моделях, тогда я бы и вправду не рекоммендовал добавлять внутрь моделей. К сожалению, по умолчанию, ActiveRecord способствует сильной связности между моделями, и данный гем продолжает эту идею решая проблему "сложных ассоциаций".

      Вообще по заголовку я верил, но не надеялся, что вы продолжили идеи гема
      ar_lazy_preload, чтобы теперь в active record вообще не приходилось
      учитывать N+1 или что-нибудь типа того.

      Так и есть, почему вы думаете что нет? Просто примеры изложены простые, и как я уже понял, очень плохие. В документации сказано про интеграцию с ArLazyPreload лучше. Но документация тоже плохая и будет переделана в ближайшее время.

      Если вы же о чем-то конкретно другом, прошу, поделитесь, я открыт к улучшениям/переработкам.

      N+1 всё так же нужно иметь в виду, всё так же он является проблемой.

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

      В любом случае, спасибо вам! Для себя, увы, не нахожу где и как применить.

      Спасибо, сожалею, что я так плохо изложил суть и применение гема. Надеюсь вы сможете пересмотреть свою позицию. Я всегда готов ответить на вопросы/предложения.


  1. Alex_ME
    24.01.2022 10:43

    В мире RoR проблема N+1 действительно является проблемой? Зачем тянуть модель базы данных непосредственно во View и там работать? Казалось, что более чистым и гибким решением будет в "логике" (в контроллере или в том, что вызывается из контроллера) выполнить необходимые запросы, а ответ представить в виде отдельного класса ViewModel.


    1. djezzzl Автор
      24.01.2022 11:52

      Вы правы, такое решение тоже может быть, но оно не гибкое, так как данные агрегаты нужно будет "протаскивать" до места их использования.

      Насчет ViewModel, а есть если вам нужны данные в Background Job? Данное же решение является универсальным и может быть использовано в любой части кода.