Percentiles, histograms, tail at scale, правильные инструменты измерения
«Без измерения нет улучшения». Правильное измерение latency — фундамент борьбы с long tail.
Представьте систему с 100 запросами:
Среднее (average): (99 × 10 + 1 × 10000) / 100 = 109 мс
Звучит неплохо! Но любой пользователь, попавший на медленный запрос, увидит 10000 мс — в 100 раз хуже «среднего».
Медиана (p50): 10 мс — показывает типичный случай
p95: 10 мс — скрывает проблему
p99: 10000 мс — вот где проблема видна!
import statistics
latencies = [10] * 99 + [10000]
print(f"Average: {statistics.mean(latencies):.1f} мс") # 109.0 мс
print(f"Median: {statistics.median(latencies):.1f} мс") # 10.0 мс
def percentile(data, p):
"""Вычисление перцентиля."""
k = (len(data) - 1) * p / 100
f = int(k)
c = f + 1 if f + 1 < len(data) else f
return data[f] + (k - f) * (data[c] - data[f]) if f != c else data[f]
print(f"p95: {percentile(sorted(latencies), 95):.1f} мс") # 10.0 мс
print(f"p99: {percentile(sorted(latencies), 99):.1f} мс") # 10000.0 мс
print(f"p99.9: {percentile(sorted(latencies), 99.9):.1f} мс") # 10000.0 мсВывод: для борьбы с long tail используйте p95, p99, p99.9, никогда не полагайтесь на среднее.
| Перцентиль | Что показывает | Когда использовать |
|---|---|---|
| p50 (медиана) | Типичный случай | Общая оценка производительности |
| p90 | Производительность для 90% пользователей | SLA для обычных пользователей |
| p95 | Производительность для 95% пользователей | Целевой метрика для оптимизаций |
| p99 | Производительность для 99% пользователей | Критичные системы, выявление редких проблем |
| p99.9 | Worst-case для 99.9% запросов | Финтех, медицина, safety-critical |
| max | Абсолютный максимум | Отладка, расследование инцидентов |
В 2011 году инженеры Google опубликовали статью "The Tail at Scale", описывающую фундаментальную проблему больших систем.
Если страница загружает данные из N независимых сервисов, и каждый имеет вероятность p быстрого ответа, то вероятность быстрого ответа страницы:
P(страница быстрая) = p^N
Пример для 100 сервисов с p99 = 10 мс (p = 0.99):
P(все 100 быстрые) = 0.99^100 ≈ 0.366
Только 36.6% запросов будут быстрыми! В 63.4% случаев хотя бы один сервис ответит медленно.
import asyncio
import random
import time
async def fetch_from_replica(replica_id, delay_ms):
"""Симуляция запроса к реплике с задержкой."""
await asyncio.sleep(delay_ms / 1000)
return f"Replica {replica_id}"
async def hedged_request(replicas, hedge_after_ms=50):
"""
Hedged request: отправляем запрос на несколько реплик,
используем первый ответ.
"""
pending = set()
# Отправляем первый запрос
first_task = asyncio.create_task(fetch_from_replica(
replicas[0],
random.randint(10, 200) # Симуляция переменной задержки
))
pending.add(first_task)
start = time.time()
while pending:
done, pending = await asyncio.wait(
pending,
timeout=hedge_after_ms / 1000,
return_when=asyncio.FIRST_COMPLETED
)
if done:
# Первый ответ получен!
result = done.pop().result()
# Отменяем остальные
for task in pending:
task.cancel()
return result
# Прошло hedge_after_ms, отправляем второй запрос
if len(pending) < len(replicas):
second_task = asyncio.create_task(fetch_from_replica(
replicas[1],
random.randint(10, 200)
))
pending.add(second_task)
raise TimeoutError("Все реплики не ответили")
# Запуск
async def main():
replicas = [0, 1, 2]
latencies = []
for _ in range(1000):
start = time.time()
await hedged_request(replicas, hedge_after_ms=50)
latencies.append((time.time() - start) * 1000)
print(f"p50: {sorted(latencies)[500]:.1f} мс")
print(f"p99: {sorted(latencies)[990]:.1f} мс")
# asyncio.run(main())Результат: hedged requests снижают p99 с ~180 мс до ~60 мс ценой ~20% дополнительных запросов.
Хранит заранее вычисленные перцентили. Плюсы:
Минусы:
# Prometheus Summary example (концептуально)
# Нельзя вычислить p95 от суммы summary за час + за деньРаспределяет значения по корзинам (buckets). Плюсы:
Минусы:
from collections import defaultdict
class LatencyHistogram:
"""Простая гистограмма для latency."""
def __init__(self, buckets=(10, 50, 100, 250, 500, 1000, 2500, 5000)):
self.buckets = {b: 0 for b in buckets}
self.buckets[float('inf')] = 0
self.sum = 0
self.count = 0
def observe(self, value_ms):
"""Записать наблюдение."""
self.sum += value_ms
self.count += 1
for bucket in sorted(self.buckets.keys()):
if value_ms <= bucket:
self.buckets[bucket] += 1
break
def percentile(self, p):
"""Вычислить перцентиль из гистограммы."""
if self.count == 0:
return 0
target_count = self.count * p / 100
cumulative = 0
prev_bucket = 0
for bucket in sorted(self.buckets.keys()):
cumulative = self.buckets[bucket]
if cumulative >= target_count:
# Интерполяция внутри бакета
if bucket == float('inf'):
return prev_bucket
ratio = (target_count - self.buckets.get(prev_bucket, 0)) / \
(cumulative - self.buckets.get(prev_bucket, 0) + 1)
return prev_bucket + (bucket - prev_bucket) * ratio
prev_bucket = bucket
return max(self.buckets.keys())
def merge(self, other):
"""Объединить две гистограммы (для агрегации)."""
for bucket in self.buckets:
self.buckets[bucket] += other.buckets[bucket]
self.sum += other.sum
self.count += other.count
# Использование
hist = LatencyHistogram()
for latency in [15, 23, 45, 67, 120, 230, 450, 890, 1200, 3500]:
hist.observe(latency)
print(f"p50: {hist.percentile(50):.1f} мс")
print(f"p95: {hist.percentile(95):.1f} мс")
print(f"p99: {hist.percentile(99):.1f} мс")from prometheus_client import Histogram, generate_latest
import time
import functools
# Определяем гистограмму с кастомными бакетами
REQUEST_LATENCY = Histogram(
'http_request_latency_seconds',
'HTTP request latency in seconds',
['method', 'endpoint'],
buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0)
)
def measure_latency(label):
"""Декоратор для измерения latency."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
try:
return func(*args, **kwargs)
finally:
duration = time.time() - start
REQUEST_LATENCY.labels(method='GET', endpoint=label).observe(duration)
return wrapper
return decorator
@measure_latency('/api/users')
def get_users():
# Симуляция работы
time.sleep(0.05)
return {'users': []}Для анализа long tail в распределённых системах недостаточно метрик — нужна трассировровка.
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor
# Настройка трассировки
trace.set_tracer_provider(TracerProvider())
tracer = trace.get_tracer(__name__)
trace.get_tracer_provider().add_span_processor(
SimpleSpanProcessor(ConsoleSpanExporter())
)
def process_order(order_id):
with tracer.start_as_current_span("process_order") as span:
span.set_attribute("order_id", order_id)
with tracer.start_as_current_span("validate_payment"):
validate_payment(order_id)
with tracer.start_as_current_span("reserve_inventory"):
reserve_inventory(order_id)
with tracer.start_as_current_span("notify_shipping"):
notify_shipping(order_id)
def validate_payment(order_id):
time.sleep(0.1) # Симуляция
def reserve_inventory(order_id):
time.sleep(0.05)
def notify_shipping(order_id):
time.sleep(0.02)
# Трассировка покажет, какой span вносит наибольший вклад в latencySLO (Service Level Objective) — целевое значение метрики.
Пример SLO для latency:
class SLOTracker:
"""Трекер соблюдения SLO для latency."""
def __init__(self, target_percentile=95, target_latency_ms=200, window_days=30):
self.target_percentile = target_percentile
self.target_latency_ms = target_latency_ms
self.window_days = window_days
self.latencies = []
self.timestamps = []
def record(self, latency_ms, timestamp=None):
"""Записать latency."""
import time
self.latencies.append(latency_ms)
self.timestamps.append(timestamp or time.time())
def check_slo(self):
"""Проверить соблюдение SLO за окно."""
import time
cutoff = time.time() - (self.window_days * 24 * 3600)
# Фильтруем данные за окно
recent = [
(lat, ts) for lat, ts in zip(self.latencies, self.timestamps)
if ts >= cutoff
]
if not recent:
return None
latencies_only = sorted([lat for lat, _ in recent])
p95_idx = int(len(latencies_only) * self.target_percentile / 100)
p95 = latencies_only[p95_idx]
# Процент запросов, укладывающихся в target_latency_ms
compliant = sum(1 for lat in latencies_only if lat <= self.target_latency_ms)
compliance_rate = compliant / len(latencies_only) * 100
return {
'p95_latency_ms': p95,
'target_latency_ms': self.target_latency_ms,
'compliance_rate': compliance_rate,
'slo_met': p95 <= self.target_latency_ms
}
# Использование
tracker = SLOTracker(target_percentile=95, target_latency_ms=200)
import random
for _ in range(10000):
# Симуляция: 97% запросов < 200 мс
if random.random() < 0.97:
latency = random.randint(10, 180)
else:
latency = random.randint(200, 2000)
tracker.record(latency)
result = tracker.check_slo()
print(f"SLO met: {result['slo_met']}")
print(f"Compliance: {result['compliance_rate']:.2f}%")
print(f"p95: {result['p95_latency_ms']} мс")Серверная метрика не включает:
# Плохо: измеряем только на сервере
def handle_request():
start = time.time()
process()
record_metric(time.time() - start)
# Хорошо: измеряем end-to-end на клиенте
def call_service():
start = time.time()
response = http_client.get('/api')
latency = time.time() - start
record_metric(latency)
return responseРазделяйте latency по:
REQUEST_LATENCY.labels(
method='POST',
endpoint='/api/orders',
operation='create',
payload_size='medium', # small: <1KB, medium: 1-10KB, large: >10KB
priority='high'
).observe(latency)Плохой алерт: average latency > 100 мс
Хороший алерт: p95 latency > 200 мс for 5 minutes
Отличный алерт: SLO burn rate > 2 (использует error budget)
# Связываем latency с конверсией
if latency > 500: # Медленный ответ
# Логгируем для анализа влияния на конверсию
logger.info(f"Slow response: {latency}ms, user_id={user_id}, action={action}")В следующей теме рассмотрим таймауты и retry паттерны — первые линии защиты от long tail.
Вопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.