포스트

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

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


평균이 숨기는 것

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

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

그래서 SLO(Service Level Objective, 서비스 수준 목표)는 평균이 아니라 백분위로 쓴다. “p99 < 300ms”는 검증 가능한 문장이고 “평균 50ms”는 그렇지 않다.


백분위는 평균 낼 수 없다

가장 흔한 실수다.

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

백분위는 분포에서 위치를 찾는 연산이라 산술 평균이 성립하지 않는다. A가 초당 10,000건, B가 초당 10건을 처리했다면 전체 p99는 A쪽에 훨씬 가깝다. 정확히 구하려면 두 분포를 합친 뒤 다시 계산해야 하고, 그러려면 백분위 값이 아니라 분포 자체를 보관해야 한다. Prometheus 문서도 Summary가 미리 계산한 분위수를 합치는 일에 대해 “aggregating the precomputed quantiles from a summary rarely makes sense”(미리 계산된 분위수를 집계하는 것은 거의 의미가 없다)라고 적는다(Prometheus: Histograms and summaries).

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

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

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


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

Histogram은 버킷별 개수만 안다. histogram_quantile은 해당 버킷 안에서 값이 고르게 분포한다고 가정하고 선형 보간한다(Prometheus: 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(virtual user, 가상 사용자)가 응답을 기다리느라 다음 요청을 늦게 보낸다. 그래서 부하가 저절로 줄어들고, 느려진 구간의 샘플이 적게 수집된다. 그 결과 꼬리가 실제보다 짧게 나온다. k6 문서는 이 현상을 coordinated omission이라는 이름으로 소개한다(k6: Open and closed models).

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도 그래프에서는 정상적인 숫자로 보이기 때문이다.


정리

  • 요청 10개를 부르는 화면에서는 서버의 p99가 사용자의 p90이 된다.
  • 여러 인스턴스를 합쳐 봐야 하면 Summary가 아니라 Histogram이어야 하고, 버킷 경계는 SLO 근처에 촘촘히 둔다.
  • 부하 도구의 p99는 dropped_iterations가 0인 open model 실행에서만 믿는다.
참고한 자료외부 출처 4

외부 출처

장애 대응과 관측
이 글은 저작권자의 CC BY 4.0 라이선스를 따릅니다.

댓글

아직 댓글이 없습니다