Một số phương pháp viết code giúp bạn tối ưu hóa code ruby

Lời mở

Để có thể tối ưu hệ thống giúp hệ thống có thể chạy tốt hơn, tốn ít tài nguyên hơn thì có rất nhiều phương pháp như thiết kế DB, chọn sử dụng loại DB SQL hay NoSQL, chọn ngôn ngữ PHP, rails, C# ... Sau đây tôi sẽ hướng dẫn các bạn một vài chú ý khi code ruby sẽ giúp code bạn chạy tốt hơn.

1. Block vs. Symbol#to_proc

Hãy sử dụng Symbol#to_proc thay vì Block thường vì Symbol#to_proc chạy nhanh hơn dùng Block 11%. Trước đây Symbol#to_proc được dùng như 1 cách viết tắt trong Rails và sau đó đã được thêm vào trong Ruby và được ruby tối ưu

ví dụ:

[13] pry(main)> RANGE = (1..100)
[14] pry(main)> t = Time.now; 10000.times {RANGE.map { |i| i.to_s }}; Time.now - t
=> 0.5848567485809326
[15] pry(main)> t = Time.now; 10000.times {RANGE.map(&:to_s )}; Time.now - t
=> 0.515679121017456

2. Enumerable#map and Array#flatten vs. Enumerable#flat_map

Đôi khi bạn cần dùng đến map và flatten cho 1 đối tượng nào đó thì flat_map là một giải pháp hữ hiệu khi lệnh này chạy nhanh hơn dùng thường 2 lệnh trên 20% vì code này chỉ cần chạy lần thay cho chạy 2 lần

pry(main)> a = [1,1,2,[4,5,6,7],[5,2,9,8]]
=> [1, 1, 2, [4, 5, 6, 7], [5, 2, 9, 8]]
pry(main)> t = Time.now; 10000.times {a.map{|i| i}.flatten}; Time.now - t
=> 0.026998281478881836
pry(main)> t = Time.now; 10000.times {a.flat_map{|i| i}}; Time.now - t
=> 0.02379751205444336

3. Enumerable#reverse and Enumerable#each vs. Enumerable#reverse_each

Tương tự phần trên, reverse_each sẽ thực thi giống hệt khi bạn gọi reverse.each. Tuy nhiên reverse_each sẽ nhanh hơn 17% so với việc dùng 2 lệnh vì nó sẽ không phải tạo ra 1 bản copy reverse.

pry(main)> b = [1,2,3,4,5,6,7,8,9]
=> [1, 2, 3, 4, 5, 6, 7, 8, 9]
pry(main)> t = Time.now; 100000.times {b.reverse.each{|i| i.to_s}}; Time.now - t
=> 0.42633795738220215
pry(main)> t = Time.now; 100000.times {b.reverse_each{|i| i.to_s}}; Time.now - t
=> 0.38994932174682617

4. Hash#keys and Enumerable#each vs. Hash#each_key

Nếu bạn chỉ muốn xử lý keys của 1 chuỗi Hash thì ruby có 1 hàm để tạo ra mảng chỉ gồm key từ chuỗi Hash đó rồi xử lý từng phần tử một keys. Ngoài ra còn có 1 hàm khác của ruby cũng dùng để hỗ trợ xử lý các key mà không phải tạo ra mảng phụ đó là each_key

pry(main)> a = {1 => 2, 2=>3, 3=>4,5=>6, 7=>8, 8=>9, 9=>1}
=> {1=>2, 2=>3, 3=>4, 5=>6, 7=>8, 8=>9, 9=>1}
pry(main)> t = Time.now; 100000.times { a.keys.each{|i| i.to_s}}; Time.now - t
=> 0.3729281425476074
pry(main)> t = Time.now; 100000.times { a.each_key{|i| i.to_s}}; Time.now - t
=> 0.34535789489746094
pry(main)> t = Time.now; 100000.times { a.each{|i,_| i.to_s}}; Time.now - t
=> 0.36351776123046875

5. Array#shuffle and Array#first vs. Array#sample

Khi bạn muốn lấy 1 giá trị ngẫu nhiên trong mảng một số bạn thường dùng kết hợp 2 hàm shuffle và gọi giá trị đầu tiên. Tuy nhiên bạn có thể lấy 1 giá trị ngẫu nhiên từ mảng với 1 hàm sample với tốc độ nhanh hơn nhiều lần

pry(main)> b = [1,2,3,4,5,6,7,4,2,1,4,6,78,9,4,2,432,4,2,123,123,1,3]
=> [1, 2, 3, 4, 5, 6, 7, 4, 2, 1, 4, 6, 78, 9, 4, 2, 432, 4, 2, 123, 123, 1, 3]
pry(main)> t = Time.now; 100000.times {  b.shuffle.first}; Time.now - t
=> 0.11196732521057129
pry(main)> t = Time.now; 100000.times { b.sample}; Time.now - t
=> 0.010792970657348633

