Một chiếc xe quảng cáo "chạy 300 km/h" — con số thật, đo trên đường thử. Nhưng đó là đường thẳng, không tải, xăng đua, lốp đặc chủng, tài xế chuyên nghiệp. Đem ra phố đông giờ tan tầm, chở bốn người và hành lý, nó chẳng liên quan gì tới 300 km/h nữa. Con số không sai; điều kiện mới là thứ quyết định nó có nghĩa gì. Benchmark cơ sở dữ liệu y hệt vậy, và bài này là lần tôi suýt dán con số "đường thử" lên chiếc xe đi phố — rồi kịp dừng lại.

Cả sê-ri này nói về đo lường trung thực, và pgbench — công cụ đo tải chuẩn của PostgreSQL — là nơi mọi nguyên tắc đo của sê-ri hội tụ về đúng một câu: một con số chỉ có nghĩa kèm điều kiện của nó. Tôi sẽ đo tps thật bằng pgbench, xem nó tăng theo tải rồi bão hòa, và kể lại lần suýt khoe một con số hoàn toàn vô nghĩa.

pgbench đo gì

pgbench đi kèm PostgreSQL để đo thông lượng. Bạn khởi tạo dữ liệu bằng pgbench -i -s N (N là scale factor — quyết định kích thước dữ liệu), rồi chạy tải bằng pgbench -c C -T t: nó mô phỏng C client đồng thời gửi giao dịch trong t giây, và báo về tps (giao dịch mỗi giây), latency trung bình và độ lệch chuẩn của latency. Tải mặc định là một giao dịch kiểu ngân hàng (đọc một tài khoản, cập nhật số dư, ghi lịch sử); cờ -S đổi sang chỉ SELECT (read-only), -M prepared dùng prepared statement (bỏ chi phí parse mỗi câu). Ngoài ra -j đặt số luồng phía client (nên khớp số lõi để chính client không thành nút cổ chai), và -t chạy theo số giao dịch thay vì theo thời gian. Chọn tham số sai — chẳng hạn một luồng client phục vụ ba mươi hai kết nối — có thể khiến bạn đo giới hạn của chính pgbench thay vì của CSDL.

Tôi khởi tạo pgbench -i -s 10 (tạo bảng tài khoản ~150MB) rồi đo. Một chi tiết quan trọng ngay từ đây: scale factor 10 nghĩa là dữ liệu chỉ 150MB — nhỏ hơn RAM của máy rất nhiều. Con số này sẽ quay lại ám ảnh tôi ở phần sau, vì một benchmark mà toàn bộ dữ liệu nằm trong bộ nhớ đo một thứ rất khác với một benchmark mà dữ liệu phải đọc từ đĩa.

Đo: tps tăng theo tải rồi bão hòa

Chạy tải read-write (mặc định) với số client -c tăng dần:

-c    read-write tps
 1      4864
 4      9161
 8     10626
16     14213
32     16052

Tps tăng khi thêm client — nhưng không tuyến tính. Từ 1 lên 4 client, tps chỉ tăng 1,9 lần (không phải 4). Từ 16 lên 32 client (gấp đôi), tps chỉ nhích từ 14213 lên 16052 — 1,13 lần. Nó đang bão hòa: máy có 10 lõi, và một khi mọi lõi đều bận, thêm client chỉ làm chúng xếp hàng chờ, không tăng thông lượng. Đây là hình dạng kinh điển của mọi benchmark đồng thời — tăng, cong, rồi phẳng (đôi khi giảm vì chi phí tranh chấp). Nó lặp lại đúng thứ bài kế hoạch song song đã đo: thêm sức tính toán chỉ giúp tới một trần, đặt bởi tài nguyên dùng chung — ở đây là số lõi CPU và băng thông đĩa. Số lõi không phải một phép nhân thần kỳ cho thông lượng.

Đổi sang read-only (-S) cho con số cao hơn hẳn:

-c    read-only tps
 1     29124
16    193954
32    203189

Read-only nhanh hơn read-write khoảng 6 tới 14 lần, vì nó không ghi WAL, không fsync, không commit tốn kém — chỉ đọc thuần túy. Và nó bão hòa cao hơn (194k ở 16 client) vì nghẽn của nó là CPU, không phải đĩa. Đây đã là chỗ con số bắt đầu đánh lừa.

Con số 200 nghìn mà tôi suýt đem đi khoe

Thấy 203189 tps read-only, phản xạ đầu tiên của tôi là khoe: "CSDL này chạy hai trăm nghìn giao dịch mỗi giây!". Nhưng khựng lại một giây thôi là thấy con số đó gần như không nói gì về hiệu năng production, vì mọi điều kiện đo đều lệch về phía dễ.

