# ActiveRecord: вирішення проблем запитів N+1 у Ruby on Rails > Повний посібник з виявлення та усунення запитів N+1 у Rails з ActiveRecord. Опануйте includes, preload, eager_load та інструменти автоматичного виявлення. - Published: 2026-02-23 - Updated: 2026-04-29 - Author: Anthony Fillion-Maillet - Tags: ruby on rails, activerecord, performance, n+1 queries, sql optimization - Reading time: 10 min --- Запити N+1 є однією з найпоширеніших проблем продуктивності у застосунках Rails. Простий цикл по записах може спричинити сотні непотрібних SQL-запитів і суттєво уповільнити час відповіді. Цей посібник охоплює техніки виявлення та усунення задля забезпечення швидких застосунків Rails. > **Вплив у продакшені** > > Сторінка, що відображає 50 статей разом із їхніми авторами, може згенерувати 51 SQL-запит замість одного. У продакшені з тисячами користувачів ця проблема стає критичною для часу відповіді та навантаження на сервер. ## Розуміння проблеми N+1 Проблема N+1 виникає, коли код виконує один запит для отримання списку записів (1 запит), а потім запускає додатковий запит для кожного запису, щоб отримати доступ до його асоціацій (N запитів). Назва "N+1" точно описує цей шаблон: 1 початковий запит + N запитів для асоціацій. Конкретний приклад зі статтями та їхніми авторами демонструє цю проблему. Без оптимізації кожен доступ до автора статті спричиняє новий SQL-запит. ```ruby # app/controllers/articles_controller.rb # Example code generating an N+1 problem class ArticlesController < ApplicationController def index # 1 query: SELECT * FROM articles @articles = Article.all end end ``` У view кожен виклик `article.author` спричиняє додатковий запит до бази даних. ```erb <% @articles.each do |article| %>

<%= article.title %>

By <%= article.author.name %>

