Ứng dụng Ruby on Rails “chậm” gần như luôn xuất phát từ 3 nhóm nguyên nhân:
- Query DB quá nhiều (đặc biệt là N+1, COUNT/EXISTS dùng sai, thiếu index).
- Load dữ liệu thừa (kéo cả bảng/cả cột “nặng”, hoặc load hết vào RAM).
- Render view / callbacks làm việc dư (render nhiều partial, format nặng, chạy callbacks/validations khi không cần).
Bài viết này tổng hợp 10 tối ưu mình dùng nhiều nhất vì dễ hiểu, dễ áp dụng, hiệu quả thấy ngay.
1. Giải quyết N+1 Query bằng includes
Vấn đề: Bạn load list posts, trong view lại gọi association như post.user.name hoặc post.comments.size. Nếu có 20 posts, Rails có thể bắn thêm 20 query (hoặc nhiều hơn).
Giải pháp: preload association trước bằng includes.
Trước: Phát sinh N+1 query
1
2
3
4
5
@posts = Post.order(created_at: :desc).limit(20)
@posts.each do |post|
post.user.name # mỗi lần có thể query SELECT users... WHERE id = ?
end
Sau: Đã sử dụng includes
1
@posts = Post.includes(:user).order(created_at: :desc).limit(20)
Rails sẽ preload users cho toàn bộ posts trong 1 query (hoặc 1 query bổ sung), tránh loop bắn query.
Dùng bất cứ khi nào bạn render list và gọi association trong loop (post.comments, post.user, order.items…).
Lưu ý quan trọng: includes có 3 dạng (tùy query), Rails có thể chọn cách preload khác nhau.
preload: luôn chạy 2 query (1 cho posts, 1 cho users).eager_load: luôn dùng LEFT OUTER JOIN (1 query lớn).includes: Rails tự quyết (có thể preload hoặc join).
2. Chỉ lấy đúng cột cần dùng: select / pluck
Vấn đề: User.all hoặc User.where(...).to_a kéo tất cả cột, kể cả cột “nặng” như bio (text), settings (jsonb), avatar_data, v.v. Trong khi bạn chỉ cần id và name.
Giải pháp:
Khi vẫn muốn object ActiveRecord (nhưng tối giản)
Dùng select:
1
2
users = User.where(active: true).select(:id, :name)
users.first.name # OK
Khi chỉ cần mảng giá trị (nhanh + ít allocations)
Dùng pluck:
1
2
ids = User.where(active: true).pluck(:id)
pairs = User.where(active: true).pluck(:id, :name) # [[1, "A"], [2, "B"]]
Lợi ích: giảm thời gian DB + giảm dữ liệu trả về + giảm Ruby allocations.
Dùng khi hiển thị trong dropdown/select box vì chỉ cần id, name, hoặc dùng trong các background job chỉ cần id để xử lý.
3. Chỉ cần truy vấn “có tồn tại không?”, hãy dùng exists?
Vấn đề: Nhiều người check tồn tại bằng any?/present? trên relation. Điều này có thể load record không cần thiết, hoặc chạy query không tối ưu.
Giải pháp:
Trước: có thể phát sinh load dữ liệu không cần thiết
1
User.where(email: email).any?
Sau: query EXISTS tối ưu hơn
1
User.exists?(email: email)
Ví dụ dùng khi check email trùng, check “user có order nào chưa”, check “bản ghi đã tồn tại chưa”…
4. Dùng đúng count, size, length để tránh query thừa
Đây là cái rất hay gây ứng dụng của bạn chậm âm thầm.
| Method | Có query DB không? | Query gì | Load record không? | Khi nào nên dùng |
|---|---|---|---|---|
| count | Có (luôn luôn) | SELECT COUNT(*) | Không | Khi cần số lượng chính xác trực tiếp từ DB |
| length | Có nếu chưa load | SELECT * | Có (load toàn bộ) | Chỉ dùng khi chắc chắn records đã load sẵn (array) |
| size | Tùy tình trạng | COUNT(*) hoặc không | Không hoặc có | Mặc định tốt nhất cho ActiveRecord và association |
Dùng khi bạn không chắc association đã load chưa
1
comments_count = post.comments.size
Mẹo: Trong view hiển thị số lượng association thì ưu tiên dùng size (hoặc counter cache nếu dùng nhiều).
5. Thêm Index cho cột hay lọc/sắp xếp
Vấn đề: Query WHERE user_id = ... mà không có index thì DB phải scan cả bảng, có thể khá nặng.
Giải pháp: thêm index cho cột thường dùng trong WHERE, JOIN, ORDER BY
Ví dụ:
1
2
3
add_index :orders, :user_id
add_index :users, :email, unique: true
add_index :orders, [:user_id, :created_at]
Nguyên tắc chọn index nhanh:
- Cột xuất hiện nhiều trong
WHERE/JOIN: thêm index. - Cột xuất hiện trong
ORDER BY+ có filter đi kèm: cân nhắc composite index (ví dụ[:user_id, :created_at])`. - Cột có giá trị duy nhất (unique field) như email/username:
unique: true.
Cách kiểm tra nhanh: Nhìn log xem có query nào chậm không, hoặc chạy EXPLAIN trong DB để xem có dùng index không (đặc biệt với Postgres/MySQL).
Lưu ý: Index giúp đọc nhanh hơn nhưng làm ghi chậm hơn chút.
6. Duyệt dữ liệu lớn bằng batch: find_each / in_batches
Vấn đề: User.where(...).each có thể load hết records vào RAM. Nếu dataset lớn (hàng chục nghìn/hàng triệu), Rails sẽ load nhiều record vào RAM, dễ tốn bộ nhớ, có thể crash worker/job.
Giải pháp: Sử dụng find_each (load theo batch)
find_each dùng pagination theo primary key và chỉ giữ một batch trong memory.
1
2
3
User.where(active: true).find_each(batch_size: 1000) do |user|
# xử lý từng user
end
Nếu cần xử lý theo batch (đặc biệt update hàng loạt), dùng:
1
2
3
User.where(active: true).in_batches(of: 1000) do |relation|
relation.update_all(flag: true)
end
7. Bulk update/delete: update_all / delete_all (khi không cần callback)
Vấn đề: Vòng lặp each { update } khi có 10k records thì sẽ phát sinh 10k queries và callback/validation. Vừa tốn 10k queries, vừa chạy validations + callbacks (đôi khi là cả email, audit log…).
Giải pháp: Chỉ cần 1 query là xong.
1
2
3
4
5
# Khi update hàng loạt
User.where(id: ids).update_all(active: false)
# Khi xóa hàng loạt
Log.where("created_at < ?", 30.days.ago).delete_all
Lưu ý quan trọng: update_all, delete_all bỏ qua validations + callbacks. Do đó chỉ dùng khi bạn chắc chắn không cần logic đó.
Dùng khi muốn đổi flag hàng loạt, ghi dữ liệu đơn giản.
8. Counter cache cho “đếm association” hay hiển thị association
Vấn đề: Trang list hiển thị “số comment” cho mỗi post, nếu bạn gọi post.comments.count nhiều lần thì sẽ khá nặng. Kể cả includes(:comments) cũng có thể nặng vì comments nhiều.
Giải pháp: lưu sẵn số lượng vào cột comments_count.
1
2
3
4
5
6
7
# Tạo cột comments_count để lưu
add_column :posts, :comments_count, :integer, default: 0, null: false
# Bật counter cache
class Comment < ApplicationRecord
belongs_to :post, counter_cache: true
end
Sau đó dùng: post.comments_count
Cách này rất hữu dụng khi phát triển các trang list, bảng admin, nơi mà “đếm” xuất hiện khắp nơi.
Lưu ý: Counter cache giúp tốc độ đọc cực tốt, đổi lại khi ghi comment sẽ update thêm 1 cột ở post.
9. Cache dữ liệu đắt bằng Rails.cache.fetch (cache kết quả query/tính toán)
Vấn đề: Một đoạn query/tính toán nặng chạy lại cho mọi request (top posts, stats, config…).
Giải pháp: cache có TTL (time to live) và key rõ ràng
1
2
3
top_posts = Rails.cache.fetch(["top_posts", Date.current], expires_in: 10.minutes) do
Post.published.order(score: :desc).limit(20).pluck(:id, :title)
end
Gợi ý đặt cache key: Dùng mảng [feature_name
, version, params...] để dễ quản lý. Nên có expires_in để tránh dữ liệu cache “vĩnh viễn” không mong muốn.
10. Cache view (fragment/collection caching) để giảm render time
Vấn đề: DB không chậm lắm nhưng render view chậm vì nhiều partial, nhiều logic format.
Giải pháp 1: fragment cache theo record
1
2
3
4
5
<% @posts.each do |post| %>
<% cache(post) do %>
<%= render "posts/post", post: post %>
<% end %>
<% end %>
Rails dùng cache key theo record (có version), khi post thay đổi thì key đổi, tự invalid.
Giải pháp 2: collection caching (gọn + nhanh)
1
<%= render partial: "posts/post", collection: @posts, cached: true %>
Lợi ích: giảm thời gian render và giảm CPU cho mỗi request rất rõ, nhất là trang list.
Rails thường chậm không phải vì framework, mà vì code tự làm nó chậm: query DB dư, load dữ liệu không cần thiết, hoặc render view quá nặng. Chỉ cần áp dụng những tối ưu cơ bản ở trên là hiệu năng cải thiện thấy rõ ngay, trong nhiều trường hợp còn nhanh gấp vài lần mà không cần cache phức tạp hay scale server.
