Редирект - це синхронне блокування. Користувач натискає на ваше коротке посилання, його браузер зупиняється, і нічого більше не відбувається, доки не прийде 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-виклик до джерела (origin) в
api-coreлише при повному промаху (~0.6% запитів). - 90-денний p95 за регіонами: FRA 12.1 мс, ASH 13.4 мс, SGP 14.2 мс. Холодний промах (cold miss) додає ~22 мс до p95, що все ще в межах бюджету.
- Інвалідація кешу при зміні посилання відбувається через Redis pub/sub, розповсюдження p99 займає менше секунди. L1 TTL тривалістю 60 секунд слугує запобіжником.
Чому саме 15 мс#
Перш ніж заглиблюватися в архітектуру: чому 15 мс, а не 50 мс чи 5 мс?
Нижня межа у 5 мс зрозуміла - це приблизно те, скільки коштує транзит по фізичній мережі в середньому для європейського відвідувача, що звертається до POP у Франкфурті. Фізику не обдуриш. Верхня межа у 50 мс занадто вільна - при p95 у 50 мс ви додаєте помітну затримку перед кожним переглядом сторінки для значної частини вашого трафіку. Дослідження продуктивності вебсайтів стабільно показують, що мережеві затримки понад 50 мс починають ставати відчутними на мобільних пристроях, де радіозатримка поєднується з часом обробки - на цьому явно наголошують рекомендації Apple щодо програмування з урахуванням мережі.
Цифра 15 мс з'явилася внаслідок кількох конкретних обмежень. По-перше, редиректи накопичуються. Якщо маркетингова кампанія спрямовує трафік через скорочене посилання, яке потім перенаправляє на сторінку продукту, затримка редиректу додається до TTFB цільової сторінки. Google Core Web Vitals використовують LCP як основний сигнал, і ланцюжок редиректів, що додає 50 мс при p95, є відчутним. По-друге, нам потрібен достатній запас бюджету, щоб виконувати оцінку правил для smart links безпосередньо на гарячому шляху - параметри маршрутизації (країна, пристрій, ОС, мова, час, реферер) мають оброблятися в межах того ж часового вікна, що й звичайний редирект, інакше нам довелося б прибрати підтримку розумних посилань з edge. При 15 мс із вартістю оцінки правил ~0.3 мс, місце для цього є.
Бюджет у 15 мс стосується трафіку з попаданням у кеш. Холодні промахи можуть бути повільнішими - виклик gRPC до джерела додає затримку - але за задумом вони трапляються досить рідко, щоб суттєво не впливати на p95.
Архітектура#
Три POP, кожен із тим самим бінарним файлом: services/edge-redirect, написаним на Go з використанням fasthttp. Пропускна здатність сервера fasthttp приблизно у 8 разів вища за net/http у тестах продуктивності, а на практиці для нас важливо те, що його шлях запиту без алокацій (zero-alloc) дозволяє передбачувано контролювати паузи GC під постійним навантаженням. Стандартна бібліотека net/http підходить для більшості сервісів; для обробника редиректів, якому потрібно підтримувати субмілісекундний час обробки при високій паралельності, уникнення алокацій у купі (heap) на кожен запит варте менш зручного API.
Caddy стоїть попереду як термінатор TLS та зворотний проксі. 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 - це найчіткіше публічне пояснення того, чому це важливо: ключова властивість полягає в тому, що відмовостійкість обробляється на рівні BGP, а не за допомогою закінчення TTL у DNS. Якщо FRA втрачає зв'язок, ASH стає найкоротшим шляхом для європейського трафіку за лічені секунди, а не хвилини. Документація хмарної мережевої інфраструктури Hetzner описує базове налаштування маршрутизації для їхніх регіонів FRA та ASH.
Важливо: на гарячому шляху немає синхронної перевірки ботів. Перевірка ботів, яка займає 10 мс, одноосібно знищила б бюджет p95. Усі сигнали якості трафіку - виявлення анонімайзерів, оцінка хостинг-ASN, дедуплікація кліків - виконуються в url-scanner та click-ingester як асинхронні фонові завдання. Редирект спрацьовує, а дані про клік ідуть у чергу Redpanda; оцінка якості відбувається вже після факту.
Дворівневий кеш#
Кеш - це саме те місце, де живе наш бюджет. Логіка така:
// Спрощений пошук у кеші: L1 → L2 → джерело, з дедуплікацією через singleflight
func (h *RedirectHandler) resolve(ctx *fasthttp.RequestCtx, slug string) (*Link, error) {
// L1: внутрішньопроцесний ristretto LRU - субмікросекунда при попаданні
if link, ok := h.l1.Get(slug); ok {
return link.(*Link), nil
}
// L2 + джерело використовують групу singleflight для запобігання thundering herd
// при одночасних холодних промахах для того самого слага (slug)
val, err, _ := h.sf.Do(slug, func() (interface{}, error) {
// L2: Redis Cluster - один RTT, зазвичай 0.3–0.8 мс у межах 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
}
}
// Джерело: gRPC до api-core - холодний промах, ~20 мс додатково
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 під час сканування (коли бот перебирає тисячі унікальних слагів) витіснятиме популярні записи, щоб звільнити місце для непопулярних, які більше ніколи не будуть запитані. Політика допуску Ristretto на основі TinyLFU протистоїть цьому - вона дешево відстежує лічильники частоти та відмовляється додавати запис, який ніколи раніше не зустрічався, коли кеш під тиском. Кінцевий результат полягає в тому, що частота попадань у кеш під час ворожого сканування залишається близькою до органічної, а не обвалюється.
L2 - це Redis Cluster. Кожен POP має власний екземпляр кластера, щоб тримати міжрегіональний трафік подалі від гарячого шляху. FRA та ASH використовують окремий екземпляр Redis для сигналів інвалідації через pub/sub (про це нижче); SGP має свій власний. Одиничний Redis GET у межах одного дата-центру стабільно займає менше 1 мс. Комбінована частота попадань L1+L2 становить приблизно 99.4% за останні 90 днів - це означає, що виклики до джерела відбуваються приблизно в 1 з 167 запитів.
Для сценарію solutions/developers - команд, що використовують API для масового створення посилань - це практично означає, що щойно створене посилання отримає по одному холодному промаху на кожен POP, а потім залишатиметься «гарячим» протягом усього часу свого TTL. Посилання, які не отримують трафіку, чисто видаляються з обох рівнів кешу без ручного втручання.
Куди витрачаються ці 15 мс#
Діаграма нижче показує розподіл бюджету p95 при попаданні в кеш за фазами:
Домінуючим сегментом є повернення по мережі - приблизно 9 мс у медіані, що означає, що фізична відстань між відвідувачем та POP складає 60% бюджету. Ми не можемо це стиснути. Мультирегіональне розгортання - єдиний важіль впливу: додавання POP зменшує медіанний RTT для відвідувачів у цьому регіоні. Наступний регіон у планах дозволить знизити p95 SGP для трафіку з Південної Азії, де наразі затримка становить 14 мс, оскільки Сінгапур є найближчим POP.
Відновлення сесії TLS за 2 мс передбачає TLS 1.3 0-RTT із вже наявним квитком сесії (session ticket). Для першого відвідування з певного пристрою повне рукостискання 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 зі слагом у якості корисного навантаження. Кожен edge POP підписаний на цей канал. Отримавши повідомлення, підписник викликає l1.Del(slug) та redis.Del(cacheKey(slug)). Наступний запит до цього слага заново наповнює обидва рівні з джерела.
60-секундний TTL для L1 - це запасний варіант, а не основний механізм. Якщо підписник pub/sub не працює - наприклад, через збій Redis або мережевий поділ між POP та екземпляром pub/sub - запис видаляється з L1 щонайбільше через 60 секунд. TTL для L2 встановлено на 300 секунд, тож збій підписки означає до 5 хвилин потенційно застарілих даних у L2, під час яких TTL для L1 є єдиним запобіжником. Ми надсилаємо сповіщення про втрату підписки pub/sub протягом 30 секунд.
Для розумних посилань (smart links) із правилами за часовими вікнами застарілість має специфічне значення: якщо правило активується о 17:00, а в L1 edge-вузла закешовано попередню версію правила з TTL, що залишився до 60 секунд, трафік між 17:00 та 17:01 може бути спрямований за попередньою адресою. Шлях pub/sub усуває це в більшості випадків; 60-секундний TTL підстраховує в крайніх випадках. Для кампаній, де межа часу має критичне значення, рекомендованим підходом є використання status=disabled для старого правила, очікування одного циклу TTL (60 секунд), а потім активація нового. Ми додали кінцеву точку опитування GET /v1/links/{id}/cache-status, щоб пайплайни могли підтвердити розповсюдження змін перед продовженням.
Вимірювання в реальних регіонах#
Наведені цифри взяті з даних демо-воркспейсу, зібраних за 90 днів, що закінчилися 12.05.2026. Вони відображають лише трафік із попаданням у кеш. Усі часові мітки вказані за UTC.
| Регіон | POP | p50 | p95 | p99 |
|---|---|---|---|---|
| EU (Франкфурт) | FRA · Hetzner | 4.8 мс | 12.1 мс | 18.4 мс |
| Схід США (Ешберн) | ASH · Hetzner | 5.2 мс | 13.4 мс | 20.1 мс |
| Пд-Сх Азія (Сінгапур) | SGP · OVH | 5.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 мс є виміряним, а не оціночним; він цілком вписується в бюджет, який ми дозволяємо для холодних промахів - 35 мс p95.
Для команд, що аналізують multi-region analytics, ці показники затримки доступні як метрика Prometheus (redirect_duration_seconds з мітками region та cache_tier) через відповідну кінцеву точку.
Режими відмов, про які ми не писали минулого разу#
Проблема «стада, що тупотить» (thundering herd) при закінченні терміну дії ключа#
До того як ми додали singleflight, одночасне закінчення терміну дії слага в L1 та L2 під помірним трафіком викликало сплеск паралельних gRPC-викликів до джерела - кожен із них робив читання з Postgres для того самого слага, і всі повертали однаковий результат. Під час тестування під навантаженням це створювало сплески навантаження на CPU в api-core, які не мали жодного стосунку до обсягу створення посилань. Група singleflight об'єднує одночасні промахи для того самого слага в один виклик до джерела. Інші горутини (goroutines), що чекають, блокуються на групі й отримують той самий результат після його виконання. Ми використали стандартний пакет Go golang.org/x/sync/singleflight.
Ми помилилися з цим у першому прототипі. Проблема thundering herd при закінченні терміну дії ключа - це один із тих режимів відмов, які не з'являються в юніт-тестах: вони проявляються лише при реальному навантаженні. Додаю це в допис, оскільки це поширена помилка в статтях про архітектуру кешування, а виправлення справді просте.
Резервний варіант при збої Redis#
Якщо POP втрачає зв'язок зі своїм кластером Redis, система не повертає помилку - шлях обробки деградує до використання лише L1 плюс прямий gRPC-виклик до джерела при промаху в L1. POP продовжує працювати. Частота попадань знижується через відсутність L2, тому обсяг викликів до джерела зростає, але шлях редиректу залишається функціональним. Сценарій зі збоєм Redis уже двічі траплявся в реальній експлуатації (обидва рази під час технічного обслуговування Hetzner). Пікова частота викликів до джерела під час другого інциденту була приблизно у 8 разів вищою за базову протягом збою (~4 хвилини). api-core впорався з цим без необхідності масштабування.
DNS-розповсюдження під час перемикання POP#
Перемикання 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. Ось чому дані analytics для певної події кліку стають доступними з невеликою затримкою (зазвичай до 5 секунд), а не миттєво.
Вбудовані перевірки на ботів. Перевірка на ботів додає як мінімум 10-50 мс синхронної роботи, а JavaScript-перевірки додають цілий додатковий RTT. Ми не робимо ні того, ні іншого на шляху редиректу. Сервіс url-scanner обробляє сигнали якості трафіку асинхронно. Для команд solutions/developers, що створюють кампанії з посиланнями, це означає, що редирект ніколи не затримується перевіркою, яка погіршує досвід для легітимного трафіку.
Валідація схеми під час редиректу. Цільова URL-адреса та правила таргетингу перевіряються під час запису, коли посилання створюється або оновлюється через api-core. На момент, коли слаг потрапляє в кеш, його структура вже гарантовано валідна. Під час редиректу немає валідації JSON-схеми, парсингу URL чи перевірки синтаксису правил. Edge-бінарник повністю довіряє запису в кеші. Це безпечно лише тому, що шлях запису проводить валідацію перед додаванням у кеш.
Непривабливі деталі#
Три речі, про які ми пишемо нечасто, бо про них нудно читати, але важливо зробити правильно.
Бюджети розміру кешу. ristretto ініціалізується з явним бюджетом вартості в байтах, а не просто за кількістю елементів. Вартість кожного закешованого посилання розраховується за його серіалізованим розміром, який варіюється залежно від кількості правил таргетингу. Посилання без правил коштує приблизно 200 байтів; посилання з 6 правилами таргетингу коштує близько 800 байтів. Бюджет встановлено так, щоб споживати не більше 10% доступної оперативної пам'яті інстансу, залишаючи місце для рантайму Go, Caddy та буферів з'єднань. Помилка тут призводить до «пробуксовування» кешу: занадто малий бюджет витісняє записи до закінчення TTL, спрямовуючи трафік до L2 та джерела.
Налаштування GC під навантаженням. Збирач сміття (garbage collector) у Go добре налаштований за замовчуванням, але стандартне значення GOGC=100 запускає GC, коли розмір купи вдвічі перевищує обсяг живих даних. Для обробника редиректів, де жива купа невелика, але швидкість алокацій помірна (fasthttp працює без алокацій на гарячому шляху, але є алокації об'єктів для подій кліків та gRPC-викликів), GC спрацьовує частіше, ніж потрібно. У продакшені ми використовуємо GOGC=400. Результат - довші цикли GC, але менша їх частота, що важливо для «хвостових» затримок (tail latency). Цикл GC, що триває 2 мс і стається раз на 4 секунди, дає менший внесок у p99, ніж цикл тривалістю 1 мс щосекунди. Ми перевірили це емпірично за допомогою make bench перед встановленням у конфігурації розгортання.
Дисципліна make bench. Edge-бінарник має набір тестів продуктивності (go test -bench=. -benchmem ./... всередині services/edge-redirect). Кожна запропонована зміна на гарячому шляху - додавання нового заголовка, зміна формату ключа кешу, коригування оцінювача правил - проходить через бенчмарки перед злиттям (merge). Зміна, що додає 0.5 мс до бенчмарку p50, - це зміна, що змінить p95 у продакшені. Бенчмарк є фільтром, а не просто перевіркою постфактум. Одного разу ми поставилися до цього легковажно під час рефакторингу логіки нормалізації слагів і випустили регресію на 1.2 мс, яка з'явилася на дашбордах регіонів через два дні. Регресія була реальною, і урок засвоєно.
Ці архітектурні рішення детальніше задокументовані за адресою /docs/architecture/edge-redirect. Якщо ви розглядаєте Elido як інфраструктурний рівень редиректів для масштабної кампанії або платформи для розробників, сторінка solutions/developers описує API та доступні SDK. Погляд на те, що дворівневий кеш означає для поведінки розумних посилань - зокрема, на вікно розповсюдження змін у правилах - ви знайдете в дописі smart links explained.
Маріус Фосс (Marius Voß) - DevRel та edge infra в Elido. Він був одним з інженерів, які пройшли шлях зі створення бінарного файлу edge-redirect від прототипу до продакшену, і з того часу уважно стежить за дашбордами його затримок.
Спробуйте Elido
Вставте URL - отримайте коротке посилання
Без реєстрації. Посилання живе 30 днів. Зареєструйтесь, щоб зберегти назавжди.
Безкоштовно, без реєстрації · 2 на день