Как мы снизили P99 latency с 200ms до 40ms
Три месяца назад P99 наших edge-ответов болтался около 200 мс. Для CDN это неприемлемо. Вот история о том, как мы нашли и устранили узкие места.
Отправная точка
Мы измеряли latency от момента получения TCP-соединения до отправки последнего байта ответа. Метрики на старте:
| Перцентиль | Latency |
|---|---|
| P50 | 45 мс |
| P95 | 120 мс |
| P99 | 200 мс |
| P99.9 | 450 мс |
P50 был приличным, но хвост распределения — катастрофа. Каждый сотый запрос ждал 200 мс, каждый тысячный — почти полсекунды.
Шаг 1: Профилирование
Первым делом мы разбили latency на компоненты:
- TLS handshake — 20-80 мс
- Request parsing — <1 мс
- Cache lookup — 1-5 мс
- Origin fetch (cache miss) — 50-300 мс
- Response write — зависит от размера
Два очевидных виновника: TLS handshake и origin fetch при промахах кэша.
Шаг 2: TLS оптимизация
TLS 1.3
Миграция с TLS 1.2 на TLS 1.3 сократила handshake с 2-RTT до 1-RTT. Для клиентов в Азии (RTT ~100 мс) это минус 100 мс на каждое новое соединение.
0-RTT Resumption
TLS 1.3 поддерживает 0-RTT resumption — клиент может отправить данные вместе с первым пакетом. Мы включили 0-RTT для GET-запросов, добавив защиту от replay-атак на уровне приложения.
OCSP Stapling
Без OCSP stapling браузер делает отдельный запрос к CA для проверки сертификата. Мы включили stapling и кэшируем OCSP-ответы агрессивно.
Результат: P99 TLS handshake снизился с 80 мс до 25 мс.
Шаг 3: TCP тюнинг
Дефолтные настройки Linux не оптимальны для высоконагруженных серверов:
# Увеличенные буферы
net.core.rmem_max = 33554432
net.core.wmem_max = 33554432
net.ipv4.tcp_rmem = 4096 87380 33554432
net.ipv4.tcp_wmem = 4096 65536 33554432
# BBR congestion control
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr
# Быстрое переиспользование TIME_WAIT сокетов
net.ipv4.tcp_tw_reuse = 1
BBR особенно важен — он лучше справляется с lossy сетями и показывает стабильно низкую latency по сравнению с CUBIC.
Шаг 4: Origin fetch
При промахе кэша мы идём к origin-серверу клиента. Это inherently медленно, но можно оптимизировать:
Connection pooling
Вместо нового TCP-соединения на каждый запрос — пул keep-alive соединений к каждому origin. Экономия 1-2 RTT на запрос.
HTTP/2 к origin
Мультиплексирование запросов через одно соединение. Особенно помогает при thundering herd — когда много клиентов одновременно запрашивают один и тот же некэшированный ресурс.
Stale-while-revalidate
Для контента с SWR мы отдаём устаревший кэш немедленно, а обновление делаем асинхронно. Пользователь не ждёт origin.
Шаг 5: Географическая оптимизация
Мы добавили PoP в регионах с высоким трафиком, но плохой связностью:
- Сингапур (для Юго-Восточной Азии)
- Сан-Паулу (для Латинской Америки)
- Мумбаи (для Индии)
Для каждого региона P50 упал на 30-50 мс просто за счёт физической близости.
Шаг 6: Edge Functions latency
Отдельная история — V8 isolates. Основные оптимизации:
- Prewarming — создаём isolates заранее, до прихода запроса
- Code caching — компилируем JS один раз, переиспользуем байткод
- Memory pooling — переиспользуем heaps между запросами
Результаты
После трёх месяцев оптимизаций:
| Перцентиль | Было | Стало | Улучшение |
|---|---|---|---|
| P50 | 45 мс | 12 мс | -73% |
| P95 | 120 мс | 28 мс | -77% |
| P99 | 200 мс | 40 мс | -80% |
| P99.9 | 450 мс | 85 мс | -81% |
Уроки
- Измеряй перцентили, не средние. Среднее может быть отличным, пока P99 убивает UX.
- Latency складывается. 10 компонентов по 5 мс = 50 мс. Оптимизируй каждый.
- Сеть — это физика. Скорость света не обманешь, ставь серверы ближе к пользователям.
- Defaults are not optimal. Дефолты ОС настроены на универсальность, не на производительность.
Работа над latency никогда не заканчивается. Мы продолжаем мониторить и оптимизировать, цель — P99 <30 мс для всех регионов.