Статьи

N + 1: когда больше запросов — это хорошо

За последнюю неделю я пытался понять, как работает рельсовая загрузка в Rails, чтобы устранить печально известную проблему N + 1 запросов за счет уменьшения числа выполненных запросов. Моя первоначальная гипотеза заключалась в том, что целью было максимально сократить количество запросов. Однако я был удивлен тем, что обнаружил.

Использование includes для уменьшения количества запросов

В большинстве сообщений, которые вы читаете о печально известной проблеме N + 1 запросов, упоминается метод include для решения этой проблемы. includes используется для активизации ассоциаций загрузки, связанных с моделью, с использованием минимально возможного количества запросов. Для этого под капотом используется предварительная нагрузка или левое внешнее соединение, в зависимости от ситуации. Я объясню обе ситуации в следующих разделах.

Это хорошо объясняется примерами в документации по методам запросов Active Record .

Когда и как использовать includes ?

Предположим, что наш пользователь может иметь много постов и может комментировать любые посты. Каждый пост может иметь много комментариев. Основная структура показана во фрагменте, показанном ниже:

 # models/users.rb class User < ApplicationRecord has_many :posts has_many :comments end # models/posts.rb class Post < ApplicationRecord has_many :comments belongs_to :user end # models/comments.rb class Comment < ApplicationRecord belongs_to :user belongs_to :post end 

Теперь, если мы хотим получить информацию о пользователях с сообщениями, сделанными пользователем, вместе с их комментариями, просто вызов User.all сначала загрузит пользователей, а затем будет получать сообщения каждого пользователя. После получения сообщений, он будет получать комментарии, сделанные пользователем для этих сообщений. Если у нас есть 10 пользователей, каждый из которых имеет 5 сообщений, и в среднем по 2 комментария к каждому сообщению, один User.all итоге выполнит около 1 + 5 + 10 запросов.

 # users_controller.rb def index @users = User.all render json: @users end 

Простым решением является использование includes чтобы сообщить Active Record, что мы хотим получить пользователей и все связанные сообщения:

 @users = User.all.includes(:posts) 
сообщения предварительно загружены, комментарии не загружены

сообщения предварительно загружены, комментарии не загружены

Это немного повышает производительность, так как сначала выбирает пользователей, а затем в следующем запросе выбирает сообщения, связанные с этими пользователями. Теперь предыдущие 1 + 5 + 10 запросов сокращены до 1 + 1 + 10 запросов. Но это будет намного лучше, если комментарии, связанные с постами, также загружаются заранее. Это уменьшит все это до 1 + 1 + 1, всего 3 запроса для извлечения всех данных. Посмотрите на фрагмент, показанный ниже, чтобы понять:

 # users_controller.rb def index @users = User.all.includes(:posts => [:comments]) render json: @users end 
Все данные загружаются всего за 3 запроса: один для пользователей, один для сообщений и один для комментариев, связанных с сообщениями.

Все данные загружаются всего за 3 запроса: один для пользователей, один для сообщений и один для комментариев, связанных с сообщениями.

Все данные загружаются всего за 3 запроса: один для пользователей, один для сообщений и один для комментариев, связанных с сообщениями.

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

 User.all.includes(:posts => [:comments => [:another_relationship]]) 

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

Получение сообщений с определенным названием

 User.all.includes(:posts => [:comments]).where('posts.title = ?', some_title) 

Это вызовет ошибку. В то время как,

 User.all.includes(:posts => [:comments]).where(posts: { title: some_title }) 

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

Задание условий хеширования для включенных отношений

Задание условий хеширования для включенных отношений

Но что, если мы хотим использовать условия чистой строки или массива вместо условий хеша, чтобы указать условия для включенных отношений? Посмотрите на следующий пример:

 User.all.includes(:posts => [:comments]).where('posts.title = ?', some_title).references(:posts) 

Заметьте references(:posts) часть? references рассказывает includes в includes чтобы принудительно соединить связь posts с левым внешним соединением . Чтобы понять это, посмотрите пример запроса, сгенерированного приведенной выше строкой кода:

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

Пример запроса, когда отношение «сообщения» принудительно соединяется с включением через ссылки

Мы сократили количество запросов с 1 + 5 + 10 до 1 запроса. Замечательно!

Но меньше не всегда больше

Посмотрите на последние два примера запросов. Оба имеют длину от 3 до 4 строк и имеют подстроки типа t0_r1 , t0_r2 ,…, t2_r5 . Это кажется необычным. Я не эксперт по SQL и не знал, что это значит. Они известны как CROSS JOIN или CARTESIAN join.

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

В документации Active Record четко сказано, что когда вам нужно запрашивать ассоциации, вы должны использовать запросы на соединение с включениями вместо references .

Несмотря на то, что Active Record позволяет вам указывать условия для загруженных ассоциаций, как и для joins , рекомендуется использовать вместо этого объединения.

Лучший способ указать условия для загруженных ассоциаций:

 User.all.joins(:posts).where('posts.title = ? ', some_title).includes(:posts => [:comments]) 

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

Вывод

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

Этот пост в Engine Yard очень хорошо объясняет проблемы, связанные с энергичной загрузкой. Например, не забудьте добавить нумерацию страниц и ограничить выборку записей.

Надеюсь, вам понравился этот быстрый пост по N + 1 запросам.