Data Engineering и ETL Качество данных, контракты, линидж и governance
0%

Качество данных, контракты, линидж и governance

Качество данных, контракты, линидж и governance

Болезненно типичная история. Во вторник в 9:40 CEO открывает дашборд и видит, что выручка вчера упала на 40%. Пожар: созвон, продуктовая команда проверяет воронку, маркетинг — кампании, дежурный SRE ищет инцидент в сервисах. К 14:00 выясняется, что накануне вечером бэкенд-разработчик выкатил рефакторинг, в котором поле amount события order_paid стало приходить не в копейках, а в рублях. Пайплайн отработал успешно: строки прочитаны, схема совпала, загрузка зелёная. Испортились не строки, а смысл строк — и ни один технический мониторинг этого не заметил.

Пайплайны из предыдущих материалов трека отвечают на вопрос «данные доехали?». Здесь мы отвечаем на три других:

  1. Качество данных — можно ли этим числам верить и как проверить это автоматически?
  2. Контракты и линидж — кто отвечает за поле и что сломается, если его изменить?
  3. Governance — кто имеет право на эти данные, что означает термин «активный клиент» и почему в компании три разных значения выручки.

1. Качество — это не свойство данных

Первая ловушка — считать качество свойством самих данных. Классическое определение Ричарда Ванга и Дианы Стронг (MIT, 1996): качество данных есть fitness for use, пригодность для использования. Это свойство пары «данные + сценарий».

Наглядно: таблица адресов, где 3% записей содержат опечатку в номере дома. Для отчёта «продажи по городам» качество отличное — город определён в 100% строк. Для курьерской логистики катастрофическое — 3% доставок не состоятся.

Отсюда вывод, экономящий месяцы: нельзя «повысить качество данных вообще». Можно только зафиксировать требования конкретных потребителей и проверять их. Проект «наведём порядок в данных» без списка потребителей провалится не потому, что команда слабая, а потому что у задачи нет критерия завершённости.

Измерения качества

Индустрия сошлась примерно на шести измерениях. Они полезны не как теория, а как чеклист при проектировании проверок: пройдя по шести пунктам, вы почти наверняка не забудете целый класс дефектов.

Критичный нюанс: пять из шести измерений проверяются машинно, внутри данных — выражаются SQL-запросом. Точность — нет. Чтобы узнать, что настоящая выручка равна 4.2 млн, нужен внешний источник истины: бухгалтерия, эталонный отчёт, ручная сверка. Поэтому точность проверяют не тестами, а реконсиляцией — регулярным сопоставлением с независимым источником. История из введения — ровно провал точности: данные валидны, полны, свежи и уникальны, но неверны.

Есть и седьмое, неформальное измерение — интерпретируемость. Идеально чистое поле бесполезно, если никто в компании не может сказать, что оно означает. Это лечится глоссарием, а не тестами.


2. Откуда берутся дефекты

Класс Пример Лекарство
Изменение источника Разработчик поменял единицы, тип, семантику Контракт данных + проверка в CI
Ошибка в трансформации LEFT JOIN размножил строки, неверный фильтр Тесты уникальности и row-count
Проблема доставки Батч не отработал, партиция пустая Проверки свежести и объёма
Дубли и опоздания Retry продюсера, событие вне окна Идемпотентность, дедупликация, watermark
Ошибка ввода Оператор ввёл дату 1900 года Валидация в источнике, правила домена
Дрейф реальности Появилась новая страна, новый статус Мониторинг распределений, не жёсткие enum
Организационный Две команды считают «активного клиента» по-разному Глоссарий, владельцы метрик

Заметьте асимметрию: больше половины строк лечится не техникой, а договорённостями. Это и есть причина, почему «качество данных» и «governance» — одна тема, а не две.

Отдельно — про тихие дефекты. Дефект, роняющий пайплайн, дешёвый: виден сразу, чинится за час, испорченных данных нет. Дорогой дефект — тихий: он проезжает в витрину, оттуда в дашборд, оттуда в решение. Стоимость обнаружения растёт примерно на порядок на каждом слое — та же логика, что в правиле «1-10-100» из управления качеством. Отсюда главный архитектурный принцип статьи: проверять как можно раньше и не пускать плохое дальше.


3. Write-Audit-Publish

Наивная реализация: загрузили в витрину → прогнали тесты → упало, шлём алерт. Проблема очевидна: между загрузкой и алертом плохие данные уже видны потребителям. Аналитик успел скачать выгрузку, дашборд закешировался, ML-фича попала в обучающую выборку.

