Когда к нам пришла команда AdPulse, у них была амбициозная идея: создать DSP-платформу (Demand-Side Platform) нового поколения для programmatic-рекламы. Платформа, которая позволит рекламодателям закупать трафик через RTB-аукционы с прозрачной аналитикой в реальном времени. Не очередной интерфейс над Google Ads, а полноценная инфраструктура для работы с рекламными биржами напрямую.

Основная сложность: RTB (Real-Time Bidding) — это мир, где каждая миллисекунда на счету. Биддер должен принять решение об участии в аукционе, рассчитать ставку и ответить за менее чем 20 миллисекунд. При потоке в миллионы запросов в секунду. Это не типичный стартап с формой регистрации и дашбордом — это инженерный вызов, который требует нестандартных решений.

Вызов: что такое RTB и почему это сложно

RTB — протокол аукциона в реальном времени. Когда пользователь загружает веб-страницу с рекламным слотом, рекламная биржа (SSP) отправляет запрос на аукцион всем подключённым DSP-платформам. У каждой DSP есть 50-100 миллисекунд на ответ — из которых 30-80ms занимает сеть. На обработку остаётся 10-20ms.

За эти 20 миллисекунд биддер должен:

  1. Распарсить OpenRTB-запрос (данные о пользователе, сайте, слоте)
  2. Проверить frequency capping (не показывали ли рекламу этому пользователю слишком часто)
  3. Применить таргетинг (география, устройство, интересы)
  4. Рассчитать оптимальную ставку на основе бюджета и исторических данных
  5. Сформировать и отправить ответ

Всё это — при нагрузке 2,000,000+ запросов в секунду. Если биддер не успевает ответить за отведённое время — ставка не учитывается, и деньги рекламодателя не расходуются эффективно.

В RTB нет «медленного, но стабильного». Либо ты быстрый, либо тебя нет в аукционе.

Исследование и проектирование: недели 1-2

Первые две недели мы полностью посвятили исследованию домена RTB и проектированию архитектуры. Мы изучили спецификации OpenRTB 2.6, проанализировали архитектуры существующих DSP (AppNexus, The Trade Desk, MediaMath) и определили ключевые требования:

Эти требования определили выбор стека. Ни один из традиционных стартап-стеков (Node.js + PostgreSQL, Python + Django) не способен обеспечить такую производительность без серьёзного overengineering.

Архитектура: Go + Kafka + ClickHouse + Redis

Мы разделили систему на четыре основных компонента:

1. Биддер (Go)

Ядро системы. Написан на Go для минимальной latency. Каждый инстанс обрабатывает 50-100K QPS. Горизонтальное масштабирование: добавляем инстансы по мере роста трафика.

Ключевые оптимизации:

// Упрощённая структура обработки bid-запроса
func (s *Server) handleBidRequest(w http.ResponseWriter, r *http.Request) {
    // Парсинг за ~1ms
    req := s.pool.Get().(*BidRequest)
    defer s.pool.Put(req)
    req.Parse(r.Body)

    // Frequency capping: Redis lookup за ~0.5ms
    if s.freqCapper.IsLimited(req.UserID, req.CampaignID) {
        w.WriteHeader(204) // No bid
        return
    }

    // Таргетинг + расчёт ставки: ~2ms
    bid := s.bidCalculator.Calculate(req)
    if bid == nil {
        w.WriteHeader(204)
        return
    }

    // Ответ за ~0.5ms
    resp := s.formatResponse(bid)
    w.Header().Set("Content-Type", "application/json")
    w.Write(resp)

    // Асинхронная отправка события в Kafka
    s.eventProducer.Send(bid.Event())
}

2. Потоковая обработка (Kafka)

Каждое событие (bid, win, impression, click) отправляется в Kafka. Это обеспечивает:

Мы используем 3 топика: bids (все ставки), wins (выигранные аукционы), events (показы, клики, конверсии). Consumer-группы читают из этих топиков и записывают в ClickHouse.

3. Аналитика (ClickHouse)

ClickHouse — колоночная OLAP-база, оптимизированная для аналитических запросов. Мы храним в ней все события с детализацией до отдельного показа.

Структура таблицы событий:

CREATE TABLE events (
    event_time DateTime,
    event_type Enum8('bid'=1, 'win'=2, 'impression'=3, 'click'=4),
    campaign_id UInt32,
    creative_id UInt32,
    user_id String,
    geo_country LowCardinality(String),
    device_type Enum8('desktop'=1, 'mobile'=2, 'tablet'=3),
    bid_price Float32,
    win_price Float32,
    domain String
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (campaign_id, event_time);

Запрос «покажи расход по кампаниям за последний час» выполняется за 50-200ms даже при 500M+ строк в таблице. На PostgreSQL это заняло бы минуты.

4. Frequency Capping (Redis)

Для frequency capping мы используем Redis с TTL-ключами. Схема простая, но эффективная:

// Ключ: fc:{user_id}:{campaign_id}
// Значение: количество показов
// TTL: 24 часа (или пользовательский период)

INCR fc:user123:campaign456
EXPIRE fc:user123:campaign456 86400

// При каждом запросе биддер проверяет:
GET fc:user123:campaign456
// Если > лимита → пропускаем аукцион

Redis-кластер из 3 нод обрабатывает 1M+ операций в секунду с latency <1ms. Этого достаточно для всех проверок частотности.

Дашборд: React + real-time

Фронтенд дашборда написан на React с Next.js. Ключевая особенность — real-time обновление метрик через Server-Sent Events (SSE). Бэкенд дашборда (Node.js) каждые 5 секунд делает запрос к ClickHouse и отправляет обновлённые данные на клиент.

Основные виджеты:

Результаты

За 8 недель мы создали рабочую DSP-платформу со следующими показателями:

Метрика Цель Результат
Latency биддера (p99) <20ms 12ms
Пропускная способность 2M QPS 2.4M QPS
Frequency capping latency <1ms 0.3ms
Дашборд latency <200ms 80ms
Бюджетная точность 99.9% 99.95%
Время разработки 10 недель 8 недель

Уроки, которые мы извлекли

1. Профилирование — с первого дня

Мы использовали pprof (встроенный профайлер Go) с самого начала. Это позволило обнаружить и устранить узкие места до того, как они стали проблемой. Главная находка: парсинг JSON через encoding/json тратил 40% CPU. Переход на easyjson снизил это до 8%.

2. Батчинг решает

Индивидуальная отправка событий в Kafka давала 5K msg/s. Переход на батч по 1000 сообщений увеличил пропускную способность до 500K msg/s. Тот же принцип — батчить записи в ClickHouse.

3. Не оптимизируйте рано, но проектируйте для оптимизации

Мы начали с простого монолитного биддера. Когда нагрузка выросла, мы без проблем выделили frequency capping в отдельный сервис и добавили инстансы биддера — потому что архитектура это позволяла.

4. Мониторинг — это не опция

Prometheus + Grafana с первого дня. Дашборды для latency, throughput, error rate, memory usage. Алерт при p99 > 15ms — это ранний сигнал, что система перегружена. Без мониторинга мы бы узнали о проблеме только от клиентов.

5. Load testing — до продакшна

Мы написали кастомный load generator на Go, который имитирует реальный RTB-трафик: разные размеры запросов, географии, устройства. Это позволило найти проблемы, которые не проявлялись при синтетических нагрузках.

Инфраструктура и деплой

Платформа развёрнута на dedicated-серверах (Hetzner) — для RTB облачные провайдеры слишком дороги из-за объёмов трафика. Конфигурация:

CI/CD через GitHub Actions: push в main → тесты → сборка Docker-образа → деплой на staging → smoke tests → деплой в production с blue-green переключением.

Подробнее о проекте — на странице кейса AdPulse. Если вам нужна подобная система — мы в Complex Solutions специализируемся именно на таких проектах.