Модуль: 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), а не назвать любимый инструмент.