Правильный паттерн — Write-Audit-Publish (WAP): пишем туда, где потребитель не видит, проверяем, и только затем атомарно публикуем.

Ключевое слово — атомарная публикация. Она почти бесплатна, если хранилище её умеет: снапшоты и ветки в Iceberg/Delta (пишем в ветку audit, проверяем, делаем fast-forward в main — см. Iceberg branching и разбор форматов хранения); обмен партиций в Hive-таблицах; транзакция или RENAME в классической СУБД; CREATE OR REPLACE VIEW mart AS SELECT * FROM mart_v42 там, где витрина — это view.

Блокирующие или предупреждающие

Самая частая эксплуатационная ошибка — сделать все проверки блокирующими. Через месяц команда отключит половину, потому что пайплайн падает по ночам из-за проверки «доля пустых middle_name выросла до 12%».

  • Блокирующая (error) — нарушение делает данные непригодными и необратимо вредными: дубли по первичному ключу, битый внешний ключ, отрицательная выручка, объём партиции ниже 10% от медианы.
  • Предупреждающая (warn)подозрительно, но данные пригодны: рост доли NULL в необязательном поле, распределение уехало на 2σ, появилось новое значение категориального поля.

Правило калибровки: если по алерту никто не побежит чинить ночью — это не error. Алерты, на которые не реагируют, тренируют команду игнорировать алерты вообще.


4. Проверки на практике

Уровень 1 — голый SQL

Любая проверка — это запрос, который должен вернуть ноль строк. Это и есть определение assertion в мире данных; с него начинали все фреймворки.

-- 1. Уникальность бизнес-ключа.
SELECT order_id, COUNT(*) FROM mart.fct_orders
WHERE dt = DATE '2026-07-16'
GROUP BY order_id HAVING COUNT(*) > 1;

-- 2. Ссылочная целостность (в аналитических БД FK обычно не enforced).
SELECT f.customer_sk FROM mart.fct_orders f
LEFT JOIN mart.dim_customer d ON d.customer_sk = f.customer_sk
WHERE f.dt = DATE '2026-07-16' AND d.customer_sk IS NULL;

-- 3. Свежесть: максимальное время события не старше 3 часов.
SELECT MAX(event_ts), EXTRACT(EPOCH FROM (NOW() - MAX(event_ts))) / 3600 AS lag_hours
FROM mart.fct_orders
HAVING EXTRACT(EPOCH FROM (NOW() - MAX(event_ts))) / 3600 > 3;

Обратите внимание на WHERE dt = ... в каждом запросе. Это не косметика, а разница между стоимостью O(размер партиции) и O(размер таблицы).

Уровень 2 — dbt tests и контракты моделей

В ELT-мире (см. ETL и ELT) стандарт де-факто — dbt: тесты объявляются рядом с моделью и превращаются в те же «запросы, возвращающие ноль строк».

# models/mart/schema.yml
version: 2
models:
  - name: fct_orders
    description: "Факт заказа, гранулярность — один заказ. Владелец: команда «Заказы»."
    config:
      contract: { enforced: true }     # dbt не даст молча поменять тип колонки
    columns:
      - name: order_id
        data_type: bigint
        constraints: [{ type: not_null }]
        data_tests:
          - unique                     # блокирующий: дубль ломает все агрегаты
          - relationships: { to: ref('stg_orders'), field: order_id }
      - name: revenue_rub
        data_type: numeric(18,2)
        description: "Выручка в рублях, без НДС, по курсу на дату заказа."
        data_tests:
          - not_null
          - dbt_utils.accepted_range: { min_value: 0, inclusive: true }
      - name: status
        data_type: varchar
        data_tests:
          - accepted_values:
              values: ['created', 'paid', 'shipped', 'cancelled', 'refunded']
              config: { severity: warn }   # новое значение — повод посмотреть, не повод падать
      - name: dt
        data_type: date
        data_tests:
          - dbt_utils.recency: { datepart: hour, field: event_ts, interval: 3 }

Ключевая деталь — contract: enforced: true (дока dbt). Без него dbt пересоберёт модель с новым типом колонки молча; с ним сборка упадёт, и разработчик обязан осознанно поменять контракт. Это ровно тот механизм, который поймал бы историю из введения, стой он на staging-слое.

Для нестандартной логики пишут singular-тест — обычный SQL-файл в tests/. Самый ценный из них — реконсиляция витрины с независимым источником:

-- tests/assert_revenue_matches_payments.sql
-- Допускаем расхождение до 0.5%: часть платежей приходит с задержкой.
WITH m AS (SELECT dt, SUM(revenue_rub) AS r FROM {{ ref('fct_revenue') }}
           WHERE dt >= CURRENT_DATE - 7 GROUP BY dt),
     p AS (SELECT paid_date AS dt, SUM(amount_rub) AS r
           FROM {{ source('billing', 'payments_ledger') }}
           WHERE paid_date >= CURRENT_DATE - 7 GROUP BY paid_date)
SELECT m.dt, m.r AS mart_revenue, p.r AS ledger_revenue
FROM m JOIN p USING (dt)
WHERE ABS(m.r - p.r) / NULLIF(p.r, 0) > 0.005

Уровень 3 — свой раннер

Фреймворки полезны, но механику надо понимать. Компактный раннер, умеющий главное: severity, метрики, единый отчёт и блокировку публикации.

"""Проверка = (имя, SQL, severity, порог, владелец).
Встраивается в Airflow-таск между WRITE и PUBLISH."""
from dataclasses import dataclass
from enum import Enum

class Severity(str, Enum):
    ERROR = "error"    # блокирует публикацию
    WARN = "warn"      # только уведомление и метрика

@dataclass(frozen=True)
class Check:
    name: str
    sql: str                    # запрос, возвращающий ОДНО число — количество нарушений
    severity: Severity = Severity.ERROR
    threshold: int = 0          # сколько нарушений допустимо
    owner: str = "data-platform"

@dataclass
class Result:
    check: Check
    violations: int
    @property
    def passed(self) -> bool:
        return self.violations <= self.check.threshold

def run_checks(conn, checks: list[Check], partition: str) -> list[Result]:
    """Прогоняет ВСЕ проверки, не прерываясь на первой ошибке: инженеру нужна полная
    картина, иначе он чинит дефекты по одному, перезапуская пайплайн N раз."""
    results = []
    for check in checks:
        with conn.cursor() as cur:
            cur.execute(check.sql, {"partition": partition})
            (violations,) = cur.fetchone()
        res = Result(check, int(violations))
        results.append(res)
        # Метрика уходит в мониторинг ВСЕГДА, даже когда проверка прошла:
        # тренд «0 → 3 → 17 нарушений» ценнее одиночного факта падения.
        emit_metric("dq.violations", res.violations,
                    tags={"check": check.name, "severity": check.severity.value})
    return results

def gate(results: list[Result]) -> None:
    """Блокирует публикацию, если провалилась хотя бы одна ERROR-проверка."""
    failed = [r for r in results if not r.passed and r.check.severity is Severity.ERROR]
    if failed:
        details = "\n".join(f"  ✗ {r.check.name}: {r.violations} нарушений "
                            f"(владелец: {r.check.owner})" for r in failed)
        raise RuntimeError(f"Публикация остановлена, провалено {len(failed)}:\n{details}")

CHECKS = [
    Check(name="fct_orders__unique_pk", owner="team-orders",
          sql="""SELECT COUNT(*) FROM (
                     SELECT order_id FROM mart.fct_orders WHERE dt = %(partition)s
                     GROUP BY order_id HAVING COUNT(*) > 1) t"""),
    Check(name="fct_orders__volume_not_collapsed",
          # Объём партиции не ниже 40% медианы последних 14 дней.
          # Ловит «пайплайн отработал, но источник отдал четверть данных».
          sql="""WITH hist AS (SELECT dt, COUNT(*) AS c FROM mart.fct_orders
                     WHERE dt BETWEEN %(partition)s::date - 14 AND %(partition)s::date - 1
                     GROUP BY dt),
                 today AS (SELECT COUNT(*) AS c FROM mart.fct_orders WHERE dt = %(partition)s)
                 SELECT CASE WHEN (SELECT c FROM today) < 0.4 *
                        (SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY c) FROM hist)
                        THEN 1 ELSE 0 END"""),
]

Что здесь важнее кода: метрика пишется всегда, а не только при падении (качество — временной ряд, а не булево); не прерываемся на первой ошибке; у каждой проверки есть владелец, иначе алерт летит в никуда; проверка объёма относительно скользящей истории ловит самый частый тихий дефект.

