0

Common Rails Idioms that Kill Database

Performance

Chính ActiveRecord là không chậm. Tôi chỉ dựng lại xem các truy vấn được sinh ra từ ActiveRecord khi thực hiện lệnh truy vấn đến dữ liệu trong database. Và hóa ra rằng, một số các truy vấn cơ sở dữ liệu thông dụng nhất được sử dụng trong các ứng dụng Rails CRUD theo mặc định khá kém khi mở rộng lên các bộ dữ liệu lớn hơn.

Trong bài báo này, chúng ta sẽ đi thảo luận về 3 thủ phạm lớn nhất. Nhưng đầu tiên, hãy nói với tôi cách các bạn biết các câu truy vấn của bạn có tốt hay không.

Measuring Performance

Mỗi DB query is hiệu suất tốt nếu bạn có số liệu của câu truy vấn đủ nhỏ. Vì vậy để kiểm ra hiệu suất thì chúng ta cần thử trên dữ liệu có kích thước đủ lớn. Chúng ta sử dụng postgres. Trong postgres, có cách bạn đo hiệu suất đó là sử dụng từ khóa explain. Ví dụ.

# explain (analyze) select * from faults where id = 1;
                                     QUERY PLAN
--------------------------------------------------------------------------------------------------
 Index Scan using faults_pkey on faults  (cost=0.29..8.30 rows=1 width=1855) (actual time=0.556..0.556 rows=0 loops=1)
   Index Cond: (id = 1)
 Total runtime: 0.626 ms

Ví dụ trên chỉ ra cho ta thấy cả giá trị ước lượng để thực hiện câu query (cost=0.29..8.30 rows=1 width=1855) và thời gian mà nó cần để thực hiện câu truy vấn đó (actual time=0.556..0.556 rows=0 loops=1).

Nếu bạn thích dễ đọc hơn bạn có thể sử dụng tính năng trả về kết quả sang định dạng YAML.

# explain (analyze, format yaml) select * from faults where id = 1;
              QUERY PLAN
--------------------------------------
 - Plan:                             +
     Node Type: "Index Scan"         +
     Scan Direction: "Forward"       +
     Index Name: "faults_pkey"       +
     Relation Name: "faults"         +
     Alias: "faults"                 +
     Startup Cost: 0.29              +
     Total Cost: 8.30                +
     Plan Rows: 1                    +
     Plan Width: 1855                +
     Actual Startup Time: 0.008      +
     Actual Total Time: 0.008        +
     Actual Rows: 0                  +
     Actual Loops: 1                 +
     Index Cond: "(id = 1)"          +
     Rows Removed by Index Recheck: 0+
   Triggers:                         +
   Total Runtime: 0.036
(1 row)

Từ bây giờ chúng ta sẽ chú ý đến Plan Rows, và Actual Rows

  • Plan Row Trong trường hợp xấu nhất, Có bao biêu dòng sẽ được DB loop qua để trả về cho câu query của bạn.
  • Actual Row Khi bạn thực hiện câu query, Có bao nhiều câu query được trả về.

Nếu "Plan Rows" is 1 giống như trên kia thì câu query thực hiện là tốt. Nếu "Plan Rows" bằng với số dòng trong database điều đó có nghĩa là câu query thi loop toàn bộ bảng như vậy là không hề tốt.

Bây giờ sau khi bạn đã biết cách đo hiệu suất của câu query. Hãy xem hiệu suất của một số hàm thông dụng trong rails nhé

Counting

Trong rails đoạn code sau rất là phổ biến

Total Faults <%= Fault.count %>

kết quả trả về câu lệnh SQL trả về như sau.

select count(*) from faults;

Hãy thực hiện câu lệnh explain câu query trên và xem điều gì xảy ra.

# explain (analyze, format yaml) select count(*) from faults;
              QUERY PLAN
