Elido
10 хв читанняІнженерія

Прийом кліків за принципом «вистрілив і забув» за допомогою Redpanda

Як граничні POP-вузли надсилають події кліків, не блокуючи перенаправлення, як воркер click-ingester групує дані в ClickHouse і чим ми жертвуємо заради виграшу в затримці

Marius Voß
DevRel · edge infra
Діаграма п'ятиетапного конвеєра, що показує шлях запиту на перенаправлення через edge-redirect до топіка Redpanda, потім до воркера click-ingester і в ClickHouse, з відповіддю 301, що відгалужується перед викликом продюсера

Шлях перенаправлення скорочувача URL має рівно одне завдання: розпізнати слаг у кінцеву адресу та повернути 301 за лічені мілісекунди. Все інше — це ведення обліку. Аналітика кліків, атрибуція, збагачення геоданими, скоринг шахрайства, розсилка вебуків — ніщо з цього не може знаходитися на шляху запиту. Бюджет затримки цього не дозволяє.

Ось інженерний трюк, який дозволяє конвеєру аналітики співіснувати з наріжним каменем p95 < 15ms для перенаправлень: край (edge) надсилає подію кліка в Redpanda і забуває про неї. Окремий воркер — click-ingester — підхоплює її пізніше, збагачує та записує в ClickHouse пакетами. Процес перенаправлення ніколи не блокується. Конвеєр аналітики ніколи не торкається «гарячого шляху». Компромісом є стійкість даних, і цей компроміс менший, ніж здається на перший погляд.

Що насправді означає «вистрілив і забув» (fire and forget)#

Обробник edge-redirect після вибору цільового URL із двоступеневого кешу робить три речі перед тим, як заголовок Location буде надісланий:

  1. Будує в пам'яті структуру click.Event із запиту (слаг, ID робочого простору, user agent, referer, IP, геодані з локальної бази GeoLite2-City mmdb, результати парсингу пристрою/браузера, прапорці підозрілої активності).
  2. Викликає producer.Emit(ctx, event) у Kafka-продюсері franz-go.
  3. Записує HTTP/1.1 301 та заголовок Location у буфер відповіді.

Виклик продюсера повертається негайно. Він не чекає на підтвердження (ack) від жодного брокера Redpanda. Бібліотека franz-go буферизує запис у процесі та відправляє його у фоновому goroutine; зворотний виклик (callback) продукції ініціюється пізніше, у пулі воркерів, який не володіє goroutine запиту. Якщо операція не вдається, callback логує помилку, і подія відкидається. Перенаправлення вже було обслуговано.

func (p *Producer) Emit(ctx context.Context, e Event) {
    if p == nil {
        return
    }
    b, err := json.Marshal(e)
    if err != nil {
        p.log.Warn("click marshal", zap.Error(err))
        return
    }
    rec := &kgo.Record{Topic: p.topic, Value: b}
    p.client.Produce(ctx, rec, func(_ *kgo.Record, err error) {
        if err != nil && p.log != nil {
            p.log.Warn("click produce", zap.Error(err))
        }
    })
}

Це весь інтерфейс. Жодної черги повторів всередині edge-процесу, жодного синхронного очікування ack, жодного запису на диск. Контракт з рештою системи простий: надсилання за принципом «кращих зусиль» (best-effort), логування збоїв, ніяких блокувань.

Захист від nil-отримувача дозволяє запускати локальну розробку без брокера Kafka. Без нього кожному розробнику знадобився б контейнер Redpanda лише для того, щоб протестувати шлях перенаправлення проти fasthttp обробників.

Чому ми не обрали синхронний запис#

Очевидною альтернативою було б записувати кожен клік безпосередньо в ClickHouse прямо з краю. Ми розглядали це. Ми відхилили це з трьох причин, які посилюють одна одну.

Затримка. Час кругової затримки (round-trip) INSERT у ClickHouse з POP-вузла у Франкфурті до кластера ClickHouse у тому ж регіоні становить 3-6ms p50 у спокійній мережі, 12-20ms p95 під навантаженням. Це весь бюджет перенаправлення. Додавання цього до шляху відповіді виштовхнуло б p95 за межі SLO у 15ms ще до того, як щось піде не так. Пост про стратегію кешування пояснює, наскільки жорстким є цей бюджет на практиці.

Зворотний тиск (Backpressure). ClickHouse чудово справляється з прийомом пакетів від 1000 до 10000 рядків за один INSERT. Він погано працює при записі по одному рядку у швидких циклах — рушій MergeTree створює файл частини для кожної вставки, а фоновий процес об'єднує ці частини. Шаблон прямого запису з багаторегіонального флоту edge-вузлів створив би мільйони крихітних частин, і черга об'єднання ніколи б не наздогнала їх. Документація ClickHouse чітка: вставляйте пакетами щонайменше по 1000 рядків, не частіше ніж раз на секунду.

Ізоляція збоїв. Перезапуск кластера ClickHouse, мережевий збій або повільний запит, який блокує репліку, поширюватимуться безпосередньо на збої перенаправлення. Edge-процес або почне працювати за таймаутом (погіршуючи p95), або почне втрачати кліки (погіршуючи якість даних). Розміщення шини повідомлень між ними дозволяє кожній стороні виходити з ладу незалежно — край продовжує перенаправляти, навіть коли ClickHouse деградований, а ClickHouse продовжує прийом, навіть коли один POP-вузол офлайн.

Redpanda поглинає всі три типи тиску. Вона сумісна з протоколом Kafka, тому franz-go працює з нею прозоро. Вона має формат одного бінарного файлу без JVM. Вона буферизує дані на диску, тому багатогодинний збій ClickHouse не призведе до втрати подій, доки тримається вікно зберігання (retention) топіка.

Воркер click-ingester#

click-ingester — це сервіс на Go, який працює як група споживачів (consumer group) у топіку подій кліків. Одна репліка на регіон, три регіони, без шардування за слагом чи робочим простором — група споживачів перебалансовується, якщо репліка перезапускається, а партиції призначаються Redpanda. Завдання споживача невелике:

  • Опитування (poll) вибірок з топіка.
  • Декодування JSON кожного запису в типізовану подію Event.
  • Додавання події до буфера записувача в пам'яті.
  • Іноді: запуск вебуків, пересилання в Klaviyo / Mixpanel / GA4 MP, публікація в живий потік кліків у додатку.

Записувач групує дані за кількістю або за часом, залежно від того, що настане раніше. Налаштування за замовчуванням: 1000 подій у пакеті, інтервал скидання (flush) — 5 секунд. Пакет збирається у виклик INSERT INTO click_events через PrepareBatch до ClickHouse і фіксується як одне додавання на стороні сервера. У разі успіху записувач позначає зміщення (offsets) відповідних записів Kafka як зафіксовані; у разі помилки нічого не фіксується, і споживач знову вибирає дані з останнього успішного зміщення під час наступного опитування.

Контракт «зміщення після скидання» є гарантією стійкості. Споживач ніколи не каже Redpanda «я обробив цей запис», доки запис не потрапить у ClickHouse як частина успішного пакета. Краш між споживанням та скиданням означає, що група споживачів перебалансується, новий власник знову опитає дані з останнього зафіксованого зміщення, і події будуть оброблені повторно. Повторна обробка є безпечною, оскільки таблиця click_events використовує ReplacingMergeTree з синтетичним ID події як ключем — дублікати вставки схлопуються при злитті.

Погані повідомлення не повторюються. Помилка декодування JSON призводить до негайної фіксації зміщення, щоб споживач не застряг на «отруйному» записі (poison record). Це невелике, але реальне джерело втрати даних; показник становить одиниці подій на день на весь флот, і зачеплені події відображаються в Prometheus-лічильнику споживача decode_error_total.

Компроміс стійкості в цифрах#

Принцип «вистрілив і забув» призводить до втрати деяких подій. Питання в тому, скільки їх і чи має це значення для конкретного випадку використання.

Ми виміряли рівень втрат у продакшені за 90-денне вікно. Число становить приблизно 0.04% надісланих подій — близько чотирьох втрачених кліків на десять тисяч. Розподіл:

  • Перезапуск edge-процесу з буфером у польоті. franz-go буферизує записи на кілька сотень мілісекунд перед скиданням брокеру. Сигнал SIGTERM під час деплою може призвести до втрати того, що в буфері. Скрипт деплою видає команду на чисте завершення, яка очищає буфер з таймаутом у 2 секунди, що покриває більшість випадків, але не всі.
  • Недоступність брокера Redpanda понад вікно повторних спроб продюсера. franz-go повторює спроби при збоях продукції, але бюджет повторів обмежений. Якщо кластер Redpanda в регіоні непрацездатний понад 30 секунд, буфер переповнюється, і нові записи відкидаються на самому краї.
  • Розрив мережі між edge POP та регіональним кластером Redpanda. Той самий ефект, що й вище. Продюсер логує попередження та відкидає події до відновлення зв'язку.

Для навантаження скорочувача URL втрата 0.04% є прийнятною. Кліки — це статистичний сигнал, а не фінансові транзакції. Когортна аналітика, атрибуція конверсій та географічний розподіл добре агрегуються навіть із такою швидкістю пропусків. Випадки використання, які б цього не терпіли — регульовані галузі з вимогами до аудиту, кількість кліків, прив'язана до білінгу — це не те, що обслуговує безпосередньо рівень перенаправлення.

Для робочих просторів, яким потрібна вища стійкість, ми пропонуємо окремий режим audit-log, який записує кожен клік синхронно в Postgres на додачу до шляху fire-and-forget. Синхронний запис додає 3-5ms p95 до перенаправлення, вмикається опціонально. Посібник з експорту в ClickHouse описує структуру audit-log для команд відповідності, яким потрібно звіряти підрахунки.

Стратегія повтору, коли ClickHouse не працює#

Продюсер працює за принципом fire-and-forget, але сторона споживача має реальну історію повторів.

Коли ClickHouse недоступний, виклики flush у записувача зазнають невдачі. Споживач продовжує опитування — цикл опитування franz-go незалежний від циклу скидання записувача — але зміщення не фіксуються, бо скидання не вдалося. Зберігання (retention) Redpanda встановлено на 72 години, що є максимально допустимим часом простою, перш ніж події почнуть застарівати.

Під час реального збою (у нас було три значної тривалості за 18 місяців), послідовність відновлення виглядає так:

  1. ClickHouse повертається в онлайн.
  2. Наступна спроба скидання проходить успішно і фіксує зміщення.
  3. Споживач наздоганяє графік, викачуючи чергу з налаштованою швидкістю пакетів. При пакеті в 1000 подій та скиданні кожні 5 секунд споживач може обробляти близько 200 подій на секунду на репліку; три репліки означають приблизно 36 тисяч подій на хвилину.
  4. Дашборд Grafana для таблиці click_events показує криву наздоганяння — швидкість вставки рядків залишається підвищеною до очищення черги.

Зберігання протягом 72 годин розраховане на те, щоб витримати багатоденну перебудову ClickHouse без втрати даних. Ми ніколи не використовували більше 4 годин у продакшені. Дисковий простір на брокерах Redpanda — це ціна, і вона невелика порівняно з втратою аналітичних даних.

Також можливе відтворення з архіву. Redpanda має багаторівневе зберігання (tiered storage), яке відправляє закриті сегменти в S3-сумісне об'єктне сховище. У нас це налаштовано, але потреби в цьому не виникало — «гаряче» відтворення покриває кожен інцидент, який ми бачили.

Що ще робить споживач#