=> ở ví dụ này hàm sample nhanh hơn shuffle.first hơn 10 lần

6. Hash#merge vs. Hash#merge!

Ở đây các bạn có thể tra trên api xem merge và merge! khác nhau ở đâu, đơn giản mà nói hàm merge sẽ tạo ra 1 bản copy của Hash rồi sau đó merge bản copy đó để trả kết quả => bản gốc sẽ không thay đổi gì. Còn hàm merge! sẽ trực tiếp thực thi hàm merge trên chính bản hash gốc. Do đó tốc độ xử lý được tăng lên khá nhiều.

pry(main)> a = {1 => 2, 2=>3, 3=>4,5=>6, 7=>8, 8=>9, 9=>1}
=> {1=>2, 2=>3, 3=>4, 5=>6, 7=>8, 8=>9, 9=>1}
pry(main)> b = {"a" => "b", "b" => "c", "c" => "d"}
=> {"a"=>"b", "b"=>"c", "c"=>"d"}
pry(main)> t = Time.now; 100000.times { a.merge b}; Time.now - t
 => 0.39247632026672363
pry(main)> t = Time.now; 100000.times { a.merge! b}; Time.now - t
=> 0.04164910316467285

=> hàm merge! nhanh hơn merge gần 10 lần ở ví dụ trên.

Như vậy chúng ta đã đi được kha khá các hàm và để thư giãn chút ít, tôi sẽ giới thiệu tới các bạn 1 gem dùng để đo và so sánh tốc độ xử lý các hàm được dùng cho ruby đó là benchmark-ips

Sau đây tôi sẽ làm lại ví dụ với hàm trên và dùng benchmark-ips

pry(main)> require 'benchmark/ips'
=> false
pry(main)> a = {1 => 2, 2=>3, 3=>4,5=>6, 7=>8, 8=>9, 9=>1}
=> {1=>2, 2=>3, 3=>4, 5=>6, 7=>8, 8=>9, 9=>1}
pry(main)> b = {"a" => "b", "b" => "c", "c" => "d"}
=> {"a"=>"b", "b"=>"c", "c"=>"d"}
pry(main)> Benchmark.ips do |x|
pry(main)*   x.report('merge') {a.merge b}
pry(main)*   x.report('merge!') {a.merge! b}
pry(main)*   x.compare!
pry(main)* end
Calculating -------------------------------------
               merge     3.496k i/100ms
              merge!     4.259k i/100ms
-------------------------------------------------
               merge    259.679k (±21.1%) i/s -      1.059M
              merge!      2.194M (± 7.9%) i/s -     10.626M

Comparison:
              merge!:  2193744.3 i/s
               merge:   259678.5 i/s - 8.45x slower

=> merge! nhanh hơn merge 8 lần ở ví dụ này.

7. Hash#fetch vs. Hash#fetch with block

fetch được thiết kế để gọi ra giá trị của 1 key nào đó trong hash một cách nhanh nhất, nhanh hơn cách gọi theo block bình thường

pry(main)> require 'benchmark/ips'
=> false
pry(main)> a = {1 => 2, 2=>3, 3=>4,5=>6, 7=>8, 8=>9, 9=>1}
=> {1=>2, 2=>3, 3=>4, 5=>6, 7=>8, 8=>9, 9=>1}
pry(main)> b = {"a" => "b", "b" => "c", "c" => "d"}
=> {"a"=>"b", "b"=>"c", "c"=>"d"}
pry(main)> Benchmark.ips do |x|
pry(main)*   x.report('fetch') {b.fetch "b"}
pry(main)*   x.report('block') {b["b"]}
pry(main)*   x.compare!
pry(main)* end
Calculating -------------------------------------
               fetch     4.175k i/100ms
               block     4.229k i/100ms
-------------------------------------------------
               fetch      2.875M (±13.4%) i/s -     13.201M
               block      3.639M (±13.7%) i/s -     16.409M

Comparison:
               block:  3639145.9 i/s
               fetch:  2875271.5 i/s - 1.27x slower

8. Parallel vs. sequential assignment Chắc đôi khi bạn thấy kiểu viết song song "parallel" trông có vẻ high level như @abc, @xyz = "bien so 1", "bien so 2",cách này tương đương với khai báo tuần tự sequential: @abc = "bien so 1"; @xyz = "bien so 2". Thực tế là bạn nên viết theo kiểu thứ 2 vì kiểu này dễ đọc hơn đồng thời chạy nhanh hơn cách song song tầm 40%

