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

Một số lưu ý cải thiện performance khi làm việc với Rails

0 0 111

Người đăng: Dat Nguyen

Theo Viblo Asia

Khi làm việc với ruby on rails chắc hẳn chúng ta sẽ làm việc với active record rất nhiều. Tuy nhiên có nhiều điều có thể ta vẫn chưa thực sự hiểu, ví dụ như ActiveRecord execute SQL query như thế nào? Và cũng còn khá nhiều lập trình viên khác cũng không để ý tới điều này. Trong bài viết dưới dây, chúng ta sẽ cùng tìm hiểu thêm về ActiveRecord qua một số trường hợp hay gặp và một số lưu ý khác để tăng tốc độ cho dự án, tránh tạo ra lỗi N+1s queries.

Rule 1

Vậy, làm sao để chúng ta biết 1 câu query là không cần thiết? Ta có thể dựa vào 1 rule để cân nhắc xem query đó có cần thiết hay không. Đó là: 1 action trong Rails controller chỉ nên execute 1 SQL query cho mỗi table. Nếu thấy nhiều hơn 1 SQL query/ table, ta có thể tìm cách để giảm 1,2 queries. Số lượng các queries trên mỗi bảng được show rất rõ ràng.
Một rule khác đó là: Các câu queries nên được execute trong nửa đầu của controller action’s response, không nên gọi queries trong partial view. Các câu query được gọi ở trong view rất dễ dẫn đến N + 1s. Nếu trên development logs, chúng ta có thể dễ dàng bắt gặp những đoạn log như:

User Load (0.6ms) SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT 1 [["id", 2]]
Rendered posts/_post.html.erb (23.2ms)
User Load (0.3ms) SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT 1 [["id", 3]]
Rendered posts/_post.html.erb (15.1ms)

Hãy chú ý preload data trước khi in ra view. Đó là 1 số rule chúng ta nên chú ý.

Sử dụng count

.count sẽ luôn execute lệnh count trong SQL mỗi khi ta gọi.
Vì vậy ta hãy luôn nhớ 1 điều là: Chỉ sử dụng count nếu muốn execute SQL count ngay lúc đó. Thông thường, chúng ta dùng count 1 association, sau đó sẽ sử dụng lại ở trong view. Điều này sẽ dẫn tới việc thừa 1 lần count. Ví dụ:

 # _messages.html.erb # @messages = user.messages.unread <h2>Unread Messages: <%= @messages.count %></h2> <% @messages.each do |message| %> ... <% end %>

Ở đây sẽ execute 2 commands, countselect. count được execute bởi lệnh @messages.count, còn @messages.each sẽ execute select để load tất cả message. Nếu chúng ta thay đổi thứ tự code trong partial và thay đổi count thành size thì sẽ loại bỏ được count query và giữ lại select

 <% @messages.each do |message| %> ... <% end %> <h2>Unread Messages: <%= @messages.size %></h2>

Bởi vì nhìn vào source code hàm size ta sẽ thấy như sau:

 # File activerecord/lib/active_record/relation.rb, line 210 def size loaded? ? @records.length : count(:all) end

Nếu relation đã được load, sẽ gọi length (1 method của Ruby để lấy ra size của 1 mảng), còn nếu ActiveRecord::Relation chưa được load, sẽ gọi lệnh count.
Còn đây là hàm count

 def count(column_name = nil) if block_given? # ... return super() end calculate(:count, column_name) end

Nó không được cache hay lưu trữ gì, chỉ đơn giản là execute SQL calculation mỗi khi được gọi. Tuy nhiên, như ở trong ví dụ trên kia, chúng ta chuyển phần tính @messages.size xuống bên dưới đoạn chạy each, như thế khi chạy vòng each, @messages đã được load, và chúng ta có thể chạy lệnh size để lấy ra. Tuy nhiên, nếu chúng ta vẫn muốn in ra size trước khi chạy each thì có thể dùng load method như sau

 <h2>Unread Messages: <%= @messages.load.size %></h2> <% @messages.each do |message| %> .. <% end %>

load sẽ thực hiện load luôn messages, thay vì load lazy. Nó zẽ trả về 1 ActiveRecord::Relation. Do đó, khi .size được gọi, @messages đã được load sẵn, không cần 1 query để tính size nữa.
Vậy khi nào thì câu query .count mới bị ignore? Chỉ khi result được cached bởi ActiveRecord::QueryCache, khi có 2 câu SQL giống hệt nhau được gọi.

 <h2>Unread Messages: <%= @messages.count %></h2> ... other code in here <h2>Unread Messages: <%= @messages.count %></h2>

Do vậy hãy dùng .size thay thế cho .count ở mọi chỗ. Ta chỉ nên dùng count khi chúng ta không thực sự load hết tất cả các record của 1 association mà ta đang tính count.

Sử dụng where

Giả sử chúng ta có 1 đoạn code như sau, trong file _post.html.erb

 <% @posts.each do |post| %> <%= post.content %> <%= render partial: :comment, collection: post.active_comments %> <% end %>

và trong file post.rb

 class Post < ActiveRecord::Base def active_comments comments.where(soft_deleted: false) end end

Việc này sẽ dẫn tới SQL query bị execute mỗi khi render ra post partial bởi vì . where luôn luôn dẫn tới việc gọi query. Vậy nếu chúng ta gọi includes hoặc sử dụng preloading methods khác trong controller thì sao? => cũng k thể loại bỏ được việc where execute a query
Điều này cũng xảy ra khi ta gọi scopes, vd trong model Comment, ta viết:

 class Comment < ActiveRecord::Base belongs_to :post scope :active, -> { where(soft_deleted: false) } end

Do đó, chúng ta có 2 rules:

  • Không gọi scopes trong associations khi chúng ta render collections
  • Không dùng query method (vd where) trong instance methods của 1 ActiveRecord::Base class Gọi scopes trong 1 associations khiến chúng ta không thể preload được result. Trong ví dụ bên trên, chúng ta có thể preload comments của 1 post, nhưng chúng ta không thể preload active comments của 1 post được. Do đó, chúng ta phải quay lại db, execute new queries cho mỗi phần tử của collection.
    Vậy có cách nào để fix N+1 cho render collection không? Câu trả lời là: Có. Chúng ta sẽ làm như sau
 class Post has_many :comments has_many :active_comments, -> { active }, class_name: "Comment" end class Comment belongs_to :post scope :active, -> { where(soft_deleted: false) } end class PostsController def index @posts = Post.includes(:active_comments) end end

Ý tưởng là chúng ta sẽ tạo ra 1 association mới, nơi chúng ta có thể preload
View ở đây không cần thay đổi gì, nhưng bây giờ nó sẽ chỉ execute 2 SQL queries, 1 query load trong bảng Post và 1 trong bảng Comment.

 <% @posts.each do |post| %> <%= post.content %> <%= render partial: :comment, collection: post.active_comments %> <% end %>

Sử dụng any?, exist? and present?

Chúng ta hãy cùng xem qua 1 ví dụ sau:

 class DocComment < ActiveRecord::Base belongs_to :doc_method, counter_cache: true def doc_method? doc_method_id.present? end end

Mục đích dùng present? ở đây chỉ là để check xem DocComment object có thuộc 1 doc_method nào không. Nó sẽ chuyển giá trị của doc_method_id từ nil hoặc 1 Integer thành true hoặc false.

 class Object def present? !blank? end end

blank? tương đương với câu hỏi: Liệu object này là truthy hay falsey? Mảng và hash rỗng là truthy nhưng nó blank, 1 string rỗng cũng là blank. Trong bí dụ bên trên, doc_method_id sẽ chỉ có 2 loại giá tri là nil và Integer, điều đó có nghĩa là: present? ở đây tương đương với việc sử dụng !!

 def doc_method? !!doc_method_id # same as doc_method_id.present? end

Ví dụ nếu muốn biết nếu 1 ActiveRecord::Relation có records nào hay không, ta có thể sử dụng any?/present?/exits? hoặc dùng: none?/blank?/emtpy?. Liệu bạn có chắc là k xảy ra vấn đề gì hay không?
Hãy xem xét ví dụ sau

 - if @comments.any? h2 Comments on this Post - @comments.each do |comment|

Sẽ có 2 câu query được thực hiện trong đoạn code trên. Một câu sẽ được gọi bởi @comments.any? (SELECT 1 AS one FROM … LIMIT 1), và 1 câu @comments.each sẽ load toàn bộ relation comments (SELECT “comments”.* FROM “comments” WHERE …)
Hãy xem tiếp ví dụ khi ta viết lại thành

 - unless @comments.empty? h2 Comments on this Post - @comments.each do |comment|

Nếu viết thế này, sẽ chỉ có 1 câu query được load: @comment.empty? sẽ load cả relation bằng câu: SELECT "comments".* FROM "comments" WHERE ..... Xét tiếp ví dụ nếu ta viết thế này:

 - if @comments.exists? This post has = @comments.size comments - if @comments.exists? h2 Comments on this Post - @comments.each do |comment|

Sẽ có 4 queries trong trường hợp này. Vì exits? không có tính năng cache và nó k load relation. exists? ở đây sẽ triggers câu: SELECT 1 ..., .size sẽ triggers câu lệnh COUNT vì relation không được cache. và lệnh exits? sẽ trigger 1 câu lệnh SELECT khác: SELECT 1 .... Cuối cùng, @comments load hết relation để chạy cho vòng each. Trong khi đó, chúng ta hoàn toàn có thể chỉ cần dùng 1 query cho đoạn code này

 - if @comments.load.any? This post has = @comments.size comments - if @comments.any? h2 Comments on this Post - @comments.each do |comment|

Do đó, một số lưu ý khi sử dụng trong trường hợp này sẽ như sau:

  • Không nên sử dung present? và blank? nếu ActiveRecord::Relation không được sử dụng toàn bộ sau khi chúng ta gọi 1 trong 2 methods này. Ví dụ: @my_relation.present?, @my_relation.first(3).each.
  • any?, none?, empty? nên được thay thế cho present?, blank? trừ khi ta chỉ lấy 1 phần của ActiveRecord::Relation sử dụng: first hoặc last. Chúng sẽ sinh ra 1 câu query SQL check nếu ta dùng entire relation. Hãy thay: @users.any?; @users.each thành @users.present?; @users.each hoặc @users.load.any?; @users.each, hoặc @users.any?; @users.first(3).each cũng ok.
  • exists? giống như count, nó không được memorized, và luôn chạy 1 câu SQL query. Không nên sử dụng nó, chúng ta nên thay bằng present? hoặc blank?

Kết

Trên đây là những gì tìm hiểu về một số lưu ý để cải thiện tốc độ cho Rails app. Hi vọng bài viết giúp ích cho mọi người. Hẹn gặp lại!

Reference

https://www.justinweiss.com/articles/how-to-preload-rails-scopes/
https://www.speedshop.co/2019/01/10/three-activerecord-mistakes.html
https://guides.rubyonrails.org/

Bình luận

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

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

Docker: Chưa biết gì đến biết dùng (Phần 3: Docker-compose)

1. Mở đầu. . .

0 0 121

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

Tích hợp VNPAY vào Rails

Xin chào 500 ae năm mới nhé. Tiếp nối câu chuyện về Thanh toán online mà mình có chia sẽ ở 2 bài trước, mọi người chưa đọc thì có thể vào xem ở đây nhé.

1 1 95

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

Tìm hiểu Adapter Pattern trong Rails

. Nếu là một web developer chắc hẳn chúng ta đã không ít lần đọc qua về các Design patterns hay cách áp dụng chúng để làm cho code trở nên hướng đối tượng hơn, dễ đọc, dễ hiểu, dễ maintain, dễ mở rộng, … Các design patterns được áp dụng khá nhiều trong các Rails projects như Service Object, Decorato

0 0 48

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

Sử dụng Searchkick để tìm kiếm thông minh trên Rails và Elasticsearch

Bạn đã bao giờ tự hỏi, ứng dụng web của mình có thể mở rộng quy mô bằng cách học được các từ khóa mà người dùng tìm kiếm? Có giải pháp nào cung cấp công cụ tìm kiếm tự động nhanh chóng với chỉ 1 từ khóa bất kì? Thật may khi có Searchkick và Elasticsearch là các công cụ hỗ trợ công việc tìm kiếm trở

0 0 103

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

Những sai lầm bạn có thể mắc phải khi code Rails

. Chào các bạn, chào các bạn. Đừng vội đóng tab nha.

0 0 47

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

Insert_all và Upsert_all trong Rails 6

Có rất nhiều trường hợp chúng ta cần thêm một loạt bản ghi ví dụ như khi có một danh sách các user bằng CSV và cần phải import vào ứng dụng của chúng ta. Rails 6 đã thêm 3 method insert_all, insert_all! và upsert_all vào ActiveRecord::Persistence, để giải quyết vấn đề trên.

0 0 52