Split View: 평균은 아무것도 말하지 않는다 — 지연 시간을 분포로 디버깅하는 법
평균은 아무것도 말하지 않는다 — 지연 시간을 분포로 디버깅하는 법
- 들어가며 — 평균은 9% 나빠졌는데 중앙값은 46% 좋아졌다
- 하나의 데이터, 네 개의 결론
- 왜 평균이 실패하는가 — 다봉 분포
- 부하 생성기가 지워 버리는 꼬리 — coordinated omission
- 무엇을 대신 그릴 것인가 — 다섯 개의 렌즈
- 진단 워크스루 — 평균이 평평한데 분포가 갈라질 때
- 운영에서 이걸 실제로 그리는 도구들
- 마치며 — 요약 통계는 가설이지 결론이 아니다
- 참고 자료
들어가며 — 평균은 9% 나빠졌는데 중앙값은 46% 좋아졌다
2026년 7월 27일 Farid Zakaria가 The mean means nothing이라는 글을 올렸고, 이틀 뒤 해커뉴스 스레드에서 73포인트를 받으며 한동안 상단에 있었습니다. 소재는 흔합니다 — 일주일에 걸쳐 캐싱 계층을 점진 배포했고, 대시보드의 평균 지연이 112ms에서 122ms로 9% 올랐습니다. 롤백 회의가 잡히기 딱 좋은 그림입니다.
그런데 같은 기간, 같은 요청 로그에서 중앙값은 99ms에서 54ms로 46% 내려갔습니다. p95는 224ms에서 454ms로, p99는 309ms에서 678ms로 두 배 넘게 올랐습니다. 캐싱은 성공했고 동시에 실패했습니다. 어느 쪽 문장도 틀리지 않았습니다.
먼저 하나 밝혀 둘 것이 있습니다. 원문의 데이터는 실제 장애 기록이 아니라 공개된 스크립트가 고정 시드로 생성한 합성 데이터입니다. 스크립트 상단 주석이 그렇게 밝히고 있고, 저자도 차트 생성에 AI의 도움을 받았다고 적었습니다. 그래서 이 글은 "이런 장애가 있었다"는 사례 보고가 아니라 "이런 모양의 데이터를 어떤 렌즈로 봐야 하는가"에 대한 교보재로 읽는 편이 정확합니다. 그리고 교보재로서는 꽤 잘 만들어졌습니다 — 캐시 적중과 미스가 섞이면서 생기는 이봉 분포는, 캐시를 하나라도 붙여 본 시스템이라면 거의 반드시 만나는 모양이기 때문입니다.
이 글은 그 상황을 출발점으로 삼아, 왜 평균이 실패하는지, 부하 생성기가 어떻게 꼬리를 지워 버리는지, 그리고 무엇을 대신 그려야 하는지를 정리합니다.
하나의 데이터, 네 개의 결론
원문이 제시하는 요약 통계는 이렇습니다.
| 통계량 | 롤아웃 전 | 롤아웃 후 | 변화 |
|---|---|---|---|
| 평균 | 112ms | 122ms | +9% |
| p50 (중앙값) | 99ms | 54ms | −46% |
| p95 | 224ms | 454ms | +103% |
| p99 | 309ms | 678ms | +119% |
네 숫자가 각각 다른 회의를 만듭니다. 평균만 보는 팀은 롤백을 결정하고, 중앙값만 보는 팀은 성공 사례로 발표하고, p99만 보는 팀은 인시던트를 엽니다. 셋 다 같은 로그를 봤습니다.
평균이 애매한 값을 내놓는 이유는 산술적으로 단순합니다. 평균은 빨라진 다수와 느려진 소수를 같은 저울에 올려 상쇄시킵니다. 절반이 45ms 빨라지고 5%가 370ms 느려지면 합계는 거의 0에 가까워집니다. 상쇄된 결과의 부호는 이 시스템에 대해 아무것도 말해 주지 않습니다. 이 점에서 평균은 틀린 지표라기보다 질문을 하나도 담고 있지 않은 지표입니다.
왜 평균이 실패하는가 — 다봉 분포
핵심은 캐싱 계층이 요청 모집단을 둘로 쪼갰다는 데 있습니다. 캐시에 적중한 요청은 백엔드를 건너뛰고 예전 기준선보다 빨라집니다. 미스가 난 요청은 캐시를 조회하는 왕복이 하나 더 붙은 뒤에야 원래 경로를 탑니다. 그래서 롤아웃 전 하나였던 봉우리가 롤아웃 후에는 두 개가 됩니다.
여기서 통계 교육의 고전들이 그대로 적용됩니다. 해커뉴스 댓글에서 가장 많이 인용된 것도 Anscombe's quartet과 그 현대판인 Same Stats, Different Graphs였습니다. 평균, 분산, 상관계수, 회귀선이 모두 같은데 그림은 전혀 다른 데이터 집합을 만들 수 있다는 것이 요지입니다. 후자의 데이터셋 중 하나는 산점도로 그리면 공룡 모양이 나옵니다.
지연 시간 데이터에서 다봉이 나오는 원인은 대개 정해져 있습니다.
- 캐시 적중과 미스
- 콜드 스타트와 웜 인스턴스
- 커넥션 풀에서 즉시 얻은 연결과 새로 맺은 TLS 핸드셰이크
- 리더 리전과 크로스 리전 폴백
- 소량 응답과 페이지네이션 없이 통째로 나가는 대량 응답
- GC 일시 정지나 컴팩션에 걸린 요청
원문의 마지막 진단이 정확히 이 목록의 마지막 항목 근처입니다 — 응답 크기와 지연을 함께 그린 산점도에서, 큰 응답이 캐시에 들어가지 못하고 미스 쪽 봉우리를 만들고 있었습니다. 그래서 처방도 "롤백"이 아니라 "캐시 용량을 늘리거나 큰 응답을 쪼갠다"가 됩니다. 분포를 나눈 축을 찾아야 처방이 나옵니다.
부하 생성기가 지워 버리는 꼬리 — coordinated omission
프로덕션 지표가 아니라 부하 테스트 결과를 보고 있다면, 분포를 그리기 전에 확인할 것이 하나 더 있습니다. 측정 자체가 꼬리를 지웠을 수 있습니다.
Gil Tene이 이름 붙인 coordinated omission은 이런 구조입니다. 부하 생성기가 초당 1,000건을 보내도록 설정돼 있는데, 응답 하나가 2초를 잡아먹었다고 합시다. 동기식 생성기라면 그 2초 동안 보냈어야 할 2,000건을 보내지 않습니다. 그 2,000건은 각각 최대 2초까지 대기했어야 할 요청이었지만, 아예 발사되지 않았으므로 히스토그램에 들어가지 않습니다. 결과적으로 시스템이 가장 느렸던 구간의 샘플이 통째로 사라지고, 부하 생성기는 자신이 만들어 낸 백프레셔에 "협조"해 버립니다.
이 결함의 증상은 특징적입니다.
- 부하를 올려도 p99가 거의 움직이지 않습니다. 실제로는 큐가 폭발하고 있는데 측정된 꼬리는 평온합니다.
- 보고된 처리량이 설정한 목표치보다 낮은데, 지연 분포는 목표치에서 측정한 것처럼 생겼습니다.
- 같은 시스템을 프로덕션 트래픽으로 관측하면 부하 테스트보다 꼬리가 훨씬 나쁩니다.
해법은 두 갈래입니다. 하나는 wrk2처럼 일정한 목표 처리량을 유지하면서 예정 발사 시각 기준으로 지연을 기록하는 생성기를 쓰는 것입니다. wrk2는 요청별 샘플 버퍼를 HdrHistogram으로 교체하고, 응답 지연을 "요청이 나갔어야 할 시각"부터 계산합니다. 다른 하나는 HdrHistogram이 제공하는 보정 API를 쓰는 것입니다. 기대 간격이 알려져 있을 때 누락된 샘플을 합성해 채워 넣습니다.
// 기대 간격 1ms(=1,000,000ns)로 기록하면, 한 샘플이 그 간격을 넘길 때
// 누락됐어야 할 중간 샘플들을 히스토그램이 스스로 채워 넣는다.
Histogram h = new Histogram(3600L * 1000 * 1000 * 1000, 3);
h.recordValueWithExpectedInterval(latencyNanos, 1_000_000L);
프로덕션 관측에는 이 문제가 없습니다. 실제 사용자는 이전 요청이 느리다고 다음 요청을 미뤄 주지 않기 때문입니다. 정확히 그 이유로, 부하 테스트 결과와 프로덕션 지표가 다르면 대개 부하 테스트 쪽을 의심해야 합니다.
무엇을 대신 그릴 것인가 — 다섯 개의 렌즈
원문이 같은 숫자를 여러 방식으로 그려 보인 부분이 이 글의 실질적인 가치입니다. 각 그래프가 답하는 질문이 다릅니다.
밀도 그래프는 봉우리가 몇 개인지 답합니다. 가장 먼저 그릴 것이고, 봉우리가 하나면 나머지 분석의 상당 부분을 생략해도 됩니다. 지연 시간은 오른쪽으로 길게 늘어지므로 x축은 로그 스케일이 기본입니다.
CDF는 "몇 퍼센트가 몇 ms 안에 끝났나"를 답합니다. 변경 전과 후의 CDF를 같은 축에 겹쳐 그렸을 때 두 곡선이 교차하면, 그 지점이 개선과 악화의 경계입니다. 원문에서는 140ms 부근에서 교차합니다. 교차하는 CDF는 "어떤 단일 백분위수도 이 변화를 요약할 수 없다"는 것의 시각적 증명입니다. 해커뉴스 댓글에는 한 단계 더 나간 제안도 있었는데, CDF 대신 1에서 뺀 값(CCDF, 즉 아직 끝나지 않은 요청의 비율)을 log-log로 그리면 CDF에서는 1에 붙은 수평선으로 뭉개지는 꼬리가 전 구간에 걸쳐 펼쳐집니다. 꼬리를 볼 때는 이쪽이 낫습니다.
시프트 함수는 "어느 백분위수까지 이득이고 어디부터 손해인가"를 답합니다. 각 백분위수 p마다 변경 후 값에서 변경 전 값을 뺀 값을 그립니다. 원문의 곡선은 p76 부근까지 음수(개선)였다가 그 위로 가파르게 양수로 넘어갑니다. 이 한 장이 "76%의 사용자는 이득을 봤고 나머지는 손해를 봤다"는 문장을 만들어 줍니다 — SLO를 어디에 걸었는지에 따라 배포 결정이 갈리는 지점입니다.
리지라인은 "언제부터 그렇게 됐나"를 답합니다. 날짜별 밀도를 위아래로 쌓으면 롤아웃 비율이 0에서 100%로 올라가는 동안 두 번째 봉우리가 자라나는 과정이 보입니다. 리지라인의 실무적 가치는 회귀가 배포와 함께 왔는지, 그 전부터 있었는지를 가리는 데 있습니다.
히트맵은 리지라인과 같은 정보를 격자에 눌러 담습니다. x축은 시간, y축은 지연 버킷, 색은 그 버킷에 떨어진 트래픽 양입니다. 리지라인보다 정보 밀도가 높고, 무엇보다 대시보드에 상시로 걸어 둘 수 있습니다. 시간 축이 며칠 이상으로 길어지면 리지라인은 겹쳐서 못 읽지만 히트맵은 계속 읽힙니다.
여기에 원문이 마지막에 쓴 두 가지를 더하면 진단이 끝납니다 — 조건부 CDF(캐시 적중/미스로 나눈 뒤 각각 그리면 다시 단봉이 됩니다)와 산점도(지연 대 응답 크기). 앞의 것은 "무엇이 분포를 갈랐는가"를 확인하고, 뒤의 것은 "왜 그 그룹인가"를 설명합니다.
진단 워크스루 — 평균이 평평한데 분포가 갈라질 때
원문의 사례는 평균이 조금이라도 움직였기 때문에 오히려 눈에 띄었습니다. 더 고약한 경우는 평균이 완전히 평평한 채로 분포만 갈라지는 것입니다. 절반이 빨라진 양이 나머지가 느려진 양과 우연히 맞아떨어지면 이런 일이 생기고, 이때는 아무 알람도 울리지 않습니다.
순서는 이렇게 잡습니다.
- 밀도 또는 히트맵을 먼저 본다. 봉우리가 몇 개인지, 언제부터 갈라졌는지 확인합니다. 여기서 단봉이면 2번으로 가지 않고 바로 6번(엔드포인트 분해)으로 갑니다.
- CDF를 전후로 겹쳐 그린다. 교차 지점을 찾습니다. 교차한다면 "평균/중앙값/p99 중 하나로 보고하겠다"는 계획을 버립니다.
- 시프트 함수로 경계 백분위수를 특정한다. SLO 임계값이 그 경계의 어느 쪽에 있는지가 배포 결정을 좌우합니다.
- 분포를 가른 축을 찾는다. 후보를 하나씩 대입해 조건부로 나눠 그립니다 — 캐시 적중 여부, 엔드포인트, 리전, 인스턴스, 클라이언트 버전, 응답 크기 구간. 나눴을 때 각각이 단봉이 되는 축이 정답입니다.
- 그 축과 다른 변수의 관계를 본다. 원문에서는 응답 크기였습니다. 여기서 처방이 나옵니다.
- 엔드포인트별로 분해해 다시 본다. 전체 지표는 트래픽 가중 평균이라, 트래픽이 적고 느린 엔드포인트의 회귀를 통째로 숨깁니다.
4번에서 후보 축이 떠오르지 않을 때 쓸 만한 요령이 하나 있습니다. 느린 봉우리에 속한 요청들만 골라 트레이스를 샘플링하는 것입니다. 분산 트레이싱을 이미 쓰고 있다면 지연 구간으로 필터링해 스팬 구성을 비교하는 것이 가장 빠릅니다. 이 부분은 관측성의 세 기둥과 LLM 워크로드 편에서 다룬 트레이스 샘플링 전략과 이어집니다.
그리고 4번을 하기 전에 꼭 확인할 것 — 팬아웃이 있는 시스템이라면 중앙값이 p99보다 더 심하게 거짓말합니다. 하나의 사용자 요청이 100개의 리프 서버로 흩어지고 전부 모여야 응답이 나가는 구조에서는, 각 리프의 p99가 사용자 지연의 중앙값을 지배합니다. Jeff Dean의 The Tail at Scale이 정리한 그대로이고, 해커뉴스 댓글에서도 이 지적이 나왔습니다. 팬아웃 시스템에서는 "구성 요소의 꼬리"가 "사용자의 평균"이 됩니다.
운영에서 이걸 실제로 그리는 도구들
교보재의 그림은 plotnine으로 그렸지만, 프로덕션에서 매일 봐야 하는 그림은 다른 파이프라인을 탑니다.
Prometheus 네이티브 히스토그램이 지금 가장 현실적인 기반입니다. 고전 히스토그램은 버킷 경계를 미리 정해야 하고, 경계를 촘촘히 잡을수록 시계열이 선형으로 늘어납니다. 네이티브 히스토그램은 지수 스케일로 버킷을 자동 배치해 이 트레이드오프를 없앱니다. v3.8.0에서 정식 기능이 됐고, OpenTelemetry의 exponential histogram과 데이터 모델이 대응되므로 OTLP 백엔드로 넘기는 경로도 열려 있습니다. 다만 켜는 데는 스크레이프 프로토콜 협상, 스토리지, 대시보드 쿼리까지 손볼 곳이 여럿이라 — 그 실무는 네이티브 히스토그램은 stable이 됐는데, 왜 아직 못 켜고 있나 편에 따로 정리해 두었습니다.
쿼리는 이렇게 생겼습니다.
# 백분위수 하나 — 이 값만 보면 이 글의 함정에 그대로 빠진다
histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))
# 히트맵용: 버킷별 증가율을 그대로 내보내고 그래프 쪽에서 색으로 매핑한다
sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
# 네이티브 히스토그램이라면 le 라벨이 없다. 시리즈 자체가 분포다
histogram_quantile(0.99, sum(rate(http_request_duration_seconds[5m])))
histogram_fraction(0, 0.14, sum(rate(http_request_duration_seconds[5m])))
마지막 줄의 histogram_fraction이 이 글 맥락에서 유용합니다. "140ms 안에 끝난 요청의 비율"을 직접 뽑아 주므로, CDF의 특정 지점을 시계열로 걸어 둘 수 있습니다. 교차점을 알고 있다면 그 지점 하나를 알람으로 만드는 것이 p99 알람보다 훨씬 정확합니다.
Grafana의 히트맵 패널은 위의 두 번째 쿼리를 그대로 받습니다. 포맷을 Heatmap으로 두고 y축을 로그 스케일로 바꾸는 것이 사실상 필수 설정입니다. 선형 y축 히트맵은 아래쪽 버킷이 전부 한 줄로 뭉개져서 이봉을 못 보여 줍니다.
커널 쪽 분포가 필요하면 bpftrace의 hist()가 가장 저렴합니다. 애플리케이션 계측 없이 특정 시스템 콜이나 함수의 지연 분포를 로그2 버킷으로 바로 출력합니다.
# 블록 I/O 완료 지연 분포 (usec, 로그2 버킷)
sudo bpftrace -e '
kprobe:blk_account_io_start { @s[arg0] = nsecs; }
kprobe:blk_account_io_done /@s[arg0]/ {
@us = hist((nsecs - @s[arg0]) / 1000); delete(@s[arg0]);
}'
eBPF로 분포를 뜨는 접근 전반은 eBPF가 관측성을 삼키고 있다 편에서 더 다뤘습니다.
부하 테스트라면 앞서 말한 wrk2 계열, 또는 HdrHistogram을 내장하고 목표 처리량을 유지하는 생성기를 씁니다. 어떤 도구든 결과를 볼 때 확인할 것은 하나입니다 — 보고된 실제 처리량이 설정한 목표 처리량과 일치하는가. 일치하지 않으면 그 실행의 지연 분포는 신뢰할 수 없습니다.
마치며 — 요약 통계는 가설이지 결론이 아니다
정리하면 이렇습니다.
- 평균은 빨라진 다수와 느려진 소수를 상쇄합니다. 상쇄된 값의 부호는 시스템에 대해 아무것도 말하지 않습니다.
- 두 CDF가 교차하면 어떤 단일 백분위수도 그 변화를 요약할 수 없습니다. 이때는 백분위수를 하나 더 고르는 대신 시프트 함수를 그려야 합니다.
- 분포가 갈라졌다면 갈라 놓은 축을 찾는 것이 진단의 전부입니다. 조건부로 나눴을 때 각각이 단봉이 되는 축이 원인입니다.
- 부하 테스트에서 꼬리가 지나치게 얌전하면 coordinated omission을 먼저 의심합니다. 목표 처리량과 실제 처리량의 불일치가 그 신호입니다.
- 팬아웃 시스템에서는 구성 요소의 꼬리가 사용자의 평균이 됩니다.
숫자 하나를 보고 롤백을 결정하기 전에 그림 하나를 그려 보는 데 드는 비용은, 이미 히스토그램을 수집하고 있다면 쿼리 한 줄입니다. 문제는 도구가 아니라 습관 쪽에 있습니다.
참고 자료
- The mean means nothing: data visualization to debug a latency problem — Farid Zakaria (2026-07-27)
- 해커뉴스 토론 (item 49096170, 2026-07-29)
- 원문 그림 생성 스크립트 gist — nix-shell + plotnine, 합성 데이터
- wrk2 — 일정 처리량 유지, coordinated omission 보정 부하 생성기
- HdrHistogram — 고정밀 지연 히스토그램과 보정 API
- Prometheus — Native Histograms 명세
- Prometheus — Histograms and summaries 실무 가이드
- The Tail at Scale — Dean & Barroso, CACM
- Same Stats, Different Graphs — Matejka & Fitzmaurice, CHI 2017
- Anscombe's quartet
- 네이티브 히스토그램은 stable이 됐는데, 왜 아직 못 켜고 있나 (관련 글)
- eBPF가 관측성을 삼키고 있다 (관련 글)
- 관측성의 세 기둥과 LLM 워크로드 (관련 글)
The Mean Tells You Nothing — How to Debug Latency Through Distributions
- Introduction — The Mean Got 9% Worse While the Median Got 46% Better
- One Dataset, Four Conclusions
- Why the Mean Fails — Multimodal Distributions
- The Tail That Load Generators Erase — Coordinated Omission
- What to Plot Instead — Five Lenses
- A Diagnostic Walkthrough — When the Mean Is Flat but the Distribution Splits
- Tools for Plotting This in Production
- Conclusion — Summary Statistics Are a Hypothesis, Not a Conclusion
- References
Introduction — The Mean Got 9% Worse While the Median Got 46% Better
On July 27, 2026, Farid Zakaria published The mean means nothing, and two days later it sat near the top of a Hacker News thread for a while, gathering 73 points. The setup is familiar — a caching layer was rolled out gradually over a week, and the dashboard's average latency rose 9%, from 112ms to 122ms. It's exactly the kind of picture that gets a rollback meeting scheduled.
Yet in the same period, from the same request logs, the median fell 46%, from 99ms to 54ms. p95 rose from 224ms to 454ms, and p99 rose from 309ms to 678ms — more than doubling. Caching succeeded and failed at the same time. Neither statement is wrong.
One thing should be made clear up front. The data in the original post is not a record of a real incident; it's synthetic data generated with a fixed seed by a published script. The comment at the top of the script says as much, and the author notes that AI assisted in generating the charts. So it's more accurate to read this piece not as an incident report saying "this outage happened," but as teaching material on "what lens should you use to look at data shaped like this." And as teaching material, it's put together quite well — a bimodal distribution created by a mix of cache hits and misses is a shape that almost any system with even one cache bolted on will eventually run into.
Taking that situation as a starting point, this post lays out why the mean fails, how load generators erase the tail, and what you should plot instead.
One Dataset, Four Conclusions
Here are the summary statistics the original post presents.
| Statistic | Before Rollout | After Rollout | Change |
|---|---|---|---|
| Mean | 112ms | 122ms | +9% |
| p50 (median) | 99ms | 54ms | −46% |
| p95 | 224ms | 454ms | +103% |
| p99 | 309ms | 678ms | +119% |
Each of these four numbers produces a different meeting. A team looking only at the mean decides to roll back; a team looking only at the median presents it as a success story; a team looking only at p99 opens an incident. All three looked at the same log.
The arithmetic reason the mean produces an ambiguous number is simple. The mean puts the many requests that got faster and the few that got slower on the same scale and cancels them out. If half the traffic gets 45ms faster and 5% gets 370ms slower, the sum lands close to zero. The sign of that canceled-out result tells you nothing about the system. In that sense, the mean isn't so much a wrong metric as a metric that doesn't contain a single question.
Why the Mean Fails — Multimodal Distributions
The core issue is that the caching layer split the request population into two. Requests that hit the cache skip the backend and come back faster than the old baseline. Requests that miss only reach the original path after one extra round trip to check the cache. So the single peak from before the rollout becomes two peaks after it.
This is exactly where the classics of statistics education apply. The most-cited references in the Hacker News comments were Anscombe's quartet and its modern successor, Same Stats, Different Graphs. The point of both is that you can construct data sets whose mean, variance, correlation coefficient, and regression line are all identical, yet whose plots look completely different. One of the data sets in the latter, when plotted as a scatter plot, forms the shape of a dinosaur.
The causes of multimodality in latency data tend to fall into a fixed set.
- Cache hits vs. misses
- Cold starts vs. warm instances
- A connection obtained instantly from the pool vs. a freshly negotiated TLS handshake
- The leader region vs. a cross-region fallback
- Small responses vs. large responses shipped whole with no pagination
- Requests caught by a GC pause or compaction
The original post's final diagnosis lands right near the last item on this list — in a scatter plot of response size against latency, large responses weren't fitting into the cache and were forming the miss-side peak. So the prescription isn't "roll back" but "increase cache capacity or split up large responses." You only get to a prescription once you find the axis that split the distribution.
The Tail That Load Generators Erase — Coordinated Omission
If what you're looking at is load-test results rather than production metrics, there's one more thing to check before you plot the distribution: the measurement itself may have erased the tail.
Here's the structure of what Gil Tene named coordinated omission. Say a load generator is configured to send 1,000 requests per second, and one response happens to take 2 seconds. A synchronous generator simply does not send the 2,000 requests it should have sent during those 2 seconds. Each of those 2,000 requests should have waited up to 2 seconds, but because they were never fired at all, they never enter the histogram. The end result is that the samples from the system's slowest stretch vanish entirely, and the load generator ends up "cooperating" with the backpressure it created itself.
The symptoms of this flaw are distinctive.
- p99 barely moves even as you push more load. The queue is actually exploding, but the measured tail looks calm.
- Reported throughput comes in below the configured target, yet the latency distribution looks as if it were measured at the target.
- Observing the same system under production traffic shows a tail that's far worse than what the load test measured.
There are two ways to fix this. One is to use a generator like wrk2 that holds a constant target throughput while recording latency against the scheduled send time. wrk2 replaces its per-request sample buffer with HdrHistogram and computes response latency starting from "the moment the request should have gone out." The other is to use the correction API that HdrHistogram provides: when the expected interval is known, it synthesizes and fills in the missing samples.
// When recording with an expected interval of 1ms (=1,000,000ns), if a sample
// exceeds that interval, the histogram fills in the intermediate samples that would have been missed.
Histogram h = new Histogram(3600L * 1000 * 1000 * 1000, 3);
h.recordValueWithExpectedInterval(latencyNanos, 1_000_000L);
Production observation doesn't have this problem, because real users don't politely delay their next request just because the previous one was slow. For exactly that reason, when load-test results and production metrics disagree, it's usually the load test you should suspect.
What to Plot Instead — Five Lenses
The real value of the original post is in showing the same numbers plotted several different ways. Each chart answers a different question.
The density plot answers how many peaks there are. Draw this one first — if there's only one peak, you can skip most of the rest of the analysis. Latency trails off to the right, so a log-scale x-axis is the default.
The CDF answers "what percentage finished within how many ms." When you overlay the before and after CDFs on the same axes, wherever the two curves cross, that point marks the boundary between improvement and regression. In the original post they cross around 140ms. A crossing CDF is the visual proof that "no single percentile can summarize this change." A Hacker News comment took this a step further: instead of the CDF, plot one minus it (the CCDF — the fraction of requests not yet finished) on a log-log scale, and the tail that gets crushed into a horizontal line at 1 on a CDF spreads out across the full range. This version is the better one for looking at tails.
The shift function answers "up to which percentile is this a win, and from where does it become a loss." For each percentile p, it plots the after-value minus the before-value. In the original post, the curve stays negative (improvement) up to around p76, then crosses steeply into positive territory above that. This one chart alone produces the sentence "76% of users benefited, and the rest lost out" — and it's exactly the point where the deploy decision splits, depending on where your SLO is set.
The ridgeline plot answers "since when has this been happening." Stacking the per-day density plots vertically shows the second peak growing as the rollout percentage climbs from 0 to 100%. The practical value of a ridgeline is in distinguishing whether a regression arrived with the deploy or predates it.
The heatmap packs the same information as a ridgeline into a grid. The x-axis is time, the y-axis is a latency bucket, and color is the amount of traffic that landed in that bucket. It has a higher information density than a ridgeline, and above all, you can leave it running permanently on a dashboard. Once the time axis stretches beyond a few days, a ridgeline turns into an unreadable overlapping mess, but a heatmap stays legible.
Add the two things the original post uses at the end, and the diagnosis is complete — the conditional CDF (split by cache hit/miss and plot each separately, and it becomes unimodal again) and the scatter plot (latency against response size). The first confirms "what split the distribution," and the second explains "why that grouping."
A Diagnostic Walkthrough — When the Mean Is Flat but the Distribution Splits
The original post's case actually stood out precisely because the mean moved at all. A nastier case is one where the mean stays completely flat while only the distribution splits. This happens when the amount one half sped up happens to match the amount the other half slowed down, and in that case, no alarm goes off at all.
Here's the order to work through.
- Look at the density or heatmap first. Check how many peaks there are and when they started splitting. If it's unimodal, skip step 2 and jump straight to step 6 (endpoint breakdown).
- Overlay the before/after CDFs. Find the crossing point. If they cross, drop any plan to "report this with one of mean/median/p99."
- Use the shift function to pin down the boundary percentile. Which side of that boundary your SLO threshold sits on determines the deploy decision.
- Find the axis that split the distribution. Test candidates one at a time, plotting conditionally on each — cache hit or miss, endpoint, region, instance, client version, response-size bucket. The correct axis is the one where each split becomes unimodal.
- Look at the relationship between that axis and other variables. In the original post, it was response size. This is where the prescription comes from.
- Break it down by endpoint and look again. The overall metric is a traffic-weighted average, which can completely hide a regression in a low-traffic, slow endpoint.
There's one trick worth using when no candidate axis comes to mind in step 4: sample traces only from requests belonging to the slow peak. If you're already running distributed tracing, filtering by latency range and comparing span composition is the fastest route. This connects to the trace-sampling strategy covered in The Three Pillars of Observability and LLM Workloads.
And there's one thing to check before step 4 — in a system with fan-out, the median lies even more badly than p99 does. In an architecture where a single user request scatters across 100 leaf servers and the response only goes out once all of them return, each leaf's p99 dominates the user-facing median. This is exactly what Jeff Dean's The Tail at Scale laid out, and the same point came up in the Hacker News comments. In a fan-out system, "the tail of a component" becomes "the average for the user."
Tools for Plotting This in Production
The teaching-material charts were drawn with plotnine, but the charts you need to look at every day in production run through a different pipeline.
Prometheus native histograms are the most realistic foundation right now. Classic histograms require you to fix bucket boundaries in advance, and the tighter you pack those boundaries, the more linearly your time series count grows. Native histograms remove this trade-off by placing buckets automatically on an exponential scale. They became a stable feature in v3.8.0, and because the data model maps onto OpenTelemetry's exponential histogram, there's also a path for handing data off to an OTLP backend. Turning it on, though, touches several things — scrape protocol negotiation, storage, dashboard queries — enough that I wrote it up separately in Native Histograms Went Stable — So Why Can't We Turn Them On Yet.
Here's what the queries look like.
# a single percentile — look at only this value and you fall straight into this post's trap
histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))
# for a heatmap: emit the per-bucket rate as-is and map it to color on the graphing side
sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
# with a native histogram there's no le label. the series itself is the distribution
histogram_quantile(0.99, sum(rate(http_request_duration_seconds[5m])))
histogram_fraction(0, 0.14, sum(rate(http_request_duration_seconds[5m])))
The histogram_fraction on the last line is especially useful in this post's context. It pulls out "the fraction of requests that finished within 140ms" directly, so you can wire up a specific point on the CDF as a time series. If you know the crossing point, turning that single point into an alert is far more accurate than a p99 alert.
Grafana's heatmap panel takes the second query above as-is. Setting the format to Heatmap and switching the y-axis to a log scale is, in practice, a required setting. A heatmap with a linear y-axis crushes all the lower buckets into a single line and can't show a bimodal shape.
When you need the kernel-side distribution, bpftrace's hist() is the cheapest option. With no application instrumentation at all, it prints the latency distribution of a specific system call or function directly into log2 buckets.
# block I/O completion latency distribution (usec, log2 buckets)
sudo bpftrace -e '
kprobe:blk_account_io_start { @s[arg0] = nsecs; }
kprobe:blk_account_io_done /@s[arg0]/ {
@us = hist((nsecs - @s[arg0]) / 1000); delete(@s[arg0]);
}'
The broader approach of capturing distributions with eBPF is covered in more depth in eBPF Is Eating Observability.
For load testing, use something in the wrk2 family mentioned earlier, or a generator that embeds HdrHistogram and maintains a target throughput. Whatever tool you use, there's exactly one thing to check when you look at the results — does the reported actual throughput match the configured target throughput. If it doesn't, the latency distribution from that run can't be trusted.
Conclusion — Summary Statistics Are a Hypothesis, Not a Conclusion
To sum up.
- The mean cancels out the many requests that got faster against the few that got slower. The sign of that canceled-out value tells you nothing about the system.
- If two CDFs cross, no single percentile can summarize the change. In that case, plot a shift function instead of reaching for one more percentile.
- If the distribution has split, finding the axis that split it is the whole diagnosis. The cause is whichever axis makes each conditional split unimodal.
- If a load test's tail looks suspiciously well-behaved, suspect coordinated omission first. A mismatch between target and actual throughput is the tell.
- In a fan-out system, the tail of a component becomes the average for the user.
The cost of drawing one chart before deciding to roll back based on a single number is, if you're already collecting histograms, just one line of query. The problem isn't the tooling — it's the habit.
References
- The mean means nothing: data visualization to debug a latency problem — Farid Zakaria (2026-07-27)
- Hacker News discussion (item 49096170, 2026-07-29)
- Original chart-generation script gist — nix-shell + plotnine, synthetic data
- wrk2 — constant-throughput, coordinated-omission-corrected load generator
- HdrHistogram — high-precision latency histogram with a correction API
- Prometheus — Native Histograms specification
- Prometheus — Histograms and summaries practical guide
- The Tail at Scale — Dean & Barroso, CACM
- Same Stats, Different Graphs — Matejka & Fitzmaurice, CHI 2017
- Anscombe's quartet
- Native Histograms Went Stable — So Why Can't We Turn Them On Yet (related post)
- eBPF Is Eating Observability (related post)
- The Three Pillars of Observability and LLM Workloads (related post)