pry(main)> require 'benchmark/ips'
=> false
pry(main)> Benchmark.ips do |x|
pry(main)*   x.report('song song') {@abc, @xyz = "bien so 1", "bien so 2"}
pry(main)*   x.report('tuan tu') {@abc = "bien so 1"; @xyz = "bien so 2"}
pry(main)*   x.compare!
pry(main)* end
Calculating -------------------------------------
           song song     4.537k i/100ms
             tuan tu     4.508k i/100ms
-------------------------------------------------
           song song      2.386M (±18.2%) i/s -     10.499M
             tuan tu      3.047M (±16.0%) i/s -     13.515M

Comparison:
             tuan tu:  3047280.2 i/s
           song song:  2385923.0 i/s - 1.28x slower

9. Throw/catch vs Exception

Sử dụng Exception thường chậm và để thay thế dùng Exception trong một số trường hợp tôi khuyên bạn dùng Throw/catch giúp hiệu quả trong việc xử lý logic code đồng thời so sánh với dùng exception thì nó còn giúp code của bạn chay nhanh hơn đến 5 lần Tốt nhất là bạn không nên dùng exception trừ phi những đoạn cần thiết.

Một số cách sử dụng câu lệnh SQL trong rails để tăng tốc xử lý

1. Pluck vs. Map

ví dụ:

User.all.map(&:id)
User.pluck(:id)

cả 2 lệnh trên đều trả ra cùng 1 kết quả là 1 mảng là các id của record trong bảng User. Tuy nhiên có 1 lưu ý đó là các thực thi 2 hàm, ở hàm thứ nhất ta phải lấy tất cả bản ghi trong DB ra sau đó mới lọc lấy các id. Còn hàm thứ 2 thì sql xử lý chỉ lấy riêng cột id và trả về mảng giá trị, do đó cách 2 sẽ nhanh hơn cách 1

pry(main)> Benchmark.ips do |x|
pry(main)*   x.report('map') {User.all.map(&:id)}
pry(main)*   x.report('pluck') {User.pluck(:id)}
pry(main)*   x.compare!
pry(main)* end

Comparison:
               pluck:     1042.9 i/s
                 map:      346.9 i/s - 3.01x slower

2. Uniq

Comment.pluck(:user_id).uniq
Comment.uniq.pluck(:user_id)

Theo bạn thì câu lệnh nào sẽ mất thời gian xử lý hơn? Ta có thể tóm tắ quy trình của câu lệnh đầu đó là tạo ra mảng chứa các user_id rồi sau đó kiểm tra tưng phần tử của mảng và tạo ra 1 mảng khác chỉ có các phần tử khác nhau. Còn ở câu lệnh thứ 2 thì chỉ tạo ra có 1 mảng và các giá trị trùng lặp đã được lọc trong câu lệnh sql.

pry(main)> Benchmark.ips do |x|
pry(main)*   x.report('unq') {Comment.pluck(:user_id).uniq}
pry(main)*   x.report('unq in sql') {Comment.uniq.pluck(:user_id)}
pry(main)*   x.compare!
pry(main)*   end
Comparison:
          unq in sql:     1067.4 i/s
                 unq:      818.0 i/s - 1.30x slower

=> ta nên lọc và xử lý các đối tượng luôn trong câu lệnh sql

3. Joins vs. Includes

Đây là 2 câu lệnh mà người dùng vẫn hay nhầm lẫn vì cách dùng tương tự nhau nhưng nó lại rất có ảnh hưởng tới hiệu suất truy xuất DB, nhất là với bảng dữ liệu lớn. Các bạn cần phân biệt rõ cách thức mà 2 hàm này chạy để từ đó dựa vào mục đích dùng mà ta có thể sử dụng một các hiệu quả. Chi tiết thì các bạn có thể dùng Google để tìm hiểu, tôi ở đây sẽ chỉ nói 1 cách sơ lược. Hàm joins sẽ chỉ Join bảng trong SQL rồi trả ra dữ liệu bảng kết quả, còn hàm includes thì lưu cả dữ liệu bảng được include. Do đó

  • Nếu bạn cần truy xuất dữ liệu của bảng liên quan thì bạn dùng includes
  • Nếu bạn không cần truy xuất dữ liệu của bảng liên quan thì bạn dùng joins

Ví dụ:

# model user.rb
class User < ActiveRecord::Base
  has_many :comments
end

#model comment.rb
class Comment < ActiveRecord::Base
  belongs_to :user
end

Ở đây ta sẽ thực hiện ví dụ bảng Comment joins/includes bảng User rồi sau đó gọi dữ liệu từ bảng User

 c = Comment.joins(:user).where(users: {visible: true})
  Comment Load (1.2ms)  SELECT `comments`.* FROM `comments` INNER JOIN `users` ON `users`.`id` = `comments`.`user_id` AND `users`.`deleted_at` IS NULL WHERE `comments`.`deleted_at` IS NULL AND `users`.`visible` = 1
