Tail latency p99: vì sao trung bình nói dối và coordinated omission ăn gian benchmark — hoatq.dev

cat blog/.md

Tail latency p99: vì sao trung bình nói dối và coordinated omission ăn gian benchmark

date: tags: backend, performance, observability, benchmarking, reliability

Có lần tôi pitch một service mới cho team: “average response 45ms, throughput 8k req/s — ngon”. Sếp nhìn một lúc rồi hỏi: “p99 bao nhiêu?”. Tôi mở Grafana ra, p99 là 1.8 giây. Một số request chậm gấp 40 lần trung bình. Service “ngon” trên paper, nhưng cứ vài chục request thì có một cái khiến user thấy app đơ.

Đó là lần đầu tôi học bài học: average latency là chỉ số gần như vô nghĩa cho user-facing system. Cái thực sự quan trọng là tail — p95, p99, p99.9. Và đáng buồn hơn: hầu hết tool benchmark phổ biến đo tail latency sai, do một thứ gọi là coordinated omission.

Vì sao trung bình nói dối

Lấy một set 100 request, 99 cái mất 50ms, 1 cái mất 5000ms.

  • Average: (99 × 50 + 5000) / 100 = 99.5ms
  • p50 (median): 50ms
  • p99: 5000ms

Nhìn vào average bạn thấy ~100ms, rất hợp lý. Nhưng cứ 100 user thì có 1 người chờ 5 giây — và trong một trang load 20 API call, xác suất ít nhất một call rơi vào p99 là 1 - 0.99^20 ≈ 18%. Tức là gần 1/5 page load sẽ chậm 5 giây, dù average của từng call chỉ 100ms.

Đây là lý do Google, Amazon, Netflix đều tracking p99 và p99.9 thay vì average. Trong một hệ thống microservice gọi nhau nhiều tầng, tail của hệ thống tổng = tổ hợp tail của từng service. Nếu mỗi service có p99 = 100ms và một request đi qua 10 service, request đó dễ dàng chạm 1 giây dù trung bình từng hop chỉ 20ms.

Latency phân bố không phải Gaussian

Sai lầm phổ biến nhất khi nói chuyện về latency là dùng từ “trung bình”. Latency trong hệ thống thật không phân bố chuông — nó là phân bố lệch phải nặng (heavy right-tailed):

  • Phần lớn request rơi vào một khoảng hẹp gần modal value
  • Một đuôi dài kéo về phía chậm vì GC pause, network jitter, disk seek, lock contention, autovacuum kick in, retry, timeout của downstream…

Trên phân bố lệch nặng, mean kéo theo outlier, median thì không. Đó là vì sao SRE luôn nói “đo phần trăm, đừng đo trung bình”.

Cách nhìn nhanh: lấy ratio p99 / p50. Nếu ratio:

  • < 3: hệ thống ổn định, tail không tệ
  • 3–10: có nguồn variance nào đó, nên điều tra
  • > 10: chắc chắn có vấn đề — GC, contention, queueing, dependency lag

Service tôi nói ở đầu bài có ratio 1800 / 50 = 36. Đỏ rực.

Coordinated omission: vì sao benchmark của bạn đang nói dối

Đây là phần ít người để ý nhưng cực kỳ quan trọng. Khái niệm được Gil Tene (Azul Systems) đưa ra và đã quote khá nhiều trong giới performance.

Một load tester điển hình hoạt động kiểu này:

loop:
  start = now()
  send_request()
  wait_for_response()
  latency = now() - start
  record(latency)

Vấn đề: tester chỉ gửi request sau khi request trước trả về. Nếu server bị stall 5 giây, tester cũng chờ 5 giây — không gửi request mới nào trong khoảng đó.

Hậu quả: các request “đáng lẽ phải gửi trong lúc server stall” biến mất khỏi dataset. Tail latency thực tế bị che giấu vì tester đồng phạm (coordinate) với server để bỏ qua (omit) các request đó.

Ví dụ: target 1000 req/s, server stall 5 giây.

  • Thực tế xảy ra với user: 5000 user đã gửi request trong 5 giây đó, mỗi user chờ 0–5 giây tùy thời điểm gửi
  • Tester naive ghi nhận: 1 request duy nhất với latency 5 giây

Bạn nhìn report thấy p99.99 = 5s, có vẻ đáng lo nhưng “chỉ 0.01% thôi”. Thực tế 20% user trong cửa sổ stall đó đã có trải nghiệm tệ.

Giải pháp: tester phải gửi request theo schedule cố định, không chờ response trước đó. Nếu response chưa về thì vẫn gửi request tiếp theo. Latency tính từ thời điểm theo schedule chứ không phải lúc send.

wrk2 (fork của wrk bởi chính Gil Tene) làm việc này. So với wrk, wrk2 có flag --rate thực sự duy trì throughput cố định và sửa coordinated omission:

