# ActiveRecord: Khắc phục các vấn đề truy vấn N+1 trong Ruby on Rails
> Hướng dẫn đầy đủ về cách phát hiện và xử lý các truy vấn N+1 trong Rails với ActiveRecord. Làm chủ includes, preload, eager_load và các công cụ phát hiện tự động.
- 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
---
Các truy vấn N+1 là một trong những vấn đề hiệu năng phổ biến nhất trong các ứng dụng Rails. Một vòng lặp đơn giản trên các bản ghi có thể kích hoạt hàng trăm truy vấn SQL không cần thiết, làm chậm đáng kể thời gian phản hồi. Hướng dẫn này trình bày các kỹ thuật phát hiện và khắc phục để đảm bảo các ứng dụng Rails có hiệu năng tốt.
> **Tác động trong môi trường production**
>
> Một trang hiển thị 50 bài viết cùng tác giả của chúng có thể tạo ra 51 truy vấn SQL thay vì chỉ một. Trong môi trường production với hàng ngàn người dùng, vấn đề này trở nên nghiêm trọng đối với thời gian phản hồi và tải máy chủ.
## Hiểu vấn đề N+1
Vấn đề N+1 xảy ra khi mã thực thi một truy vấn để lấy danh sách bản ghi (1 truy vấn), sau đó chạy một truy vấn bổ sung cho mỗi bản ghi để truy cập các liên kết của nó (N truy vấn). Tên gọi "N+1" mô tả chính xác mẫu này: 1 truy vấn ban đầu + N truy vấn cho các liên kết.
Một ví dụ cụ thể với các bài viết và tác giả của chúng minh họa rõ vấn đề. Không có tối ưu hóa, mỗi lần truy cập tác giả của một bài viết đều kích hoạt một truy vấn SQL mới.
```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
```
Trong view, mỗi lần gọi `article.author` đều kích hoạt một truy vấn bổ sung tới cơ sở dữ liệu.
```erb
<% @articles.each do |article| %>
<%= article.title %>
By <%= article.author.name %>
<% end %>
```
Đối với 100 bài viết, đoạn mã này tạo ra 101 truy vấn SQL. Log của Rails thể hiện rõ vấn đề thông qua các truy vấn lặp đi lặp lại.
```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
```
## Khắc phục với includes
Phương thức `includes` là giải pháp phổ biến nhất và được khuyến nghị để khắc phục các vấn đề N+1. Phương thức này yêu cầu ActiveRecord tải trước các liên kết trong một hoặc hai truy vấn được tối ưu hóa.
```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
```
Với `includes`, ActiveRecord chỉ thực thi hai truy vấn bất kể số lượng bài viết là bao nhiêu. Truy vấn đầu tiên lấy tất cả các bài viết, truy vấn thứ hai lấy tất cả các tác giả liên quan.
```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, ...)
```
Các liên kết lồng nhau cũng có thể được tải trước bằng cú pháp hash. Cách tiếp cận này là cần thiết khi các view truy cập nhiều cấp độ liên kết.
```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
```
> **Quy tắc vàng**
>
> Nếu một view truy cập một liên kết bên trong một vòng lặp, liên kết đó phải được tải trước trong controller bằng `includes`. Cần luôn kiểm tra các mẫu truy cập liên kết trong các view.
## Sự khác biệt giữa includes, preload và eager_load
Rails cung cấp ba phương thức để tải trước các liên kết. Mỗi phương thức sử dụng một chiến lược SQL khác nhau, với các trường hợp sử dụng cụ thể.
### preload: các truy vấn riêng biệt
Phương thức `preload` luôn thực thi các truy vấn riêng biệt cho từng liên kết. Phương thức này hoạt động hiệu quả khi không có điều kiện WHERE nào lọc trên các liên kết.
```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
Phương thức `eager_load` sử dụng LEFT OUTER JOIN để tải dữ liệu trong một truy vấn duy nhất. Phương thức này trở nên bắt buộc khi cần lọc hoặc sắp xếp theo các cột của liên kết.
```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: hành vi thông minh
Phương thức `includes` tự động chọn chiến lược tốt nhất. Mặc định, phương thức này dùng `preload`, nhưng chuyển sang `eager_load` nếu có mệnh đề WHERE tham chiếu đến liên kết.
```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
```
Bảng dưới đây tóm tắt sự khác biệt giữa ba phương thức.
| Phương thức | Chiến lược SQL | Trường hợp sử dụng |
|-------------|----------------|--------------------|
| `preload` | Truy vấn riêng biệt | Tải trước đơn giản, không lọc |
| `eager_load` | LEFT OUTER JOIN | Lọc/sắp xếp trên liên kết |
| `includes` | Tự động | Dùng chung, mặc định khuyến nghị |
## Phát hiện N+1 tự động
Việc phát hiện thủ công các vấn đề N+1 rất tẻ nhạt và dễ sai sót. Một số công cụ tự động hóa việc phát hiện này trong môi trường phát triển và CI.
### Bullet: phát hiện theo thời gian thực
Gem Bullet phân tích các truy vấn SQL theo thời gian thực và cảnh báo về các vấn đề N+1 được phát hiện. Gem này cũng đề xuất các cách khắc phục phù hợp.
```ruby
# Gemfile
# Bullet detects N+1 in development
group :development do
gem 'bullet'
end
```
Cấu hình trong môi trường phát triển cho phép kích hoạt nhiều chế độ cảnh báo khác nhau.
```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
```
Khi phát hiện một vấn đề N+1, Bullet hiển thị một thông báo rõ ràng kèm theo giải pháp được khuyến nghị.
```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 trong CI**
>
> Trong tích hợp liên tục, kích hoạt `Bullet.raise = true` khiến các bài kiểm thử thất bại nếu phát hiện vấn đề N+1. Điều này giúp ngăn chặn các sự thoái lùi về hiệu năng.
### Prosopite: lựa chọn thay thế nhẹ
Gem Prosopite cung cấp một lựa chọn thay thế nhẹ hơn so với Bullet, với cấu hình tối thiểu và tương thích với các bài kiểm thử.
```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
```
## Các kỹ thuật tối ưu hóa nâng cao
Ngoài các phương thức cơ bản, một số kỹ thuật cho phép tinh chỉnh việc tối ưu hóa truy vấn ActiveRecord.
### Strict Loading: phòng ngừa mặc định
Rails 6.1+ cung cấp chế độ tải nghiêm ngặt, ném ra một ngoại lệ nếu một liên kết chưa được tải trước được truy cập. Cách tiếp cận phòng ngừa này buộc phải xử lý N+1 ngay trong quá trình phát triển.
```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
```
Tải nghiêm ngặt cũng có thể được kích hoạt cho một truy vấn cụ thể.
```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 và Pluck cho dữ liệu một phần
Khi chỉ cần một số cột nhất định, `select` và `pluck` giảm lượng dữ liệu được truyền từ cơ sở dữ liệu.
```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 cho việc đếm
Việc đếm các liên kết (`article.comments.count`) tạo ra một truy vấn SQL trên mỗi lần gọi. Counter cache lưu trữ số đếm này trực tiếp trong bảng cha.
```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
```
Migration thêm cột đếm với một giá trị mặc định.
```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
```
Sau khi cấu hình này, `article.comments_count` đọc trực tiếp cột mà không cần truy vấn SQL bổ sung.
```ruby
# app/views/articles/index.html.erb
# Using counter cache (no SQL query)
<% @articles.each do |article| %>