Как оптимизировать ваши запросы для решения типичных узких мест масштабируемости в Rails

1656666611 kak optimizirovat vashi zaprosy dlya resheniya tipichnyh uzkih mest masshtabiruemosti

Усама Ашраф

(Идеальное) решение для проблемы N+1

Csu1xKKjVyRarXO3zKOQi-PwvPuR750NjwYt

Проблема запроса 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

Вот весь код. Наслаждайтесь!

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

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