--------------------------------------
 - Plan:                             +
     Node Type: "Aggregate"          +
     Strategy: "Plain"               +
     Startup Cost: 1840.31           +
     Total Cost: 1840.32             +
     Plan Rows: 1                    +
     Plan Width: 0                   +
     Actual Startup Time: 24.477     +
     Actual Total Time: 24.477       +
     Actual Rows: 1                  +
     Actual Loops: 1                 +
     Plans:                          +
       - Node Type: "Seq Scan"       +
         Parent Relationship: "Outer"+
         Relation Name: "faults"     +
         Alias: "faults"             +
         Startup Cost: 0.00          +
         Total Cost: 1784.65         +
         Plan Rows: 22265            +
         Plan Width: 0               +
         Actual Startup Time: 0.311  +
         Actual Total Time: 22.839   +
         Actual Rows: 22265          +
         Actual Loops: 1             +
   Triggers:                         +
   Total Runtime: 24.555
(1 row)

Woah! Câu query đơn giản của chúng ta lặp qua 22,265 dòng - Toàn bộ bảng! Trong Postgres, hàm counts luôn luôn lặp qua toàn bộ tập hợp dữ liệu. Bạn có thể làm giảm kích thước của tập hợp bản ghi truy vấn bằng cách thêm điều kiện where vào mỗi câu truy vấn. Tùy thuốc vào yêu cầu của bạn, Bạn sẽ lấy được kích thước đủ nhỏ với hiệu suất có thể chấp nhận được. Đối với hàng count trên kia chúng ta có có một cách để xử lý đó là cache giá trị đếm của bạn lại.

belongs_to :project, :counter_cache => true

Có một cách thay thế khác có sẵn khi kiểm tra xem có câu có dữ liệu trong bảng không. Thay vì việc sử dụng Users.count > 0 hãy dùng Users.exists?. Kết quả trả về có hiệu suất khá tốt

Sorting

Trong trang index. Hầu hết tất cả mọi app đều có ít nhất một trang index. Bạn muốn lấy về 20 bản ghi mới nhất và muốn hiển thị chúng lên màn hình index. Cách đơn giản mình nghĩ đến luôn là gì nhỉ ? Đoạn code đơn giản để load các bản ghi có thể như sau.

@faults = Fault.order(created_at: :desc)

và đây là đoạn sql

select * from faults order by created_at desc;

kết quả phân tích như sau.

# explain (analyze, format yaml) select * from faults order by created_at desc;
              QUERY PLAN
--------------------------------------
 - Plan:                             +
     Node Type: "Sort"               +
     Startup Cost: 39162.46          +
     Total Cost: 39218.12            +
     Plan Rows: 22265                +
     Plan Width: 1855                +
     Actual Startup Time: 75.928     +
     Actual Total Time: 86.460       +
     Actual Rows: 22265              +
     Actual Loops: 1                 +
     Sort Key:                       +
       - "created_at"                +
     Sort Method: "external merge"   +
     Sort Space Used: 10752          +
     Sort Space Type: "Disk"         +
     Plans:                          +
       - Node Type: "Seq Scan"       +
         Parent Relationship: "Outer"+
         Relation Name: "faults"     +
         Alias: "faults"             +
         Startup Cost: 0.00          +
         Total Cost: 1784.65         +
         Plan Rows: 22265            +
         Plan Width: 1855            +
         Actual Startup Time: 0.004  +
         Actual Total Time: 4.653    +
         Actual Rows: 22265          +
         Actual Loops: 1             +
   Triggers:                         +
   Total Runtime: 102.288
(1 row)

Đây, chúng ta nhìn thấy rằng DB là đang sắp xếp tất cả 22,265 dòng mỗi lần bạn thực hiện câu query trên. Khi default, mỗi câu lệnh "order by" thực hiện sẽ sắp xếp lại bộ dữ liệu của bạn ngay sau đó. Không được cache lại không có phép thuật nào có thể giúp bạn.

Có một giải pháp để giải quyết vấn đề này đó là sử dụng indexes. Như trường hợp ví dụ đơn giản trên, chúng ta đánh index cho cột created_at sẽ tăng tốc câu query thêm một chút.

class AddIndexToFaultCreatedAt < ActiveRecord::Migration
  def change
    add_index(:faults, :created_at)
  end
