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.

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.
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.
# 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
endTrong 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.
<!-- app/views/articles/index.html.erb -->
<!-- This view generates N additional queries -->
<% @articles.each do |article| %>
<div class="article">
<h2><%= article.title %></h2>
<!-- Each call generates: SELECT * FROM users WHERE id = ? -->
<p>By <%= article.author.name %></p>
</div>
<% 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.
-- 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 queriesKhắ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.
# 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
endVớ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.
-- 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.
# 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
endNế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.
# 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.
# 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" ASCincludes: 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.
# 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
endBả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.
# Gemfile
# Bullet detects N+1 in development
group :development do
gem 'bullet'
endCấ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.
# 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
endKhi 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ị.
# 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:5Trong 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ử.
# Gemfile
# Prosopite as an alternative to Bullet
group :development, :test do
gem 'prosopite'
end# config/environments/development.rb
# Prosopite configuration
Rails.application.configure do
config.after_initialize do
Prosopite.rails_logger = true
Prosopite.raise = Rails.env.test?
end
endCá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.
# 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
endTải nghiêm ngặt cũng có thể được kích hoạt cho một truy vấn cụ thể.
# 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
endSelect 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.
# 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
endCounter 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.
# 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
endMigration thêm cột đếm với một giá trị mặc định.
# 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
endSau 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.
# app/views/articles/index.html.erb
# Using counter cache (no SQL query)
<% @articles.each do |article| %>
<p><%= article.title %> - <%= article.comments_count %> comments</p>
<% end %>Sẵn sàng chinh phục phỏng vấn Ruby on Rails?
Luyện tập với mô phỏng tương tác, flashcards và bài kiểm tra kỹ thuật.
Thực hành tốt nhất và checklist
Một cách tiếp cận có hệ thống ngăn ngừa các vấn đề N+1 trong các phát triển mới và sửa chữa dần các đoạn mã hiện có.
Phân tích view trước khi viết mã
Trước khi viết mã controller, nên phân tích view để xác định tất cả các liên kết được truy cập. Việc dự đoán này giúp tránh thiếu sót.
# 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
endScope có thể tái sử dụng
Việc tập trung các includes thường dùng vào scope giúp đơn giản hóa việc bảo trì và đảm bảo tính nhất quán.
# 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
}
endChecklist phòng ngừa
Checklist này tóm tắt các điểm kiểm tra thiết yếu để tránh các vấn đề N+1:
- Cài đặt và cấu hình Bullet hoặc Prosopite trong môi trường phát triển
- Kích hoạt Bullet.raise trong CI để chặn các sự thoái lùi
- Phân tích các view để xác định liên kết trước khi viết controller
- Sử dụng
includesmặc định,eager_loadnếu lọc trên liên kết - Tạo các scope tái sử dụng cho các mẫu tải trước thường gặp
- Áp dụng strict loading trên các model nhạy cảm
- Thêm counter cache cho các phép đếm thường xuyên
- Kiểm tra log SQL thường xuyên trong môi trường phát triển
Tải trước quá nhiều liên kết tiêu tốn bộ nhớ một cách không cần thiết. Chỉ những liên kết thực sự được view sử dụng mới nên được tải trước. Các công cụ như Bullet cũng phát hiện "unused eager loading".
Kết luận
Các truy vấn N+1 là một vấn đề hiệu năng quan trọng nhưng dễ phòng ngừa trong các ứng dụng Rails. Sự kết hợp giữa các công cụ phát hiện tự động và các thực hành phát triển tốt sẽ loại bỏ vấn đề này một cách hiệu quả.
Những điểm chính:
includesgiải quyết hầu hết các trường hợp N+1 bằng cách tải trước các liên kếteager_loadlà cần thiết khi lọc hoặc sắp xếp trên các liên kết- Bullet và Prosopite tự động phát hiện các vấn đề trong môi trường phát triển
- Strict loading ngăn chặn N+1 bằng cách ném ra ngoại lệ
- Counter cache tối ưu hóa các phép đếm thường xuyên
- Phân tích các view trước khi viết controller giúp tránh thiếu sót
Bắt đầu luyện tập!
Kiểm tra kiến thức với mô phỏng phỏng vấn và bài kiểm tra kỹ thuật.
Thẻ
Chia sẻ
Bài viết liên quan

Câu hỏi phỏng vấn Ruby on Rails: Top 25 năm 2026
25 câu hỏi phỏng vấn Ruby on Rails phổ biến nhất. Kiến trúc MVC, Active Record, migration, kiểm thử RSpec, REST API kèm câu trả lời chi tiết và ví dụ mã.

Ruby on Rails 7: Hotwire va Turbo cho Ung Dung Phan Hoi
Huong dan day du ve Hotwire va Turbo trong Rails 7. Xay dung ung dung phan hoi khong can JavaScript voi Turbo Drive, Frames va Streams.

Rails API Mode năm 2026: Xây Dựng RESTful API, Serialization và Câu Hỏi Phỏng Vấn
Hướng dẫn Rails API Mode 2026: RESTful route, serialization Alba vs jsonapi-serializer, JWT, xử lý lỗi và RSpec testing.