Elido
14 мин чтенияИнженерия
Ключевая

Достижение p95 < 15 мс для редиректов из FRA, ASH и SGP

Как путь edge-redirect в Elido удерживает бюджет p95 в 15 мс при попадании в кэш (cache HIT) в трех регионах - архитектура, стратегия кэширования и измерения в реальных регионах

Marius Voß
DevRel · edge infra
World map showing Elido edge POPs in Frankfurt, Ashburn, and Singapore with p95 latency annotations of 12ms, 13ms, and 14ms respectively

Редирект - это синхронный блок. Пользователь нажимает на вашу короткую ссылку, его браузер замирает, и ничего не происходит до тех пор, пока не придет ответ 302 и не начнется загрузка следующей страницы. Редирект - это не фоновая задача, которую можно деприоритизировать. Каждая миллисекунда, добавленная здесь, вычитается из времени загрузки страницы, которая действительно важна.

Вот почему мы установили жесткий бюджет еще до того, как написали первую строку кода services/edge-redirect: p50 - 5 мс, p95 - 15 мс при попадании в кэш, измеренные на POP, без учета полного TLS-рукопожатия. Это не просто пожелание. Если что-то выводит нас за эти рамки, это удаляется или переносится в асинхронный путь.

Мы уже несколько месяцев эксплуатируем три продуктовых региона - Франкфурт (FRA), Эшберн (ASH) и Сингапур (SGP). Этот пост - полный отчет о том, как работает горячий путь (hot path), почему цифры выглядят именно так и в чем мы ошиблись в первый раз.

TL;DR#

  • Горячий путь построен на Go + fasthttp в Hetzner FRA/ASH и OVH SGP, за Caddy с anycast-маршрутизацией. Никакого синхронного скоринга ботов или JS-проверки на пути редиректа.
  • Двухуровневый кэш: внутрипроцессный ristretto LRU (L1, ~88% попаданий), поддерживаемый Redis Cluster (L1+L2 суммарно ~99.4%). Обращение к источнику через gRPC к api-core только при полном промахе (~0.6% запросов).
  • p95 за 90 дней по регионам: FRA 12.1 мс, ASH 13.4 мс, SGP 14.2 мс. Промах кэша (cold miss) добавляет около 22 мс на уровне p95, что все еще в рамках бюджета.
  • Инвалидация кэша при изменении ссылки происходит через Redis pub/sub, распространение p99 занимает меньше секунды. TTL уровня L1 составляет 60 секунд в качестве страховки.

Почему предел именно в 15 мс#

Перед тем как углубляться в архитектуру: почему 15 мс, а не 50 мс или 5 мс?

Нижний порог в 5 мс очевиден - это примерно то, во сколько обходится физический транзит по сети в медиане для европейского посетителя, попадающего на POP во Франкфурте. Физику не обманешь. Верхний предел в 50 мс слишком свободен - при p95 в 50 мс вы добавляете заметную задержку перед просмотром каждой страницы для значительной части трафика. Исследования производительности веба последовательно показывают, что задержки сети более 50 мс начинают становиться ощутимыми на мобильных устройствах, где задержка радиосвязи суммируется со временем обработки - на этот факт явно указывают рекомендации Apple по сетевому программированию.

Цифра 15 мс появилась из нескольких конкретных ограничений. Во-первых, редиректы суммируются. Если маркетинговая кампания направляет трафик через сокращенную ссылку, которая затем перенаправляет на страницу продукта, задержка редиректа добавляется к TTFB целевой страницы. Google Core Web Vitals используют LCP как основной сигнал, и цепочка редиректов, добавляющая 50 мс на уровне p95, измерима. Во-вторых, нам нужно достаточно запаса бюджета для выполнения оценки правил для умных ссылок прямо на горячем пути - параметры маршрутизации (страна, устройство, ОС, язык, время, реферер) должны обрабатываться в том же временном интервале, что и обычный редирект, иначе нам пришлось бы убрать поддержку умных ссылок с edge. При 15 мс и стоимости оценки правил ~0.3 мс, место есть.