end

đoạn code trên sẽ chạy câu query SQL như sau.

CREATE INDEX index_faults_on_created_at ON faults USING btree (created_at);

Bây giờ chúng ta chạy thực hiện lại việc lấy 20 bản ghi mới nhất. Chúng ta sẽ không còn nhìn thấy việc sắp xếp lại bản ghi nữa mà đơn giản chỉ là việc đọc lại dữ liệu đã được sắp xếp từ index.

# explain (analyze, format yaml) select * from faults order by created_at desc;
                  QUERY PLAN
----------------------------------------------
 - Plan:                                     +
     Node Type: "Index Scan"                 +
     Scan Direction: "Backward"              +
     Index Name: "index_faults_on_created_at"+
     Relation Name: "faults"                 +
     Alias: "faults"                         +
     Startup Cost: 0.29                      +
     Total Cost: 5288.04                     +
     Plan Rows: 22265                        +
     Plan Width: 1855                        +
     Actual Startup Time: 0.023              +
     Actual Total Time: 8.778                +
     Actual Rows: 22265                      +
     Actual Loops: 1                         +
   Triggers:                                 +
   Total Runtime: 10.080
(1 row)

Nếu bạn muốn sắp xếp nhiều cột thì bạn có thể tạo một index được sắp xếp bởi nhiều dòng. Đây à đoạn code add index nhiều dòng trong rails.

add_index(:faults, [:priority, :created_at], order: {priority: :asc, created_at: :desc)

Limits and Offsets

Thay vì việc chúng ta phân trang mà chỉ show ra 10 hoặc 30 hoặc 50 items. Cách thông dụng nhất để làm điều này là sử dụng limit và offset. Trong rails đoạn code đó như sau.

Fault.limit(10).offset(100)

câu lệnh sql trả về là

select * from faults limit 10 offset 100;

Bây giờ chúng ta chạy thêm câu lệnh explain, chúng ta sẽ nhìn thấy có một số thứ kì cục. Số lượng dòng được scanner là 110 bằng tổng của limit và offset.

# explain (analyze, format yaml) select * from faults limit 10 offset 100;
              QUERY PLAN
--------------------------------------
 - Plan:                             +
     Node Type: "Limit"              +
     ...
     Plans:                          +
       - Node Type: "Seq Scan"       +
         Actual Rows: 110            +
         ...

Nếu bạn chuyển offsets thành 10000 bạn sẽ nhìn thấy số dòng dược scanned là 10010 và query chậm hơn 64 lần

# explain (analyze, format yaml) select * from faults limit 10 offset 10000;
             QUERY PLAN
--------------------------------------
- Plan:                             +
    Node Type: "Limit"              +
    ...
    Plans:                          +
      - Node Type: "Seq Scan"       +
        Actual Rows: 10010          +
        ...

Điều này dẫn đến một kết luận khá là buồn. Khi mà phân trang, các trang ở phía sau là được load chậm hơn các trang trước. Nếu bạn có 100 items trên một trang như ví dụ trên, trang thứ 100 sẽ trậm hơn 13 lần so với trang 1.

Vậy, chúng ta phải làm gì để cải thiện điều này ? Thẳng thắn mà nói, Tôi không tìm ra được một giải pháp hoàn hảo. Có một cách đặt cược tốt nhất đó là

# You could use a date range
Fault.where("created_at > ? and created_at < ?", 100.days.ago, 101.days.ago)

# ...or even an id range
Fault.where("id > ? and id < ?", 100, 200)

Conclusion

Tôi hi vọng bài báo này có thể thuyết phục được bạn nên tận dụng lợi thế của hàm explain cho việc tìm ra vấn đề về hiệu suất khi làm việc với db trong project của bạn. Thậm chi những câu query đơn giản nhất cũng có thể gây ra những vấn đề nghiêm trọng về hiệu suất của việc truy vấn databases.

Tài liệu tham khảo

http://blog.honeybadger.io/common-rails-idioms-that-kill-database-performance/?utm_source=rubyweekly&utm_medium=email


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí