1. N+1 Query là gì?
N+1 query là một vấn đề hiệu năng thường gặp khi sử dụng ActiveRecord trong Rails. Nó xảy ra khi bạn thực hiện 1 truy vấn để lấy danh sách các bản ghi (N), và sau đó Rails lại thực hiện thêm 1 truy vấn cho mỗi bản ghi trong danh sách đó để lấy dữ liệu liên quan.
🔴 Ví dụ:
# Post has_many :comments
posts = Post.all
posts.each do |post| puts post.comments.count
end
❌ Điều gì đang xảy ra?
Rails sẽ thực hiện:
- 1 query để lấy toàn bộ posts
- Rồi 1 query cho mỗi post để lấy comments, dẫn đến N truy vấn phụ
==> Tổng cộng: 1 + N queries → gọi là N+1 query problem. Khi kiểm tra logs bạn sẽ thấy có những query lặp lại như:
Post Load (0.4ms) SELECT "posts".* FROM "posts" ORDER BY "posts"."name" ASC LIMIT $1
Comment Load (0.1ms) SELECT "comments".* FROM "comments" WHERE "comments"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
Comment Load (0.1ms) SELECT "comments".* FROM "comments" WHERE "comments"."id" = $1 LIMIT $2 [["id", 2], ["LIMIT", 1]]
Comment Load (0.1ms) SELECT "comments".* FROM "comments" WHERE "comments"."id" = $1 LIMIT $2 [["id", 3], ["LIMIT", 1]]
Comment Load (0.1ms) SELECT "comments".* FROM "comments" WHERE "comments"."id" = $1 LIMIT $2 [["id", 4], ["LIMIT", 1]]
Comment Load (0.1ms) SELECT "comments".* FROM "comments" WHERE "comments"."id" = $1 LIMIT $2 [["id", 5], ["LIMIT", 1]]
Comment Load (0.1ms) SELECT "comments".* FROM "comments" WHERE "comments"."id" = $1 LIMIT $2 [["id", 6], ["LIMIT", 1]]
Comment Load (0.1ms) SELECT "comments".* FROM "comments" WHERE "comments"."id" = $1 LIMIT $2 [["id", 7], ["LIMIT", 1]]
2. Tại sao đây là vấn đề lớn?
-
Dễ không phát hiện trong dev, nhưng cực kỳ tốn kém trong production.
-
Với vài dòng code tưởng chừng vô hại, bạn có thể khiến hệ thống thực hiện hàng trăm hoặc hàng ngàn truy vấn SQL không cần thiết.
-
Tăng tải cho database, giảm performance, làm chậm response time rõ rệt.
3. Cách nhận biết N+1 Query
Dùng gem bullet
# Gemfile
group :development do gem 'bullet'
end
# config/environments/development.rb
config.after_initialize do Bullet.enable = true Bullet.alert = true Bullet.bullet_logger = true Bullet.console = true
end
Gem bullet sẽ cảnh báo bạn ngay khi xảy ra N+1.
4. Các Dạng N+1 Query Trong Rails và Cách Xử Lý Từng Trường Hợp
Trường hợp 1
❌ Code gây ra N+1:
posts = Post.all
posts.each do |post| puts post.comments.count
end
🧨 Vấn đề:
Post.all
chạy 1 query
post.comments.count
gọi 1 query riêng cho mỗi post → tổng cộng 1 + N query
Giải pháp:
Sử dụng includes để preload toàn bộ comments cho posts chỉ với 1 truy vấn phụ.
Dùng .size (thay vì .count) giúp Rails dùng mảng đã preload thay vì query lại DB.
posts = Post.includes(:comments)
posts.each do |post| puts post.comments.size
end
Trường hợp 2
❌ Code gây ra N+1:
posts = Post.all
posts.each do |post| puts post.comments.where(created_at: ..(Time.now - 1.day)).count
end
🧨 Vấn đề:
Dù bạn có dùng includes(:comments) thì điều kiện where(created_at: …) sẽ khiến Rails không thể tái sử dụng bản preload vì Rails không biết trước điều kiện để preload đúng dữ liệu. Vì vậy mỗi post sẽ phải gọi một SQL như sau:
SELECT COUNT(*) FROM comments WHERE post_id = ? AND created_at <= ?
✅ Giải pháp: Dùng eager_load hoặc tự preload theo cách thủ công với group_by.
Cách 1: Dùng eager_load nếu cần lọc tại DB:
posts = Post.eager_load(:comments).where(comments: { created_at: ..(Time.now - 1.day) })
posts.each do |post| puts post.comments.count
end
eager_load ép Rails dùng LEFT OUTER JOIN để lấy dữ liệu từ cả posts và comments trong một truy vấn SQL duy nhất. Điều này rất hữu ích khi bạn cần filter hoặc sort theo bảng con. Từ đó giúp tối ưu khi số lượng bản ghi lớn và bạn muốn lọc ngay trong SQL.
Cách 2: Tự preload toàn bộ comment, rồi lọc bằng Ruby:
posts = Post.includes(:comments)
posts.each do |post| filtered = post.comments.select { |c| c.created_at <= (Time.now - 1.day) } puts filtered.size
end
📌 **Lưu ý: **Cách 1 hiệu quả hơn nếu dữ liệu lớn, và cần filter trong DB. Cách 2 tốt nếu bạn đã preload tất cả và filter nhẹ bằng Ruby.
Trường hợp 3
❌ Code gây ra N+1 * 2:
posts = Post.all
posts.each do |post| total_active_comments = post.comments.where(active: true).count total_inactive_comments = post.comments.where(inactive: true).count puts "Active: #{total_active_comments}" puts "Inactive: #{total_inactive_comments}"
end
🧨 Vấn đề:
2 truy vấn cho mỗi post: tổng cộng 1 + 2×N queries!, khiến includes không xử lý được nhiều where khác nhau trên cùng một quan hệ.
✅ Giải pháp:
Rails không hỗ trợ includes với nhiều điều kiện khác nhau trong cùng một quan hệ. Do đó, cần preload tất cả rồi xử lý bằng Ruby (hoặc custom preload bằng SQL như trên).
Cách 1: Preload và filter trong Ruby
Sử dụng includes để load danh sách comments, sau đó sử dụng select của Array để lọc lại danh sách mà không thực hiện lại truy vấn. Tuy nhiên cách này chỉ nên áp dụng đối với các tập dữ liệu nhỏ.
posts = Post.includes(:comments)
posts.each do |post| active_comments = post.comments.select(&:active) inactive_comments = post.comments.select(&:inactive) puts "Active: #{active_comments.size}" puts "Inactive: #{inactive_comments.size}"
end
Cách 2: Custom preload với điều kiện (nếu dữ liệu rất lớn)
Nếu số lượng comment cực lớn, bạn có thể preload có điều kiện riêng:
# preload theo cách riêng bằng SQL
comments = Comment.where(active: [true, false], post_id: Post.select(:id)) .group_by(&:post_id) posts = Post.all
posts.each do |post| post_comments = comments[post.id] || [] active_count = post_comments.count(&:active) inactive_count = post_comments.count(&:inactive) puts "Active: #{active_count}" puts "Inactive: #{inactive_count}"
end
So Sánh: includes vs preload vs eager_load
Tiêu chí | includes |
preload |
eager_load |
---|---|---|---|
Mục đích | Tự động chọn giữa preload và eager_load tùy ngữ cảnh |
Luôn preload dữ liệu bằng các truy vấn riêng biệt | Luôn dùng LEFT OUTER JOIN để preload dữ liệu trong một truy vấn duy nhất |
Số lượng truy vấn SQL | Tùy ngữ cảnh: thường là 2 (posts , comments ) |
Luôn 2 truy vấn riêng biệt | Thường chỉ 1 truy vấn với JOIN |
Có thể dùng khi có điều kiện ở bảng liên kết? | Có, nếu dùng kết hợp với references(:association) |
❌ Không hỗ trợ, vì không có JOIN | ✅ Hỗ trợ lọc, sắp xếp theo bảng liên kết vì JOIN |
Tốc độ thực thi | Nhanh và thông minh nếu không cần lọc bảng liên kết | Nhanh hơn JOIN khi số bản ghi nhỏ và không cần truy vấn bảng con | Có thể chậm hơn nếu JOIN tạo bản ghi trùng lặp |
Có bị trùng bản ghi? | Không | Không | ✅ Có thể bị trùng nếu quan hệ 1-nhiều |
Dễ hiểu, dễ debug | ⚠️ Khó đoán – Rails tự chọn giữa preload hoặc eager_load | ✅ Rõ ràng, dễ đoán | ✅ Rõ ràng, dễ đoán |
Thích hợp khi nào? | Khi bạn không chắc có cần JOIN hay không | Khi bạn chỉ cần preload và không lọc bảng liên kết | Khi cần lọc, sort, query theo bảng liên kết |
Dùng được với .where trên bảng liên kết? |
⚠️ Có thể – cần .references(:association) |
❌ Không – không JOIN → ActiveRecord sẽ raise error hoặc sai kết quả | ✅ Có thể – JOIN có sẵn |
Tương thích với distinct , group , select tùy chỉnh? |
⚠️ Có nhưng phải cẩn thận, nhất là nếu Rails chọn eager_load |
✅ Tương thích cao | ⚠️ Cẩn trọng – dễ gây lỗi hoặc kết quả sai khi kết hợp với group , select |
Query SQL sinh ra | Linh hoạt: JOIN nếu có điều kiện, IN nếu không | Luôn 2 truy vấn IN |
JOIN phức tạp, có thể nhiều cột lặp lại |
Tổng Kết
N+1 query là một trong những lỗi phổ biến nhất trong ứng dụng Rails, dễ mắc phải nhưng lại gây ảnh hưởng nghiêm trọng đến hiệu năng, đặc biệt khi ứng dụng scale. Qua các ví dụ thực tế, ta thấy rằng:
- Việc gọi .count, .where, .each, hay truy cập các quan hệ liên kết mà không preload đúng cách sẽ dẫn đến N+1 query.
- Rails cung cấp nhiều công cụ để xử lý vấn đề này: includes, preload, eager_load, mỗi loại có ưu và nhược điểm riêng, và cần được sử dụng đúng ngữ cảnh.
- Việc chọn đúng kỹ thuật không chỉ cải thiện hiệu năng mà còn giúp hệ thống bền vững hơn về lâu dài.