Бюджет в 15 мс относится к трафику с попаданием в кэш. Промахи кэша (cold misses) могут быть медленнее - вызов gRPC к источнику добавляет задержку - но промахи по дизайну случаются достаточно редко, чтобы не оказывать значимого влияния на p95.

Архитектура#

Три POP, каждый с одинаковым бинарным файлом: services/edge-redirect, написанным на Go с использованием fasthttp. Пропускная способность сервера fasthttp примерно в 8 раз выше, чем у net/http в наборе бенчмарков, и, что более важно для нас на практике, его путь обработки запросов без аллокаций (zero-alloc) делает паузы GC предсказуемыми под постоянной нагрузкой. Стандартная библиотека net/http хороша для большинства сервисов; для обработчика редиректов, которому необходимо поддерживать время обработки менее миллисекунды при высокой конкурентности, отказ от аллокаций в куче на каждый запрос стоит менее эргономичного API.

Caddy стоит впереди как TLS-терминатор и обратный прокси. TLS по требованию (on-demand TLS) для кастомных доменов арендаторов (подробно описано на странице функций кастомных доменов) выпускает сертификаты при первом запросе. Мы рассматривали HAProxy и nginx в качестве альтернатив - оба быстрые, оба имеют зрелые паттерны развертывания anycast, но on-demand TLS в Caddy - это самый чистый путь к жизненному циклу сертификатов без ручного вмешательства для любого количества клиентских доменов, и это для нас важнее, чем выигрыш еще доли миллисекунды на уровне прокси.

Anycast-маршрутизация означает, что когда посетитель заходит на f.elido.me, s.elido.me или b.elido.me, DNS разрешается в общий anycast-префикс, и сеть направляет TCP-соединение к ближайшему POP. Логики гео-маршрутизации на уровне приложения нет: сеть сама выбирает POP. Введение в anycast от Cloudflare - лучшее публичное объяснение того, почему это важно. Ключевое свойство заключается в том, что отказ (failover) обрабатывается на уровне BGP, а не по истечении TTL в DNS. Если FRA теряет связность, ASH становится кратчайшим путем для европейского трафика в течение секунд, а не минут. Документация облачной сетевой инфраструктуры Hetzner описывает базовую настройку маршрутизации для их регионов FRA и ASH.

Важно: на горячем пути нет синхронного скоринга ботов. Проверка ботов, занимающая 10 мс, в одиночку уничтожила бы бюджет p95. Все сигналы качества трафика - обнаружение анонимайзеров, скоринг ASN хостинга, дедупликация кликов - выполняются в url-scanner и click-ingester как асинхронные воркеры на холодном пути. Редирект срабатывает, и клик уходит в очередь Redpanda; оценка качества происходит постфактум.

Двухуровневый кэш#

Кэш - это то, за счет чего живет бюджет. Логика:

// Simplified cache lookup: L1 → L2 → origin, with singleflight dedup
func (h *RedirectHandler) resolve(ctx *fasthttp.RequestCtx, slug string) (*Link, error) {
    // L1: in-process ristretto LRU - sub-microsecond on hit
    if link, ok := h.l1.Get(slug); ok {
        return link.(*Link), nil
    }

    // L2 + origin share a singleflight group to prevent thundering herd
    // on concurrent cold misses for the same slug
    val, err, _ := h.sf.Do(slug, func() (interface{}, error) {
        // L2: Redis Cluster - single RTT, typically 0.3–0.8ms within POP
        if data, err := h.redis.Get(ctx, cacheKey(slug)).Bytes(); err == nil {
            link, err := unmarshalLink(data)
            if err == nil {
                h.l1.Set(slug, link, linkCost(link))
                return link, nil
            }
        }

        // Origin: gRPC to api-core - cold miss, ~20ms extra
        link, err := h.origin.GetLink(ctx, &pb.GetLinkRequest{Slug: slug})
        if err != nil {
            return nil, err
        }
        payload, _ := marshalLink(link)
        h.redis.Set(ctx, cacheKey(slug), payload, redisTTL)
        h.l1.Set(slug, link, linkCost(link))
        return link, nil
    })
    if err != nil {
        return nil, err
    }
    return val.(*Link), nil
}