Thứ nhất, nó là read-only — không có ghi, không commit, không fsync. Ứng dụng thật gần như luôn có ghi, và ghi là phần đắt nhất (như bài gom commit đã đo, fsync mỗi commit là nút cổ chai). Thứ hai, tôi đo trên đĩa máy ảo, nơi fsync gần như miễn phí — một điều tôi nhắc lại suốt sê-ri; trên đĩa vật lý thật, tps read-write sẽ thấp hơn nhiều vì bị chặn bởi tốc độ fsync. Thứ ba, 150MB dữ liệu nằm gọn trong RAM, nên không hề có đọc đĩa thật; một CSDL production với dữ liệu lớn hơn RAM sẽ phải đọc từ đĩa và chậm hơn hẳn.

Đổi bất kỳ điều kiện nào trong ba cái đó về phía thật — thêm ghi, đĩa thật, dữ liệu lớn hơn RAM — và 203k tps sụp xuống một con số hoàn toàn khác. Cái sai của tôi là đọc một con số như thể nó là thuộc tính của "CSDL", trong khi nó chỉ là thuộc tính của CSDL trong đúng điều kiện tôi đo: nhỏ, read-only, fsync rẻ. Một benchmark thiếu điều kiện là một con số nói dối — đúng bài học xuyên suốt sê-ri: "nhanh" vô nghĩa nếu chưa hỏi đo ở đâu, với gì, tải nào.

Còn một cái bẫy thứ hai nữa nằm ngay trong bảng số: đừng ngoại suy tuyến tính. Nếu tôi lấy tps của 1 client (4864) rồi nhân cho 32, tôi sẽ dự đoán 155.648 tps. Thực đo ở 32 client chỉ 16.052 — sai mười lần. Vì bão hòa, một client không nói gì về ba mươi hai client. Đo ở đúng mức đồng thời mình quan tâm, đừng suy từ một điểm. Đường cong tps theo số client là dữ liệu; một điểm trên đường cong đó nhân lên chỉ là phỏng đoán — và phỏng đoán này gần như luôn quá lạc quan vì nó bỏ qua chính cái trần mà benchmark sinh ra để tìm.

Về đến code hằng ngày

Điều đầu tiên: benchmark phải giống production ở những trục quan trọng. Scale factor đủ lớn để dữ liệu vượt RAM (nếu production vậy), tỉ lệ đọc/ghi giống tải thật, chạy trên phần cứng giống đích, ở mức đồng thời thật. Một con số đẹp trên laptop với dữ liệu tí xíu không hứa hẹn gì về máy chủ thật. Như bài kết nối và bài checkpoint đã cho thấy, các nút cổ chai (fsync, I/O đĩa, cache) chỉ lộ ra ở đúng quy mô và đúng phần cứng.

Điều thứ hai: đọc tps luôn phải kèm latency và độ lệch chuẩn, và đo cả đường cong chứ không đo một điểm. Gói lại bằng số: pgbench đo tps/latency của một tải; tps tăng theo số client rồi bão hòa ở số lõi (read-write 4864→16052 tps từ 1 tới 32 client, read-only tới 203k vì nghẽn ở CPU) — nhưng con số cao ở đây vô nghĩa cho production vì đo read-only trên đĩa VM fsync rẻ với 150MB vừa RAM; benchmark chỉ có nghĩa ở đúng điều kiện (scale > RAM, đĩa thật, tải có ghi), và ngoại suy 1 client lên 32 sai 10 lần vì bão hòa. Trước khi tin một con số benchmark, hãy hỏi nó đo ở điều kiện nào.

Và bài học này không đóng khung trong pgbench hay PostgreSQL — nó là cùng một sai lầm mà mọi lập trình viên đều dễ mắc khi khoe hiệu năng. Một microbenchmark JMH của Java báo "2 tỉ thao tác/giây" thường là vì JIT đã tối ưu mất cả vòng lặp (dead-code elimination), y như 150MB vừa RAM ở đây làm biến mất đọc đĩa. Một go test -bench chạy trên dữ liệu nhỏ vừa cache L2 sẽ cho con số bốc hơi khi dữ liệu thật vượt cache. Mẫu số chung: một con số hiệu năng chỉ là thuộc tính của điều kiện đo, không phải của hệ thống — nên khi ai đó (kể cả chính bạn) trích một con số "nhanh", câu hỏi đầu tiên không phải "bao nhiêu" mà là "đo ở điều kiện nào". Đó là toàn bộ tinh thần của sê-ri gói trong một phản xạ.

Muốn tự thấy thì nếu có PostgreSQL, chạy pgbench -i -s 5 để khởi tạo, rồi pgbench -c 1 -T 5 và pgbench -c 8 -T 5 — so hai con số tps. Bạn sẽ thấy 8 client không cho tps gấp 8 lần 1 client; tỉ số nhỏ hơn nhiều, và càng tăng -c tỉ số càng co lại khi chạm số lõi máy. Rồi thêm -S (read-only) và chạy lại: tps vọt lên vì bỏ hết phần ghi. Nửa phút đó cho bạn thấy hai điều cùng lúc: thông lượng bão hòa theo số lõi, và một con số tps thay đổi hoàn toàn tùy bạn đo đọc hay ghi — nên đừng bao giờ trích một con số tps mà không kèm điều kiện đã đo ra nó.