Готовые инструменты решают ту же задачу с батарейками: Great Expectations (библиотека ожиданий, профилирование, Data Docs), Soda Core (лаконичный YAML-DSL), Deequ от AWS и его порт PyDeequ. Deequ ценен тем, что решает реальную инженерную проблему: посчитать десятки метрик за один скан, а не за десять.


5. Статистические проверки

Правила ловят то, что вы предвидели. Дрейф — то, чего не предвидели.

Наивный подход «алерт при отклонении больше 3σ» работает плохо: среднее и σ сами разрушаются выбросами — один аномальный день раздувает σ, и следующая аномалия проходит незамеченной. Робастная замена — modified z-score на медиане и MAD (median absolute deviation).

import numpy as np

def robust_zscore(history: np.ndarray, value: float) -> float:
    """Modified z-score (Iglewicz & Hoaglin, NIST Handbook 1.3.5.17).
    Медиана и MAD не «съезжают» от одиночных выбросов, в отличие от mean/std.
    0.6745 приводит MAD к масштабу σ нормального распределения; порог |z| > 3.5 — рекомендация NIST.
    Сложность O(n log n) по окну истории (десятки точек) — пренебрежимо на фоне скана данных."""
    med = np.median(history)
    mad = np.median(np.abs(history - med))
    if mad == 0:                        # вырожденный случай: метрика константна
        return 0.0 if value == med else np.inf
    return 0.6745 * (value - med) / mad

# Обычные будни ~1000 строк, был один выброс 5000 (акция).
history = np.array([980, 1010, 995, 5000, 1002, 990, 1015, 1008, 975, 1020])
print(robust_zscore(history, 400))    # ≈ -49.8 → обвал пойман
print(robust_zscore(history, 1030))   # ≈  1.6  → норма
# Через mean/std порог 3σ дал бы σ≈1250, и обвал до 400 остался бы «в норме».

Что ещё стоит мониторить статистически:

  • Дрейф категориального распределения — расстояние между вчерашним и сегодняшним распределением долей. Практичные метрики: PSI (Population Stability Index, порог 0.25) и дивергенция Дженсена — Шеннона.
  • Дрейф числового — сравнение квантилей (p50/p90/p99), а не среднего: среднее чувствительно к хвосту, квантили стабильнее.
  • Сезонность. Понедельник не похож на воскресенье, 1 января не похоже ни на что. Сравнивайте день с тем же днём недели, иначе получите два ложных алерта каждую неделю.

Учтите цену: статистические проверки дают ложные срабатывания по построению. При пороге в 1% ложных тревог и 300 отслеживаемых метриках вы получите три ложных алерта в день. Поэтому такие сигналы почти всегда warn — в дашборд и еженедельный обзор, а не в пейджер.


6. Контракты данных

Все проверки выше оборонительные: мы стоим на приёме и ловим присланное. Контракт переносит ответственность туда, где дефект возникает — к производителю.

Контракт данных — версионируемый машиночитаемый документ, в котором производитель фиксирует обязательства перед потребителями: схема, семантика, гарантии качества, SLA доставки, владелец, политика изменений. Ключевое слово — машиночитаемый: контракт в Confluence контрактом не является, потому что его нельзя проверить в CI.

# contracts/orders/order_paid.v2.yaml
apiVersion: v2
kind: DataContract
id: orders.order_paid
version: 2.1.0
owner: { team: team-orders, slack: "#team-orders", oncall: "orders-oncall@example.com" }
description: >
  Событие успешной оплаты заказа. Публикуется сервисом billing после подтверждения
  от эквайринга. Одно событие на одну успешную оплату.

schema:
  format: avro
  fields:
    - { name: order_id, type: long, required: true }
    # Имя поля само защищает от истории из введения: единицы зашиты в название.
    - name: amount_minor_units
      type: long
      required: true
      description: "Сумма в минорных единицах валюты (копейки/центы). НЕ в рублях."
    - { name: currency, type: string, required: true, enum: ["RUB", "USD", "EUR", "KZT"] }
    - { name: paid_at, type: long, logicalType: timestamp-micros, required: true }
    - { name: promo_code, type: ["null", "string"], required: false }

semantics:
  grain: "одна строка = одна успешная оплата"
  primary_key: ["order_id", "paid_at"]
  excludes:                        # что НЕ входит — не менее важно, чем что входит
    - "частичные возвраты — отдельное событие order_refunded"
    - "неуспешные попытки оплаты — не публикуются"