# wrk: dễ bị coordinated omission, không có --rate đúng nghĩa
wrk -t4 -c100 -d30s http://api.local/users

# wrk2: --rate giữ throughput cố định, hiệu chỉnh CO
wrk2 -t4 -c100 -d30s -R1000 http://api.local/users

hdrhistogram, bombardier, vegeta đều có support tương tự. Khi đọc benchmark, câu đầu tiên cần hỏi: tool này có xử lý coordinated omission không? Nếu không, p99 trong report rất có thể đẹp giả.

Đo latency trong production cho đúng

Production khác benchmark ở chỗ traffic không phải bạn sinh ra. Bạn chỉ ghi nhận thôi. Nhưng vẫn có vài bẫy.

Đừng dùng average rồi bias bằng standard deviation. Phân bố lệch không Gaussian, std dev gần như vô nghĩa. Hãy dùng histogram với bucket logarit.

Prometheus có histogram và summary:

from prometheus_client import Histogram

http_duration = Histogram(
    'http_request_duration_seconds',
    'HTTP request latency',
    buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0),
)

@app.middleware("http")
async def measure(request, call_next):
    with http_duration.time():
        return await call_next(request)

Lưu ý: chọn bucket cho phù hợp với SLO. Nếu SLO là p99 < 200ms, bucket cần có resolution tốt ở vùng 100–300ms. Bucket xa quá thì histogram_quantile trả về số ước lượng rất thô.

Aggregation hộ thân: nếu bạn average p99 từ nhiều instance, đó không phải p99 toàn cục. Phần trăm không cộng tuyến tính. Cách đúng: dùng histogram_quantile() trên metric histogram đã được sum theo bucket:

histogram_quantile(
  0.99,
  sum(rate(http_request_duration_seconds_bucket[5m])) by (le)
)

Nếu bạn export p99 đã tính sẵn từ mỗi pod rồi average lại — bạn đang trung bình một thống kê phi tuyến. Số trả ra không có ý nghĩa gì cả.

p99.9 và “tail của tail”

Nhiều team dừng ở p99, nhưng với hệ thống lớn, p99.9 mới là cái quyết định. Lý do: một user load 1 trang có thể trigger 100 API call. Xác suất ít nhất một call rơi vào p99 là 1 - 0.99^100 ≈ 63%. Nói cách khác, với p99 = 1 giây, hơn nửa số page load của bạn có ít nhất một call chậm 1 giây.

Để giữ 95% page load dưới 1 giây trong cảnh đó, bạn cần p99.9 < 1s, không phải p99.

Đây là lý do các hệ thống lớn nói chuyện về p99.9, p99.99. Số càng dài đuôi, càng cần data nhiều để estimate đáng tin. Một báo cáo p99.99 với chỉ 1000 mẫu là vô nghĩa — bạn cần ít nhất 10.000 mẫu cho percentile đó có ý nghĩa thống kê.

Vài nguồn gây tail latency hay quên

Sau khi đo và thấy tail xấu, đây là các nghi can tôi thường check trước:

  • GC pause — JVM, Go, Node đều có. Bật GC log, xem có pause >100ms không.
  • Connection pool exhaustion — request chờ acquire connection. Pool nhỏ + slow query là combo kinh điển.
  • Lock contention — pg_stat_activity có row nào waiting = true không.
  • Retry storm — downstream chậm → upstream retry → load tăng → chậm hơn. Cần circuit breaker.
  • Cold cache — sau deploy hoặc cache eviction, vài request đầu chạm DB. Pre-warm hoặc cache stampede protection.
  • Autovacuum, checkpoint, fsync — Postgres pause định kỳ. Trace thời điểm spike khớp với log autovacuum.
  • Noisy neighbor — trên K8s/EC2 share host, instance bên cạnh ăn CPU/IO của bạn.
  • DNS lookup — bất ngờ phổ biến trong microservice. Cache DNS hoặc dùng IP trực tiếp trong service mesh.

Tóm lại

Khi nói chuyện về performance:

  • Không bao giờ trust “average latency”. Hỏi ngay percentile.
  • p99/p50 ratio > 10 là dấu hiệu có thứ gì lệch.
  • Benchmark dùng tool tính đúng coordinated omission (wrk2, vegeta, hdrhistogram). wrk thường nói dối.
  • Production tracking dùng histogram, không phải summary đã pre-calculated quantile (vì không thể aggregate).
  • Với hệ thống fan-out cao, ngắm p99.9 thay vì p99.

Trung bình là cái cám dỗ vì nó là một con số dễ nhìn. Nhưng user của bạn không sống ở trung bình — họ sống ở tail. Tracking đúng tail là khởi đầu của mọi quyết định performance có ý nghĩa.

// reactions


cat comments.log


hoatq@dev : ~/blog $