
Содержание статьи
Усама Ашраф
(Идеальное) решение для проблемы N+1

Проблема запроса n+1 — одно из самых распространенных узких мест масштабируемости. Он предполагает получение списка ресурсов из базы данных, содержащей другие связанные ресурсы в них. Это значит, что нам, возможно, придется спрашивать связанные ресурсы по отдельности. Итак, если у вас есть список из n родительских объектов, еще n запросов нужно будет выполнить для получения связанных ресурсов. Давайте попробуем избавиться от этой головоломки O(n).
Если вы хорошо знаете Rails, Active Model Serializers и уже хорошо представляете, в чем наша проблема, тогда, возможно, вы можете сразу перейти к коду здесь.
Конкретный пример
Скажем, вы получаете массив Опубликовать объектов в конечной точке GET Вы также хотите скачать соответствующих авторов сообщений, вставив автор в каждом из объектов публикации. Вот наивный способ сделать это:
class PostsController < ApplicationController def index posts = Post.all render json: posts endend
class Post belongs_to :author, class_name: 'User'end
class PostSerializer < ActiveModel::Serializer attributes :id, :title, :details
belongs_to :author end
Для каждого из n Опубликовать отображаемых объектов будет выполнен запрос для получения соответствующего Пользователь объект. Поэтому мы выполним n+1 запрос. Это катастрофически. И вот как это исправить, нетерпеливо скачивая Пользователь объект:
class PostsController < ApplicationController def index # Runs a SQL join with the users table. posts = Post.includes(:author).all render json: posts endend
Когда простое объединение невозможно
До сих пор для ветеранов не было ничего нового.
Но давайте усложним это. Предположим, что пользователи сайта не хранятся в той же RDMS, что и публикации. Скорее, пользователи – это документы, хранящиеся в MongoDB (по какой-либо причине). Как мы можем изменить наш Опубликовать сериализатор для получения пользователя сейчас оптимально? Это будет возвращение к началу:
class PostSerializer < ActiveModel::Serializer attributes :id, :title, :details, :author
# Will run n Mongo queries for n posts being rendered. def author User.find(object.author_id) endend
# This is now a Mongoid document, not an ActiveRecord model.class User include Mongoid::Document include Mongoid::Timestamps # ...end
Затруднительное положение, когда наши пользователи сейчас находятся в базе данных Mongo, можно заменить, скажем, вызовом посторонней службы HTTP для получения пользователей или сохранения их в другой RDMS. Нашей основной проблемой остается то, что нет способа присоединить хранилище данных пользователей к таблице публикаций и получить нужный ответ в одном запросе.
Конечно, мы можем улучшить. Мы можем получить полный ответ по двум запросам:
- Получить все сообщения без автор атрибут (1 SQL-запрос).
- Получите всех авторов, запустив запрос where-in с идентификаторами пользователей, взятыми из массива сообщений (1 запрос Mongo с предложением IN).
posts = Post.allauthor_ids = posts.pluck(:author_id)authors = User.where(:_id.in => author_ids)
# Somehow pass the author objects to the post serializer and# map them to the correct post objects. Can't imagine what # exactly that would look like, but probably not pretty.render json: posts, pass_some_parameter_maybe: authors
Введите Batch Loader
Таким образом, наша первоначальная проблема оптимизации была сведена к «как сделать этот код читабельным и поддерживаемым». Люди из Universe придумали абсолютную жемчужину (слишком очевидную?). Пакетный загрузчик недавно был для меня невероятно полезен.
gem 'batch-loader'
bundle install
class PostSerializer < ActiveModel::Serializer attributes :id, :title, :details, :author
def author object.get_author_lazily endend
class Post def get_author_lazily # The current post object is added to the batch here, # which is eventually processed when the block executes. BatchLoader.for(self).batch do |posts, batch_loader|
author_ids = posts.pluck(:author_id) User.where(:_id.in => author_ids).each do |user| post = posts.detect { |p| p.author_id == user._id.to_s } #'Assign' the user object to the right post. batch_loader.call(post, user) end end endend
Если вы знакомы с JavaScript Promises, подумайте о get_author_lazily
метод возвращает Promise, который оценивается позже. Я считаю, что это достойная аналогия BatchLoader
использует отложенные объекты Ruby. По умолчанию, BatchLoader
кэширует загруженные значения, поэтому, чтобы поддерживать ответы в актуальном состоянии, вы должны добавить это к своему config/application.rb
:
config.middleware.use BatchLoader::Middleware
Это! Мы разрешили расширенную версию проблемы запросов n+1, сохраняя код чистым и правильно используя сериализаторы активных моделей.
Использование AMS для вложенных ресурсов
Но одна проблема. Если у вас есть a Пользователь сериализатор (сериализаторы активных моделей также работают с Mongoid) не будет быть вызванным для лениво загруженных автор объектов, в отличие от ранее. Чтобы исправить это, мы можем использовать блок Ruby и сериализировать автор объекты, прежде чем они будут «предназначены» для постов.
class PostSerializer < ActiveModel::Serializer attributes :id, :title, :details, :author
def author object.get_author_lazily do |author| # Serialize the author after it has been loaded. ActiveModelSerializers::SerializableResource .new(author) .as_json[:user] end endend
class Post def get_author_lazily # The current post object is added to the batch here, # which is eventually processed when the block executes. BatchLoader.for(self).batch do |posts, batch_loader|
author_ids = posts.pluck(:author_id) User.where(:_id.in => author_ids).each do |user| modified_user = block_given? ? yield(user) : user post = posts.detect { |p| p.author_id == user._id.to_s } # 'Assign' the user object to the right post. batch_loader.call(post, modified_user) end end endend
Вот весь код. Наслаждайтесь!