Khi ai đó hỏi "dịch vụ của bạn nhanh không?", câu trả lời tự nhiên là một con số trung bình: "khoảng 30 mili giây". Nhưng con số đó che giấu điều quan trọng nhất — không phải request điển hình nhanh cỡ nào, mà request chậm nhất chậm cỡ nào, vì chính những lần chậm đó là thứ người dùng nhớ và phàn nàn. Đó là trễ đuôi (tail latency), thường đo bằng phân vị p99. Bài này dựng một đường mạng có kiểm soát trong container, thêm dần jitter rồi mất gói, và đo chính xác cái đuôi p99 biến dạng thế nào — rồi phát hiện mình đã đoán sai thủ phạm.
p50, p99 và cái đuôi
Khi đo trễ của hàng nghìn request, bạn có cả một phân bố, không phải một con số. Vài cách tóm tắt nó:
- Trung vị (p50): một nửa request nhanh hơn con số này. Đây là trải nghiệm điển hình.
- p99: 99% request nhanh hơn con số này — tức là con số mà 1% chậm nhất phải chịu. Đây là "cái đuôi" của phân bố.
- Trung bình: tổng chia số lượng — dễ bị vài giá trị cực lớn kéo lệch, nhưng cũng dễ che chúng nếu chúng hiếm.
Vì sao quan tâm tới 1% xui nhất? Vì 1% vẫn là người dùng thật, và ở quy mô lớn 1% là rất nhiều lượt. Tệ hơn, một thao tác của người dùng thường cần nhiều request (một trang gọi 20 API); nếu mỗi request có 1% khả năng rơi vào đuôi, thì xác suất cả trang dính ít nhất một request chậm là gần 20% — cái đuôi p99 của từng phần biến thành trải nghiệm chậm của một phần năm số lần tải trang. Trễ đuôi khuếch đại chứ không tan biến.
Đo: p50 đứng yên, p99 nổ tung
Tôi bắn 800 request nhỏ tuần tự trên một kết nối keep-alive, RTT nền ~20 ms, rồi dùng tc netem thêm dần nhiễu trên cả hai chiều, đo phân vị mỗi lần:
| Tình huống | p50 | p90 | p99 | max | trung bình |
|---|---|---|---|---|---|
| Sạch (RTT ~20ms) | 28,2 | 31,2 | 32,0 | 33,2 | 28,0 |
| + Jitter (±5ms) | 28,0 | 39,6 | 47,2 | 56,0 | 28,0 |
| + Mất gói 1% | 28,4 | 31,3 | 264,5 | 287,9 | 33,1 |
| + Mất gói 3% | 28,3 | 31,7 | 299,3 | 573,1 | 45,8 |
Nhìn cột p50: nó gần như bất động ở ~28 ms qua cả bốn tình huống. Nhìn cột p99: nó bình yên với jitter (32 lên 47 ms), rồi nổ tung lên 264 ms ngay khi thêm chỉ 1% mất gói — gấp hơn tám lần. Với 3% mất gói, không chỉ p99 mà cả max bay lên 573 ms (những request xui xẻo mất hai gói). Con số đơn (đvt: ms) kể một câu chuyện rất rõ: mất gói hầu như không chạm tới đa số, nhưng tàn phá cái đuôi.
Một lần tôi đo hớ: jitter không phải thủ phạm chính
Đề bài của tôi là "đo p99 khi thêm jitter", nên tôi bước vào với một định kiến gọn gàng: jitter — độ trễ dao động lên xuống — chính là thứ làm giãn cái đuôi. Phép đo jitter có vẻ xác nhận: thêm ±5 ms dao động, p99 giãn từ 32 lên 47 ms. Tôi đã định chốt bài quanh jitter, coi nó là nguyên nhân trung tâm của trễ đuôi.
Rồi tôi thêm một biến khác — 1% mất gói — chỉ để so sánh. Và nó bác bỏ ngay giả định của tôi: p99 nhảy lên 264 ms, tệ hơn jitter cả năm lần, dù "1% mất gói" nghe nhỏ nhặt hơn nhiều so với dao động ±5 ms. Thủ phạm chính của trễ đuôi không phải jitter; nó là mất gói.
Lý do nằm ở cách TCP xử lý một gói mất. Jitter chỉ cộng thêm vài mili giây vào mỗi gói — một con dốc thoai thoải. Nhưng khi một gói mất hẳn, TCP không biết ngay; nó phải chờ hết thời gian chờ truyền lại (RTO — retransmission timeout) rồi mới gửi lại gói đó. Và trên Linux, RTO có một sàn tối thiểu ~200 ms (TCP_RTO_MIN), bất kể RTT thực tế nhỏ đến đâu. Nên một gói mất trên đường của tôi (RTT 20 ms) không thêm 20 ms — nó thêm nguyên một vách đứng ~200 ms. Đó là vì sao p99 nhảy thẳng lên ~264 ms: đúng bằng RTO cộng chút RTT và xử lý. Mất gói không làm chậm dần; nó ném request xui xẻo qua một vực.
Bài học đo lường có hai tầng. Tầng một: đừng dừng ở biến mà đề bài gợi ý — nếu tôi chỉ đo jitter như được bảo, tôi đã kết luận sai rằng jitter là nguyên nhân chính, trong khi một biến khác (mất gói) mới là thủ phạm lớn hơn hẳn. Tầng hai: đừng đánh giá sức khỏe bằng trung bình hay p50 — qua cả bốn cảnh, p50 đứng yên và trung bình chỉ nhích nhẹ, nên một bảng theo dõi chỉ vẽ độ trễ trung bình sẽ báo "mọi thứ ổn" trong khi 1% người dùng đang chịu trễ gấp tám lần. Cái đuôi là nơi nỗi đau trốn, và nó trốn khỏi đúng những thước đo mà người ta hay nhìn nhất.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên là theo dõi p99 (và p999), không chỉ trung bình. Trung bình và p50 nói cho bạn về ngày đẹp trời; p99 nói cho bạn về thứ đang âm thầm làm khổ một phần người dùng. Một dịch vụ có p50 tuyệt vời nhưng p99 khủng khiếp là chuyện rất thường gặp — và nó chỉ lộ ra khi bạn đo đúng phân vị. Nếu hệ thống giám sát của bạn chỉ có một biểu đồ "độ trễ trung bình", bạn đang mù trước đúng loại sự cố khó chịu nhất: loại làm chậm 1% mà không kéo trung bình lên đủ để ai đó chú ý.
Hệ quả thứ hai là coi mất gói là kẻ thù số một của trễ đuôi, và thiết kế để chịu đựng nó. Vì mỗi gói mất là một vách RTO, các kỹ thuật giảm tác động của nó đáng giá: giữ RTT nhỏ (đặt dịch vụ gần nhau) để phần "cộng thêm" ngoài RTO nhỏ đi; dùng các giao thức hiện đại giảm hình phạt mất gói (HTTP/3 trên QUIC truyền lại thông minh hơn và không bị nghẽn đầu dòng như một luồng TCP đơn); và ở tầng ứng dụng, đặt timeout hợp lý kèm thử lại (retry) cho các thao tác quan trọng, để một request xui không kéo cả thao tác chờ hết vách RTO. Một retry sau 50 ms có thể về đích trước khi lần truyền lại đầu tiên của gói gốc kịp xảy ra.
Hệ quả thứ ba là hiểu hiệu ứng khuếch đại đuôi trong hệ phân tán. Khi một request phải gọi nhiều dịch vụ con và chờ tất cả trả lời (fan-out), độ trễ tổng bị chi phối bởi cái chậm nhất — nên p99 của từng dịch vụ con trở thành trễ điển hình của request tổng hợp. Đây là lý do các hệ thống lớn ám ảnh với việc cắt đuôi: gửi request dự phòng tới bản sao thứ hai và lấy cái nào về trước, đặt hạn chót cho từng lời gọi con. Con số mang theo: mất gói 1% giữ nguyên p50 (~28ms) nhưng đẩy p99 lên ~264ms vì mỗi gói mất là một vách RTO ~200ms — trễ đuôi trốn khỏi trung bình, và mất gói hại nó nhiều hơn jitter. Đo phân vị, không đo trung bình; và khi thấy p99 xấu mà p50 đẹp, hãy nghi mất gói trước tiên.
Thử ba mươi giây
Nếu công cụ giám sát của bạn cho xem phân vị, mở biểu đồ độ trễ một dịch vụ và đặt cạnh nhau đường p50 và đường p99 — bạn gần như chắc chắn sẽ thấy p99 cao hơn p50 nhiều lần, và dao động dữ hơn nhiều. Những gai nhọn trên đường p99 mà đường p50 không hề nhúc nhích chính là dấu hiệu của mất gói hoặc tranh chấp tài nguyên đâu đó — cái đuôi đang kể chuyện mà trung bình giấu đi. Không có sẵn biểu đồ đó? Chạy ping -c 200 mot-may-chu và nhìn dòng tổng kết cuối: min/avg/max cùng mdev. Khoảng cách giữa avg và max, và con số mdev, chính là cái đuôi của bạn đo tại nhà — và nếu có dòng X% packet loss, bạn vừa tìm ra thủ phạm hay gặp nhất của nó.