Прискорення запитів з вкладеними областями в Rails

У моєму нещодавньому проєкті я працював з областями (scopes) на різних моделях, і мені потрібно було застосувати вкладену область для більш складного запиту.

Для досягнення цього з продуктивним запитом я використовував різні концепції, і цей блог саме про це!!

Давайте повторимо основи!
По-перше, почнемо з що таке оптимізація запитів?

Оптимізація запитів — це ключ до кращої продуктивності застосунку.

Використовуючи різні техніки ORM, такі як includes, eager_load, preload тощо, ми можемо зменшити кількість викликів до бази даних і уникнути запитів типу N+1!!

Що таке запити N+1?

Запити N+1 виникають, коли застосунок виконує один запит для отримання основних даних, а потім виконує додаткові запити для кожного пов’язаного запису.

class Post < ApplicationRecord  
 has_many :comments  
end
@posts = Post.all  

@posts.each do |post|  
 post.comments.count  
end

Тут ми можемо побачити,

  1. Один запит отримує всі (N) пости
  2. Для кожного поста (+1) виконується окремий запит для отримання його коментарів

Це призводить до кількох викликів до бази даних, що значно уповільнює застосунок.

Як вирішити проблему з запитами N+1

Зазвичай для цього використовуються preload і includes!

Коли використовувати preload:

preload використовується, коли нам потрібно лише перебирати асоційовані записи без їх фільтрації або запитів до них. preload не виконує об'єднання таблиць; натомість він виконує окремий запит для асоційованих записів.

Приклад: Якщо ми хочемо вивести всі коментарі до всіх постів

@posts = Post.preload(:comments)   

@posts.each do |post|  
 puts "Post: #{post.title}"  
 post.comments.each do |comment|  
 puts "Comment: #{comment.body}"  
 end  
end
  1. Post.preload(:comments) отримує всі пости та коментарі в двох окремих запитах.
  2. Не виконується об'єднання таблиць, що робить цей підхід ефективним для простого перебору.

Коли використовувати includes:

includes використовується, коли потрібно фільтрувати або запитувати пов'язані записи. Однак він не завжди поводиться однаково — він динамічно визначає, чи використовувати preload чи eager_load, в залежності від того, чи використовуються асоційовані записи для фільтрації.

Якщо пов'язані записи не фільтруються, includes поводиться як preload (виконуючи окремі запити).
Якщо застосовується фільтрація для пов'язаних записів, Rails автоматично перетворює includes на eager_load, що виконує SQL LEFT OUTER JOIN замість цього.

Приклад: Якщо ми хочемо першого користувача, чий пост має заголовок "Rails"

user = User.includes(:posts).where(posts: { title: "Rails" }).first

Тут,

  1. includes об'єднує таблицю posts з users
  2. where фільтрує за posts.title
  3. Оскільки застосовується фільтрація, Rails трактує includes як eager_load, що забезпечує отримання всіх даних в одному запиті.

Основна різниця між preload і includes:

  1. Основна різниця між preload і includes:

pic

Переходимо до більш складних запитів!

Тепер давайте розглянемо сценарій з вкладеними областями!

Дивіться нижче моделі та сценарій запиту:

class User < ApplicationRecord  
 has_many :posts  
 has_many :comments  
 has_many :recent_posts, -> { recent }, class_name: "Post"  
end  

class Post < ApplicationRecord  
 belongs_to :user  
 has_many :comments  
 has_many :recent_comments, -> { recent }, class_name: "Comment"  

 scope :recent, -> { where(date: (Date.yesterday..Date.today)) }  
end  

class Comment < ApplicationRecord  
 belongs_to :post  
 belongs_to :user  

 scope :recent, -> { where(date: (Date.yesterday..Date.today)) }  
end

Тут у нас є таблиці users, posts та comments, і ми маємо область “recent” у posts і comments, щоб отримати останні (за останні 2 дні) пости та коментарі.
Я додав нові асоціації recentposts і recentcomments для виклику області recent на постах і коментарях.

Сценарій: Отримати останні пости конкретного користувача (@user), які мають коментарі, додані адміністратором

Я намагався застосувати область recent_comments безпосередньо до recent_comments для користувача,

filtered_posts = @user.recent_posts  
 .recent_comments  
 .where(comments: { user_id: admin.id })

Але, область recent_comments визначена для Post, а не для колекції постів, і @user.recent_posts повертає колекцію, тому це не працюватиме!

Тоді я знайшов рішення, де область може бути застосована безпосередньо до класу!

filtered_posts = @user.recent_posts  
 .includes(comments: :user)  
 .where(comments: { user_id: admin.id })  
 .merge(Comment.recent)

Тут:

  1. recent_posts — це область, визначена для отримання останніх постів за останні 2 дні.
  2. Метод includes об'єднує таблиці і завантажує асоціацію comments, і в межах коментарів також завантажує асоційованого user.
  3. Фільтрує коментарі, включаючи лише ті, що були створені вказаним користувачем admin.
  4. Застосовує merge до області recent для коментарів. Ви можете побачити запит merge тут.
  5. .merge(Comment.recent) застосовує область recent до класу Comment, який об'єднується з recent_posts.
  6. Ми також можемо використовувати joins замість includes, щоб забезпечити фільтрацію на рівні бази даних.

Таким чином, ми можемо ланцюжити області, оптимізувати запити та підвищити продуктивність застосунку!!

Дякую 🙂

Перекладено з: Speed Up Queries with Nested Scopes in Rails