quality:
  - { rule: uniqueness, columns: ["order_id", "paid_at"] }
  - { rule: not_null, columns: ["order_id", "amount_minor_units", "currency", "paid_at"] }
  - { rule: range, column: amount_minor_units, min: 1, max: 100000000 }
  - { rule: freshness, column: paid_at, max_lag: PT15M }   # ISO-8601 duration

sla: { availability: 99.5, max_delivery_lag: PT15M, backfill_window: P7D }
compatibility: { policy: BACKWARD, deprecation_notice: P90D }
privacy: { contains_pii: false, classification: internal, retention: P1095D }

Контракт работает, только если его нарушение физически блокирует релиз производителя:

Механика совместимости в Confluent Schema Registry:

Политика Что можно менять Кто обновляется первым
BACKWARD Удалить поле, добавить поле с default Потребители
FORWARD Добавить поле, удалить поле с default Производители
FULL Только добавление/удаление полей с default Любой порядок
NONE Всё Никто, и это гарантированный инцидент

Практичный дефолт для аналитики — BACKWARD: разрешает добавлять поля (самая частая операция) и запрещает удалять обязательные (самая опасная).

Чего контракт не решает. Нужен владелец с полномочиями: если платформенная команда не может заблокировать релиз продуктовой, контракт превращается в пожелание. Нужен инструмент, генерирующий из контракта и тесты, и схему таблицы, и документацию, — иначе YAML устареет за квартал. И главное: контракт не ловит семантический дрейф. Если status формально остался строкой, но значение paid теперь ставится на этапе резервирования, — совместимость соблюдена, а метрика сломана. Против этого работают только тесты распределений и человеческая коммуникация.

Начинать разумно не со всех источников, а с 5–10 таблиц, кормящих деньги и ML. Хороший старт — Open Data Contract Standard (Bitol, Linux Foundation) и datacontract.com.


7. Линидж

Линидж — ориентированный ациклический граф, где вершины суть датасеты (или колонки), а рёбра — задания, порождающие одни из других. Он отвечает на два дорогих без него вопроса: impact analysis («что сломается, если я изменю это поле?») и root cause analysis («в дашборде мусор — откуда он приехал?»).

Два уровня детализации, и разница принципиальная. Табличный: fct_revenue зависит от stg_orders и dim_customer — дёшево получить (распарсить FROM/JOIN), мало пользы: при 200 колонках ответ «зависит» не сужает поиск. Колоночный: fct_revenue.revenue_rub вычисляется из stg_orders.amount_rub и stg_orders.currency, отфильтрован по stg_orders.status — дорого получить (нужен разбор AST), но именно он даёт точный радиус поражения.

Колоночный линидж и радиус поражения от изменения типа поля

Из картинки два практических вывода. Первый: колоночный граф превращает «затронуто 3 таблицы» в «затронуты 2 метрики на дашборде продаж, владелец — команда аналитики», то есть в готовый список адресатов уведомления. Второй, менее очевидный: линидж показывает колонки без потребителей. updated_at не входит ни во что — значит, её можно исключить из CDC и хранения. В зрелых компаниях такая уборка регулярно снимает десятки процентов затрат на пайплайны.

# Колоночный линидж из SQL без запуска запроса — через разбор AST.
# sqlglot парсит ~20 диалектов и строит граф зависимостей колонок.
from sqlglot.lineage import lineage

SQL = """
CREATE TABLE mart.fct_revenue AS
SELECT o.order_id, o.amount_rub * COALESCE(r.rate, 1) AS revenue_rub, o.dt
FROM staging.stg_orders o
LEFT JOIN staging.fx_rates r ON r.currency = o.currency AND r.dt = o.dt
WHERE o.status = 'paid'
"""

node = lineage("revenue_rub", SQL, dialect="postgres")   # граф происхождения колонки
for n in node.walk():
    if n.source:
        print(n.name, "<-", n.source.sql(dialect="postgres")[:80])
# Такой разбор вешают на CI: каталог обновляется сам, граф не поддерживают руками.

Три источника линиджа в проде, обычно комбинируются:

  1. Статический разбор SQL/кодаsqlglot, SQLLineage, парсер dbt. Дёшево и до запуска, но слепо к динамическому SQL и к Python/Spark-коду с UDF.
  2. Рантайм-события движкаOpenLineage: открытый стандарт событий START/COMPLETE/FAIL с input/output-датасетами и расширяемыми facets (схема, статистика, колоночный линидж). Интеграции есть у Airflow, Spark, dbt, Flink; достаточно включить плагин и указать URL коллектора. Точнее статики: фиксирует, что реально прочиталось и записалось.
  3. Логи запросов хранилища — query history в Snowflake, BigQuery, ClickHouse. Показывает фактическое потребление, включая ad-hoc-запросы аналитиков, — то, чего не видит ни один парсер репозитория.

Собранный граф живёт в каталоге: Marquez (референсная реализация OpenLineage), DataHub, OpenMetadata, Amundsen — из открытых; Collibra, Alation, Unity Catalog — коммерческие.


8. Каталог: модель, а не свалка

Каталог часто воспринимают как «википедию таблиц». Полезнее думать о нём как о базе данных с нормальной моделью (см. моделирование данных) — тогда видно, на какие вопросы он умеет отвечать.

Такая модель отвечает на вопросы, которые в обычной компании занимают день переписки: «какие витрины содержат PII без политики маскирования?» (обход COLUMN → CLASSIFICATION), «кому писать, если сломалась dim_customer?» (DATASET.owner_team), «что означает “активный клиент” в этом отчёте?» (COLUMN → GLOSSARY_TERM), «какие датасеты Tier-1 не покрыты ни одной проверкой?» (антиджойн DATASET ⟕ QUALITY_CHECK).

Последний запрос — лучший стартовый KPI программы качества: он даёт конкретный конечный список работ вместо лозунга «повысим качество».

Обратите внимание на tier. Разделение датасетов по критичности — простейший и самый недооценённый инструмент governance. Tier-1 (деньги, регуляторика, ML в проде) получает контракты, блокирующие проверки, SLO и дежурство. Tier-3 (эксперимент аналитика) не получает ничего, и это нормально. Одинаковые требования ко всему ведут либо к параличу, либо к формальному отношению.


9. Своевременность: SLO на данные

Свежесть — единственное измерение качества, естественно выражающееся в терминах SRE, поэтому здесь стоит прямо заимствовать SLI/SLO/error budget:

  • SLI: лаг = now − max(event_time) в таблице.
  • SLO: лаг < 3 часов в 99% пятнадцатиминутных интервалов месяца.
  • Error budget: 1% месяца ≈ 7 часов нарушений. Пока бюджет не исчерпан — команда двигает фичи; исчерпан — приоритет уходит на надёжность.

Пилообразный график лага свежести и нарушение SLO

Три тонкости, видные на графике и постоянно упускаемые:

  1. Лаг никогда не равен нулю: его минимум равен времени обработки. SLO «данные свежее 5 минут» при батче длиной 30 минут недостижим по построению — это не инцидент, а ошибка проектирования.
  2. Пилообразность нормальна. Алертить надо не на «лаг вырос», а на «лаг выше порога дольше T минут», иначе каждый цикл перед загрузкой генерирует алерт.
  3. Пустая партиция — не то же самое, что свежая. Джоб может отработать успешно и записать ноль строк; max(event_time) останется вчерашним. Проверять надо и лаг, и объём.

SLO — это обещание потребителям, а не техническая метрика. Формулировка «витрина fct_orders готова к 08:00 МСК в 99% рабочих дней» позволяет аналитику ставить встречу на 08:30 и не спрашивать в чате «данные приехали?». Без явного SLO этот вопрос задаётся вручную несколько раз в день — скрытая стоимость отсутствия governance.


10. Стоимость проверок

Класс проверки Сложность Комментарий
NOT NULL, домен, диапазон O(N) по партиции, одна колонка В колоночном формате почти бесплатно
Уникальность ключа O(N) с hash-агрегацией Требует shuffle в распределённом движке
Ссылочная целостность O(N + M), broadcast join Дорого, если справочник большой
Реконсиляция сумм O(N + M), две таблицы Ограничивают окном 7–30 дней
Дрейф распределения O(N) + O(k log k) по бинам Гистограмма или квантили
Проверка «по всей истории» O(размер таблицы) Почти всегда ошибка проектирования

