- vừa được xem lúc

Xử Lý Triệt Để N+1 Query Trong Rails

0 0 1

Người đăng: Hữu Ngọc Tiên Sinh

Theo Viblo Asia

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

Bình luận

Bài viết tương tự

- vừa được xem lúc

Bài toán tìm đường đi ngắn nhất với giải thuật Dijkstra

Với các bạn sinh viên chuyên ngành công nghệ thông tin, chắc không lạ gì với bài toán tìm đường đi ngắn nhất (Shortest Path Problems) trong đồ thị trọng số nữa. Ở bài viết lần này, mình sẽ làm 3 việc:.

0 0 146

- vừa được xem lúc

Tôi cá là bạn không biết những điều này - Ruby on rails ( Phần 2)

Các bạn có thể theo dõi phần 1 ở đây :. https://viblo.asia/p/toi-ca-la-ban-khong-biet-nhung-dieu-nay-ruby-on-rails-phan-1-WAyK8DDeKxX. 5.

0 0 235

- vừa được xem lúc

Những thay đổi trong ruby 3.0

. 2020 là một năm lớn đối với cộng đồng Ruby. Những người sáng lập Ruby có một món quà thực sự tuyệt vời cho chúng ta vào giáng sinh với việc phát hành Ruby 3.

0 0 60

- vừa được xem lúc

Có gì đặc biệt trong phiên bản Ruby 3x3 ?

Hello guys, chắc hẳn thời gian vừa rồi chúng ta cũng đã nghe qua thông tin Ruby sắp cho ra mắt Ruby version 3, hay còn được gọi là ruby 3x3, vậy liệu Ruby version 3 này có gì mới, và có những update nào đáng phải kể đến, và tại sao mọi người lại gọi nó là ruby version 3x3, thì trong bài ngày hôm nay

0 0 51

- vừa được xem lúc

Cách sử dụng class Time & Date trong Ruby (Phần 1)

Time là một class trong Ruby, nó sẽ giúp chỉnh sửa format, trích xuất thông tin một cách hiệu quả theo ý của bạn. . Topic hôm nay chúng ta có gì nào. .

0 0 105

- vừa được xem lúc

Ruby 3.0 có gì mới

Ruby 3.0.0 đã được ra mới được ra mắt vào tháng 12/2020, mục tiêu của bản 3.0.

0 0 50