Прийом кліків — це не лише запис у ClickHouse. Споживач є центральною точкою розсилки для кожної дочірньої системи, яка цікавиться кліками.

  • Диспетчер вебуків. Налаштовані клієнтами вебуки спрацьовують зі споживача, а не з краю. Споживач ставить у чергу завдання вебука для кожного кліка, що відповідає налаштованому фільтру. Повторні спроби, підписання та доставка відбуваються в webhook-dispatcher.
  • Пересилання подій на стороні сервера. Klaviyo, Mixpanel, GA4 Measurement Protocol, Meta CAPI. Споживач зберігає кеш конфігурації для кожного робочого простору і виконує відповідний POST для кожного кліка, який робочий простір налаштував. Пересилання працює за принципом «кращих зусиль» з невеликим повтором у пам'яті; постійні збої потрапляють у таблицю «мертвих повідомлень» (dead-letter table).
  • Живий потік кліків. Перегляд у додатку «спостерігайте за кампанією наживо» підписується на канал Redis pub/sub. Споживач публікує подію мінімальної форми для кожного кліка, що відповідає активній живій сесії. Це єдина частина конвеєра, яка відчувається як синхронна, і вона працює за принципом best-effort — події відкидаються, коли канал перевантажений.
  • Спрацьовування пікселів. Пікселі конверсії (ретаргетинг та офлайн-конверсія) спрацьовують зі споживача на основі конфігурації кожного посилання. Спрацьовування пікселів — це окрема область збоїв; помилки логуються, але не створюють зворотного тиску на записувач ClickHouse.

Все це виконується після фіксації зміщення, але перед наступним опитуванням. Повільний ендпоінт пікселя може сповільнити ефективну пропускну здатність споживача. Таймаут для кожного пересилання (жорстке обмеження в 1 секунду) та ліміт паралельності на пакет (16 у польоті) не дають повільному шляху домінувати.

Чому така форма, а не Kinesis чи черга#

Було оцінено кілька альтернативних форм шин подій, які не були обрані.

SQS або RabbitMQ як черга. Жодна з них не має такої пропускної здатності на брокер, як Redpanda при обсягах подій кліків. SQS виставляє рахунки за запит, що робить високовольтні потоки дорогими; RabbitMQ чинить опір щільним топікам.

AWS Kinesis. Розумно, якби ми базувалися в AWS. Але це не так — Hetzner FRA, Hetzner ASH, OVH SGP. Самостійно хостовані Kafka або Redpanda — правильна форма для розгортання з пріоритетом в ЄС.

Звичайна Kafka. Працює. Ми обрали Redpanda за операційний профіль — один бінарний файл, без Zookeeper, без тюнінгу JVM. Протокол ідентичний, і franz-go не бачить різниці. Самостійне розгортання Elido може замінити Redpanda на Apache Kafka без зміни коду.

Керовані сервіси, як-от Confluent Cloud. Не базуються в ЄС так, як нам потрібно. Рівень перенаправлення потребує затримки шини повідомлень у тому ж регіоні.

Рішення більш детально задокументовано на сторінці архітектури edge-redirect, яка є джерелом істини для вибору конфігурації рівня перенаправлення.

Що б ми зробили інакше наступного разу#

Шаблон fire-and-forget є правильним. Реалізація має шорсткості, про які варто згадати тим, хто копіює дизайн.

Очищення при вимкненні. 2-секундний таймаут очищення franz-go призводив до втрати подій під час деплоїв, коли буфер заповнений. Виправленням є хук SIGTERM, який синхронно скидає буфер перед виходом процесу, з довшим таймаутом і примусовим завершенням, якщо брокер недоступний.

Шлях dead-letter для помилок декодування. Позначати «отруйні» записи як зафіксовані та йти далі — це добре для пропускної здатності, але погано для спостережливості. Майбутня ітерація передбачає запис сирих байтів та помилки декодування в таблицю click_events_decode_failures, щоб команда могла перевірити, що саме там з'являється.

Паралельність пересилання для кожного робочого простору. Сьогодні пересилачі кожного робочого простору ділять глобальний пул споживача. Активний робочий простір з повільним ендпоінтом Mixpanel може «голодувати» інших. Очевидним виправленням є ліміт на кожен робочий простір; ми його ще не побудували.

Жодне з цього не спричинило інцидентів у продакшені. Це речі, які ви записуєте в беклог ADR і поступово виправляєте.

Що ще почитати#

Спробуйте Elido

URL-скорочувач із хостингом у ЄС: власні домени, глибока аналітика, відкритий API. Безкоштовний тариф — без кредитної картки.

Теги
прийом кліків fire-and-forget
події кліків Redpanda
пакетна вставка ClickHouse
конвеєр аналітики скорочувача URL
edge redirect kafka
продюсер franz-go
стійкість подій кліків

Читати далі