Четыре приёма, снижающие счёт на порядок:

  1. Проверять партицию, а не таблицу. WHERE dt = :partition превращает O(таблица) в O(партиция) — при разумном партиционировании, см. пакетную обработку.
  2. Считать метрики одним проходом. Наивно 20 проверок дают 20 сканов; один запрос с 20 агрегатами читает данные один раз:
    SELECT COUNT(*)                                            AS row_count,
           COUNT(*) - COUNT(order_id)                          AS null_order_id,
           COUNT(*) - COUNT(DISTINCT order_id)                 AS dup_order_id,
           SUM(CASE WHEN revenue_rub < 0 THEN 1 ELSE 0 END)    AS negative_revenue,
           MAX(event_ts)                                       AS max_event_ts,
           APPROX_QUANTILES(revenue_rub, 100)[OFFSET(50)]      AS p50_revenue
    FROM mart.fct_orders WHERE dt = @partition;
    
    Ровно на этой идее построен Deequ: вы описываете набор проверок, движок планирует минимум сканов.
  3. Выборка для дорогих проверок. Дрейф прекрасно детектируется по 1% выборке; для уникальности выборка бесполезна. Правило: агрегатные свойства можно семплировать, свойства существования — нельзя.
  4. Использовать статистику файлов. Parquet и Iceberg хранят min/max/null_count в метаданных: «есть ли отрицательные значения» иногда отвечается по футерам, вообще без чтения данных.

Ориентир по бюджету: проверки должны стоить 1–5% от стоимости пайплайна. Если выходит 30% — вы либо сканируете историю, либо делаете отдельный скан на каждую проверку.


11. Governance, который не превращается в бюрократию

Data governance — система решений о данных: кто владеет, кто имеет доступ, что означают термины, сколько хранить, как удалять. Слово имеет заслуженно плохую репутацию, потому что часто реализуется как комитет, согласующий доступы три недели.

Разница между работающим и неработающим governance ровно одна: работающий встроен в инструменты, неработающий живёт в документах. Практический тест: если правило нельзя нарушить, не получив красную сборку или отказ в доступе, — это правило. Иначе — пожелание.

1. Владение. У каждого датасета Tier-1/2 есть команда-владелец, указанная в коде (meta.owner в dbt, тег в каталоге), а не в голове. Ключевая мысль Data Mesh Жамак Дегани: владеть данными должна доменная команда, которая их порождает и понимает смысл, а платформенная даёт инструменты и стандарты. Это прямо перекликается с ограниченными контекстами — см. трек про DDD.

2. Глоссарий с владельцами определений. «Активный клиент» — тот, кто заходил за 30 дней или покупал за 90? Ответ должен существовать в единственном экземпляре, иметь steward’а, и метрика в BI должна на него ссылаться. Отсюда естественно вырастает семантический слой (dbt Semantic Layer, Cube, LookML): метрика определяется один раз, а не копипастится в 40 дашбордов, где шесть устарели.

3. Классификация и доступ. Колонки помечаются (pii, financial, secret), политика следует из метки автоматически. Для регуляторики (GDPR, 152-ФЗ) критично, что удаление данных субъекта должно пройти по всем копиям, а список копий даёт линидж: право на забвение без линиджа технически невыполнимо — вы просто не знаете, где ещё лежат эти строки.

-- Динамическое маскирование на уровне хранилища (синтаксис Snowflake;
-- аналоги есть в BigQuery и Databricks Unity Catalog).
CREATE MASKING POLICY mask_email AS (val string) RETURNS string ->
  CASE
    WHEN CURRENT_ROLE() IN ('ANALYST_PII', 'DATA_ENGINEER_PROD') THEN val
    WHEN val IS NULL THEN NULL
    ELSE REGEXP_REPLACE(val, '^[^@]+', '***')      -- ***@example.com
  END;

ALTER TABLE mart.dim_customer MODIFY COLUMN email SET MASKING POLICY mask_email;
-- Политика привязана к КОЛОНКЕ, а не к запросу: её нельзя обойти,
-- написав другой SELECT или создав вьюху поверх.

4. Жизненный цикл инцидента. Качество данных — не состояние, а операционный процесс. Инциденту нужен тот же жизненный цикл, что и инциденту в сервисе.

Главное правило постмортема: каждый инцидент качества заканчивается либо новой автоматической проверкой, либо новым пунктом контракта. Если заканчивается только «поговорили с командой» — он повторится через месяц.


12. Типичные ошибки

  1. Проверки после публикации. Тесты есть, но данные уже видны. Лечится WAP.
  2. Всё блокирующее. Через месяц проверки отключены целиком.
  3. Алерты без адресата. Канал #data-alerts на 200 человек, где не отвечает никто.
  4. Проверка есть, метрики нет. Записывается факт падения, а не значение — невозможно увидеть медленную деградацию: доля NULL росла с 1% до 9% полгода и никого не разбудила.
  5. Тесты только на витринах. Дефект пойман на пятом слое, когда посчитаны 30 моделей. Дешевле всего проверять близко к источнику.
  6. Порог-константа. «Строк должно быть больше 100 000» — верно год назад, бизнес вырос втрое, проверка обессмыслилась. Пороги привязывают к скользящей статистике.
  7. Игнорирование сезонности → ложные алерты каждое воскресенье → команда перестаёт реагировать.
  8. Линидж как ручная диаграмма. Нарисовали в Miro, через квартал он разошёлся с реальностью и стал вредным: люди принимают решения по неверной карте. Линидж должен генерироваться.
  9. Каталог без обязательности. Описания «по желанию» дают 8% покрытия. Работает только принуждение в CI: нет описания и владельца у Tier-1-модели — сборка красная.
  10. Комитет вместо инструментов. Три недели на согласование доступа → аналитики выгружают CSV на ноутбуки, и вы теряете governance полностью, включая тот, что был.
  11. Дедупликация без определения дубля. Дубль — это полное совпадение строки, совпадение бизнес-ключа или совпадение ключа в пределах окна? Три ответа дают три разных пайплайна; для потоков это критично — см. потоковую обработку.

13. Маршрут по уровням зрелости

Не стройте всё сразу — это надёжный способ не построить ничего.

0 → 1 (недели). Инвентаризация Tier-1-датасетов с владельцами. На каждый — три проверки: свежесть, объём относительно медианы, уникальность ключа; метрики в общий дашборд. Уже здесь ловится большинство громких инцидентов: «данные не приехали» и «приехала четверть» — самые частые отказы.

1 → 2 (месяцы). WAP на критичных пайплайнах: плохое перестаёт публиковаться. Тесты переезжают в dbt/GE и живут в репозитории рядом с моделями. Появляется автоматический табличный линидж. Заводится процесс инцидентов с постмортемами.

2 → 3 (кварталы). Контракты на 5–10 важнейших источников с проверкой совместимости в CI производителя. Колоночный линидж и impact analysis прямо в PR: «этот PR затрагивает 4 витрины и 2 дашборда». SLO на свежесть Tier-1 с error budget. Классификация PII и автоматическое маскирование.

3 → 4 (годы). Федеративное владение по доменам, семантический слой как единственный источник метрик, обнаружение аномалий с приемлемым уровнем ложных срабатываний, оптимизация затрат по линиджу.

Полезные ориентиры реального опыта: Data Quality at Airbnb (как формализовали доверие к датасету в измеримый скор) и Monitoring Data Quality at Scale от Uber.


14. Мини-итог

  • Качество — пригодность для конкретного использования, а не абсолютное свойство. Без списка потребителей задача не имеет критерия завершённости.
  • Шесть измерений — рабочий чеклист. Пять проверяются машинно, точность — только реконсиляцией с внешним источником.
  • Стоимость дефекта растёт на порядок на каждом слое. Отсюда WAP: проверяем до публикации и публикуем атомарно.
  • Разделяйте error и warn. Алерт, на который не реагируют, хуже отсутствия алерта.
  • Пишите метрику качества всегда, а не только при падении: деградация обычно медленная.
  • Пороги привязывайте к скользящей робастной статистике (медиана + MAD) и учитывайте сезонность.
  • Контракт переносит ответственность к производителю, но работает, только если ломает сборку. Контракт в вики — не контракт.
  • Колоночный линидж превращает «что сломается?» из расследования в запрос и заодно показывает колонки без потребителей, которые можно удалить.
  • Governance работает, когда встроен в инструменты: владелец в коде, политика маскирования на колонке, обязательные поля каталога в CI.
  • Tier-система — самый дешёвый способ не утонуть: жёсткие требования только там, где цена ошибки реальна.
  • Бюджет проверок — 1–5% стоимости пайплайна: партиционирование, один проход, статистика файлов.
  • Каждый инцидент заканчивается новой проверкой или пунктом контракта — иначе он повторится.

Источники

Что дальше

Мы научились доверять данным: проверять их до публикации, договариваться с производителями через контракты, знать происхождение каждой колонки и управлять доступом. Остался последний участок — тот, ради которого всё строилось: как из проверенных витрин рождаются метрики, почему одна и та же «выручка» в трёх дашбордах различается и что именно инженер данных обязан передать команде машинного обучения, чтобы фичи в обучении и в проде совпадали.

Дальше: Аналитика, метрики и стык с машинным обучением.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов