Data engineering
Собираем вместе: система ломается на стыках, а не в хранилищах
Поддержка эскалирует тикет: клиент купил ноутбук, который поиск в каталоге всё ещё показывает «в наличии», AI-ассистент всё ещё его рекомендует, а дашборд аналитики считает его в выручке этой недели дважды. Четыре команды открывают четыре расследования. Postgres корректен — заказ есть, продукт soft-deleted. Склад корректен — для снимка, который он последний раз обновил. Поисковый индекс корректен — для последнего CDC-события, которое до него дошло. Vector index корректен — для эмбеддингов, которые он последний раз пересобрал. Каждое хранилище проходит свои unit-тесты. Баг живёт в зазоре между ними, и им не владеет никто.
Один факт, семь копий
К моменту, когда продукт существует в зрелой системе, одна и та же строка живёт в семи местах. Postgres — система записи (OLTP): транзакция, которая взяла деньги, и есть истина. CDC стримит эту истину в колоночный склад на Parquet/Iceberg. dbt прогоняет её через bronze (сырой landing), silver (очищенный, согласованный) и gold (бизнес-агрегаты). Materialized views и роллапы обслуживают дашборды без пере-сканирования миллиардов строк. Event-sourced поток аудита фиксирует каждое изменение состояния для комплаенса. Полнотекстовый индекс (Elasticsearch/OpenSearch) питает поиск по каталогу. Vector index питает семантический поиск и RAG. Семь хранилищ, один факт: «доступен ли этот продукт и по какой цене?»
Каждый юнит, который ты изучал раньше в этом треке, оптимизировал своё хранилище под одну задачу — и именно эта оптимизация заставляет копии расходиться. Склад построен под throughput, поэтому батчит; батчинг означает lag. Поисковый индекс построен под latency запросов, поэтому денормализует; денормализация означает, что он не видит изменение foreign-key, пока ему кто-то о нём не скажет. Vector index дорого пересобирать, поэтому эмбеддинги пересчитывают по расписанию, а не на каждую запись. Ничего из этого не баг. Это корректный дизайн каждого хранилища. Системный баг в том, что никто не спроектировал контракт между ними.
Стык — это замаскированная проблема dual-write
Посмотри на самый частый источник drift: твой сервис пишет заказ в Postgres, потом публикует «OrderPlaced» в Kafka, чтобы поиск и аналитика среагировали. Две записи, две системы, общей транзакции нет. Если процесс умрёт после коммита в Postgres, но до публикации, в Postgres заказ есть, а остальная система о нём так и не услышит. Это проблема dual-write, и это каноничный отказ стыка — в разборе Confluent прямо сказано, что «нет способа иметь одну атомарную транзакцию» поверх базы и брокера сообщений.
Фикс сеньора — transactional outbox: в той же транзакции БД, что пишет заказ, вставляешь строку в таблицу outbox. Коммит атомарен — заказ и событие живут или умирают вместе. Отдельный relay (или CDC-коннектор вроде Debezium, читающий WAL) отправляет строки outbox в Kafka с at-least-once доставкой. Ты сменил dual-write на одну запись плюс идемпотентного консьюмера, и идемпотентность не обсуждается: at-least-once означает, что то же событие придёт дважды, поэтому каждое хранилище ниже по потоку обязано дедуплицировать по event id, иначе оно дважды посчитает выручку ровно как в Hook.
| Стык (хранилище → хранилище) | Как расходится | Контракт, который чинит |
|---|---|---|
| OLTP → Kafka → поисковый индекс | CDC-событие потеряно; удалённый продукт всё ещё ищется | Transactional outbox + идемпотентный консьюмер + периодическая полная сверка |
| OLTP → склад → MV → дашборд | Refresh lag: MV отдаёт число, которое источник уже поменял | Объявленный freshness SLA + freshness-проверки, валящие build |
| OLTP → vector index (RAG) | Эмбеддинги пересобираются ночью; возвращает удалённый час назад продукт | Фильтровать выдачу по живым id из OLTP; tombstone при удалении |
| поток событий → gold-агрегат | События не по порядку / переигранные считаются дважды | Event id + версия; дедуп и сортировка по sequence, а не по приходу |
Свежесть и lineage — свойства пути, а не хранилища
Спроси «дашборд корректен?» — и единственный честный ответ: «по состоянию на когда?» Gold-MV может быть идеальной для снимка, который она обновила в 02:00, но если возврат прошёл в 09:00, число корректно и неверно — корректно для своего снимка, неверно для вопроса, который реально задаёт руководитель. dbt формализует это через source freshness: ты объявляешь окно warn_after и error_after на каждый источник, и freshness-джоб валит пайплайн, когда данные старше твоего SLA. Собственное правило dbt — запускать freshness-проверку как минимум вдвое чаще самого жёсткого SLA, чтобы обнаружить заглохший фид до того, как дашборд проврёт целый час.
Lineage — другое сквозное свойство. Когда число неверно, ты дебажишь не дашборд — ты идёшь по пути назад: gold → silver → bronze → CDC offset → OLTP. Поэтому важны и разделение medallion (bronze/silver/gold), и неизменяемый формат таблиц. Снимки Iceberg делают каждое изменение таблицы полной версионированной точкой во времени, поэтому можно выполнить time-travel запрос на ровно том снимке, который прочитал дашборд, и доказать, склад ли был устаревшим или трансформ был неверным. Задержка в bronze каскадит в silver и gold, поэтому один заглохший батч проявляется как «вчерашняя выручка» тремя слоями ниже — и только lineage даёт найти, какой слой заглох.
Почему это работает
Reconciliation-джоб — неэффектная часть, которую никто не закладывает в бюджет и которая нужна всем. CDC рано или поздно потеряет или продублирует событие — рестарт коннектора, заполнившийся и сброшенный WAL-слот, пропущенное poison-сообщение. Фикс не в том, чтобы «сделать CDC идеальным»; это периодическая полная пере-синхронизация, которая считает чексумму (количество строк + хеш ключевых колонок) на партицию в OLTP и в индексе, а потом чинит разницу. Считай живой CDC быстрым путём, а reconciliation — страховочной сеткой; и то, и другое, всегда.
Ты проектируешь контракт, а не только хранилища
Сдвиг от мидла к сеньору здесь — в том, чья проблема этот стык. Мидл владеет хранилищем и делает его корректным. Сеньор владеет контрактом данных: схемой, event id и версией, гарантией доставки, freshness SLA и reconciliation, которая замыкает петлю. Контракт — это то, что позволяет семи независимо-корректным хранилищам сложиться в одну систему, которая согласуется сама с собой.
Конкретно контракт отвечает на четыре вопроса для каждого стыка: какова каноничная схема и кто может её менять (ломающее переименование колонки в OLTP молча занулит колонку ниже по потоку)? Какова гарантия доставки и, значит, должны ли консьюмеры быть идемпотентными (at-least-once → да)? Каков freshness SLA и что валится при его нарушении? И что сверяет drift, как часто и кого зовут по пейджеру? Пропусти любой — и получишь Hook: четыре корректных хранилища, одна некорректная система, владельца нет.
Сервис должен обновить Postgres, а затем сделать изменение видимым в поисковом индексе и на складе. Выбери дизайн интеграции.
Продукт soft-deleted в Postgres в 14:00. В 14:30 RAG-ассистент всё ещё его рекомендует. Vector index пересобирает эмбеддинги ночью. Какой фикс сеньора?
Дашборд выручки расходится с ручным SQL-подсчётом по Postgres. Оба «корректны». Какова самая вероятная причина?
Число на дашборде неверно. Расставь обход lineage, который делает сеньор, чтобы локализовать drift:
- 1 Спроси «по состоянию на когда?» — установи время снимка/обновления, которое прочитал дашборд
- 2 Проверь историю обновления gold-MV / агрегата против freshness SLA
- 3 Поднимись по medallion: silver устарел, потому что bronze приземлился поздно?
- 4 Проверь CDC offset / лаг outbox между OLTP и bronze
- 5 Сравни чексумму (количество строк + хеш ключей) OLTP против партиции склада, чтобы подтвердить, где они расходятся
- 01Объясни коллеге, почему поисковый индекс показывает продукт, который Postgres удалил, хотя оба хранилища проходят свои тесты, и как ты закроешь стык.
- 02Объясни, почему «дашборд корректен?» — неверный вопрос, и при чём тут свежесть и lineage.
К моменту, когда продукт в проде, один факт живёт в семи хранилищах: OLTP как система записи, колоночный склад, питаемый CDC, слои bronze/silver/gold в dbt, materialized views для дашбордов, event-sourced поток аудита, полнотекстовый поисковый индекс и vector index для RAG. Каждое хранилище корректно оптимизировано под свою задачу — и именно эта оптимизация заставляет копии расходиться. Система ломается не внутри хранилища, а на стыке между двумя корректными: потерянное CDC-событие оставляет удалённый продукт в поиске, refresh lag заставляет MV расходиться с OLTP-источником, ночью пересобранный vector index рекомендует удалённый час назад продукт. Ход сеньора — перестать владеть хранилищами и начать владеть контрактами: transactional outbox, чтобы убить dual-write, идемпотентные консьюмеры для at-least-once доставки, объявленный freshness SLA, валящий build, lineage через версионированные слои medallion, чтобы идти по drift назад, и периодический reconciliation, который сверяет OLTP с каждой копией и чинит разницу. Консистентность, свежесть и lineage — сквозные свойства: ты проектируешь их на стыках, иначе система расходится сама с собой, пока каждая её часть проходит тесты.