Có một câu cảnh báo cũ dành cho dân thống kê: đừng bao giờ lội qua con sông "sâu trung bình một mét". Trung bình một mét nghe an toàn — nhưng nếu hai bờ cạn 20 phân còn khúc giữa sâu ba mét, người không biết bơi vẫn chết đuối đúng chỗ giữa. Cái trung bình đẹp đẽ ấy che mất cái mà bạn thật sự đâm đầu vào: khúc sâu nhất. Latency của một hệ dưới tải là một con sông y hệt vậy — và nếu bạn chỉ đo độ sâu trung bình, bạn sắp lội qua nó với niềm tin sai lầm.

Suốt sê-ri ta đo hiệu năng bằng nhiều con số — ns/op, op/giây, tỉ lệ tăng tốc. Giờ lùi lại một bước để hỏi: đo hiệu năng thế nào cho đúng? Có hai chỉ số cơ bản mà người ta hay lẫn lộn hoặc dùng sai: throughput (làm được bao nhiêu việc mỗi giây) và latency (một việc mất bao lâu). Chúng khác nhau, đôi khi trái chiều, và — quan trọng nhất — latency không phải một con số mà là một phân phối. Tôi đo một workload có tranh chấp và thu latency của từng thao tác, và con số phơi bày hai sai lầm phổ biến về đo hiệu năng.

Hai chỉ số, không thay thế nhau

Throughput = op/giây: dung lượng của hệ, bao nhiêu việc trôi qua mỗi đơn vị thời gian. Latency = thời gian một op: một request mất bao lâu từ lúc bắt đầu tới lúc xong. Chúng không là một: một hệ có throughput cao vẫn có thể có latency tệ (nhiều việc trôi qua, nhưng mỗi việc chờ lâu trong hàng đợi), và ngược lại. Ta đã thấy chúng trái chiều ở batching: gom lô tăng throughput nhưng tăng latency (item chờ đầy lô).

Tôi đo trong container gcc:13: nhiều luồng cùng thao tác trên một biến chung được bảo vệ bởi một mutex, mỗi thao tác có một vùng tới hạn nhỏ. Với mỗi op tôi bấm giờ latency (thời gian từ lúc xin khóa tới lúc xong), gom toàn bộ vào mảng, sắp xếp, và tính các phân vị: trung vị (p50), phân vị 99 (p99 — 1% chậm nhất bắt đầu từ đây), p99,9, và max. Đo ở 1, 4, 8 luồng.

Đo (a): throughput giảm, latency tăng — cùng lúc

        throughput | mean lat | p50   | p99      | p99.9
N=1     14,0 M/s   |  54 ns   |  42   |  84      |  84
N=4      8,4 M/s   | 456 ns   | 125   | 6.875    | 13.375
N=8      5,5 M/s   | 1.420 ns | 125   | 32.292   | 78.917

Nhìn cột throughput: nó giảm khi thêm luồng (14 → 8,4 → 5,5 triệu op/giây). Vì tất cả tranh một mutex, thêm luồng chỉ tăng tranh chấp — đúng scale âm. Nhìn cột latency trung bình: nó tăng (54 → 456 → 1.420 ns). Cả hai chỉ số cùng xấu đi — thêm luồng ở đây vừa giảm throughput vừa tăng latency. Đây là bằng chứng trực tiếp rằng "nhiều luồng" không đồng nghĩa "nhanh hơn", và rằng throughput và latency phải nhìn cùng lúc — tối ưu một chỉ số mà không nhìn cái kia dễ dẫn tới kết luận sai. (Ngược lại, batching sẽ cho throughput tăng mà latency cũng tăng — trái chiều; ở đây tranh chấp cho cả hai cùng xấu.)

Đo (b): trung bình giấu cái đuôi

Đây là điểm quan trọng nhất, và là sai lầm phổ biến nhất trong đo latency — đúng khúc giữa con sông. Nhìn hàng N=8 kỹ hơn:

p50 (trung vị) : 125 ns      <- một nửa số op nhanh cỡ này
mean (trung bình): 1.420 ns   <- gấp 11 lần trung vị
p99            : 32.292 ns    <- 1% chậm nhất: gấp 258 lần trung vị
p99,9          : 78.917 ns    <- 0,1% chậm nhất
max            : 336.417 ns

Trung vị chỉ 125 ns — một nửa số thao tác nhanh như vậy. Nhưng p99 = 32.292 ns — nghĩa là 1% số thao tác chậm gấp 258 lần trung vị. Và trung bình (1.420 ns) nằm đâu đó ở giữa, bị kéo lên bởi cái đuôi nhưng vẫn giấu mức độ tệ của nó (p99 gấp 23 lần chính mean). Nếu bạn chỉ báo cáo "latency trung bình 1,4 µs", bạn đang che giấu sự thật rằng cứ 100 request thì có 1 request mất 32 µs, và cứ 1000 thì có 1 mất 79 µs.

Vì sao điều này chết người: người dùng gặp cái đuôi, không gặp trung bình. Một trang web tải 100 tài nguyên; nếu p99 latency tệ, gần như mọi lần tải trang sẽ đụng ít nhất một tài nguyên chậm (1 − 0,99^100 ≈ 63%). Một dịch vụ gọi 10 microservice; p99 của mỗi cái dồn lại thành p99 tệ hơn nhiều của toàn request. Trung bình đẹp không cứu được bạn nếu đuôi xấu. Đây là lý do các hệ thống nghiêm túc đo và cam kết theo phân vị (p99, p99,9), không theo trung bình.