L1 - это ristretto, кэш LRU с контролем допуска от Dgraph. Контроллер допуска имеет значение: наивный LRU при нагрузке сканированием (бот, перебирающий тысячи уникальных путей) будет вытеснять горячие записи, чтобы освободить место для холодных, которые больше никогда не будут запрошены. Политика допуска TinyLFU в Ristretto противостоит этому - она дешево отслеживает счетчики частоты и отказывается добавлять запись, которая никогда раньше не встречалась, когда кэш находится под давлением. В результате процент попаданий в кэш при враждебном трафике сканирования остается близким к органическому, а не обрушивается.

L2 - это Redis Cluster. У каждого POP есть свой экземпляр кластера, чтобы исключить межрегиональный трафик из горячего пути. FRA и ASH используют отдельный экземпляр Redis для сигналов инвалидации через pub/sub (подробнее об этом ниже); у SGP - свой собственный. Один Redis GET внутри того же дата-центра надежно укладывается в 1 мс. Суммарный процент попаданий L1+L2 за последние 90 дней составляет примерно 99.4% - это означает, что вызовы к источнику происходят примерно в 1 из 167 запросов.

Для сценария использования solutions/developers - команд, использующих API для массового создания ссылок - практическим следствием является то, что только что созданная ссылка испытает по одному промаху на каждый POP, а затем будет оставаться «горячей» в течение всего срока TTL. Ссылки, по которым нет трафика, чисто вымываются из обоих уровней кэша без ручного удаления.

Куда уходят 15 мс#

На диаграмме ниже показана разбивка бюджета p95 при попадании в кэш по фазам:

Horizontal stacked bar showing the 15ms p95 cache-hit budget decomposed into TLS resume 2ms, L1 lookup 0.4ms, header build 1ms, network return 9ms, and margin 2.6ms. Illustrative FRA median values.

Доминирующим сегментом является возврат по сети - медиана около 9 мс, что означает, что физическое расстояние между посетителем и POP составляет 60% бюджета. Мы не можем сжать этот показатель. Единственный рычаг - многорегиональное развертывание: добавление POP сокращает медианный RTT для посетителей в этом регионе. Следующий регион в дорожной карте сократит p95 в SGP для трафика из Южной Азии, где сейчас мы фиксируем 14 мс, так как Сингапур является ближайшим POP.

Возобновление сеанса TLS за 2 мс предполагает TLS 1.3 0-RTT с уже имеющимся тикетом сеанса. При первом посещении с данного устройства полное TLS-рукопожатие добавляет сверху примерно 10-15 мс - именно поэтому бюджет в 15 мс явно ограничен трафиком с попаданием в кэш и возобновленным сеансом, что на практике составляет подавляющее большинство трафика кликов. RFC 7234 регулирует семантику кэширования для уровня HTTP; примечательно, что ответы 302 не сохраняются в кэшах браузеров по умолчанию (§4.2.2), что является правильным поведением для нашего случая - каждый запрос редиректа достигает edge, для каждого редиректа принимается собственное решение о маршрутизации, никаких устаревших пунктов назначения в кэше браузера.

Запас в 2.6 мс - это реальный операционный резерв, а не просто пустые цифры. В среде Go ожидаются периодические паузы stop-the-world длительностью порядка 0.5-1 мс даже при настроенных параметрах GOGC. Накладные расходы прокси Caddy добавляют небольшую фиксированную стоимость. Запас не дает нам выйти за рамки бюджета, когда эти эффекты суммируются.

Инвалидация кэша#

