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.

Khắc phục các vấn đề truy vấn N+1 với ActiveRecord trong Ruby on Rails

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
<!-- 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.

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, selectpluck 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| %>
  <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.

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

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ẻ

#ruby on rails
#activerecord
#performance
#n+1 queries
#sql optimization

Chia sẻ

Bài viết liên quan