(Môi trường đo: container gcc:13 trên host ARM AArch64, latency từng op dưới mutex chung, sắp xếp lấy phân vị, đo ở 1/4/8 luồng. Con số tuyệt đối đổi theo máy; điều bền vững là hình dạng: dưới tranh chấp, throughput giảm và latency tăng cùng lúc, và phân phối latency lệch nặng — đuôi p99 xa median hàng trăm lần.)

Hai niềm tin đối nghịch, cùng sai

Tôi vào đo với hai niềm tin phổ biến. Thứ nhất: "throughput cao nghĩa là hệ nhanh". Sai — throughput và latency là hai chiều khác nhau; một hệ có thể đẩy nhiều op/giây mà mỗi op vẫn chậm (xếp hàng), và như đo được, dưới tranh chấp cả hai cùng tệ. Thứ hai, nguy hiểm hơn: "đo latency bằng con số trung bình là đủ". Sai — latency là một phân phối lệch (skewed) với cái đuôi dài; trung bình bị đuôi kéo lệch và đồng thời giấu mức độ của đuôi. Ở 8 luồng, mean là 1.420 ns nhưng p99 là 32.292 ns — nếu tôi chỉ nhìn mean, tôi đã bỏ lỡ hoàn toàn việc 1% request chậm gấp hàng chục lần.

Hai niềm tin gặp nhau ở một sự thật áp cho chính việc đo: một con số đơn (trung bình) không mô tả nổi hiệu năng đồng thời — phải đo phân phối, và đặc biệt là cái đuôi. Trung bình phù hợp cho đại lượng đối xứng, nhưng latency dưới tải là bất đối xứng nặng: đa số nhanh, một thiểu số chậm khủng khiếp (do xếp hàng, tranh khóa, convoy, GC, chuyển ngữ cảnh). Chính cái thiểu số đó quyết định trải nghiệm. Đúng tinh thần đo lường vi mô đúng cách: đo đúng đại lượng quan trọng như đo chính xác — và với latency, đại lượng đúng là phân vị đuôi dưới tải thực, không phải trung bình lúc rảnh.

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

Điều đầu tiên: đo và cam kết latency theo phân vị, không theo trung bình. Khi đánh giá hay đặt mục tiêu (SLO) cho một dịch vụ, dùng p99/p99,9 ("99% request dưới X ms"), không dùng "trung bình dưới X ms". Trung bình có thể đẹp trong khi đuôi thảm họa. Ghi lại phân phối (histogram), không chỉ một con số.

Điều thứ hai: nhìn throughput và latency cùng lúc, và biết bạn đang tối ưu cho cái nào. Một hệ thiên throughput (xử lý theo mẻ, ETL) chấp nhận latency cao để đẩy nhiều việc; một hệ thiên latency (giao dịch tương tác, game) hy sinh throughput để mỗi request nhanh. Batching, buffer lớn, hàng đợi sâu — tất cả tăng throughput mà tăng latency. Chọn có ý thức, và đo cả hai đường cong khi tăng tải.

Chỗ này ngành công nghiệp đã học bài "trung bình nói dối" bằng máu, nên có cả một kho công cụ và tài liệu chỉ để đo đuôi cho đúng. Prometheus tách hẳn histogram/summary để tính quantile thay vì chỉ trung bình; HdrHistogram của Gil Tene sinh ra để ghi phân vị chính xác tới p99,999 với chi phí thấp — và bài nói kinh điển "How NOT to Measure Latency" của ông chỉ ra một cái bẫy tinh vi hơn cả bài này: coordinated omission, khi công cụ đo bỏ sót đúng những request chậm nhất vì chính nó cũng bị kẹt, làm đuôi trông đẹp giả tạo. Google Dean & Barroso viết hẳn "The Tail at Scale" về việc p99 của từng dịch vụ dồn lại thành thảm họa ở quy mô fan-out. Ở tầng database, mọi DBA lành nghề nhìn p95/p99 của query chứ không nhìn "avg query time" — vì một truy vấn đuôi dài khóa bảng làm cả nghìn request khác chờ theo. Java thì System.nanoTime() + HdrHistogram là combo chuẩn để đo dưới tải, còn Go phơi phân vị GC pause qua runtime/metrics. Mẫu số chung: ai đo hiệu năng nghiêm túc đều đo phân phối và đuôi, và câu "latency trung bình" gần như luôn là dấu hiệu ai đó chưa nhìn xuống khúc sông sâu.

Muốn tự thấy thì nhìn cách bạn (hay nhóm bạn) đang báo cáo hiệu năng một dịch vụ hoặc hàm nóng, và hỏi: chúng ta đo latency bằng gì — trung bình, hay phân vị? Nếu chỉ có "latency trung bình", bạn đang mù trước cái đuôi: cứ đo thêm p99 và p99,9, gần như chắc chắn bạn sẽ thấy chúng tệ hơn trung bình nhiều lần, và đó mới là cái người dùng cảm nhận. Rồi hỏi câu thứ hai: khi tôi tăng tải (nhiều luồng, nhiều request), throughput và latency đổi thế nào? Vẽ cả hai đường: bạn sẽ thấy "đầu gối latency" — điểm mà latency đang phẳng bỗng vọt lên khi hệ gần bão hòa, dù throughput chỉ nhích thêm chút. Vài phút thêm hai phân vị và một trục tải đó biến một bức tranh hiệu năng dối trá (trung bình đẹp) thành bức tranh thật — và nhắc rằng đo đúng đại lượng quan trọng ngang đo chính xác: với latency, đại lượng đúng là cái đuôi dưới tải, không phải độ sâu trung bình của con sông.