Як ми будували data-платформу: архітектура, неймінг, ELT, модульність і робота з legacy

Привіт усім! Мене звати Андрій, я — Data Engineering Lead у продуктовій компанії HOLYWATER з екосистеми Genesis. Уже понад 8 років я працюю в ІТ, останні чотири будував data-платформу в HOLYWATER. За цей час ми кілька разів змінювали підходи, ламали власні рішення й поверталися до простіших.

Один з висновків, до якого ми дійшли: якщо архітектуру та неймінг зробити правильно, потреба в «повноцінній» документації різко зменшується. Але далі починаються питання стійкості пайплайнів, типів даних, backfill’ів, «джерела правди», модульності й того, як не перенести legacy-складність у нову систему.

З чого все почалося

Перше, з чим я зіштовхнувся — потреба каталогізації даних. На старті усі дані зберігались у DWH, джерел було багато. Це було для нас плюсом, адже зазвичай дані розкидані різними базами даних. З мінусів — регулярно виникали питання на кшталт «Звідки беруться дані?», «Як зберігаються?», «Де лежать?» та «Для чого використовуються?».

Ми спробували описати ці дані й досить швидко зрозуміли: описаний хаос не перестає бути хаосом. Документація застаріває, каталоги потребують постійної підтримки, а відповіді на базові питання все одно залишаються неочевидними.

Тоді ми вирішили сфокусуватися на іншому й зробити так, щоб сама структура даних відповідала на ці питання без додаткових пояснень.

Неймінг як частина архітектури

Ключове рішення — винести сенс у назву. Назва таблиці або view* має одразу відповідати на три питання:

  1. звідки дані;
  2. на якому етапі трансформації;
  3. для чого вони призначені.

Так з’явилась layered data architecture з self-documenting неймінгом.

*У нашому випадку під назвою таблиці йдеться також про назву датасету (схеми).

Шари даних:

import_*

Сирі дані з зовнішніх джерел:

  • import_stripe
  • import_facebook_ads

Жодних трансформацій — лише збереження «як є» (raw файл або навіть JSON-рядком).

Якщо дані віддаємо назовні для якогось сервісу — використовуємо export_*.

stage_*

Проміжний шар, де відбувається:

  • дедублікація;
  • перейменування колонок;
  • маскування;
  • об’єднання різних endpoint’ів одного джерела.

Приклад: stage_stripe.

core_*

На цьому рівні це вже не просто «дані зі Stripe чи з Facebook», а показники про бізнес, тобто це revenue або marketing_spend. Для дебагу буде не зайвим додати колонку із назвою таблиці джерела.

dm_* (data marts)

Шар для репортингу.

Назва завжди явно містить гранулярність, наприклад dm_user_date_revenue (доходи розбиті на користувача та дату).

Гранулярність — ключова характеристика датамарту після самих метрик.

sandbox_*

Єдине місце, де дозволено руками створювати й видаляти таблиці та view — це персональні sandbox аналітиків.

service_* — технічні таблиці, логи, службові дані.

dictionary_* — довідники (країни, валюти тощо).

Цей підхід спростив навігацію, зменшив кількість питань «куди класти/де шукати» і дав структурну основу для подальшої модульності, зводячи документацію до опису неймінгу (шарів).

Правила неймінгу, які ми для себе зафіксували

Це дрібниці, але саме вони зменшують когнітивне навантаження в щоденних задачах.

  • Не використовуємо абревіатури, якщо це не загальноприйняті поняття. Не економимо літери коштом чужих мізків.
  • Не використовуємо множину: user, а не users.
  • Не використовуємо all:
    revenue_ad — це дохід з реклами,
    revenue — це увесь дохід.
  • Не додаємо слова, що не несуть сенсу: table/data/value та інші.
  • Назва описує отриману сутність, а не спосіб її отримання: success_transaction, а не transactions_filtered. Це дає змогу абстрагуватися від upstream-логіки й значно спрощує рефакторинг.

Далі постало не менш критичне питання: як зробити пайплайни стійкими до змін, збоїв і зростання. Тут і з’являються ELT, типи даних, idempotency та рішення, які дали нам змогу перестати боятися backfill’ів.

Чому ми перейшли на ELT

Одна з проблем — падіння конвеєрів даних.

Серед причин:

  • у джерелі з’явилась нова колонка*;
  • змінився тип поля;
  • API повернуло неочікувану структуру.

*Важливо не використовувати всюди «*», аби перелік колонок був детермінований, а поява нової колонки не руйнувала пайплайни.

Якщо пайплайн падає на етапі трансформації — дані втрачаються. А втрачати дані через те, що не вдалося їх розпарсити через нову колонку чи тип, доволі неприємно. Аби цього уникнути й не робити ручний backfill у випадку падіння, ми перейшли на ELT.

  • Load — зберігаємо дані «як є» (raw файл або JSON рядком).
  • Transform — парсимо, нормалізуємо й агрегуємо вже всередині DWH.

Таким чином, навіть якщо схема даних змінилася — load відпрацював коректно, а transform-крок можна змінити під нову схему й запустити із потрібного нам місця, не будуючи два окремі пайплайни для штатного та аварійного завантаження даних.

Трохи особливостей використання різних типів даних

Типи даних — ще одне джерело прихованих проблем.

  • string — для ідентифікаторів. Часто вони прилітають з різних джерел в одну колонку та можуть бути схожими на integer. Але з іншого джерела це може бути uuid чи base64. Автовизначення типів — зло. Адже ідентифікатор виду «36478890184» у BigQuery легко приводиться до дати «Monday, December 21, 3125 9:03:04 AM».
  • integer — конкретна кількість/лічильники.
  • float — обʼєм і суми з дробами.
  • datetime — для дат, завжди в UTC, щоб не думати про часові зони.

Діапазони замість «за вчора»

Пайплайн, що обробляє батчі, має залежати від діапазону дат у вхідних параметрах, а не обробляти дані «за вчора». Це:

  • спрощує backfill даних при падінні, зміні схеми;
  • дозволяє більш гнучко працювати із великими періодами часу, щоб пробігтися по всьому попередньому року, простіше передати діапазон, аніж 365.2425 раз викликати джобу;
  • дає гнучкість при переході на частіші батчі та дозволяє розбити день на менші частини — до прикладу, по 8 годин.

Discrepancy, SSOT і модульність

На певному етапі до нас почали регулярно приходити з питанням: «Чому одна й та ж метрика в різних таблицях має різні значення?»

Типові поради:

  • навісити більше тестів;
  • додати семантичний шар.

Ми почали з іншого — дослідження причини розбіжностей. Результат — виявили, що проблема майже завжди виникала через дублювання логіки розрахунків у різних місцях.

Розрахунки повторюються, але з певними відмінностями, що викликані хаотичністю розробки через відсутність пріоритетів, поспіхом, плаваючими відповідальностями... Та ми тут про технічні виклики. Отож, ми пішли шляхом модульності:

  • метрика рахується в єдиному місці;
  • використовується в кількох датамартах;
  • зміни вносяться централізовано.

Розрахунки декомпозуються згідно з naming convention і відповідальністю шарів.

Результат: зменшення discrepancy, швидші правки та вища довіра до даних.

Швидкість vs якість: як ми домовились із бізнесом