Механизм реализации - Redis pub/sub. Когда ссылка изменяется в api-core - изменен адрес назначения, обновлены правила таргетинга, ссылка архивирована - обработчик мутаций публикует сообщение в канал link:invalidate со слагом (slug) в качестве полезной нагрузки. Каждый POP на границе подписан на этот канал. При получении подписчик вызывает l1.Del(slug) и redis.Del(cacheKey(slug)). Следующий запрос к этой ссылке повторно заполняет оба уровня из источника.

TTL уровня L1 в 60 секунд - это страховочный механизм, а не основной. Если подписчик pub/sub недоступен - скажем, из-за сбоя Redis или сетевого разделения между POP и экземпляром pub/sub - запись истечет в L1 максимум через 60 секунд. TTL уровня L2 установлен на 300 секунд, поэтому сбой подписчика означает до 5 минут потенциально устаревших данных в L2, в течение которых TTL L1 является единственной страховкой. Мы настраиваем алерты на потерю подписки pub/sub в течение 30 секунд.

Для умных ссылок с правилами, привязанными к временным окнам, устаревание имеет специфическое значение: если правило активируется в 17:00, а в L1 граничного POP кэширована предыдущая версия правила с оставшимся TTL до 60 секунд, трафик между 17:00 и 17:01 может уйти по старому назначению. Путь через pub/sub устраняет это в обычном случае; 60-секундный TTL подстраховывает в крайнем случае. Для кампаний, где границы времени имеют критическое значение, рекомендуется использовать status=disabled для старого правила, подождать один цикл TTL (60 секунд), а затем активировать новое. Мы добавили эндпоинт для опроса GET /v1/links/{id}/cache-status, чтобы конвейеры могли подтвердить распространение перед продолжением.

Измерения в реальных регионах#

Следующие цифры взяты из данных demo-workspace, собранных за 90 дней, закончившихся 2026-05-12. Они отражают только трафик с попаданием в кэш. Все временные метки указаны в UTC.

РегионPOPp50p95p99
EU (Франкфурт)FRA · Hetzner4.8 мс12.1 мс18.4 мс
US East (Эшберн)ASH · Hetzner5.2 мс13.4 мс20.1 мс
SE Asia (Сингапур)SGP · OVH5.6 мс14.2 мс22.8 мс

FRA - самый быстрый, потому что большая часть рабочей нагрузки приходится на Европу, поэтому медианный RTT ниже. SGP обслуживает более широкий географический охват - трафик из Юго-Восточной Азии имеет более низкий RTT, в то время как трафик из Южной и Восточной Азии добавляет задержки в хвосте распределения.

Показатели p99 превышают бюджет в 15 мс. Это сделано намеренно. Бюджетом является p95, а не p99. p99 формируется под влиянием исключительных условий: переключение сотовых сетей, повторные передачи TCP, случайные скачки задержки Redis. Мы отслеживаем p99, но не включаем его в SLA. Инженерное решение заключается в том, что p95 охватывает опыт «почти всех почти всегда», а оптимизация последнего 1% потребовала бы устранения источников естественной сетевой изменчивости, которые не находятся под нашим контролем.

p95 холодного промаха составляет примерно 22 мс. Это нижний порог, которого мы можем достичь, учитывая, что gRPC к источнику добавляет задержку на круг внутри одного дата-центра (FRA → FRA по частной сети составляет около 0.3 мс) плюс поиск в Postgres в api-core (обычно 1-3 мс для поиска ссылки по ключу). Цифра 22 мс получена путем измерений, а не оценок; она укладывается в бюджет, который мы отводим для путей холодного промаха, установленный на уровне p95 в 35 мс.

Для команд, оценивающих многорегиональную аналитику, эти показатели задержки доступны в виде метрики Prometheus (redirect_duration_seconds с метками region и cache_tier) через эндпоинт метрик.

Сценарии отказов, о которых мы не писали в первый раз#

«Грохочущее стадо» при истечении срока действия ключа#

