Статьи

Повышение производительности вашего Rails-приложения с энергичной загрузкой

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

Active Records и ORM — очень мощные инструменты в Ruby on Rails, но только если мы знаем, как использовать и использовать эту мощь. В начале вы найдете множество способов выполнить аналогичную задачу в RoR, но только когда вы копаете немного глубже, вы действительно узнаете стоимость использования одного над другим.

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

Но перед этим давайте быстро сгенерируем фиктивное приложение, чтобы поиграть с ним.

Запустите свой терминал и введите эти команды, чтобы создать новое приложение:

1
2
rails new blog
cd blog

Создайте ваше приложение:

1
2
rails g scaffold Author name:string
rails g scaffold Post title:string body:text author:references

Разверните его на локальном сервере:

1
2
rake db:migrate
rails s

И это было все! Теперь у вас должно быть запущенное фиктивное приложение.

Так должны выглядеть обе наши модели (Автор и Пост). У нас есть посты, принадлежащие автору, и у нас есть авторы, которые могут иметь много постов. Это самая основная связь / связь между этими двумя моделями, с которыми мы будем играть.

1
2
3
4
5
6
7
8
9
# Post Model
class Post < ActiveRecord::Base
    belongs_to :author
end
 
# Author Model
class Author < ActiveRecord::Base
    has_many :posts
end

Взгляните на свой «Контроллер сообщений» — вот как он должен выглядеть. Наше основное внимание будет сосредоточено только на методе индекса.

1
2
3
4
5
6
7
8
# Controller
class PostsController < ApplicationController
 
    def index
        @posts = Post.order(created_at: :desc)
    end
 
end

И последнее по порядку, но не по значению — наш индекс индекса постов. Может показаться, что у вас есть несколько дополнительных строк, но я хочу, чтобы вы сосредоточились на них, особенно на строке с post.author.name .

1
2
3
4
5
6
7
8
9
<tbody>
  <% @posts.each do |post|
    <tr>
      <td><%= post.title %></td>
      <td><%= post.body %></td>
      <td><%= post.author.name %></td>
    </tr>
  <% end %>
</tbody>

Давайте просто создадим фиктивные данные, прежде чем мы начнем. Перейти к вашей консоли рельсов и добавить следующие строки. Или вы можете просто зайти на http://localhost:3000/posts/new и http://localhost:3000/authors/new чтобы добавить некоторые данные вручную.

1
2
3
4
5
authors = Author.create([{ name: ‘John’ }, { name: ‘Doe’ }, { name: ‘Manish’ }])
 
Post.create(title: ‘I love Tuts+’, body: », author: authors.first)
Post.create(title: ‘Tuts+ is Awesome’, body: », author: authors.second)
Post.create(title: ‘Long Live Tuts+’, body: », author: authors.last)

Теперь, когда вы все настроили, давайте запустим сервер с rails s и нажмем localhost:3000/posts .

Вы увидите некоторые результаты на вашем экране, как это.

Это изображение показывает страницу, отображающую все сообщения

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

1
2
3
4
Post Load (0.6ms) SELECT «posts».* FROM «posts» ORDER BY «posts».»created_at» DESC
Author Load (0.5ms) SELECT «authors».* FROM «authors» WHERE «authors».»id» = ?
Author Load (0.1ms) SELECT «authors».* FROM «authors» WHERE «authors».»id» = ?
Author Load (0.1ms) SELECT «authors».* FROM «authors» WHERE «authors».»id» = ?

Хорошо, хорошо, я согласен, что это всего лишь четыре запроса, но представьте, что в вашей базе данных есть 3000 сообщений вместо трех. В этом случае наша база данных будет заполнена 3000 + 1 запросами, поэтому эту проблему называют проблемой N+1 .

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

В нашем случае, во-первых, это контроллер, который запрашивает все сообщения.

1
2
3
def index
    @posts = Post.order(created_at: :desc)
end

Второе — это представление, в котором мы перебираем посты, извлеченные контроллером, и отправляем запрос, чтобы получить имя автора для каждого поста отдельно. Отсюда проблема N+1 .

1
2
3
4
5
6
7
8
<% @posts.each do |post|
  <tr>
    .
    .
    .
    <td><%= post.author.name %></td>
  </tr>