Запит «зробіть якось, аби швидше» — неминучий. І, як ми знаємо, nothing`s more permanent than a temporary solution. Ще на початку своєї кар’єри я зауважив: менеджери часто просять реалізувати завдання швидко і водночас оцінюють його виконання за якістю. Сліпе слідування вимогам дуже швидко призводить до скриптів-милиць, крихкості системи, відсутності довіри до даних, неможливості підтримки й розвитку.

Ми спробували розглянути бізнес-замовників як наших партнерів, а не ворогів інженерії, та почали шукати спосіб працювати і швидко, і якісно. Рідко це можна зробити одночасно, тому для себе зафіксували процесний контракт:

  • MVP (aka «на милицях») можна робити швидко за умови, що ми знаємо правильний спосіб (який довший);
  • для MVP є дата/спринт, коли рішення допрацьовується;
  • обмеження MVP чітко проговорені з бізнесом, щоб на його базі не ухвалювали ризиковані рішення.

Наприклад, для обробки S2S-подій ми швидко реалізували Cloud Function, але одразу зафіксували, що це тимчасове рішення до рефактору основного сервісу.

Legacy: як стикувати старе і нове

Тут все просто: створили нове, у 10 разів краще — переїхали, старе вимкнули — й насолоджуйтеся.

Звісно, я жартую. Деякі переїзди займають роки й часто постає питання: «Як стикувати частини нової інфраструктури зі старою?». В цьому випадку ми намагаємось адаптувати legacy під нове, бо legacy ми зрештою вимкнемо, а перенос складності в нову «чисту» інфраструктуру — це створення нового legacy.

Принцип: складність має залишатися в legacy, а не мігрувати в нову систему.

Для нас «нове» майже завжди означає «правильне». Тому базове правило звучить так: нові компоненти не повинні підлаштовуватися під старі обмеження, якщо ми плануємо від них відмовлятися. Якщо ж адаптувати нову архітектуру під legacy, ми фактично створюємо новий legacy, тільки з кращою документацією.

Практичний кейс: уніфікація аналітики кількох продуктів

Компанія розвиває багато застосунків. Колись їхня аналітика розвивалася паралельно в різних проєктах зі своїми особливостями. У новій архітектурі ми пішли шляхом уніфікації й створили спільне джерело для всіх. Умовно: є спільна таблиця маркетингових витрат із колонкою «назва застосунку». Далі частина legacy-розрахунків почала використовувати нове спільне джерело, але фільтрувати дані тільки для «свого» застосунку.

Таким чином:

  • нова система розвивається незалежно;
  • старі частини поступово «підʼїжджають» до неї;
  • ми не дублюємо логіку у двох місцях.

Звісно, виключення можуть бути й без compatibility layer не обійтися. У таких випадках ми, до прикладу:

  • додаємо окремі поля для зворотної сумісності з legacy (legacy_app_name);
  • не використовуємо ці поля в нових розрахунках.

Ключова ідея — можливість легко відʼєднати legacy, коли настане час. Якщо для цього достатньо видалити одну колонку або джойн — значить рішення було правильним.

Підсумок

У цій частині ми:

  • заклали фундамент data-платформи через layered architecture і self-documenting неймінг;
  • перейшли на ELT, щоб зробити пайплайни стійкими до змін і backfill’ів;
  • зменшили discrepancy між метриками завдяки модульності й SSOT;
  • виробили підхід до компромісів між швидкістю, якістю та legacy.

У наступній частині я розповім про:

  • те, чим workaround відрізняється від «милиць»;
  • «не ускладнювати» як головний принцип розробки;
  • data-продукти, незалежні від застосунків;
  • observability, алерти й контроль костів.

Якщо знаєте краще, як з’їжджати із legacy, поділіться інструкцією в коментарях :)

Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.

👍ПодобаєтьсяСподобалось17
До обраногоВ обраному4
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Стаття клас, дуже цікаво!

з даними не працюю але цікаво

особливо початкова система про неймінги і підхід до цього

Дякую, Станіславе!
Боротьба з ентропією може набувати різних форм:)
Найближчим часом вийде продовження

Дуже цікаво!

Дякую за статтю, досить гарно продуманий підхід до побудови архітектури. На нашому проєкті ми використовуємо концепцію MIO (Managed Information Object) і вся архітектура побудована навколо MIOs (можна почитати тут: www.linkedin.com/...​l-redshift-bruno-freitag). Цей підхід схожий в тому, що використовується ELT. Але знаючи всі його недоліки, розумію що підхід, описаний тут, гарно вирішує багато з них. Особливо коли DWH дуже розростається, JOIN операції між різними MIO стаються надто заплутаними.

Дякую за коментар і цікаве посилання на концепцію MIO!

Ми вже встигли поспілкуватися з Юрієм «off the record» про виклики оперування великим контекстом на рівні організації. Дійшли висновку, що виклики в компаніях різного розміру так чи інакше схожі. Все зводиться до типових питань поділу відповідальності: як між командами, так і між частинами інфраструктури (наприклад, через шари).

Ті ділянки, що обділені увагою, неминуче відставатимуть, а технічний борг там лише накопичуватиметься. Саме тому ми намагаємося фіксувати архітектурні «правила гри» ще на старті.

Який у Вас набір інструментів/ технологічний стек на проекті?
В якому шарі виконуєте версіонування і чи є у Вас датамарти з Time Travel?
Де виконуються flatten-like операції для складних структур даних?
На якому шарі зʼявляються перші дата модельки і яку взагалі архітектуру DWH обрали?

Стек: Наш підхід базується на Modern Data Stack в екосистемі GCP. Використовуємо BigQuery як основне сховище, Airflow для оркестрації та Cloud Functions / PubSub / GCS. Для підключення джерел також комбінуємо Fivetran та Airbyte (Open Source). Трансформації — на dbt, а вся інфраструктура описана через Terraform (IaC).

Версіонування: Ми підтримуємо історичність (SCD2 / Snapshots) на різних рівнях, залежно від бізнес-логіки.

Flatten-операції: Ми виконуємо розгортання складних структур саме в Stage-шарі. Це дозволяє ізолювати «сирі» формати (JSON, вкладені структури) і передавати в наступні шари вже структуровані пласкі таблиці з чіткою схемою.

Моделювання та архітектура: Ми дотримуємося Layered Architecture з елементами Data Vault. Логіка наступна: у Stage ми можемо робити Join-и в межах таблиць одного джерела для первинної очистки. А от об’єднання різних джерел та формування єдиних бізнес-сутностей відбувається виключно на рівні Core. Це дозволяє тримати Core як «Single Source of Truth».

Якщо є додаткові питання щодо архітектурних нюансів або хочете обговорити детальніше — буду радий поспілкуватися в особистих: LinkedIn

Версіонування: Ми підтримуємо історичність (SCD2 / Snapshots) на різних рівнях, залежно від бізнес-логіки.

Це ж не версіонування, а просто фіксація історії змін. Версіонування передбачає зміну схеми данних, а не самі данні.

Дмитре, опишіть, будь ласка, use-case, щоб я міг відповісти предметно

OLTP system (data source) — table `Address`

1. Address schema version 1  (Schema Active since-to: 2024-01-01 - 2024-12-31)
CREATE TABLE Address (    
    StreetLine NVARCHAR(100), # e.g.: Lincoln street 30
    ...        
)


2. Address schema version 2 (Schema Active since-to: 2025-01-01 - 2024-06-01)
CREATE TABLE Address (    
    Street NVARCHAR(100), #e.g.: Lincoln
    StreetKind NVARCHAR(20), # e.g.: street
    BuildingNum NVARCHAR(10), # e.g.: 30
    ...    
)

3. Address schema version 3(Schema  Active since-to: 2025-06-02 - ..) 
CREATE TABLE Address (    
    StreetAddress JSON # e.g.: {"name": "Lincoln", "kind": "street", "building": "30", block: "B" }    
)
Потрібно зробити репорти де фільтраціі/агрегація/сортування може відбуватись по складовим Address за період від 2024-01-01 і до поточногу моменту. майбутні зміни схеми цієї таблиці мають мінімально впливати на оновлення складових вашого репортингу у майбутньому.

Дякую за наочний приклад, це типова ситуація! Ми проходили етап, коли підтримували кілька версій парсерів одночасно, але згодом обрали більш прагматичний шлях, щоб не накопичувати технічний борг у коді трансформацій.
Наш підхід у таких випадках наступний:
* Стандартизація: Якщо історичні дані (версії 1 та 2) вже статичні й не оновлюються, ми виконуємо одноразову зміни трансформації до найактуальнішої структури. Немає сенсу тримати старі парсери в активному коді, якщо дані «заморожені».
* Forward-migration даних: Ми адаптуємо історію під нову структуру, а не навпаки. Це забезпечує стабільний контракт для Core-шару. Для полів, яких раніше не існувало, використовуємо null або дефолтні значення-заглушки.
* Спрощення логіки: Це дозволяє нам мати одну чисту модель у dbt, яка працює за єдиним стандартом, замість того, щоб нагромаджувати проєкт складними CASE або UNION для кожної зміни схеми в джерелі.
Такий підхід значно спрощує підтримку репортингу в майбутньому, оскільки аналітики завжди працюють із передбачуваною структурою, незалежно від «віку» даних.

Тобто ви з кожним оновленням схеми oltp системи плануєте новий реліз і міграцію всього репортингу і всієї логіки у всіх репортах під кожну зміну source таблиці щоб ваша схема відповідала останній версії контракту джерела? У вас напевно дуже відповідальні команди залежних систем, що у вас э для цього можливість і час) правда я назвав би це не прагматичний, а ідеалістичний скоріше варіант)

🤔Цікавий кейс! На щастя, ми з такою частою зміною схеми не зіштовхувалися: у нашому випадку це стається 1-2 рази на рік, а деструктивні зміни — ще рідше. А чим викликана така періодичність у вас? Це якась особливість бізнесу чи специфіка джерел даних?

Хіба Airflow з dbt при всій повазі — це не той дядько в зеленому костюмі на ваших слайдах?

Підписатись на коментарі