Elido
10 мин чтенияИнженерия

Ingestion кликов по принципу «отправил и забыл» с Redpanda

Как краевые POP отправляют события кликов, не блокируя редирект, как воркер click-ingester выполняет пакетную вставку в ClickHouse и чем мы жертвуем ради выигрыша в задержке

Marius Voß
DevRel · edge infra
Схема из пяти шагов, показывающая поток запроса редиректа через edge-redirect в топик Redpanda к воркеру click-ingester и далее в ClickHouse, при этом ответ 301 ответвляется до вызова продюсера

Путь редиректа в сокращателе URL имеет ровно одну задачу: сопоставить слаг с адресом назначения и вернуть 301 за считанные миллисекунды. Все остальное — это бухгалтерия. Аналитика кликов, атрибуция, обогащение геоданными, скоринг фрода, рассылка вебхуков — ничто из этого не должно находиться на пути основного запроса. Бюджет задержки этого не позволяет.

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

Что на самом деле означает «отправил и забыл»#

Обработчик 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 буферизует запись внутри процесса и отправляет ее в фоновой горутине; обратный вызов (callback) отправки вызывается позже, в пуле воркеров, который не владеет горутиной запроса. Если отправка не удалась, 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))
        }
    })
}

Это и есть весь интерфейс. Никакой очереди повторов внутри процесса края, никакого синхронного ожидания подтверждения, никакой записи на диск. Контракт с остальной частью системы прост: отправка по принципу best-effort, логирование сбоев, отсутствие блокировок.

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

Почему мы не выбрали синхронную запись#

Очевидная альтернатива — записывать каждый клик напрямую в ClickHouse с края. Мы рассматривали этот вариант. Мы отвергли его по трем причинам, которые усиливают друг друга.

Задержка (Latency). Round-trip для ClickHouse INSERT из POP во Франкфурте в кластер ClickHouse в том же регионе составляет 3-6ms p50 в спокойной сети и 12-20ms p95 под нагрузкой. Это весь бюджет редиректа. Добавление этого в путь ответа вытолкнуло бы p95 за пределы SLO в 15ms еще до того, как что-то пойдет не так. В посте о стратегии кэширования объясняется, насколько жестким является этот бюджет на практике.

Обратное давление (Backpressure). ClickHouse отлично справляется с вставкой пакетов от 1000 до 10000 строк за один INSERT. Но он плохо переносит вставку отдельных строк в плотных циклах — движок MergeTree создает файл части (part file) на каждую вставку, а фоновый процесс объединяет эти части. Паттерн прямой записи из многорегионального парка краевых узлов создал бы миллионы крошечных частей, и очередь слияния никогда бы не догнала нагрузку. Документация ClickHouse говорит прямо: вставляйте пакетами минимум по 1000 строк, не чаще одного раза в секунду.