<% end %>

Чтобы спасти нас от таких ситуаций, Rails предлагает нам функцию, называемую готовой загрузкой.

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

Да, потому что у нас их три, и все они служат одной и той же цели, но, в зависимости от ситуации, любой из них может снова снизить или превзойти производительность.

1
2
3
preload()
eager_load()
includes()

Теперь вы можете спросить, какой использовать в этом случае? Что ж, начнем с первого.

1
2
3
def index
    @posts = Post.order(created_at: :desc).preload(:author)
end

Сохрани это. Хит URL снова localhost:3000/posts .

Это изображение показывает страницу, отображающую все сообщения

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

1
2
SELECT «posts».* FROM «posts» ORDER BY «posts».»created_at» DESC
SELECT «authors».* FROM «authors» WHERE «authors».»id» IN (3, 2, 1)

Предварительная загрузка использует два отдельных запроса для загрузки основных данных и связанных данных. На самом деле это намного лучше, чем иметь отдельный запрос для каждого имени автора (проблема N + 1), но этого нам недостаточно. Благодаря отдельному подходу к запросам, он выдаст исключение в таких сценариях, как:

  1. Заказ постов по имени автора.
  2. Найти сообщения только от автора «Джон».
1
2
3
4
5
# Order posts by authors name.
 
def index
    @posts = Post.order(«authors.name»).eager_load(:author)
end

Результирующий запрос в журналах разработки:

1
2
3
4
SELECT «posts».»id» AS t0_r0, «posts».»title» AS t0_r1, «posts».»body» AS t0_r2, «posts».»author_id» AS t0_r3, «posts».»created_at» AS t0_r4, «posts».»updated_at» AS t0_r5, «authors».»id» AS t1_r0, «authors».»name» AS t1_r1, «authors».»created_at» AS t1_r2, «authors».»updated_at» AS t1_r3
FROM «posts»
LEFT OUTER JOIN «authors» ON «authors».»id» = «posts».»author_id»
ORDER BY authors.name
1
2
3
4
5
# Find posts from the author «John» only.
 
def index
    @posts = Post.order(created_at: :desc).eager_load(:author).where(«authors.name = ?», «Manish»)
end

Результирующий запрос в журналах разработки:

1
2
3
4
5
SELECT «posts».»id» AS t0_r0, «posts».»title» AS t0_r1, «posts».»body» AS t0_r2, «posts».»author_id» AS t0_r3, «posts».»created_at» AS t0_r4, «posts».»updated_at» AS t0_r5, «authors».»id» AS t1_r0, «authors».»name» AS t1_r1, «authors».»created_at» AS t1_r2, «authors».»updated_at» AS t1_r3
FROM «posts»
LEFT OUTER JOIN «authors» ON «authors».»id» = «posts».»author_id»
WHERE (authors.name = ‘Manish’)
ORDER BY «posts».»created_at» DESC
1
2
3
def index
    @posts = Post.order(created_at: :desc).eager_load(:author)
end

Результирующий запрос в журналах разработки:

1
2
3
4
SELECT «posts».»id» AS t0_r0, «posts».»title» AS t0_r1, «posts».»body» AS t0_r2, «posts».»author_id» AS t0_r3, «posts».»created_at» AS t0_r4, «posts».»updated_at» AS t0_r5, «authors».»id» AS t1_r0, «authors».»name» AS t1_r1, «authors».»created_at» AS t1_r2, «authors».»updated_at» AS t1_r3
FROM «posts»
LEFT OUTER JOIN «authors» ON «authors».»id» = «posts».»author_id»
ORDER BY «posts».»created_at» DESC

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

Во-первых, eager_load() всегда использует LEFT OUTER JOIN любом случае. Во-вторых, он получает все связанные данные в одном запросе, что наверняка превосходит метод preload() в ситуациях, когда мы хотим использовать связанные данные для дополнительных задач, таких как упорядочение и фильтрация. Но один простой запрос и LEFT OUTER JOIN также могут быть очень дорогими в простых сценариях, подобных описанным выше, где все, что вам нужно, это отфильтровать нужных авторов. Это похоже на использование базуки, чтобы убить крошечную муху.

