Модуль: System Design · Уровень: Senior

TL;DR#

Аналитический пайплайн принимает миллиарды событий в сутки от продуктовых SDK/бэкендов, буферизует их в Kafka, обрабатывает потоково (Flink/Spark Structured Streaming) и пакетно, складывает в колоночное OLAP-хранилище (ClickHouse/Druid/BigQuery) и обслуживает дашборды и ad-hoc аналитику. Ключевые оси решений: stream vs batch (latency vs полнота/точность), lambda vs kappa (две кодовые базы vs reprocess из лога), exactly-once vs at-least-once + дедупликация, raw vs pre-aggregated (rollups). Главная единица параллелизма — партиции Kafka; главные узкие места — skew/hot partition, конкуренция query vs ingest в OLAP и стоимость хранения/retention.

Требования#

Functional

  • Приём событий разных типов (page_view, click, purchase, custom) с произвольными атрибутами.
  • Near-real-time метрики (DAU/MAU, воронки, retention) с задержкой секунды–минуты.
  • Точные пакетные пересчёты (биллинг, отчётность) с гарантией полноты.
  • Произвольные ad-hoc запросы аналитиков по сырым/полусырым данным.
  • Возможность переиграть (reprocess) исторические данные при изменении логики обработки.
  • Эволюция схемы событий без простоя.

Non-functional

  • Throughput: устойчиво принимать пиковые ~1–2M events/s.
  • Latency: stream-метрики p99 < 10 s end-to-end; batch — часы.
  • Durability: 0 потерь принятых событий (ack только после persist в Kafka).
  • Availability: ingestion 99.99% (приём важнее обработки — лучше отложить обработку, чем потерять событие).
  • Доступность данных: stream-слой может терять точность, batch-слой — источник истины.
  • Стоимость под контролем (это часто доминирующий критерий: hot/cold tiers, retention, rollups).
  • Идемпотентность: повторная доставка не искажает агрегаты.

Оценки нагрузки#

Возьмём крупный продукт: 100M DAU, в среднем 100 событий на пользователя в сутки.

  • Events/day: 100M × 100 = 10 млрд событий/сутки (10^10).
  • Средний events/s: 10^10 / 86400 ≈ 115k events/s.
  • Пик (×8 от среднего, дневной профиль + всплески): ≈ ~1M events/s, проектируем headroom до 2M.
  • Средний размер события: сырой JSON ~1 KB; после нормализации/упаковки в Avro ~300–400 B.

Throughput Kafka

  • Сырой входящий поток: 10^10 × 1 KB = 10 TB/сутки до сжатия.
  • Со сжатием (lz4/zstd, ~4–5x на JSON-подобных данных): ~2–2.5 TB/сутки на диск.
  • С репликацией RF=3: ~6–7.5 TB/сутки реального диска в кластере.
  • Bandwidth на пике: 1M ev/s × 1 KB = ~1 GB/s входящего трафика (× RF на репликацию = ~3 GB/s межброкерного).
  • Партиции: при целевых ~50 MB/s на партицию (consume) и потоке ~1 GB/s → нужно порядка 200–500 партиций на основной топик (плюс запас под consumer-параллелизм Flink).

Storage

  • Raw за год (Kafka retention обычно 3–7 дней; «raw» долгоживущий хранится в объектном сторадже/data lake, Parquet+zstd): 10 TB/сутки × 365 ≈ 3.65 PB/год до сжатия; в Parquet+zstd ~0.7–1 PB/год.
  • Aggregated за год (rollups по минуте/часу/дню, размерности: гео, платформа, версия и т.п.): типично 1–3 порядка меньше raw → единицы–десятки TB/год, что и обслуживает 95% дашбордовых запросов.
  • Kafka retention: 7 дней × 2.5 TB/сутки × RF3 ≈ ~52 TB на «горячий» лог (буфер для reprocess и медленных консьюмеров).

