12/08/2018, 15:02

Rails: Tối ưu ActiveRecord queries

ActiveRecord là một trong những điều tuyệt vời khi làm việc với RoR. Nó ngắn gọn và dễ đọc hơn những dòng SQL dài ngoằng khó chịu. Nhưng nếu bạn không cẩn thận thì cũng rất dễ viết ra những ActiveRecord queries tạo ra những câu SQL kém hiệu quả, phí bộ nhớ đặc biệt nếu làm việc với database với ...

ActiveRecord là một trong những điều tuyệt vời khi làm việc với RoR. Nó ngắn gọn và dễ đọc hơn những dòng SQL dài ngoằng khó chịu. Nhưng nếu bạn không cẩn thận thì cũng rất dễ viết ra những ActiveRecord queries tạo ra những câu SQL kém hiệu quả, phí bộ nhớ đặc biệt nếu làm việc với database với nhiều bảng dữ liệu lớn. May thay, ActiveRecord cung cấp cho chúng ta 1 bộ công cụ để viết những câu queries với hiệu suất tốt hơn. Dưới đây mình sẽ list ra những điểm nổi bật của tool này để các bạn tiện theo dõi.

Một trong 2 phương thức bên dưới nhanh hơn cái còn lại.

Transaction.sum(&:amount)
Transaction.sum(:amount)

Khác nhau rất nhỏ, chỉ 1 dấu & (xem thêm Symbol#to_proc) nhưng ở phương thức đầu tiên, tổng được tính bằng Array#sum, thay vì sử dụng SQL. Điều này nghĩa là Rails phải select tất cả các cột và khởi tạo model cho mỗi dòng để tạo ra 1 mảng rất lớn.

Phương thức thứ 2 chỉ đơn giản để cho db tính tổng - trông có vẻ tốt hơn nhiều so với Ruby, không select những columns không cần thiết hay khởi tạo model nào cả.

SELECT SUM(amount) FROM `transactions`;

Tin tốt là việc gọi ActiveRecord::Calculations#sum với 1 block đã không còn được chấp nhận ở Rails 4.0, vì thế bạn sẽ nhận được 1 cảnh báo nếu bạn vô tình(hoặc cố ý) add thêm &.

Transaction.all.map(&:user_id)
Transaction.pluck(:user_id)

map là một phương thức hữu dụng khác dùng cho Array. Nhưng hắn cũng là vấn đề. Vì phương thức này dùng cho arrays, nên Rails sẽ cần phải select tất cả các cột để tạo ra model cho mỗi dòng, trong khi chúng ta chỉ muốn 1 cột thôi. Ở ví dụ trên, tất cả các cột từ table transactions sẽ được select ra, dù tất cả những gì chúng ta cần chỉ là user_id.

Với phương thức pluck(available từ Rails 3.2.1), thì chỉ 1 cột được select ra, và chẳng có model nào được tạo cả. Vì vậy câu SQL chỉ đơn giản là:

SELECT user_id FROM `transactions`;

Tất nhiên nếu bạn đã có sẵn các array chứa các model dành riêng cho việc tính toán thì việc map các array này lại có vẻ nhanh hơn nhiều việc query xuống db. Trong những trường hợp này, tốt nhất là so sánh cả 2 cách và chọn cái tối ưu nhất.

Tưởng tượng nếu bạn muốn lấy ra 1 list những user ids duy nhất từ bảng transactions, thì với việc sử dụng pluck chúng ta có thể dễ dàng get ra được 1 list. Cộng thêm việc sử dụng uniq, có ít nhất 2 cách để lọc ra những đối tượng trùng lặp từ list đó, 1 trong 2 cách sau nhanh hơn nhiều so với thằng còn lại:

Transaction.pluck(:user_id).uniq
Transaction.uniq.pluck(:user_id)

Trong 2 phương thức trên không có phương thức nào tạo ra model hay select những trường ko cần thiết. Điểm khác nhau giữa chúng là cách lọc ra những đối tượng bị trùng lặp. Phương thức đầu tiên, pluck trả về 1 mảng của user_ids cho mỗi dòng trong bảng transactions. Thằng nào trùng lặp sẽ bị lọc ra bởi Array#uniq. Ở phương thức thứ 2, uniq chính là thằng ActiveRecord::QueryMethod#uniq, nó sẽ add thêm keyword DISTINCT vào câu SQL:

SELECT DISTINCT user_id FROM `transactions`;

Rõ ràng việc query database sẽ nhanh hơn nhiều, đặc biệt nếu user_id được đánh index.

Giả sử bạn cần phải xử lý gì đó trên từng thằng transaction, thì với 1 mảng nhỏ, việc sử dụng each cũng ok:

Transaction.where(processed: false).each { |t| ... }

Nhưng nếu cần xử ký cỡ 100,000 records thì thế nào? (boiroi). Với .all.each thì đống kết quả trả về sẽ phải load vào bộ nhớ và lặp nhiều lần, to quá thì tèo. Để ngăn chặn điều đó thì ActiveRecord đã cũng cấp cho chúng ta phương thức find_each, nhóm 1000 kết quả trả về 1 nhóm, nên toàn bộ kết quả trả về chỉ được load vào bộ nhớ 1 lần. Nhìn sơ qua thì có vẻ cũng giống each:

Transaction.where(processed: false).find_each { |t| ... }

find_each là một bao đóng của ActiveRecord::Batches#find_in_batches, cho phép config được batch size.

joins và includes nếu được lựa chọn đúng cũng có thể tạo ra 1 tác động lớn đến performance. joins chỉ đơn giản add JOIN vào câu SQL, còn nếu muốn access vào association (vd: user.transactions) thì nên sử dụng includes. Rails có thể load has_many association chỉ trong nháy mắt.

Mặt khác, nếu chỉ muốn join các table lại để lọc ra tập kết quả nào đó trong SQL thì việc sử dụng includes quả là 1 sự phí phạm, vì nó sẽ select 1 đống columns và models không cần thiết.

Nên thận trọng trong việc sử dụng ActiveRecord queries vì chúng là 1 con dao 2 lưỡi cho performance. Nên để mắt đến development.log. Cũng có nhiều GEM khá hay dành cho việc quản lý performance:

  • Bullet - Giúp kiểm soát N+1 queries
  • Peek
  • RailsPanel

Bài viết được dịch lại từ Web ascender

0