Tôi nhớ lần đầu tiên tôi thấy ActiveRecord của Rails. Đó là một sự khám phá. Quay lại 2005 và tôi đang hand-code SQL queries cho một PHP app. Bất chợt, dùng database từ một việc nhàm chán, trở nên dễ dàng và - dám nói - vui vẻ.
...sau đó tôi bắt đầu nhận ra vấn đề hiệu năng.
ActiveRecord bản thân không chậm. Tôi chỉ ngừng chú ý đến các query thực sự đang chạy. Và kết quả là, một số query database idiomatic nhất dùng trong Rails CRUD apps mặc định khá kém ở việc scale lên dataset lớn hơn.
Trong bài này, chúng ta sẽ thảo luận ba thủ phạm lớn nhất. Nhưng trước, hãy nói về cách bạn có thể nói nếu DB queries của bạn sẽ scale tốt.
Đo lường hiệu năng
Mọi DB query đều performant nếu bạn có dataset đủ nhỏ. Vì vậy để thực sự cảm nhận hiệu năng, chúng ta cần benchmark với database cỡ production. Trong ví dụ của chúng ta, chúng ta sẽ dùng một bảng tên faults với khoảng 22,000 records.
Chúng ta dùng postgres. Trong postgres, cách bạn đo hiệu năng là dùng 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
Đây cho thấy cả estimated cost để thực hiện query (cost=0.29..8.30 rows=1 width=1855) và thời gian thực tế nó mất (actual time=0.556..0.556 rows=0 loops=1)
Nếu bạn thích format dễ đọc hơn, bạn có thể yêu cầu postgres in kết quả dưới 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)
Hiện tại chúng ta chỉ tập trung vào "Plan Rows" và "Actual Rows."
- Plan Rows Trong worst case, DB sẽ phải loop qua bao nhiêu row để respond query của bạn
- Actual Rows Khi nó thực thi query, DB thực sự loop qua bao nhiêu row?
Nếu "Plan Rows" là 1, như trên, thì query có thể scale tốt. Nếu "Plan Rows" bằng số row trong database, nghĩa là query sẽ làm "full table scan" và không scale tốt.
Bây giờ bạn biết cách đo query performance, hãy xem một số idiom rails phổ biến và xem chúng stack up như thế nào.
Đếm
Rất phổ biến thấy mã như sau trong Rails views:
Total Faults <%= Fault.count %>
Điều đó dẫn đến SQL trông như sau:
select count(*) from faults;
Hãy plug vào explain và xem chuyện 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! Query count đơn giản của chúng ta đang loop qua 22,265 rows — toàn bộ bảng! Trong postgres, counts luôn loop qua toàn bộ record set.
Bạn có thể giảm kích thước record set bằng cách thêm điều kiện where vào query. Tùy vào yêu cầu của bạn, bạn có thể giảm size đủ thấp nơi hiệu năng chấp nhận được.
Cách duy nhất khác để bypass vấn đề này là cache giá trị count. Rails có thể làm điều này cho bạn nếu bạn setup:
belongs_to :project, :counter_cache => true
Một lựa chọn thay thế khác có sẵn khi kiểm tra xem query có trả về record nào không. Thay vì Users.count > 0, thử Users.exists?. Query kết quả hiệu năng hơn nhiều. (Cảm ơn độc giả Gerry Shaw đã chỉ ra điều này cho tôi.)
Sắp xếp
Trang index. Gần như mọi app có ít nhất một. Bạn pull 20 record mới nhất từ database và hiển thị chúng. Điều gì có thể đơn giản hơn?
Mã load record có thể trông như sau:
@faults = Fault.order(created_at: :desc)
SQL cho nó trông như sau:
select * from faults order by created_at desc;
Vậy hãy analyze nó:
# 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 thấy DB đang sort tất cả 22,265 rows mỗi lần bạn làm query này. Không tốt!
Mặc định, mọi mệnh đề "order by" trong SQL của bạn khiến record set được sort ngay lúc đó, real-time. Không có caching. Không có magic cứu bạn.
Giải pháp là dùng indexes. Cho case đơn giản như này, thêm một sorted index vào cột created_at sẽ tăng tốc query khá nhiều.
Trong Rails migration bạn có thể để:
class AddIndexToFaultCreatedAt < ActiveRecord::Migration
def change
add_index(:faults, :created_at)
end
end
Chạy SQL sau:
CREATE INDEX index_faults_on_created_at ON faults USING btree (created_at);
Ở cuối đó, (created_at) chỉ định thứ tự sort. Mặc định nó là ascending.
Bây giờ, nếu chúng ta re-run sort query, chúng ta thấy nó không còn bao gồm bước sorting. Nó đơn giản đọc dữ liệu pre-sorted 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 sort bằng nhiều cột, bạn sẽ cần tạo index được sort bằng nhiều cột cũng vậy. Đây là giao diện trong Rails migration:
add_index(:faults, [:priority, :created_at], order: {priority: :asc, created_at: :desc)
Khi bạn bắt đầu làm query phức tạp hơn, ý tưởng tốt là chạy chúng qua explain. Làm sớm và thường xuyên. Bạn có thể thấy một số thay đổi đơn giản của query đã làm cho postgres không thể dùng index để sort.
Limits và Offsets
Trong trang index của chúng ta, chúng ta khó bao giờ include mọi item trong database. Thay vào đó chúng ta paginate, chỉ show 10 hoặc 30 hoặc 50 items tại một thời điểm. Cách phổ biến nhất để làm điều này là dùng limit và offset cùng nhau. Trong Rails nó trông như sau:
Fault.limit(10).offset(100)
Điều đó tạo ra SQL trông như sau:
select * from faults limit 10 offset 100;
Bây giờ nếu chúng ta run explain, chúng ta thấy điều gì đó lạ. Số row scanned là 110, bằng limit cộng 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 đổi offset thành 10,000 bạn sẽ thấy số row scanned nhảy lên 10010, và query chậm gấp 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 kết luận khó chịu: khi paginate, các trang sau chậm hơn trang trước. Nếu chúng ta giả định 100 items mỗi trang trong ví dụ trên, trang 100 chậm gấp 13 lần trang 1.
Vậy bạn làm gì?
Thành thật, tôi chưa tìm được giải pháp hoàn hảo. Tôi sẽ bắt đầu bằng xem nếu tôi có thể giảm kích thước dataset để không phải có 100s hoặc 1000s trang từ đầu.
Nếu bạn không thể giảm record set, cái tốt nhất có thể là thay thế offset/limit bằng mệnh đề where.
# Bạn có thể dùng date range
Fault.where("created_at > ? and created_at < ?", 100.days.ago, 101.days.ago)
# ...hoặc thậm chí id range
Fault.where("id > ? and id < ?", 100, 200)
Kết luận
Tôi hy vọng bài này đã thuyết phục bạn rằng bạn thực sự nên tận dụng hàm explain của postgres để tìm vấn đề hiệu năng tiềm ẩn với db queries của bạn. Ngay cả query đơn giản nhất cũng có thể gây vấn đề hiệu năng lớn, nên đáng để check. :)