=> [#<Comment id: 11, content: "dfg", user_id: 11, campaign_id: 1, created_at: "2015-02-01 00:53:20", updated_at: "2015-02-27 09:54:34", deleted_at: nil, visibility: false>,
 #<Comment id: 16, content: "aqqqq", user_id: 8, campaign_id: 15, created_at: "2015-02-06 06:04:26", updated_at: "2015-02-06 06:04:26", deleted_at: nil, visibility: true>,
 .... ]
c.first.user
  User Load (1.3ms)  SELECT  `users`.* FROM `users`  WHERE `users`.`deleted_at` IS NULL AND `users`.`id` = 11 LIMIT 1
=> #<User id: 11, name: "Le Ngoc Cuong", support_id: "OJ_0f6YB", m_country_id: 1, state: "activated", access_token: "utJ57tN-mr_euL2oHk6CUg", locale: nil, visible: true, push_campaign: true, push_reserved_released: true, push_other: true, gcm_registration_id: "APA91bFTjD7eilbfnq_YNva4EJBt8GlUGloGjXj3qOdVPfmhos...", login_count: 5, last_login_at: "2015-02-02 10:07:43", deleted_at: nil, created_at: "2015-01-30 10:29:37", updated_at: "2015-05-18 08:18:50", m_language_id: 1, current_point: 0, invite_code: nil, code_input_seen: false>
c = Comment.includes(:user).where(users: {visible: true})
  SQL (1.8ms)  SELECT `comments`.`id` AS t0_r0, `comments`.`content` AS t0_r1, `comments`.`user_id` AS t0_r2, `comments`.`campaign_id` AS t0_r3, `comments`.`created_at` AS t0_r4, `comments`.`updated_at` AS t0_r5, `comments`.`deleted_at` AS t0_r6, `comments`.`visibility` AS t0_r7, `users`.`id` AS t1_r0, `users`.`name` AS t1_r1, `users`.`support_id` AS t1_r2, `users`.`m_country_id` AS t1_r3, `users`.`state` AS t1_r4, `users`.`access_token` AS t1_r5, `users`.`locale` AS t1_r6, `users`.`visible` AS t1_r7, `users`.`push_campaign` AS t1_r8, `users`.`push_reserved_released` AS t1_r9, `users`.`push_other` AS t1_r10, `users`.`gcm_registration_id` AS t1_r11, `users`.`login_count` AS t1_r12, `users`.`last_login_at` AS t1_r13, `users`.`deleted_at` AS t1_r14, `users`.`created_at` AS t1_r15, `users`.`updated_at` AS t1_r16, `users`.`m_language_id` AS t1_r17, `users`.`current_point` AS t1_r18, `users`.`invite_code` AS t1_r19, `users`.`code_input_seen` AS t1_r20 FROM `comments` LEFT OUTER JOIN `users` ON `users`.`id` = `comments`.`user_id` AND `users`.`deleted_at` IS NULL WHERE `comments`.`deleted_at` IS NULL AND `users`.`visible` = 1
=> [#<Comment id: 11, content: "dfg", user_id: 11, campaign_id: 1, created_at: "2015-02-01 00:53:20", updated_at: "2015-02-27 09:54:34", deleted_at: nil, visibility: false>,
 #<Comment id: 16, content: "aqqqq", user_id: 8, campaign_id: 15, created_at: "2015-02-06 06:04:26", updated_at: "2015-02-06 06:04:26", deleted_at: nil, visibility: true>, ...]
c.first.user
=> #<User id: 11, name: "Le Ngoc Cuong", support_id: "OJ_0f6YB", m_country_id: 1, state: "activated", access_token: "utJ57tN-mr_euL2oHk6CUg", locale: nil, visible: true, push_campaign: true, push_reserved_released: true, push_other: true, gcm_registration_id: "APA91bFTjD7eilbfnq_YNva4EJBt8GlUGloGjXj3qOdVPfmhos...", login_count: 5, last_login_at: "2015-02-02 10:07:43", deleted_at: nil, created_at: "2015-01-30 10:29:37", updated_at: "2015-05-18 08:18:50", m_language_id: 1, current_point: 0, invite_code: nil, code_input_seen: false>

Ta có thể thấy sự khác biệt ở lệnh gọi c.first.user, nếu dùng includes thì ta cần phải thêm 1 câu lệnh sql để truy xuất vào bảng User, tuy nhiên nếu dùng includes thì nội dung bảng User đã được lưu vào trong bộ nhớ nên ta sẽ không phải truy xuất lại vào bảng csdl.

Tham khảo:

All Rights Reserved