포스트

백분위 통계 - 평균이 거짓말하는 이유와 히스토그램의 집계 오류

지연 시간의 평균이 20ms인데 사용자는 느리다고 한다. p99를 보니 3초다. 여기까지는 널리 알려진 이야기인데, 그 다음 질문들이 남는다. 여러 서버의 p99를 평균 내도 되는가. 대시보드의 p99는 실제 p99인가. 부하 도구가 보여주는 p99는 믿을 수 있는가. 셋 다 답이 “아니다”에 가깝다.

평균이 숨기는 것

지연 분포는 정규분포가 아니다. 대부분 빠르고 꼬리가 길다. GC 정지, 캐시 미스, 재시도, 락 대기가 전부 오른쪽 꼬리에 모인다.

요청 100건 중 99건이 10ms, 1건이 2,000ms면 평균은 30ms다. 평균만 보면 건강한 서버다. 그런데 한 페이지가 백엔드 요청 10개를 부르면, 그 페이지가 2초짜리를 하나라도 만날 확률은 1 - 0.99^10 ≈ 10%다. p99가 사용자의 p90이 된다. 요청 수가 많은 화면일수록 꼬리가 사용자에게 더 자주 보인다.

그래서 SLO는 평균이 아니라 백분위로 쓴다. “p99 < 300ms”는 검증 가능한 문장이고 “평균 50ms”는 그렇지 않다.

백분위는 평균 낼 수 없다

가장 흔한 실수다.

1
2
3
서버 A의 p99 = 100ms
서버 B의 p99 = 900ms
→ 전체 p99 = 500ms?   틀렸다

백분위는 분포에서 위치를 찾는 연산이라 산술 평균이 성립하지 않는다. A가 초당 10,000건, B가 초당 10건을 처리했다면 전체 p99는 A쪽에 훨씬 가깝다. 정확히 구하려면 두 분포를 합친 뒤 다시 계산해야 하고, 그러려면 백분위 값이 아니라 분포 자체를 보관해야 한다.

이것이 Prometheus에 두 종류의 지연 지표가 있는 이유다.

 SummaryHistogram
계산 위치클라이언트(애플리케이션)서버(쿼리 시점)
저장 형태계산된 분위수버킷별 누적 카운트
여러 인스턴스 합산불가가능 (histogram_quantile(0.99, sum by (le) (...)))
비용낮음버킷 수만큼 시계열

여러 인스턴스를 합쳐 봐야 한다면 Histogram이어야 한다. Summary의 분위수는 그 인스턴스 안에서만 의미가 있다.

히스토그램의 백분위는 추정값이다

Histogram은 버킷별 개수만 안다. histogram_quantile은 해당 버킷 안에서 값이 고르게 분포한다고 가정하고 선형 보간한다. 실제 분포가 버킷 안에서 한쪽에 몰려 있으면 그만큼 틀린다.

실무에서 이 오차가 크게 나는 경우는 정해져 있다.

  • 버킷 경계가 실제 분포와 안 맞을 때. 기본 버킷이 .005, .01, .025, ...인데 실제 지연이 전부 2~5초라면, 마지막 버킷(+Inf) 하나에 다 들어가 p99가 의미 없는 값이 된다.
  • 가장 위 버킷에 걸릴 때. +Inf 버킷에 들어간 값들은 상한이 없어 보간이 불가능하다. Prometheus는 마지막 유한 버킷의 경계를 돌려준다. p99가 정확히 버킷 경계값으로 딱 떨어져 보인다면 그것을 의심해야 한다.

대응은 자기 서비스의 SLO 근처에 버킷을 촘촘히 두는 것이다. SLO가 300ms면 그 주변에 경계를 여럿 둔다. (Prometheus의 native histogram은 이 문제를 줄이지만 아직 전환 중인 기능이다.)

부하 도구의 p99: coordinated omission

부하 테스트의 p99는 더 조심해야 한다. closed model(가상 사용자 고정)에서는 서버가 느려지면 각 VU가 응답을 기다리느라 다음 요청을 늦게 보낸다. 부하가 저절로 줄어들고, 느려진 구간의 샘플이 적게 수집된다. 꼬리가 실제보다 짧게 나온다.

open model(도착률 고정)은 응답이 오든 말든 같은 속도로 보낸다. 실제 사용자와 상위 서비스가 그렇게 행동한다.

spring-ops-lab의 S1에서 이 함정을 실제로 만났다. 첫 실행에서 k6가 VU를 미리 할당하지 않아 요청 1,889건을 버렸고(dropped_iterations), 그 순간 open model이 조용히 closed model이 됐다. 서버가 받지도 않은 요청이 통계에서 사라지므로 꼬리가 짧게 나온다. 그래서 dropped_iterations: 0을 임계값으로 걸고, 그 조건을 못 지킨 실행은 폐기하기로 했다.

이 설명이 깨지는 곳

  • 표본이 적으면 p99는 흔들린다. 초당 10건이면 1분에 600건이고 p99는 상위 6건이 정한다. 짧은 창의 p99는 노이즈다.
  • p99.9와 max는 또 다른 이야기다. 배포, 스케일 아웃, GC 같은 드문 사건이 거기에 있고, 그것들은 SLO보다 사후 분석의 대상이다.
  • 집계 창의 길이가 값을 바꾼다. 1분 p99와 1시간 p99는 다른 질문의 답이다.
  • 서버 측 지연은 큐잉 시간을 포함하지 않을 수 있다. 애플리케이션이 재는 것은 스레드를 받은 뒤부터다. 사용자가 겪는 시간은 그보다 길다.

무엇을 재면 확인되는가

  1. 같은 서버를 closed와 open 모델로 각각 재고 p50과 p99를 비교한다. p50은 비슷하고 p99만 갈린다.
  2. 버킷 경계를 바꿔 가며 histogram_quantile 결과가 얼마나 움직이는지 본다.
  3. 인스턴스 여러 대의 Summary 분위수를 평균 낸 값과, Histogram으로 합산해 계산한 값을 비교한다.

1번이 spring-ops-lab의 S3 시나리오다.

실무와의 접점

Datadog로 병목을 추적할 때 대시보드의 지연 그래프를 근거로 삼았다. 그 값이 Summary였는지 Histogram이었는지, 여러 인스턴스를 어떻게 합쳤는지는 확인하지 않았다. 지금이라면 지표를 근거로 쓰기 전에 그 지표가 어떻게 계산됐는지를 먼저 확인할 것이다. 틀린 방법으로 계산된 p99는 틀린 결론을 그럴듯하게 만든다.

정리

  • 지연 분포는 꼬리가 길다. 평균은 그 꼬리를 숨기고, 요청이 여러 개 모이는 화면에서는 꼬리가 사용자의 평상이 된다.
  • 백분위는 평균 낼 수 없다. 합치려면 값이 아니라 분포를 보관해야 한다.
  • Summary는 인스턴스 안에서만, Histogram은 합산이 가능하다.
  • 히스토그램의 백분위는 버킷 안 균등 분포를 가정한 추정값이다. 버킷 경계를 SLO 근처에 둬야 한다.
  • p99가 버킷 경계값과 정확히 같다면 그것은 값이 아니라 상한일 수 있다.
  • closed model의 p99는 꼬리를 숨긴다. 부하 도구의 설정이 결과를 만든다.

참고

장애 대응과 관측
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다