Изоляция сбоев. Перезагрузка кластера ClickHouse, сетевой сбой или медленный запрос, блокирующий реплику, напрямую привели бы к ошибкам редиректа. Краевой процесс либо начал бы работать по таймауту (ухудшая 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, публиковать в поток живых кликов внутри приложения.

Записывающее устройство (writer) выполняет пакетную обработку по количеству или по времени, в зависимости от того, что наступит раньше. Значения по умолчанию: 1000 событий на пакет, 5-секундный интервал сброса. Пакет формируется в вызов INSERT INTO click_events PrepareBatch к ClickHouse и фиксируется как одно добавление на стороне сервера. В случае успеха writer помечает офсеты записей Kafka как зафиксированные (committed); в случае неудачи ничего не фиксируется, и потребитель снова запрашивает данные с последнего успешного офсета при следующем опросе.

Контракт «офсет после сброса» (offset-after-flush) является гарантией сохранности данных. Потребитель никогда не говорит Redpanda «я обработал эту запись», пока запись не попадет в ClickHouse как часть успешного пакета. Краш между потреблением и сбросом означает, что группа потребителей перебалансируется, новый владелец начнет опрос с последнего зафиксированного офсета, и события будут обработаны повторно. Повторная обработка безопасна, так как таблица click_events использует ReplacingMergeTree с ключом по синтетическому ID события — дубликаты схлопываются при слиянии.

Плохие сообщения не обрабатываются повторно. Ошибка декодирования JSON сразу помечается как зафиксированная, чтобы потребитель не застрял на «ядовитой записи» (poison record). Это небольшой, но реальный источник потери данных; скорость составляет единичные события в день на весь парк, и затронутые события отображаются в счетчике Prometheus decode_error_total потребителя.

Трейд-офф сохранности в цифрах#

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

Мы измерили уровень потерь в продакшене за 90-дневное окно. Это число составляет примерно 0,04% от отправленных событий — около четырех потерянных кликов на десять тысяч. Разбивка:

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

Для нагрузки сокращателя URL потеря 0,04% приемлема. Клики — это статистический сигнал, а не финансовые транзакции. Когортная аналитика, атрибуция конверсий и географическое распределение отлично агрегируются при таком уровне потерь. Варианты использования, которые не терпят такого — регулируемые отрасли с требованиями аудита, количество кликов, привязанное к биллингу — не обслуживаются напрямую уровнем редиректа.

Для воркспейсов, которым нужна более высокая сохранность, мы предлагаем отдельный режим аудит-лога, который записывает каждый клик синхронно в Postgres в дополнение к пути «отправил и забыл». Синхронная запись добавляет 3-5ms к p95 редиректа, включается опционально. Руководство по экспорту в ClickHouse документирует формат аудит-лога для комплаенс-команд, которым нужно сверять показатели.

Стратегия повтора при отключении ClickHouse#

Продюсер работает по принципу «отправил и забыл», но на стороне потребителя есть настоящая история с повторами (replay).

Когда ClickHouse недоступен, вызовы сброса буфера завершаются ошибкой. Потребитель продолжает опрос — цикл опроса franz-go не зависит от цикла сброса writer — но офсеты не фиксируются, так как сброс не удался. Удержание (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. Потребитель является центральной точкой разветвления (fan-out) для каждой нижележащей системы, которой важны клики.

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

Все это выполняется после фиксации офсета, но до следующего опроса. Медленный эндпоинт пикселя может снизить эффективную пропускную способность потребителя. Таймаут для каждой пересылки (жесткий лимит 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 может заменить ее на Apache Kafka без изменений кода.

Управляемые сервисы вроде Confluent Cloud. Не подходят нам по географии присутствия в ЕС так, как мы хотим. Уровню редиректа нужна задержка шины сообщений в том же регионе.

Это решение более подробно описано на странице архитектуры edge-redirect, которая является источником истины для выбора конфигурации уровня редиректа.

Что бы мы сделали иначе в следующий раз#

Паттерн «отправил и забыл» верен. В реализации есть шероховатости, о которых стоит знать тем, кто копирует дизайн.

Очистка при завершении. 2-секундный таймаут очистки franz-go приводил к потере событий во время деплоев, когда буфер был загружен. Решением является хук SIGTERM, который выполняет синхронный сброс перед выходом процесса с более длинным таймаутом и жестким завершением, если брокер недоступен.

Путь Dead-letter для ошибок декодирования. Помечать «ядовитые записи» как зафиксированные и идти дальше — это нормально для пропускной способности, но плохо для наблюдаемости. Будущая итерация будет записывать сырые байты вместе с ошибкой декодирования в таблицу click_events_decode_failures, чтобы команда могла проверить, что там появляется.

Конкурентность пересылки на воркспейс. Сегодня пересылки каждого воркспейса используют общий пул потребителя. Активный воркспейс с медленным эндпоинтом Mixpanel может заставлять других ждать. Очевидное решение — лимит на воркспейс; мы его еще не построили.

Ничто из этого не привело к инцидентам в продакшене. Это те вещи, которые вы заносите в бэклог ADR и постепенно исправляете.

Рекомендуемое чтение#

Попробуйте Elido

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

Теги
ingestion кликов отправил и забыл
события кликов redpanda
пакетная вставка clickhouse
пайплайн аналитики url-сокращателя
редирект на краю kafka
продюсер franz-go
сохранность событий кликов

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