До того как мы добавили singleflight, одновременное истечение срока действия слага и в L1, и в L2 под умеренным трафиком вызывало всплеск одновременных вызовов gRPC к источнику - каждый из них выполнял чтение из Postgres для одного и того же слага, и все возвращали одинаковый результат. При нагрузочном тестировании это приводило к скачкам загрузки CPU api-core, не имеющим отношения к объему создания ссылок. Группа singleflight сворачивает одновременные промахи для одного и того же слага в один вызов к источнику. Остальные ожидающие горутины блокируются на группе и получают тот же результат, когда он возвращается. Реализация выполнена с помощью стандартного пакета Go golang.org/x/sync/singleflight.

Мы ошиблись в этом в первом прототипе. «Грохочущее стадо» при истечении срока действия ключа - один из тех сценариев отказа, которые не проявляются в юнит-тестах: они показывают себя только при реалистичной конкурентности. Добавляем это в пост, потому что это частое упущение в описаниях архитектур кэширования, а исправление действительно простое.

Отказоустойчивость при кратковременных сбоях Redis#

Если POP теряет связь со своим кластером Redis, это не приводит к ошибке - путь выполнения деградирует до использования только L1 и прямого вызова gRPC к источнику при промахе в L1. POP продолжает работать. Процент попаданий падает из-за недоступности L2, поэтому объем вызовов к источнику резко возрастает, но путь редиректа остается функциональным. Путь обработки сбоев Redis дважды проверялся в продакшене (оба раза это были окна обслуживания Hetzner). Пиковая частота вызовов к источнику во время второго инцидента была примерно в 8 раз выше базовой на протяжении всего сбоя (~4 минуты). api-core справился с этим без событий масштабирования.

Распространение DNS при переключении POP#

Failover в anycast происходит на уровне BGP - не нужно ждать TTL DNS или таймаута проверки работоспособности на уровне приложения в пути запроса. Выход POP из строя вызывает отзыв маршрута BGP, и сетевой трафик переключается на следующий ближайший POP в пределах окна сходимости BGP (обычно 15-90 секунд в зависимости от количества сетевых узлов до затронутого пути). Релевантным операционным параметром является наш интервал проверки работоспособности: мы запускаем проверки TCP каждые 10 секунд на каждый POP. Неудачная проверка инициирует отзыв маршрута. Интервал проверки в 10 секунд означает, что упавший POP может отдавать неудачные запросы до 10 секунд до отзыва маршрута. Мы намеренно протестировали этот порог; фактическое влияние в двух инцидентах в продакшене было ниже интервала проверки.

Чего мы НЕ делаем на горячем пути#

Каждый элемент, которого нет на горячем пути, - это осознанный выбор, а не упущение.

Синхронная запись кликов. Клики отправляются в Redpanda по принципу «выстрелил и забыл». Обработчик редиректов добавляет событие клика в топик Kafka (clicks.raw) со слагом, временной меткой, усеченным IP и хешем user-agent, а затем отвечает 302. Запись не является блокирующей. Если Redpanda недоступна, клик отбрасывается, а не редирект. Мы сделали сознательный выбор в пользу того, что потеря клика при отказе инфраструктуры приемлема, а отказ редиректа - нет. Потребитель click-ingester обрабатывает топик Redpanda и записывает данные в ClickHouse. Вот почему данные аналитики для данного события клика доступны с небольшой задержкой (обычно менее 5 секунд), а не мгновенно.

Встроенные проверки ботов. Проверка ботов добавляет минимум 10-50 мс синхронной работы, а JavaScript-проверки - целый дополнительный цикл приема-передачи. Мы не делаем ни того, ни другого на пути редиректа. Сервис url-scanner обрабатывает сигналы качества трафика асинхронно. Для команд solutions/developers, создающих кампании со ссылками, это означает, что редирект никогда не преграждается проверкой, которая ухудшает опыт для легитимного трафика.

