# 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| %>