<% end %> ``` Для 100 статей цей код генерує 101 SQL-запит. Логи Rails чітко відображають проблему через повторювані запити. ```sql -- Rails logs showing the N+1 problem -- 1 initial query SELECT "articles".* FROM "articles" -- N queries for authors (repeated for each article) SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1 SELECT "users".* FROM "users" WHERE "users"."id" = 2 LIMIT 1 SELECT "users".* FROM "users" WHERE "users"."id" = 3 LIMIT 1 -- ... 97 more queries ``` ## Розв'язання за допомогою includes Метод `includes` є найпоширенішим і рекомендованим рішенням для усунення проблем N+1. Він вказує ActiveRecord попередньо завантажити асоціації одним або двома оптимізованими запитами. ```ruby # app/controllers/articles_controller.rb # Solution with includes - preloading authors class ArticlesController < ApplicationController def index # Preloads authors with articles # Generates only 2 queries instead of N+1 @articles = Article.includes(:author).all end end ``` З `includes` ActiveRecord виконує лише два запити незалежно від кількості статей. Перший отримує всі статті, другий — усіх відповідних авторів. ```sql -- Rails logs with includes (only 2 queries) SELECT "articles".* FROM "articles" SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3, 4, 5, ...) ``` Вкладені асоціації також можна попередньо завантажити за допомогою синтаксису hash. Цей підхід є необхідним, коли view звертаються до кількох рівнів асоціацій. ```ruby # app/controllers/articles_controller.rb # Preloading nested associations class ArticlesController < ApplicationController def index # Preloads author -> company and all comments @articles = Article.includes(author: :company, comments: :user) end end ``` > **Золоте правило** > > Якщо view звертається до асоціації всередині циклу, цю асоціацію слід попередньо завантажити в контролері за допомогою `includes`. Слід завжди перевіряти шаблони доступу до асоціацій у view. ## Відмінності між includes, preload та eager_load Rails надає три методи для попереднього завантаження асоціацій. Кожен використовує іншу SQL-стратегію з конкретними сценаріями застосування. ### preload: окремі запити Метод `preload` завжди виконує окремі запити для кожної асоціації. Він ефективно працює, коли жодна умова WHERE не фільтрує по асоціаціях. ```ruby # app/models/article.rb # preload always uses separate queries class Article < ApplicationRecord scope :with_authors, -> { preload(:author) } end # Usage in controller @articles = Article.with_authors.limit(20) # SQL generated: # SELECT "articles".* FROM "articles" LIMIT 20 # SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3, ...) ``` ### eager_load: LEFT OUTER JOIN Метод `eager_load` використовує LEFT OUTER JOIN для завантаження даних одним запитом. Він стає обов'язковим, коли потрібне фільтрування або сортування за стовпцями асоціацій. ```ruby # app/controllers/articles_controller.rb # eager_load allows filtering on associations class ArticlesController < ApplicationController def verified_authors # Filters articles by author status # Requires eager_load because WHERE references users @articles = Article.eager_load(:author) .where(users: { verified: true }) .order("users.name ASC") end end # SQL generated (single query with JOIN): # SELECT "articles"."id", "articles"."title", ... # FROM "articles" # LEFT OUTER JOIN "users" ON "users"."id" = "articles"."author_id" # WHERE "users"."verified" = TRUE # ORDER BY "users"."name" ASC ``` ### includes: розумна поведінка Метод `includes` автоматично обирає найкращу стратегію. За замовчуванням використовує `preload`, але переходить на `eager_load`, якщо клауза WHERE звертається до асоціації. ```ruby # app/controllers/articles_controller.rb # includes adapts automatically to context class ArticlesController < ApplicationController def index # No condition on association: uses preload (2 queries) @articles = Article.includes(:author).all end def by_verified_authors # With condition on association: uses eager_load (JOIN) @articles = Article.includes(:author) .where(users: { verified: true }) end end ``` Наступна таблиця підсумовує відмінності між трьома методами. | Метод | SQL-стратегія | Сценарій | |-------|---------------|----------| | `preload` | Окремі запити | Просте попереднє завантаження, без фільтрації | | `eager_load` | LEFT OUTER JOIN | Фільтрація/сортування по асоціаціях | | `includes` | Автоматична | Загальне використання, рекомендоване за замовчуванням | ## Автоматизоване виявлення N+1 Ручне виявлення проблем N+1 є нудним і схильним до помилок. Кілька інструментів автоматизують це виявлення в розробці та CI. ### Bullet: виявлення в реальному часі Gem Bullet аналізує SQL-запити в реальному часі та сповіщає про виявлені проблеми N+1. Він також пропонує відповідні виправлення. ```ruby # Gemfile # Bullet detects N+1 in development group :development do gem 'bullet' end ``` Конфігурація в середовищі розробки дозволяє вмикати різні режими сповіщень. ```ruby # config/environments/development.rb # Bullet configuration to detect N+1 Rails.application.configure do config.after_initialize do Bullet.enable = true # Display JavaScript alert in browser Bullet.alert = true # Add footer with details Bullet.bullet_logger = true # Display in Rails logs Bullet.rails_logger = true # Raise exception (useful in CI) Bullet.raise = false end end ``` Коли проблема N+1 виявляється, Bullet відображає однозначне повідомлення з рекомендованим рішенням. ```text # Example Bullet alert in logs USE eager loading detected Article => [:author] Add to your query: .includes([:author]) Call stack: /app/views/articles/index.html.erb:5 ``` > **Bullet у CI** > > У безперервній інтеграції увімкнення `Bullet.raise = true` спричиняє провал тестів, якщо виявляється проблема N+1. Це запобігає регресіям продуктивності. ### Prosopite: легка альтернатива Gem Prosopite пропонує легшу альтернативу Bullet з мінімальною конфігурацією та сумісністю з тестами. ```ruby # Gemfile # Prosopite as an alternative to Bullet group :development, :test do gem 'prosopite' end ``` ```ruby # config/environments/development.rb # Prosopite configuration Rails.application.configure do config.after_initialize do Prosopite.rails_logger = true Prosopite.raise = Rails.env.test? end end ``` ## Розширені техніки оптимізації Окрім базових методів, кілька технік дозволяють точно налаштовувати оптимізацію запитів ActiveRecord. ### Strict Loading: запобігання за замовчуванням Rails 6.1+ надає режим суворого завантаження, який викликає виняток при доступі до попередньо не завантаженої асоціації. Цей запобіжний підхід змушує вирішувати N+1 ще на етапі розробки. ```ruby # app/models/article.rb # Enable strict loading by default on the model class Article < ApplicationRecord # Any non-preloaded association access raises an exception self.strict_loading_by_default = true belongs_to :author has_many :comments end ``` Суворе завантаження також можна вмикати для конкретного запиту. ```ruby # app/controllers/articles_controller.rb # Strict loading on a specific query class ArticlesController < ApplicationController def index # Raises StrictLoadingViolationError if a non-included # association is accessed @articles = Article.strict_loading.includes(:author) end end ``` ### Select і Pluck для часткових даних Коли потрібні лише певні стовпці, `select` і `pluck` зменшують обсяг даних, що передаються з бази. ```ruby # app/controllers/reports_controller.rb # Optimization with select and pluck class ReportsController < ApplicationController def titles_only # select returns Article objects with only id and title @articles = Article.select(:id, :title) end def title_array # pluck returns an Array of values, not AR objects # More performant when only values are needed @titles = Article.pluck(:title) # => ["First article", "Second article", ...] end end ``` ### Counter Cache для підрахунків Підрахунки асоціацій (`article.comments.count`) генерують SQL-запит при кожному виклику. Counter cache зберігає цей підрахунок безпосередньо в батьківській таблиці. ```ruby # app/models/comment.rb # Counter cache configuration class Comment < ApplicationRecord # Rails automatically maintains the counter in articles.comments_count belongs_to :article, counter_cache: true end ``` Міграція додає стовпець підрахунку зі значенням за замовчуванням. ```ruby # db/migrate/20260223_add_comments_count_to_articles.rb # Migration to add counter cache class AddCommentsCountToArticles < ActiveRecord::Migration[7.1] def change add_column :articles, :comments_count, :integer, default: 0, null: false # Initialize counters for existing data Article.find_each do |article| Article.reset_counters(article.id, :comments) end end end ``` Після цієї конфігурації `article.comments_count` зчитує стовпець безпосередньо без додаткових SQL-запитів. ```ruby # app/views/articles/index.html.erb # Using counter cache (no SQL query) <% @articles.each do |article| %>

<%= article.title %> - <%= article.comments_count %> comments

<% end %> ``` ## Найкращі практики та чекліст Систематичний підхід запобігає проблемам N+1 у нових розробках і поступово виправляє існуючий код. ### Аналіз view перед написанням коду Перед написанням коду контролера варто проаналізувати view, щоб виявити всі асоціації, до яких відбувається звернення. Така попередня робота запобігає пропускам. ```ruby # app/controllers/articles_controller.rb # Pre-analyze view to identify required includes class ArticlesController < ApplicationController def show # View accesses: author, author.company, comments, comments.user # All these must be preloaded @article = Article.includes( author: :company, comments: :user ).find(params[:id]) end end ``` ### Багаторазові scope Централізація частих includes у scope спрощує підтримку та забезпечує узгодженість. ```ruby # app/models/article.rb # Reusable scopes for preloading class Article < ApplicationRecord # Scope for list display scope :with_author, -> { includes(:author) } # Scope for detailed display scope :with_full_details, -> { includes( author: :company, comments: { user: :avatar_attachment }, tags: [] ) } # Scope for admin with all relations scope :for_admin, -> { includes(:author, :comments, :tags, :category) .with_attached_cover_image } end ``` ### Запобіжний чекліст Цей чекліст підсумовує ключові пункти перевірки для уникнення проблем N+1: - Встановити та налаштувати Bullet або Prosopite у розробці - Увімкнути Bullet.raise у CI для блокування регресій - Аналізувати view, щоб ідентифікувати асоціації перед написанням контролерів - За замовчуванням використовувати `includes`, `eager_load` при фільтрації по асоціаціях - Створювати багаторазові scope для частих шаблонів попереднього завантаження - Застосовувати strict loading до чутливих моделей - Додавати counter cache для частих підрахунків - Регулярно перевіряти SQL-логи у розробці > **Обережно з надмірним попереднім завантаженням** > > Попереднє завантаження занадто великої кількості асоціацій непотрібно споживає пам'ять. Слід попередньо завантажувати лише ті асоціації, які view дійсно використовує. Інструменти на кшталт Bullet також виявляють "unused eager loading". ## Висновок Запити N+1 є вагомою проблемою продуктивності, якій легко запобігти у застосунках Rails. Поєднання автоматизованих інструментів виявлення та найкращих практик розробки ефективно усуває цю проблему. **Ключові висновки:** - `includes` вирішує більшість випадків N+1 шляхом попереднього завантаження асоціацій - `eager_load` потрібен при фільтрації або сортуванні по асоціаціях - Bullet і Prosopite автоматично виявляють проблеми у розробці - Strict loading запобігає N+1, викликаючи винятки - Counter cache оптимізує часті підрахунки - Аналіз view перед написанням контролерів запобігає пропускам --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/uk/blog/ruby-on-rails/activerecord-fixing-n-plus-1-query-problems