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

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

<% end %> ``` ## 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. ```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 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. ```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 ``` ### Checklist 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 `includes` mặc định, `eager_load` nế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 > **Cảnh báo về tải trước quá mức** > > 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:** - `includes` giả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ết - `eager_load` là 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 --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/vi/blog/ruby-on-rails/activerecord-fixing-n-plus-1-query-problems