Валидация схемы во время редиректа. URL назначения и правила таргетинга проверяются во время записи, когда ссылка создается или обновляется через api-core. К моменту, когда слаг попадает в кэш, его структура уже гарантированно валидна. Во время редиректа нет валидации JSON-схемы, парсинга URL или проверки синтаксиса правил. Граничный бинарный файл полностью доверяет записи в кэше. Это безопасно только потому, что путь записи выполняет валидацию перед допуском в кэш.

Несамые эффектные, но важные детали#

Три вещи, о которых мы пишем недостаточно, потому что их скучно читать, но важно сделать правильно.

Бюджеты размера кэша. ristretto инициализируется с явным бюджетом стоимости в байтах, а не простым количеством элементов. Каждая закэшированная ссылка оценивается по ее сериализованному размеру, который варьируется в зависимости от количества правил таргетинга. Ссылка без правил стоит примерно 200 байт; ссылка с 6 правилами таргетинга стоит ближе к 800 байт. Бюджет установлен таким образом, чтобы потреблять не более 10% доступной оперативной памяти инстанса, оставляя запас для среды выполнения Go, Caddy и буферов соединений. Ошибка здесь приводит к «дрожанию» кэша: слишком малый бюджет вытесняет записи до истечения TTL, перенаправляя трафик в L2 и к источнику.

Настройка GC под нагрузкой. Сборщик мусора Go по умолчанию хорошо настроен, но стандартное значение GOGC=100 запускает GC при двукратном увеличении размера живой кучи. Для обработчика редиректов, где живая куча невелика, но скорость аллокаций умеренная (fasthttp работает без аллокаций на горячем пути, но есть аллокации объектов для событий кликов и вызовов gRPC), GC срабатывает чаще, чем необходимо. Мы используем GOGC=400 в продакшене. Эффект заключается в более длинных циклах GC, но меньшей частоте - что важно для задержек в хвосте распределения. Цикл GC, занимающий 2 мс и происходящий раз в 4 секунды, вносит меньший вклад в p99, чем цикл в 1 мс каждую секунду. Мы проверили это эмпирически с помощью make bench перед установкой в конфигурации развертывания.

Дисциплина make bench. У граничного бинарного файла есть набор бенчмарков (go test -bench=. -benchmem ./... внутри services/edge-redirect). Каждое предлагаемое изменение в горячем пути - добавление нового заголовка, изменение формата ключа кэша, корректировка оценщика правил - проходит через бенчмарки перед слиянием. Изменение, добавляющее 0.5 мс к бенчмарку p50, - это изменение, которое сдвинет p95 в продакшене. Бенчмарк - это барьер, а не проверка постфактум. Один раз мы проявили в этом слабость при рефакторинге логики нормализации слагов и выпустили регрессию в 1.2 мс, которая проявилась на дашбордах регионов через два дня. Регрессия была реальной, и урок был усвоен.


Решения по архитектуре более подробно задокументированы на странице /docs/architecture/edge-redirect. Если вы рассматриваете Elido в качестве уровня инфраструктуры редиректов для масштабной кампании или платформы разработчика, страница solutions/developers описывает поверхность API и варианты SDK. О том, что двухуровневый кэш означает для поведения умных ссылок - в частности, об окне распространения изменений правил - подробно рассказывается в посте объяснение умных ссылок.


Мариус Фосс - DevRel и edge-инфраструктура в Elido. Он был одним из инженеров, которые довели бинарный файл edge-redirect от прототипа до продакшена, и с тех пор не сводит глаз с дашбордов задержек.

Попробуйте Elido

Вставьте URL - получите короткую ссылку

Без регистрации. Ссылка живёт 30 дней. Зарегистрируйтесь, чтобы оставить её навсегда.

Бесплатно, без регистрации · 2 в день

Попробуйте Elido

URL-сокращатель с хостингом в ЕС: собственные домены, глубокая аналитика, открытый API. Бесплатный тариф - без банковской карты.

Теги
url shortener performance
edge redirect latency
multi-region url shortener
redirect cache strategy
fasthttp
anycast routing

Читать дальше