Я понимаю, что это всего лишь два простых примера, и в реальных сценариях может быть очень трудно выбрать тот, который лучше всего подходит для вашей ситуации. Вот почему Rails дал нам метод includes() .

С помощью includes() Active Record позаботится о сложном решении. Это намного умнее, чем методы eager_load() preload() и eager_load() и решает, какой из них использовать самостоятельно.

1
2
3
4
5
# Order posts by authors name.
 
def index
    @posts = Post.order(«authors.name»).includes(:author)
end

Результирующий запрос в журналах разработки:

1
2
3
4
SELECT «posts».»id» AS t0_r0, «posts».»title» AS t0_r1, «posts».»body» AS t0_r2, «posts».»author_id» AS t0_r3, «posts».»created_at» AS t0_r4, «posts».»updated_at» AS t0_r5, «authors».»id» AS t1_r0, «authors».»name» AS t1_r1, «authors».»created_at» AS t1_r2, «authors».»updated_at» AS t1_r3
FROM «posts»
LEFT OUTER JOIN «authors» ON «authors».»id» = «posts».»author_id»
ORDER BY authors.name
1
2
3
4
5
6
7
8
# Find posts from the author «John» only.
 
def index
    @posts = Post.order(created_at: :desc).includes(:author).where(«authors.name = ?», «Manish»)
 
    # For rails 4 Don’t forget to add .references(:author) in the end
    @posts = Post.order(created_at: :desc).includes(:author).where(«authors.name = ?», «Manish»).references(:author)
end

Результирующий запрос в журналах разработки:

1
2
3
4
5
SELECT «posts».»id» AS t0_r0, «posts».»title» AS t0_r1, «posts».»body» AS t0_r2, «posts».»author_id» AS t0_r3, «posts».»created_at» AS t0_r4, «posts».»updated_at» AS t0_r5, «authors».»id» AS t1_r0, «authors».»name» AS t1_r1, «authors».»created_at» AS t1_r2, «authors».»updated_at» AS t1_r3
FROM «posts»
LEFT OUTER JOIN «authors» ON «authors».»id» = «posts».»author_id»
WHERE (authors.name = ‘Manish’)
ORDER BY «posts».»created_at» DESC
1
2
3
def index
    @posts = Post.order(created_at: :desc).includes(:author)
end

Результирующий запрос в журналах разработки:

1
2
SELECT «posts».* FROM «posts» ORDER BY «posts».»created_at» DESC
SELECT «authors».* FROM «authors» WHERE «authors».»id» IN (3, 2, 1)

Теперь, если мы сравним результаты с eager_load() , первые два случая будут иметь схожие результаты, но в последнем случае было решено перейти на метод preload() для повышения производительности.

Нет, потому что в этой гонке производительности, иногда стремительная загрузка тоже может не хватать. Я надеюсь, что некоторые из вас уже заметили, что всякий раз, когда активные методы загрузки используют JOINS , они используют только LEFT OUTER JOIN . Кроме того, в каждом случае они загружают слишком много ненужных данных в память — они выбирают каждый столбец из таблицы, тогда как нам нужно только имя автора.

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

Как рекомендуется в документации по rails, метод joins() один шаг вперед в этих ситуациях. Он присоединяется к связанной таблице, но загружает только необходимые данные модели в память, как в нашем случае сообщения. Следовательно, мы не загружаем лишние данные в память без необходимости — хотя, если мы хотим, мы можем сделать это тоже.

1
2
3
4
5
# Order posts by authors name.
 
def index
    @posts = Post.order(«authors.name»).joins(:author)
end

Результирующий запрос в журналах разработки:

1
2
3
4
SELECT «posts».* FROM «posts» INNER JOIN «authors» ON «authors».»id» = «posts».»author_id» ORDER BY authors.name
SELECT «authors».* FROM «authors» WHERE «authors».»id» = ?
SELECT «authors».* FROM «authors» WHERE «authors».»id» = ?
SELECT «authors».* FROM «authors» WHERE «authors».»id» = ?
1
2
3
4
5
# Find posts from the author «John» only.
 
def index
    @posts = Post.order(published_at: :desc).joins(:author).where(«authors.name = ?», «John»)
end

Результирующий запрос в журналах разработки:

1
2
SELECT «posts».* FROM «posts» INNER JOIN «authors» ON «authors».»id» = «posts».»author_id» WHERE (authors.name = ‘Manish’) ORDER BY «posts».»created_at» DESC
SELECT «authors».* FROM «authors» WHERE «authors».»id» = ?
1
2
3
def index
    @posts = Post.order(published_at: :desc).joins(:author)
end

Результирующий запрос в журналах разработки:

1
2
3
4
SELECT «posts».* FROM «posts» INNER JOIN «authors» ON «authors».»id» = «posts».»author_id» ORDER BY «posts».»created_at» DESC
SELECT «authors».* FROM «authors» WHERE «authors».»id» = ?
SELECT «authors».* FROM «authors» WHERE «authors».»id» = ?
SELECT «authors».* FROM «authors» WHERE «authors».»id» = ?

Первое, что вы можете заметить из результатов выше, это то, что проблема N+1 вернулась, но давайте сначала сосредоточимся на хорошей части.

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

1
SELECT «posts».* FROM «posts» INNER JOIN «authors» ON «authors».»id» = «posts».»author_id» ORDER BY authors.name

Извлекает все столбцы из сообщений. Он хорошо объединяет обе таблицы и сортирует или фильтрует записи в зависимости от условия, но без извлечения каких-либо данных из связанной таблицы. Что мы и хотели в первую очередь.

Но после первых запросов мы увидим 1 3 или N запросов в зависимости от данных в вашей базе данных, например:

1
2
3
SELECT «authors».* FROM «authors» WHERE «authors».»id» = ?
SELECT «authors».* FROM «authors» WHERE «authors».»id» = ?
SELECT «authors».* FROM «authors» WHERE «authors».»id» = ?

Теперь вы можете спросить: почему эта проблема N+1 возвращается? Именно из-за этой строки в нашем представлении post.author.name .

1
2
3
4
5
6
7
8
9
<tbody>
  <% @posts.each do |post|
    <tr>
      <td><%= post.title %></td>
      <td><%= post.body %></td>
      <td><%= post.author.name %></td>
    </tr>
  <% end %>
</tbody>

Эта строка запускает все эти запросы. Так что в примере, где нам нужно было только заказать наши сообщения, нам не нужно отображать имя автора в наших представлениях. В этом случае мы можем исправить эту проблему, удалив строку post.author.name из представления.

Но тогда вы можете спросить: «Эй, МК, а как насчет примеров, когда мы хотим отобразить имя автора в представлении?»

В таком случае метод joins() сам по себе не исправит это. Нам нужно будет указать joins() чтобы выбрать имя автора или любой другой столбец из таблицы по этому вопросу. И мы можем сделать это, добавив оператор select() в конце, например так:

1
2
3
def index
    @posts = Post.order(published_at: :desc).joins(:author).select(«posts.*, authors.name as author_name»)
end

Я создал псевдоним «author_name» для author.name. Мы увидим, почему через секунду.

Результирующий запрос в журналах разработки:

1
2
3
4
SELECT posts.*, authors.name as author_name
FROM «posts»
INNER JOIN «authors» ON «authors».»id» = «posts».»author_id»
ORDER BY «posts».»created_at» DESC

Итак, наконец, чистый SQL-запрос без проблем N+1 , без ненужных данных и только то, что нам нужно. post.author.name только использовать этот псевдоним в вашем представлении и изменить post.author.name на post.author_name . Это потому, что author_name теперь является атрибутом нашей модели Post, и после этого изменения страница выглядит так:

Это изображение показывает страницу, отображающую все сообщения

Все точно так же, но под капотом многое изменилось. Если я изложу все в двух словах, чтобы решить N+1 вы должны стремиться к загрузке , но иногда, в зависимости от ситуации, вы должны взять все под свой контроль и использовать соединения для улучшения параметров. Вы также можете предоставить необработанные SQL-запросы методу joins() для дополнительной настройки.

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

И последнее, но не менее важное: может быть сложно найти области в приложении до сборки, где вы должны улучшить производительность в целом или найти проблемы N+1 . В этих случаях я рекомендую хороший драгоценный камень под названием Bullet. Он может уведомить вас, когда вы должны добавить готовую загрузку для N+1 запросов, и когда вы используете нетерпеливую загрузку без необходимости.