Вывод: raw нужен дёшево и в data lake; OLAP держит агрегаты + ограниченное окно raw для drill-down.

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

                                        ┌─────────────────────── Batch layer ───────────────────────┐
                                        │                                                            │
 ┌──────────┐   HTTPS/gRPC   ┌────────────────┐   ┌─────────┐   ┌──────────────┐   ┌──────────────┐ │
 │ Producers │ ─────────────▶ │  Collector /   │──▶│  Kafka  │──▶│ Spark / batch │─▶│  Data Lake    │ │
 │ SDK/web/  │   batched      │  Gateway       │   │ (topics │   │ (hourly/daily │   │ (S3/GCS,      │ │
 │ mobile/   │                │  - auth        │   │ partit. │   │  ETL, recompute)  │  Parquet)     │ │
 │ backend   │                │  - validate    │   │ + Schema│   └──────┬───────┘   └──────┬───────┘ │
 └──────────┘                 │  - enrich      │   │ Registry│          │                  │         │
       ▲                      │  - rate-limit  │   │  Avro)  │          │ batch views      │ raw     │
       │ sampling/            │  - buffer/ack  │   └────┬────┘          ▼                  ▼         │
       │ config               └────────────────┘        │       ┌──────────────────────────────────┘
       │                                                 │       │
       │                                          ┌──────▼──────┐│  ┌─────────────────────┐
       │                                          │   Stream     ││ │   OLAP Store         │   ┌──────────┐
       └──────────────────────────────────────── │  processing  │┼▶│ ClickHouse / Druid / │──▶│Dashboards│
                                                  │ (Flink)      ││ │ BigQuery (columnar)  │   │ /BI/ API │
                                                  │ window/agg   ││ │ rollups + raw window │   └──────────┘
                                                  │ dedup/EOS    ││ └─────────────────────┘        ▲
                                                  └──────────────┘│         ▲                       │
                                                   Speed layer ───┘         └── ad-hoc queries ─────┘

Компоненты

  • Producers / SDK: клиентские и серверные SDK. Локальная буферизация и батчинг (снижает RPS), retry с backoff, идемпотентные ключи (event_id/UUID). Поддержка серверного семплинга через конфиг (для дешёвого high-volume трафика).
  • Collector / Gateway (stateless, на Go): терминирует HTTPS/gRPC, аутентификация (API key/HMAC), валидация против схемы, обогащение (geo по IP, user-agent parsing, server timestamp), rate limiting, и — критично — подтверждает приём только после успешной записи в Kafka (durability boundary). Горизонтально масштабируется за L7 LB.
  • Kafka: durable буфер и единый журнал. Разделяет ingest и обработку (backpressure isolation). Schema Registry + Avro для контролируемой эволюции схемы. Топики по доменам событий; партиционирование по ключу (см. ниже).
  • Stream processing (Flink): speed layer. Stateful windowing, агрегации, дедупликация, обогащение из side-inputs, exactly-once в OLAP/Kafka через checkpoints. Низкая задержка, приблизительная полнота (late events).
  • Batch (Spark): пересчёт по полным данным из data lake, тяжёлые джойны, backfill, source of truth для отчётности. Запускается по расписанию (hourly/daily).
  • Data Lake (S3/GCS, Parquet): дешёвое долговременное хранение raw для reprocess и ad-hoc через Trino/Athena/Spark.
  • OLAP store (ClickHouse/Druid/BigQuery): колоночное хранилище для дашбордов; держит rollups (быстрые запросы) + скользящее окно raw (drill-down).
  • Dashboards/BI/API: Grafana/Superset/собственный API поверх OLAP.

Ключевые решения и trade-offs#

Ingestion — Kafka

  • Партиционирование: ключ = user_id/session_id даёт упорядоченность per-user и стабильную дедупликацию, но создаёт skew при «китах» (боты, нагруженные tenant’ы). Альтернатива — random/round-robin: идеальный баланс, но теряем per-key порядок (тогда дедуп и упорядочивание решаются ниже по стеку). Компромисс: композитный ключ (tenant_id + hash bucket) или sticky-партиционирование с детектором горячих ключей.
  • Schema Registry + Avro: компактнее JSON, строгая схема, контроль совместимости (BACKWARD/FORWARD). Продюсер шлёт schema_id, не саму схему. Эволюция без простоя: добавление полей с default’ами — backward-compatible. Альтернатива Protobuf (лучше для gRPC), JSON Schema (читаемо, но дорого по объёму).
  • Backpressure: Kafka = буфер, поглощающий всплески; обработчики читают в своём темпе. Если консьюмеры отстают — растёт lag, но данные не теряются (в пределах retention). На gateway — rate limit и load shedding (сначала режем семплируемый трафик, биллинговые события не дропаем). Acks=all + min.insync.replicas=2 для durability.

Stream (Flink) vs Batch (Spark): latency vs completeness

  • Stream: задержка секунды, но к моменту закрытия окна часть событий ещё «в пути» (late/out-of-order) → приблизительный результат.
  • Batch: видит все данные за период → точный результат, но задержка часы.
  • На практике stream даёт оперативную картину, batch — авторитетную; дашборд показывает stream-значение, перетираемое batch-значением (reconciliation).

Lambda vs Kappa

  • Lambda: batch layer (точность, полнота) + speed layer (свежесть) + serving layer (мёрж). Минус — дублирование логики в двух стеках (Spark + Flink), дрейф между ними, двойная стоимость поддержки.
  • Kappa: только stream. Пересчёт = переигрывание лога с нужного offset через новый job. Одна кодовая база. Требует: достаточный retention в Kafka (или tiered storage в lake), детерминированной обработки, версионирования job’ов. Минус — тяжёлый backfill за большие периоды (надо прогнать весь лог через стрим). Современный тренд — kappa, если retention/стоимость reprocess приемлемы; lambda оправдан, когда batch-вычисления принципиально дешевле/точнее (сложные джойны по годовым данным).

Exactly-once, watermarks, late/out-of-order, windowing

  • Exactly-once (EOS) в Flink: барьерный чекпойнтинг (Chandy–Lamport) + транзакционный sink (Kafka transactions, идемпотентная запись в ClickHouse через дедуп-ключ). По сути «effectively-once»: at-least-once доставка + идемпотентность стейта.
  • Watermarks: оценка «времени события, до которого мы уже всё видели». Окно закрывается, когда watermark проходит его конец. Allowed lateness — грейс-период на доопоздавшие; что позже — в side output (dead-letter) или корректируется batch’ем.
  • Out-of-order: event-time обработка (по timestamp события), не processing-time. Bounded-out-of-orderness watermark с дельтой (например, 30 s).
  • Windowing: tumbling (непересекающиеся, для агрегатов по интервалам), sliding (скользящие, дороже по стейту), session (по неактивности, для сессий).

OLAP columnar store

  • ClickHouse: предельная скорость на агрегациях, MergeTree, материализованные представления для rollups, дешёвый self-hosted; слабее на частых апдейтах/джойнах высокой кардинальности, eventual consistency реплик.
  • Druid: заточен под real-time ingest + low-latency timeseries дашборды, встроенный rollup на инжесте; сложнее в эксплуатации.
  • BigQuery: serverless, бесконечный масштаб, separation storage/compute; pay-per-query (риск дорогих сканов), вендор-лок.
  • Общий принцип: колоночное хранение + сжатие + партиционирование по времени + сортировка по частым предикатам.

Pre-aggregation / rollups vs raw

  • Rollups (агрегаты по минуте/часу + размерности) уменьшают объём на 1–3 порядка и дают суб-секундные дашборды. Минус — фиксируют набор размерностей: нельзя задать вопрос, под который нет rollup’а; высокая кардинальность (user_id) плохо роллапится.
  • Raw нужен для drill-down и непредвиденных вопросов, но дорог и медленнее. Гибрид: rollups в OLAP для дашбордов + raw в lake (и короткое окно raw в OLAP) для ad-hoc.

Идемпотентность и дедупликация

  • Каждое событие несёт уникальный event_id. Источники дублей: retry продюсера, at-least-once Kafka, переигрывание после сбоя.
  • Дедуп в стриме: stateful по (event_id) с TTL-окном (например, 24 ч) — окно ограничивает размер стейта, но дубли за пределами окна проскакивают.
  • Дедуп в OLAP: ReplacingMergeTree (ClickHouse) по ключу, дедуп при мёрже (eventual). Для точных счётчиков — батч-дедуп по полному датасету в lake.
  • Для approximate distinct (uniq users) — HyperLogLog: компактно и мёржится, ценой ~1–2% погрешности.

Масштабирование и узкие места#

  • Партиции Kafka — единица параллелизма: число консьюмеров в группе (≈ слотов Flink) не больше числа партиций. Под throughput и параллелизм закладывают сотни партиций; переразбиение «на лету» дорого (меняет распределение ключей и порядок) — лучше с запасом изначально.
  • Hot partition / skew: один «кит» перегружает партицию → лаг, неравномерная утилизация, отставание стейта в Flink. Лечение: композитный/бакетированный ключ, salting горячих ключей, отдельный топик/пул для высоконагруженных tenant’ов, детектор горячих ключей на gateway.
  • OLAP query vs ingest: непрерывный высокочастотный ingest конкурирует с тяжёлыми аналитическими запросами за CPU/IO/merge. Решения: разделение нод на ingest и query (или storage/compute separation как в BigQuery/Snowflake), батчинг вставок (вставлять крупными блоками, не по одному событию — иначе деградация MergeTree), async materialized views, отдельные реплики для read-heavy дашбордов.
  • Stateful stream scaling: стейт Flink (окна, дедуп) растёт с кардинальностью и lateness. RocksDB state backend, incremental checkpoints, TTL на стейт. Ребалансировка стейта при rescale — через savepoint.
  • Retention / cost: трёхуровневое хранение — hot (OLAP, дни–недели, дорого/быстро), warm (lake Parquet, месяцы–годы, дёшево), cold/archive (Glacier). Агрессивные rollups + TTL на raw в OLAP. Tiered storage в Kafka переносит старые сегменты в объектный сторадж, удешевляя длинный retention для kappa-reprocess.
  • Скейл gateway: stateless, линейно за LB; узкое место — сериализация/валидация (CPU). Батчинг от SDK снижает RPS и накладные расходы на соединение.

Вопросы на собеседовании#

В: Чем отличаются lambda и kappa архитектуры и когда выбрать каждую? О: Lambda держит отдельные batch (точность/полнота) и speed (свежесть) слои с мёржем в serving; цена — дублирование логики в двух стеках и их дрейф. Kappa — только стрим, пересчёт через переигрывание лога. Kappa выбираем при достаточном retention/tiered storage и детерминированной обработке (меньше кода, один источник истины); lambda — когда batch принципиально дешевле или точнее на больших окнах (тяжёлые годовые джойны), а reprocess всего лога через стрим неподъёмен.

В: Как добиться exactly-once при записи из Flink в OLAP? О: Строго exactly-once в распределёнке недостижим; делаем «effectively-once» = at-least-once доставка + идемпотентность. В Flink — барьерный чекпойнтинг для консистентного стейта плюс транзакционный или идемпотентный sink: Kafka transactions, либо запись в ClickHouse с дедуп-ключом (ReplacingMergeTree) / upsert по event_id. Сбой и повтор не меняют результат.

В: Что такое watermark и как обрабатывать опоздавшие события? О: Watermark — оценка прогресса event-time: «событий с временем меньше W мы больше не ждём». По нему закрываются окна. Bounded-out-of-orderness задаёт допустимую дельту опоздания. Allowed lateness даёт грейс на доопоздавшие (окно пересчитывается); ещё более поздние идут в side output / dead-letter или корректируются batch-слоем. Обработка строго по event-time, не processing-time.

В: Как выбрать ключ партиционирования в Kafka и чем грозит неудачный выбор? О: Ключ по user_id/session_id даёт per-key порядок и стабильную дедупликацию, но при «китах» создаёт hot partition: лаг, перекос утилизации, отставание стейта. Random балансирует, но теряет порядок. Компромисс — композитный ключ (tenant + hash-bucket), salting горячих ключей, выделенный топик для тяжёлых tenant’ов. Число партиций задаём с запасом — переразбиение меняет распределение и дорого.

В: Stream даёт 1.0M уникальных пользователей, batch за тот же период — 1.02M. Это баг? О: Не обязательно. Stream закрывает окна по watermark и не видит опоздавшие/out-of-order события, плюс может использовать approximate distinct (HyperLogLog, ~1–2% погрешности). Batch считает по полным данным и точно. Расхождение в пределах единиц процентов ожидаемо; serving-слой перетирает stream-значение batch-значением (reconciliation). Баг — если расхождение растёт во времени или превышает ожидаемую погрешность.

В: Зачем pre-aggregation/rollups, и какие у них ограничения? О: Rollups сжимают данные на 1–3 порядка и дают суб-секундные дашборды. Ограничение — они фиксируют набор размерностей: вопрос без заранее заготовленного rollup’а не ответить, а высококардинальные поля (user_id) роллапятся плохо. Поэтому гибрид: rollups для дашбордов + raw в lake (и короткое окно raw в OLAP) для drill-down и непредвиденной аналитики.

В: Как гарантировать, что принятое событие не потеряется? О: Durability boundary — на gateway: ack клиенту только после успешной записи в Kafka с acks=all и min.insync.replicas=2 (выдерживает падение одного брокера). До Kafka — клиентский retry с идемпотентным event_id. После Kafka обработчики отстают, но не теряют данные в пределах retention; при сбое стрима — restart с чекпойнта. Раннее долговременное хранение raw в lake страхует от ошибок логики обработки (reprocess).

В: Откуда берутся дубли и как с ними бороться на каждом уровне? О: Дубли — от retry продюсера, at-least-once Kafka и переигрывания после сбоев. Уровни: продюсер ставит уникальный event_id; стрим дедуплицирует по event_id в stateful-окне с TTL (ограничивает стейт, но пропускает дубли вне окна); OLAP дедуплицирует через ReplacingMergeTree/upsert (eventual); batch делает точный дедуп по полному датасету. Для approximate-метрик — HLL, устойчивый к дублям по природе.

В: Что делать, когда тяжёлые аналитические запросы тормозят ingest в OLAP? О: Разделить ответственность: отдельные ноды/реплики под ingest и под query, либо хранилище с separation of storage/compute. Вставлять крупными батчами, а не по событию (иначе деградация MergeTree от мелких партов). Async materialized views для rollup’ов, отдельные read-реплики под дашборды, кэширование частых запросов и партиционирование/сортировка под предикаты.

На что копают на senior+#

  • Durability boundary — понимаешь ли ты, в какой именно момент событие считается «принятым» (ack после Kafka), и что acks/ISR значат на практике при падении брокера.
  • Event-time vs processing-time и осознанная работа с watermarks/lateness — не «просто окна», а компромисс корректность/задержка и куда деваются опоздавшие.
  • Честность про exactly-once: кандидат, говорящий «exactly-once из коробки», слабее того, кто объясняет effectively-once = at-least-once + идемпотентность и где именно достигается идемпотентность.
  • Skew как первоклассная проблема: умеешь ли заранее проектировать против hot partition, а не лечить постфактум.
  • Стоимость как design driver: tiered storage, rollups vs raw, retention, separation compute/storage — на больших объёмах архитектуру диктует бюджет, и senior это учитывает явно с цифрами.
  • Reprocess/backfill как сценарий, а не послемыслие: можно ли переиграть историю при смене логики, чего это стоит в kappa vs lambda, версионирование джобов.
  • Reconciliation stream и batch: как два слоя сходятся в одном дашборде без двойного счёта и как объясняются расхождения бизнесу.
  • Schema evolution: совместимость Avro (backward/forward), как катить изменение схемы без простоя продюсеров и консьюмеров.
  • Способность обосновать выбор OLAP под конкретный профиль нагрузки (real-time ingest vs ad-hoc vs cost), а не назвать любимый инструмент.