Trong bài này
- Cái panel màu xanh
- Con số trung bình nói dối thế nào: 100 request
- Percentile: p50, p95, p99, p999
- Ai quan tâm đến p999?
- Khuếch đại đuôi: phép tính 63%
- Trung bình nói dối cả hai chiều
- Tính percentile mà không tự lừa mình
- Đừng bao giờ lấy trung bình của percentile
- Prometheus và HdrHistogram
- Coordinated omission
- Thuần hóa cái đuôi
- Nói chuyện với sếp
- Biến nó thành SLO
- Kết: hãy hỏi về cái đuôi
- Câu hỏi luyện tập
Cái panel màu xanh
Stand-up sáng thứ Hai. Panel Grafana trên màn hình ghi thời gian phản hồi trung bình: 150 ms, màu xanh, thoải mái dưới ngưỡng 200 ms của team. Hai tin nhắn bên dưới ở kênh support: “checkout thỉnh thoảng quay tròn tầm ba giây, app hỏng hả?”. Mình trả lời đúng như một backend engineer vừa nhìn thấy panel xanh vẫn hay trả lời: số liệu ổn mà, chắc do mạng của họ. Rồi có người gửi một đoạn video quay màn hình. Ba giây quay tròn, trong một hệ thống đang báo 150 ms. Cả hai điều cùng đúng một lúc, và cả bài viết này xoay quanh đúng chuyện đó.
Đây là bài thứ ba trong loạt ghi chú của mình về Designing Data-Intensive Applications của Martin Kleppmann (chương 1 ở bản 1, phần mô tả hiệu năng). Ý chính gói được trong một câu: con số trung bình mô tả một khách hàng không tồn tại. Nếu đo độ trễ, hãy đo bằng percentile, và nhớ rằng cái đuôi chậm không đứng yên một chỗ: mỗi lần một trang gọi nhiều service, nó lại bị nhân lên.
- dashboard hiển thị
- 150 ms
- user cảm nhận
- 3 s
- trang dính một lời gọi p99, khi có 100 lời gọi
- 63%
- doanh số mất cho mỗi 100 ms chậm thêm (Amazon)
- ~1%
Con số trung bình nói dối thế nào: 100 request
Mình làm cho cú lừa này cụ thể bằng một mẫu đồ chơi mà bạn kiểm bằng tay được. Một trăm request tới endpoint checkout, xếp từ nhanh nhất đến chậm nhất. Đa số nhanh, một ít chậm, hai cái tệ hại, khá giống hình dạng của hệ thống thật.
| Thứ hạng sau khi xếp | Độ trễ mỗi cái | Số lượng | Tổng thời gian |
|---|---|---|---|
| 1 đến 80 | 40 ms | 80 | 3.200 ms |
| 81 đến 92 | 100 ms | 12 | 1.200 ms |
| 93 đến 98 | 500 ms | 6 | 3.000 ms |
| 99 | 3.000 ms | 1 | 3.000 ms |
| 100 | 4.600 ms | 1 | 4.600 ms |
Tổng thời gian là 15.000 ms cho 100 request, nên trung bình đúng 150 ms, đúng con số dashboard hiển thị. Giờ đọc percentile bằng cách đếm dọc danh sách đã xếp:
| Thống kê | Giá trị | Ai thực sự trải qua nó |
|---|---|---|
| Trung bình | 150 ms | Không ai cả. Các giá trị có mặt là 40, 100, 500, 3.000, 4.600. |
| p50 (trung vị) | 40 ms | Request điển hình. Cái thứ 50 theo thứ tự. |
| p95 | 500 ms | Khoảng 1 trên 20 request chờ ngần này hoặc hơn. Cái thứ 95 theo thứ tự. |
| p99 | 3.000 ms | Khoảng 1 trên 100 (ở đây là 2 trên 100). Cái thứ 99 theo thứ tự. |
| Lớn nhất | 4.600 ms | User xui nhất trong giờ. |
Đọc dòng đầu hai lần. Không request nào trong mẫu mất 150 ms. Trung bình là phép tính của một khách vừa nhanh vừa thảm họa, và người đó chưa bao giờ bước vào cửa. Tám mươi phần trăm user nhận 40 ms, tốt hơn dashboard nói; hai user nhận một kết quả họ sẽ nhớ cả tuần, tệ hơn nhiều. Trung bình là trọng tâm của phân phối, không phải một vị trí mà ai đó đang đứng.
-- PostgreSQL: percentile của một giờ gần nhất, tính thẳng từ request log
SELECT
count(*) AS n,
round(avg(ms)) AS mean_ms,
percentile_disc(0.50) WITHIN GROUP (ORDER BY ms) AS p50_ms,
percentile_disc(0.95) WITHIN GROUP (ORDER BY ms) AS p95_ms,
percentile_disc(0.99) WITHIN GROUP (ORDER BY ms) AS p99_ms
FROM request_log
WHERE route = 'POST /checkout'
AND ts > now() - interval '1 hour';
-- percentile_disc trả về một giá trị có thật (nearest-rank, 3000 với mẫu của ta).
-- percentile_cont nội suy giữa hai giá trị kề nhau (3016 với mẫu của ta): mượt hơn, nhưng không phải một request có thật.Percentile: p50, p95, p99, p999
Vậy đọc cái gì thay cho trung bình? Hình dung một nhà ga giờ cao điểm. Hai mươi cổng soi vé, ông trưởng ga đứng dưới chiếc đồng hồ lớn bấm giờ từng hành khách qua cổng. Nếu ông chỉ ghi một con số cho cả buổi sáng, “hành khách trung bình mất 40 giây”, ông đang mô tả một người không có thật: đa số qua trong 15 giây, và vài người kẹt ở đúng cổng có máy soi vé bị treo mười phút. Một bản báo cáo tốt hơn xếp mọi người theo thời gian đã mất rồi đọc theo vị trí trong hàng. Vị trí 50 trên 100 là hành khách điển hình. Vị trí 99 là người xui xẻo.

Đó chính là percentile. Xếp mọi thời gian phản hồi từ nhanh đến chậm; giá trị pN là mức thời gian mà N% request nhanh hơn, tức là (100 − N)% còn lại chậm hơn. Những cái tên bạn sẽ gặp:
- p50, trung vị: một nửa số request nhanh hơn. Đây là con số đơn lẻ tốt nhất cho câu hỏi “điển hình là bao nhiêu”.
- p95, p99, p999: mức mà 5%, 1% và 0,1% số request chậm hơn. Gộp lại, chúng là tail latency (độ trễ ở đuôi).
- Đọc ngược lại: nếu p50 là 200 ms nhưng p99 là 4 giây, thì cứ một trăm request có một cái chờ bốn giây. Một user lướt một trăm trang sẽ trung bình gặp độ chờ đó một lần, và có 63% khả năng gặp ít nhất một lần.
Ai quan tâm đến p999?
Amazon, nổi tiếng với chuyện này. Sách mô tả cách họ đặt yêu cầu cho các service nội bộ ở percentile thứ 99,9, dù nó chỉ chạm một request trên một nghìn. Lý lẽ mới là chỗ mình thấy ấn tượng: những khách có request chậm nhất thường chính là những khách có nhiều dữ liệu nhất trong tài khoản, nghĩa là những người đã mua nhiều nhất, khách hàng giá trị nhất. Trang của họ chậm vì nó lớn, và họ đúng là những người bạn không thể chọc giận. Sách cũng nhắc quan sát của Amazon rằng response time chậm thêm 100 ms thì doanh số giảm khoảng 1%.
Đối trọng thành thật, cũng có trong sách: đẩy lên p9999 tốn kém rất nhanh, và đến một lúc nào đó độ chậm do các sự kiện ngẫu nhiên nằm ngoài tầm kiểm soát (GC pause, rớt gói tin, page fault), lợi ích không còn bù được chi phí. Bạn chọn percentile mà business của bạn thực sự quan tâm.
Khuếch đại đuôi: phép tính 63%
Giờ đến phần làm mình đặt cuốn sách xuống và mở máy tính. Giả sử một service hoàn toàn khỏe theo chuẩn của chính bạn: p99 là một giây, tức 99% số lời gọi nhanh hơn. Ổn. Nhưng một trang hiện đại không gọi một service. Trang sản phẩm có thể gọi pricing, tồn kho, review, gợi ý, profile, một slot quảng cáo, một service feature flag: hàng chục đến cả trăm lời gọi backend, thường là song song. Trang chỉ xong khi lời gọi chậm nhất trả về.
Mỗi lời gọi có 99% khả năng nhanh, và chúng độc lập với nhau. Khả năng cả 100 đều nhanh là 0,99 nhân với chính nó một trăm lần. Vậy khả năng có ít nhất một cái chậm là phần bù:
// P(ít nhất một trong n lời gọi độc lập rơi vào phần chậm nhất (1 - q))
const hitTail = (n: number, q = 0.99) => 1 - q ** n;
hitTail(1); // 0.010 -> 1,0%
hitTail(10); // 0.096 -> 9,6%
hitTail(100); // 0.634 -> 63,4% <- con số chính
hitTail(100, 0.999); // 0.095 -> 9,5% <- cùng trang đó nhưng backend đạt p999| Số lời gọi mỗi trang | Backend ở p99 | Backend ở p999 |
|---|---|---|
| 1 | 1,0% | 0,1% |
| 10 | 9,6% | 1,0% |
| 50 | 39,5% | 4,9% |
| 100 | 63,4% | 9,5% |
| 200 | 86,6% | 18,1% |
| 500 | 99,3% | 39,4% |
Vậy là từng service, xét riêng, trông đều ổn. Đặt chúng sau một trang và trang chậm gần như mọi lúc. Đó là tail latency amplification, và là lý do sách muốn bạn nhìn percentile của thứ user chạm vào, chứ không phải của các bộ phận. Nó cũng là, như một hệ quả phụ, một lý do khiến kiến trúc microservices có thể chậm hơn cái monolith mà nó thay thế: mỗi service thêm vào trang là thêm một lần bốc thăm. (Phép nhân này cũng nằm sẵn trong đường đọc của một timeline, xem bài 01.)
Trung bình nói dối cả hai chiều
Cho đến giờ con số trung bình lạc quan quá mức. Ý của sách rộng hơn: nó bị outlier bóp méo theo cả hai chiều, nên là nền tảng tồi cho cả sự yên tâm lẫn báo động.
- Lên, báo động giả. Một lô 10.000 request ở 40 ms, cộng một request bị kẹt mất 60 giây, có trung bình 46 ms. Alert nổ; user vẫn ổn. Một dòng log xấu đã đẩy con số chính của bạn lên 15%.
- Xuống, nỗi đau thật bị che. Giả sử một triệu lượt ping health check mất 1 ms mỗi cái và mười nghìn lượt checkout thật mất 800 ms. Trung bình là khoảng 9 ms. Dashboard trộn cả hai báo một hệ thống khỏe, trong khi mọi khách thật chờ gần một giây.
Trường hợp thứ hai cho thêm một bài học. Request thật là 10.000 trên 1.010.000, tức 0,99%, ngay dưới ngưỡng 1%, nên ngay cả p99 của bể trộn đó cũng chỉ 1 ms. Percentile không phải phép màu; nó thừa hưởng mọi thứ bạn đổ vào histogram. Cách chữa là tách theo route (checkout, search, health) và đừng bao giờ để traffic rẻ pha loãng traffic đắt.
Tính percentile mà không tự lừa mình
Đừng bao giờ lấy trung bình của percentile
Lỗi phổ biến nhất mình hay gặp: mỗi server báo p99 của riêng nó, và dashboard hiện trung bình của các p99 đó làm p99. Về mặt toán học điều này vô nghĩa. Giả sử server A xử lý 9.000 request với p99 là 120 ms, server B xử lý 1.000 request với p99 là 2.400 ms. Trung bình của hai số là 1.260 ms. P99 thật trên cả 10.000 request phụ thuộc vào toàn bộ hình dạng của cả hai phân phối, và không có hàm nào của hai con số kia cho bạn đáp án. Tương tự khi lấy trung bình các p99 theo phút để ra p99 theo giờ.
Cách đúng là gộp phân phối trước, rồi mới lấy percentile. Nghĩa là mỗi server gửi lên một histogram (số đếm theo từng bucket độ trễ), các histogram cộng với nhau từng bucket một, và bạn đọc percentile trên histogram đã gộp.
Prometheus và HdrHistogram
Với Prometheus, expose một metric dạng histogram và để câu query lo việc gộp. Để ý thứ tự: sum by (le) cộng các bộ đếm bucket của mọi instance trước, rồi histogram_quantile mới nội suy.
# Đúng: gộp bucket của mọi pod, rồi mới lấy percentile thứ 99
histogram_quantile(
0.99,
sum by (le) (rate(http_request_duration_seconds_bucket{route="/checkout"}[5m]))
)
# Sai: p99 cho từng pod, rồi lấy trung bình các p99
avg(
histogram_quantile(0.99, rate(http_request_duration_seconds_bucket{route="/checkout"}[5m]))
)Hai lưu ý. Một, histogram_quantile nội suy tuyến tính bên trong một bucket, nên độ chính xác bị giới hạn bởi ranh giới bucket: nếu các bucket cao nhất là 1 s và 10 s, thì p99 bạn nhận được chỉ là “đâu đó giữa hai mốc”. Hãy đặt một mép bucket tại mọi ngưỡng bạn cam kết (ví dụ 0,5 s cho SLO) và thêm vài mép quanh đuôi. Hai, quantile summary tính sẵn trên từng instance không thể gộp được, đó là lý do nên chuộng histogram cho mọi thứ cần aggregate.
Với việc đo ở phía ứng dụng, HdrHistogram là công cụ quen thuộc: nó ghi giá trị trong dải rất rộng (nano giây tới hàng giờ) với độ chính xác tương đối cố định, ví dụ ba chữ số có nghĩa, trong một lượng bộ nhớ nhỏ và không đổi, và hai histogram có thể cộng được với nhau.
Histogram a = new Histogram(3_600_000_000L, 3); // theo dõi tới 1 giờ tính bằng micro giây, 3 chữ số có nghĩa
Histogram b = new Histogram(3_600_000_000L, 3);
a.recordValue(micros); // với mỗi request ở service A
b.recordValue(otherMicros); // với mỗi request ở service B
a.add(b); // gộp: cộng từng bucket
long p99 = a.getValueAtPercentile(99.0); // p99 thật của cả hai tậpCoordinated omission
Một cái bẫy cuối, cho ai hay load test. Benchmark ngây thơ gửi một request, chờ trả lời, rồi gửi cái tiếp theo. Khi server đứng hình năm giây, bộ sinh tải cũng ngừng gửi năm giây, nên hàng trăm request lẽ ra đã xếp hàng sau cú đứng hình đó không bao giờ được gửi và không bao giờ được đo. Kết quả: một p99 đẹp đẽ do một client lịch sự đã ngừng hỏi. Hiện tượng này gọi là coordinated omission (thuật ngữ của Gil Tene). Cách chữa là gửi theo lịch cố định, không phụ thuộc vào phản hồi, và tính thời gian của mỗi request từ lúc nó đáng lẽ được gửi; wrk2 làm việc này cho HTTP load, còn HdrHistogram có recordValueWithExpectedInterval để điền bù các mẫu bị thiếu.
Thuần hóa cái đuôi
Ngoài sách. Khi đã nhìn thấy cái đuôi, ta làm gì với nó? Sách cho bạn biết vì sao nó đau; các kỹ thuật dưới đây là những thứ mình thấy hiệu quả hoặc đọc được trong bài “The Tail at Scale” của Dean và Barroso. Chúng nằm ngoài sách, và cái nào cũng có giá.
- Hedged request. Gửi lời gọi tới một replica; nếu không có phản hồi trong khoảng p95 của chính lời gọi đó, gửi bản sao tới replica thứ hai và lấy cái nào về trước, hủy cái còn lại. Chỉ dùng cho thao tác đọc idempotent. Bài báo ghi nhận p999 cải thiện rõ rệt chỉ với cỡ vài phần trăm request thừa trong thí nghiệm BigTable của họ (mình trích từ trí nhớ, nên hãy kiểm lại số liệu). Cái giá là tải phụ và nhu cầu cancellation thật sự.
- Timeout kèm ngân sách. Một lời gọi không có timeout thừa hưởng hành vi tệ nhất của thứ nó gọi. Đặt timeout từng lời gọi gần p99,9 của backend, truyền một deadline tổng xuống cả chuỗi, và giới hạn retry (ngân sách retry khoảng 10% traffic) để một cơn bão timeout không thành sự cố thứ hai.
- Ít hop tuần tự hơn. Độ trễ các lời gọi trong một chuỗi cộng lại, đuôi của chúng cũng vậy. Bỏ những hop chỉ tồn tại cho gọn gàng, cache thứ ít đổi, và gọi song song những việc độc lập. Đánh đổi chính là phép tính fan-out ở trên: gọi song song chặn được tổng nhưng tăng cơ hội dính đuôi, nên giữ fan-out nhỏ đến mức trang thực sự cần.
- Degrade thay vì chờ. Cho các phần tùy chọn của trang (gợi ý, quảng cáo) một deadline ngắn và render mà không có chúng khi trễ. Khách thấy một trang đơn giản hơn chút ngay lập tức, thay vì một trang hoàn hảo sau ba giây.
Để ý thứ còn thiếu: “làm mọi service nhanh hơn ở mức trung bình”. Gọt trung bình gần như không giúp gì cho cái đuôi, vì đuôi thường do một thứ khác với đường đi điển hình gây ra (cache lạnh, GC pause, hàng xóm ồn ào, hàng đợi đầy, chờ lock trên một dòng nóng, compaction làm khựng một LSM-tree).
Nói chuyện với sếp
Quay lại buổi stand-up. Sếp bảo dashboard 150 ms là ngon rồi, hỏi sao user vẫn than. Trực giác của mình lúc đó chưa thành từ vựng nhưng đúng hướng: cần xem các request chậm nhất là bao nhiêu; gần 150 ms thì không sao, còn hai ba giây thì phải tìm cách. Trực giác đó chính là tư duy percentile mặc thường phục. Những câu mình sẽ nói bây giờ:
- “Trung bình bị đa số request nhanh kéo xuống, nên nó không cho thấy được các request chậm.”
- “Em đề xuất xem histogram và p95, p99 thay vào đó.”
- “Nếu p99 là 3 giây thì mỗi ngày 1% lượt dùng có trải nghiệm tệ. User quên 99 lần mượt và nhớ lần lag.”
- “Nên mình đặt mục tiêu trên cái đuôi, ví dụ p99 dưới 500 ms.”
Biến nó thành SLO
Câu cuối chính là một service level objective (SLO), và sách có nhắc rằng SLO, SLA thường được viết bằng percentile chính vì lý do này. Một SLO đầy đủ nêu route, percentile, ngưỡng và cửa sổ thời gian: 99% request POST /checkout hoàn thành dưới 500 ms, đo trong 28 ngày. Cách nhìn tương đương: error budget là 1% còn lại. Nếu trong cả cửa sổ có 1,4% request chậm hơn 500 ms, bạn đã tiêu lố ngân sách 1% và việc về độ tin cậy được ưu tiên trước tính năng. Cách đóng khung này (và ý tưởng ngân sách) là thực hành SRE tiêu chuẩn chứ không nằm trong sách, nhưng nó biến “user đang than” thành một con số mà cả team có thể lên kế hoạch quanh nó.
# Tỷ lệ request /checkout nhanh hơn 500 ms trong 28 ngày gần nhất
# (cần có mép bucket đúng tại le="0.5")
sum(increase(http_request_duration_seconds_bucket{route="/checkout", le="0.5"}[28d]))
/
sum(increase(http_request_duration_seconds_count{route="/checkout"}[28d]))
# SLO đạt khi giá trị này >= 0.99Kết: hãy hỏi về cái đuôi
Khi ai đó nói với bạn con số trung bình, hãy hỏi: còn cái đuôi trông thế nào?
Câu thần chú của mình từ sáng thứ Hai đó
Nếu chỉ giữ bốn điều từ bài này: trung bình không mô tả ai cả; p50 là user điển hình, p99 và p999 là cái đuôi, và đuôi là nơi các khách hàng giá trị nhất của bạn hay sống; một trang gọi 100 service ở mức p99 sẽ chậm 63% thời gian; và percentile phải gộp từ histogram, không bao giờ lấy trung bình. Mình thêm một luật phụ: tách theo route, và test bằng một client không lịch sự chờ đợi. Panel Grafana trên tường vẫn xanh. Chỉ là mình không đọc nó một mình nữa: p99 nằm ngay bên cạnh, và chính con số đó đã không cho mình nói “chắc do mạng của họ”. Bài kế tiếp, bài 04, đi xuống bên dưới: vì sao database nhanh hay chậm, LSM-tree so với B-tree.
Câu hỏi luyện tập
- 10.000 request, p99 là 2 giây. Khoảng bao nhiêu request mất hơn 2 giây? (Khoảng 100. Câu này mình trả lời đúng lúc đọc sách.)
- Một trang gọi song song 50 lời gọi tới các backend có p99 là 300 ms. Bao nhiêu phần trăm lượt tải trang chậm ít nhất ngần đó? (1 − 0,99^50 ≈ 39,5%.)
- Dashboard của bạn hiển thị trung bình các p99 của từng server. Sai ở đâu, bạn sẽ đổi thế nào? (Vô nghĩa; export histogram và gộp chúng.)
- Sếp nói “trung bình 150 ms, ổn rồi”. Bạn đáp thế nào? (Xin histogram, p95 và p99; đề xuất SLO như p99 dưới 500 ms.)
