Модуль 11. MVP-проект: «Аналитика OrderFlow»
Цель модуля: собрать всё, что вы построили руками в курсе, в один работающий и защищаемый стенд. Это развитие брокерского MVP: Order Service остаётся на PostgreSQL (источник правды), аналитика переезжает в ClickHouse, между ними — честный CDC-пайплайн. Шесть спринтов, у каждого — проверяемый checkpoint: не «сделал», а «проверил аварией».
Перед началом: Модули 1–9 (Модуль 10 RAG — не требуется). Инструменты: docker-compose, Go 1.22+, pgx, clickhouse-go, Debezium, Patroni, PgBouncer, Grafana, pgbench, k6. Трудоёмкость: ~16–20 ч (6 спринтов по 2.5–3 ч + защита).
🔥 Инцидент: генеральная репетиция защиты
Стенд OrderFlow собран: PostgreSQL под Patroni, Debezium стримит заказы в Kafka, синк наполняет ClickHouse, Grafana рисует выручку. Шесть спринтов формально закрыты, дашборд отвечает за 180 мс, до защиты — один вечер. Решили прогнать сбой-сценарии — не для галочки, а чтобы понять, что стенд переживёт на защите. Репетиция показала, что закрыто было не всё.
Первый прогон — kill -9 мастера PG. Patroni поднял нового мастера за 12
секунд, приложение пережило переключение, но CDC-слот на новом мастере оказался
пуст: события, закоммиченные в те секунды, до Kafka не доехали — в отчёте за
день выручка просела на 380 000 ₽. Второй прогон — перезапуск синкера посреди
батча: та же выручка посчиталась дважды, за среду вместо 2 100 000 ₽ в отчёте
появилось 4 200 000 ₽. При этом дашборд «работал»: ошибок нет, алертов нет,
панели бодро рисовали неверные числа.
Этот модуль — шесть спринтов MVP и главное правило: checkpoint — это эксперимент над отказом, а не галочка в чек-листе. Здесь стенд собирают по частям — и каждую проверяют аварией: потеря события на failover, дубль при ретрае, отставание пайплайна под нагрузкой — до того, как это сделает комиссия.
📋 Карта проекта: шесть спринтов
| Спринт | Что делаем | Проверка (checkpoint) |
|---|---|---|
| S1 | PostgreSQL: схема заказов/платежей, pg_stat_statements, pg_partman по events | EXPLAIN показывает партиционный prune; бэкап/restore pgBackRest отработали |
| S2 | HA: Patroni ×3 + PgBouncer + pg_failover_slots | kill -9 мастера → приложение не упало, CDC-слот жив |
| S3 | CDC: Debezium → Kafka (топик orderflow.public.orders) | сообщение в Kafka ≤ 2 сек после коммита |
| S4 | ClickHouse 2×2 (ReplicatedMergeTree + Distributed) + Go-синк с батчами и идемпотентностью | kill синкера → дублей в отчётах нет |
| S5 | Аналитика: MV-агрегаты + Grafana-дашборд (revenue, p99 платежей, топ товаров) | дашборд из CH отвечает < 300ms |
| S6 | Нагрузка: pgbench + k6 → отчёт p99/throughput; намеренный инцидент + разбор | lag < 30 сек при пиковой вставке |
Правило проекта: checkpoint — это эксперимент над отказом, а не галочка. Каждый спринт заканчивается скриншотом Grafana или выводом команды в README.
💻 Спринты в деталях
S1. PostgreSQL-фундамент: схема, статистика, партиции
Что строим. Схема заказов и платежей — нормализованная, под OLTP; поток событий — партиционированный по времени. Миграции — goose (Модуль 8.2).
sqlCREATE TABLE orders ( order_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, tenant_id bigint NOT NULL, status smallint NOT NULL DEFAULT 0, total numeric(18,2) NOT NULL DEFAULT 0, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX idx_orders_tenant_created ON orders (tenant_id, created_at); CREATE TABLE payments ( payment_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, order_id bigint NOT NULL REFERENCES orders, amount numeric(18,2) NOT NULL, pay_latency_ms int NOT NULL, paid_at timestamptz NOT NULL DEFAULT now() ); -- поток событий: range-партиции по дню (pg_partman) CREATE TABLE order_events ( event_id bigint GENERATED ALWAYS AS IDENTITY, tenant_id bigint NOT NULL, order_id bigint NOT NULL, kind text NOT NULL, payload jsonb NOT NULL DEFAULT '{}', created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (event_id, created_at) ) PARTITION BY RANGE (created_at); SELECT partman.create_parent( p_parent_table := 'public.order_events', p_control := 'created_at', p_interval := '1 day' ); UPDATE partman.part_config SET retention = '90 days' WHERE parent_table = 'public.order_events';
pg_stat_statements включаем первым делом (Модуль 3) — это ваши глаза на профиль на протяжении всего проекта.
Checkpoint S1:
sqlEXPLAIN ANALYZE SELECT count(*), avg((payload->>'latency')::int) FROM order_events WHERE created_at >= now() - interval '3 days'; -- → Subplans Removed: N — prune отсёк ненужные партиции
bash# бэкап-рутина из Модуля 8.1: полный бэкап + тест восстановления pgbackrest --stanza=main --type=full backup pgbackrest --stanza=main --delta --type=time \ --target='2026-09-15 12:00:00+03' --target-action=promote restore
Проверка зачтена, когда prune виден в EXPLAIN и restore доведён до поднятого кластера с проверкой строк.
S2. HA: Patroni ×3 + PgBouncer + pg_failover_slots
Что строим. Три узла PG под Patroni (DCS — etcd ×3), перед ними PgBouncer в transaction mode, на репликах — pg_failover_slots: без него CDC-слот умирает вместе с мастером (Модуль 3.2, 6.5).
yaml# patroni.yml (фрагмент, одинаков на трёх узлах, кроме имени и адреса) bootstrap: dcs: postgresql: parameters: wal_level: logical # CDC max_wal_senders: 10 max_replication_slots: 10 hot_standby_feedback: on # дашборды на реплике (Модуль 9.1) postgresql: parameters: shared_preload_libraries: - pg_failover_slots # синхронизация logical-слотов на standby
ini;; pgbouncer.ini (фрагмент) [databases] orderflow = host=patroni-lb port=5432 dbname=orderflow [pgbouncer] pool_mode = transaction max_client_conn = 5000 default_pool_size = 20
Приложение знает один адрес (patroni-lb — VIP/балансер записи) и переживает
переключения на ретраях: pgx с настроенным retry на transient-ошибки и
короткий timeout — вся «магия» отказоустойчивости приложения.
Checkpoint S2:
bashdocker kill -9 patroni-node1 # умирает текущий мастер # приложение: 1–2 ретрая — и продолжает работать # Debezium: переподключается — слот найден на новом мастере # на новом мастере: psql -c "SELECT slot_name, active FROM pg_replication_slots;" -- orderflow_slot | f → t (после переподключения Debezium)
Зачтено, когда ни один запрос пользователя не упал с 500, а слот пережил промоцию. Запишите в README, сколько миллисекунд занял failover.
S3. CDC: Debezium → Kafka
Что строим. Kafka + Kafka Connect с Debezium PostgreSQL connector
(pgoutput, Модуль 6.2). Топики — orderflow.public.orders,
orderflow.public.payments; ключ сообщения — PK таблицы, порядок по сущности
сохранён.
json{ "name": "orderflow-connector", "config": { "connector.class": "io.debezium.connector.postgresql.PostgresConnector", "database.hostname": "patroni-lb", "database.port": "5432", "database.user": "debezium", "database.dbname": "orderflow", "slot.name": "orderflow_slot", "publication.autocreate.mode": "filtered", "table.include.list": "public.orders,public.payments,public.order_events", "topic.prefix": "orderflow", "tombstones.on.delete": "false", "decimal.handling.mode": "string", "time.precision.mode": "adaptive_time_microseconds", "heartbeat.interval.ms": "5000", "snapshot.mode": "initial" } }
Отличия от черновика, которые стоят инцидента: plugin.name не задаём —
в Debezium 2.x+ для PostgreSQL поддерживается только pgoutput, и это
значение по умолчанию (свойство осталось, но указывать его незачем);
tombstones.on.delete=false — как в Модуле 6.2, иначе в поток
полетят пустые надгробия, которые Go-приёмник обязан фильтровать;
decimal.handling.mode=string и time.precision.mode=adaptive_time_microseconds
— иначе numeric приедет base64-числом, а timestamptz — epoch-числом, и
синк молча напишет в витрину 1970 год.
heartbeat.interval.ms — не косметика: без heartbeat слот на тихой БД не
двигает restart_lsn, и WAL копится (Модуль 12, вопрос про это обязательно
будет).
Checkpoint S3. Метрика «коммит → Kafka»: время ts_ms из сообщения
Debezium против времени записи в Kafka, гистограмма в Grafana. Зачтено,
когда p99 ≤ 2 сек при обычной нагрузке — и вы знаете, где цифра живёт.
(Оба значения — часы разных машин: NTP обязателен, иначе гистограмма врёт.)
S4. ClickHouse 2×2 + Go-синк
Что строим. Кластер 2 шарда × 2 реплики + Keeper ×3 (Модуль 5); Go-синк из Kafka в CH — батчи, версии, идемпотентность (Модуль 6.6).
sqlCREATE TABLE analytics.events_local ON CLUSTER orderflow ( version UInt64, -- source.lsn: версия строки, dedup + порядок tenant_id UInt32, order_id UInt64, event_time DateTime64(3), -- из updated_at PG, НЕ now() на стороне синка kind LowCardinality(String), amount Decimal(18, 2) DEFAULT 0, pay_latency_ms UInt32 DEFAULT 0, -- обогащение в синке: нужно для p99 в S5 _deleted UInt8 DEFAULT 0 -- DELETE из PG = новая версия с флагом ) ENGINE = ReplicatedReplacingMergeTree(version) PARTITION BY toYYYYMM(event_time) ORDER BY (tenant_id, order_id, event_time); CREATE TABLE analytics.events ON CLUSTER orderflow AS analytics.events_local ENGINE = Distributed(orderflow, analytics, events_local, cityHash64(order_id));
Три решения в этом DDL, без которых чекпойнт S4 не сойдётся:
ReplicatedReplacingMergeTree(version), а неReplicatedMergeTree. Обычный MergeTree ничего не схлопывает: повтор батча после kill -9 останется в данных навсегда, и «дублей нет» окажется неправдой.- Дедупликация Replacing работает по ключу сортировки — по тройке
(tenant_id, order_id, event_time). Отсюда требование кevent_time: брать время из события в PG, а неtime.Now()в синке, иначе при повторе ключ «поедет» и версия не погасит дубль. version=source.lsn(Модуль 6.4): монотонная позиция в WAL, а не timestamp — две транзакции в одну миллисекунду иначе недетерминированы.
Скелет синка — три инварианта: батч, вставка до коммита оффсетов, версия на
каждой строке (полная реализация с метриками, DLQ и тестами —
boilerplate/internal/sink):
goconst ( maxBatchRows = 5_000 // 4.4/5.4: части плодятся от частоты вставок, не от объёма flushEvery = 500 * time.Millisecond ) func (s *Sink) Run(ctx context.Context) error { pending := make(map[RowKey]Row, maxBatchRows) // RowKey = (tenant, order, event_time) var inflight []kafka.Message ticker := time.NewTicker(flushEvery) defer ticker.Stop() for { m, err := s.reader.FetchMessage(ctx) // читаем, НЕ коммитим if err != nil { return s.shutdown(ctx, pending, inflight) // ctx отменён — дожимаем хвост } row, perr := s.parse(ctx, m) // конверт Debezium + обогащение pay_latency_ms if perr != nil { // flush → DLQ → commit: коммит оффсета poison-сообщения БЕЗ флаша // потерял бы уже накопленные строки (разбор — Модуль 6.2) if err := s.poison(ctx, m, perr, pending, inflight); err != nil { return err } continue } if prev, ok := pending[row.Key]; !ok || row.Version >= prev.Version { pending[row.Key] = row // внутри батча держим только последнюю версию } inflight = append(inflight, m) if len(pending) >= maxBatchRows || ticked(ticker) { // ИНВАРИАНТ: сначала Send (эффект), потом CommitMessages (отметка) if err := s.flush(ctx, pending, inflight); err != nil { s.metrics.FlushErrors.Inc() return err // без коммита оффсетов: Kafka отдаст батч снова } s.metrics.Flushed.Observe(float64(len(pending))) clear(pending) inflight = inflight[:0] } } }
Повтор после падения синкера между вставкой и коммитом — те же строки с теми
же version вставятся ещё раз, но ReplicatedReplacingMergeTree(version)
схлопнёт их при мердже; витринные запросы дожимают дедуп через
FINAL/argMax (Модуль 6.3). Это и есть exactly-once «по факту».
Checkpoint S4:
bashdocker kill -s KILL sink # смерть посреди батча (SIGKILL) docker compose up -d sink
sql-- 1) сырых строк БОЛЬШЕ, чем ключей: повторы приехали, это норма at-least-once SELECT count() AS raw_rows, uniqExact((tenant_id, order_id, event_time)) AS keys FROM analytics.events WHERE kind = 'created'; -- 2) после схлопывания версий — ровно по одной строке на ключ SELECT count() AS final_rows FROM analytics.events FINAL WHERE kind = 'created'; -- final_rows == keys: дублей в актуальном состоянии нет -- 3) сверка с источником правды (Модуль 6.5) SELECT count(*) FROM orders WHERE status = 2; -- в PostgreSQL
Зачтено, когда после серии убийств синкера на живой нагрузке final_rows == keys,
сверка с PG сходится, а лаг возвращается в норму. Первый запрос здесь важнее
второго: он доказывает, что повторы были и были корректно погашены, а не
что «просто ничего не случилось».
S5. Аналитика: MV-агрегаты + Grafana
Что строим. Слой витрин из Модуля 7: MV досчитывает агрегаты при вставке,
дашборд читает компактные состояния, а не сырые миллиарды. Три требования,
которые легко нарушить: движок витрины — Replicated (иначе в 2×2 она не
реплицируется), MV вешается на локальную таблицу и на каждом узле
(ON CLUSTER), а деньги считаются после дедупликации по ключу.
sql-- Слой 1 (схема из Модуля 7.5): сводим версии заказа к одной строке ДО денег CREATE TABLE analytics.orders_1m ON CLUSTER orderflow ( minute DateTime, tenant_id UInt32, order_id UInt64, version SimpleAggregateFunction(max, UInt64), status AggregateFunction(argMax, LowCardinality(String), UInt64), amount SimpleAggregateFunction(max, Decimal(18, 2)), pay_ms SimpleAggregateFunction(max, UInt32), deleted SimpleAggregateFunction(max, UInt8) ) ENGINE = ReplicatedAggregatingMergeTree( '/clickhouse/tables/{shard}/orders_1m', '{replica}') PARTITION BY toYYYYMM(minute) ORDER BY (tenant_id, minute, order_id); -- MV — на events_local, не на Distributed: триггер срабатывает там, куда -- физически лёг блок, то есть на локальной таблице каждого шарда. CREATE MATERIALIZED VIEW analytics.mv_orders_1m ON CLUSTER orderflow TO analytics.orders_1m AS SELECT toStartOfMinute(event_time) AS minute, tenant_id, order_id, max(version) AS version, -- max идемпотентен к повторам вставок argMaxState(kind, version) AS status, max(amount) AS amount, -- max(дубль, дубль) = значение; max(pay_latency_ms) AS pay_ms, -- sum здесь задвоил бы деньги (7.2, ловушка 3) max(_deleted) AS deleted FROM analytics.events_local GROUP BY minute, tenant_id, order_id;
sql-- панель дашборда: выручка и p99 платежей за сутки SELECT minute, tenant_id, count() AS orders, -- по одной строке на заказ sum(amount) AS revenue, quantileTDigest(0.99)(pay_ms) AS pay_p99 FROM ( SELECT minute, tenant_id, order_id, argMaxMerge(status) AS status, -- AggregateFunction → -Merge max(amount) AS amount, -- SimpleAggregateFunction → max() max(pay_ms) AS pay_ms, max(deleted) AS deleted FROM analytics.orders_1m WHERE minute >= now() - INTERVAL 24 HOUR GROUP BY minute, tenant_id, order_id ) WHERE status = 'paid' AND deleted = 0 GROUP BY minute, tenant_id ORDER BY minute;
Когда p95 этого запроса упрётся в скан слоя 1 (Модуль 7.4 — по метрике, не
«для архитектуры»), добавляется слой 2 со -State-колонками, который
обновляется по расписанию с водяным знаком; читается он через sumMerge /
uniqMerge / quantileTDigestMerge — но не через sumSum: такого
комбинатора нет.
Grafana: datasource ClickHouse, три панели — revenue по минутам, p99
платежей, топ товаров; алерты из Модуля 8.3 рядом. Checkpoint S5:
запрос дашборда < 300ms на объёме после S6-нагрузки; проверяется
clickhouse-benchmark-ом или таймингом в UI.
S6. Нагрузка и намеренный инцидент
Нагрузка. Два генератора: pgbench давит OLTP-часть, k6 — API.
bashpgbench -c 50 -j 4 -T 600 -P 10 -f bench/order_mix.sql orderflow
javascript// k6: ramping-vus на POST /orders import http from 'k6/http'; import { check } from 'k6'; export const options = { scenarios: { orders: { executor: 'ramping-vus', startVUs: 10, stages: [ { duration: '2m', target: 200 }, { duration: '5m', target: 200 }, { duration: '1m', target: 0 }, ], }, }, }; export default function () { const res = http.post('http://api:8080/orders', JSON.stringify({ tenant_id: 1, total: 42.5 }), { headers: { 'Content-Type': 'application/json' } }); check(res, { 'status 201': (r) => r.status === 201 }); }
Инцидент. На пике нагрузки — docker pause sink на 10 минут. Смотрите на
графике: растёт consumer lag, растёт слот-лаг, срабатывают алерты из Модуля
8.3. Потом docker unpause — пайплайн доигрывает очередь, дубли схлопываются
версиями. Разбор — постмортем в README: таймлайн, где было видно за N минут
до паники, что сработало, что нет.
Checkpoint S6: lag < 30 сек при пиковой вставке; отчёт p99/throughput по обоим генераторам; постмортем.
📦 Финальный артефакт
orderflow-analytics/
├── docker-compose.yml # профили: pg, kafka, ch, obs, app
├── patroni/ # patroni.yml ×3, etcd
├── pgbouncer/ # pgbouncer.ini
├── debezium/ # конфиг коннектора
├── clickhouse/ # DDL кластера + MV + витрины
├── services/ # Go: api, sink (с тестами идемпотентности)
├── bench/ # pgbench-сценарий, k6-скрипт
├── grafana/ # дашборды + правила алертов
└── README.md # ADR: топология и «почему»
README — это не инструкция «как поднять», а ADR «почему такая топология». Формат каждой записи:
markdown## ADR-002: CDC через Kafka, а не напрямую в ClickHouse **Контекст:** латентность аналитики ≤ 30 сек; CH может простаивать на деплоях. **Решение:** Debezium → Kafka → Go-синк. **Альтернативы:** Kafka engine в CH (меньше кода, нет replay-контроля); прямой http-синк (нет буфера на простой CH). **Цена:** ещё один stateful-компонент (Kafka) и его мониторинг.
Минимум ADR: топология PG (Patroni ×3), топология CH (2×2), CDC-транспорт, модель дедупликации (Replacing + lsn), выбор PgBouncer transaction mode.
✅ Чек-лист код-ревью по спринтам
- S1: партиции по дате с prune в EXPLAIN (скриншот); retention 90 дней; pgBackRest restore-сценарий отработал и время записано.
- S1: миграции — goose, без длинных блокировок; CONCURRENTLY для индексов.
- S2: failover-тест скриптом в репозитории; приложение переживает его без 500; слот жив после промоции.
- S2: PgBouncer в transaction mode; в коде нет session-состояния (SET без LOCAL, advisory locks).
- S3: heartbeat.interval.ms настроен; метрика «коммит → Kafka» на дашборде.
- S4: оффсеты коммятся только после вставки в CH; kill -9 посреди батча не создаёт дублей (тест автоматизирован).
- S4: каждая строка несёт lsn-версию; DELETE — как новая версия с флагом; витринные запросы FINAL/argMax.
- S5: дашборд читает MV-агрегаты, не raw; p99-запрос < 300ms под нагрузкой.
- S5: витрины восстановимы перезаливкой из raw (процедура в README).
- S6: отчёт p99/throughput обоих генераторов; bottleneck назван по данным.
- S6: постмортем инцидента: таймлайн, обнаружение, действие, профилактика.
- README: каждый ADR имеет альтернативу и цену, а не только решение.
🧩 Quiz "Проверь себя"
✅ Итоги модуля
- Собрать стенд end-to-end: PG (Patroni ×3 + PgBouncer) → Debezium → Kafka → Go-синк → ClickHouse 2×2 → Grafana.
- Проверить каждый спринт экспериментом над отказом, а не галочкой.
- Показать prune в EXPLAIN, живой слот после failover, отсутствие дублей после kill -9.
- Обосновать каждую топологию ADR с альтернативами и ценой.
- Провести нагрузку pgbench + k6 и назвать bottleneck по данным.
- Разобрать собственный инцидент в формате постмортема.
🔗 Мостик к Модулю 12
Стенд собран и выдержал ваши собственные атаки. Осталось последнее: финальный баттл — 50 вопросов по всему курсу (15 интерактивных + 35 разборов «вопрос → эталон») и защита MVP: чек-лист ревью и типовые вопросы «почему так, а не иначе» с эталонными ответами. Берите стенд с собой — защищать будете его.