Качество данных, контракты, линидж и governance
Болезненно типичная история. Во вторник в 9:40 CEO открывает дашборд и видит, что выручка вчера упала
на 40%. Пожар: созвон, продуктовая команда проверяет воронку, маркетинг — кампании, дежурный SRE ищет
инцидент в сервисах. К 14:00 выясняется, что накануне вечером бэкенд-разработчик выкатил рефакторинг,
в котором поле amount события order_paid стало приходить не в копейках, а в рублях. Пайплайн
отработал успешно: строки прочитаны, схема совпала, загрузка зелёная. Испортились не строки, а
смысл строк — и ни один технический мониторинг этого не заметил.
Пайплайны из предыдущих материалов трека отвечают на вопрос «данные доехали?». Здесь мы отвечаем на три других:
- Качество данных — можно ли этим числам верить и как проверить это автоматически?
- Контракты и линидж — кто отвечает за поле и что сломается, если его изменить?
- 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): пишем туда, где потребитель не видит, проверяем, и только затем атомарно публикуем.
запись в staging-ветку
или temp-партицию] B --> C{AUDIT: блокирующие проверки} C -- нарушены --> D[Остановить DAG,
партиция не публикуется] D --> E[Алерт владельцу данных
+ карантин сырых строк] E --> F[Ручной разбор или
автоматический backfill] C -- пройдены --> G{AUDIT: предупреждающие проверки} G -- есть отклонения --> H[Записать метрику,
уведомить, НЕ блокировать] G -- чисто --> I[PUBLISH
атомарная замена указателя:
swap партиции / commit снапшота] H --> I I --> J[Downstream и BI видят
согласованные данные] J --> K[Пост-фактум: реконсиляция
с внешним источником истины] K -- расхождение --> E
Ключевое слово — атомарная публикация. Она почти бесплатна, если хранилище её умеет: снапшоты и
ветки в 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: каталог обновляется сам, граф не поддерживают руками.
Три источника линиджа в проде, обычно комбинируются:
- Статический разбор SQL/кода —
sqlglot,SQLLineage, парсер dbt. Дёшево и до запуска, но слепо к динамическому SQL и к Python/Spark-коду с UDF. - Рантайм-события движка — OpenLineage: открытый стандарт
событий
START/COMPLETE/FAILс input/output-датасетами и расширяемыми facets (схема, статистика, колоночный линидж). Интеграции есть у Airflow, Spark, dbt, Flink; достаточно включить плагин и указать URL коллектора. Точнее статики: фиксирует, что реально прочиталось и записалось. - Логи запросов хранилища — 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 «данные свежее 5 минут» при батче длиной 30 минут недостижим по построению — это не инцидент, а ошибка проектирования.
- Пилообразность нормальна. Алертить надо не на «лаг вырос», а на «лаг выше порога дольше T минут», иначе каждый цикл перед загрузкой генерирует алерт.
- Пустая партиция — не то же самое, что свежая. Джоб может отработать успешно и записать ноль
строк;
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(размер таблицы) | Почти всегда ошибка проектирования |
Четыре приёма, снижающие счёт на порядок:
- Проверять партицию, а не таблицу.
WHERE dt = :partitionпревращает O(таблица) в O(партиция) — при разумном партиционировании, см. пакетную обработку. - Считать метрики одним проходом. Наивно 20 проверок дают 20 сканов; один запрос с 20
агрегатами читает данные один раз:
Ровно на этой идее построен Deequ: вы описываете набор проверок, движок планирует минимум сканов.
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; - Выборка для дорогих проверок. Дрейф прекрасно детектируется по 1% выборке; для уникальности выборка бесполезна. Правило: агрегатные свойства можно семплировать, свойства существования — нельзя.
- Использовать статистику файлов. 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. Жизненный цикл инцидента. Качество данных — не состояние, а операционный процесс. Инциденту нужен тот же жизненный цикл, что и инциденту в сервисе.
аномалия, жалоба потребителя Обнаружен --> Триаж: дежурный смотрит
severity и tier датасета Триаж --> Ложное: порог был некорректен Ложное --> Калибровка: правим порог,
а не отключаем проверку Калибровка --> [*] Триаж --> Локализован: по линиджу найден
первичный датасет Локализован --> Сдержан: партиция откачена,
потребители уведомлены Сдержан --> Исправлен: правка у источника
или в трансформации Исправлен --> Backfill: пересчёт затронутых
партиций и downstream Backfill --> Проверен: реконсиляция
подтвердила корректность Проверен --> Постмортем: почему проехало,
какой проверки не хватало Постмортем --> [*]: добавлена проверка
или пункт контракта note right of Сдержан Сдерживание раньше исправления: сначала перекрыть распространение плохих данных, потом искать причину. end note
Главное правило постмортема: каждый инцидент качества заканчивается либо новой автоматической проверкой, либо новым пунктом контракта. Если заканчивается только «поговорили с командой» — он повторится через месяц.
12. Типичные ошибки
- Проверки после публикации. Тесты есть, но данные уже видны. Лечится WAP.
- Всё блокирующее. Через месяц проверки отключены целиком.
- Алерты без адресата. Канал
#data-alertsна 200 человек, где не отвечает никто. - Проверка есть, метрики нет. Записывается факт падения, а не значение — невозможно увидеть медленную деградацию: доля NULL росла с 1% до 9% полгода и никого не разбудила.
- Тесты только на витринах. Дефект пойман на пятом слое, когда посчитаны 30 моделей. Дешевле всего проверять близко к источнику.
- Порог-константа. «Строк должно быть больше 100 000» — верно год назад, бизнес вырос втрое, проверка обессмыслилась. Пороги привязывают к скользящей статистике.
- Игнорирование сезонности → ложные алерты каждое воскресенье → команда перестаёт реагировать.
- Линидж как ручная диаграмма. Нарисовали в Miro, через квартал он разошёлся с реальностью и стал вредным: люди принимают решения по неверной карте. Линидж должен генерироваться.
- Каталог без обязательности. Описания «по желанию» дают 8% покрытия. Работает только принуждение в CI: нет описания и владельца у Tier-1-модели — сборка красная.
- Комитет вместо инструментов. Три недели на согласование доступа → аналитики выгружают CSV на ноутбуки, и вы теряете governance полностью, включая тот, что был.
- Дедупликация без определения дубля. Дубль — это полное совпадение строки, совпадение бизнес-ключа или совпадение ключа в пределах окна? Три ответа дают три разных пайплайна; для потоков это критично — см. потоковую обработку.
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% стоимости пайплайна: партиционирование, один проход, статистика файлов.
- Каждый инцидент заканчивается новой проверкой или пунктом контракта — иначе он повторится.
Источники
- Richard Y. Wang, Diane M. Strong. Beyond Accuracy: What Data Quality Means to Data Consumers, JMIS, 1996 — классика про fitness for use.
- Barr Moses, Lior Gavish, Molly Vorwerck. Data Quality Fundamentals, O’Reilly, 2022 — источник концепции «пяти столпов наблюдаемости данных».
- Martin Kleppmann. Designing Data-Intensive Applications, гл. 4 — эволюция схем и совместимость форматов. dataintensive.net
- Zhamak Dehghani. Data Mesh Principles and Logical Architecture, martinfowler.com.
- Open Data Contract Standard и datacontract.com — спецификации контрактов.
- OpenLineage и Marquez — стандарт и референсный каталог линиджа.
- dbt: Model Contracts и dbt tests.
- Confluent Schema Registry: Schema Evolution and Compatibility.
- Great Expectations, Soda Core, Deequ — три подхода к декларации проверок.
- Apache Iceberg: Branching and Tagging — техническая основа WAP.
- Google SRE Book. Service Level Objectives.
- NIST/SEMATECH e-Handbook, 1.3.5.17 Detection of Outliers — modified z-score.
- DAMA-DMBOK — свод по governance; справочник терминов, а не руководство к действию.
Что дальше
Мы научились доверять данным: проверять их до публикации, договариваться с производителями через контракты, знать происхождение каждой колонки и управлять доступом. Остался последний участок — тот, ради которого всё строилось: как из проверенных витрин рождаются метрики, почему одна и та же «выручка» в трёх дашбордах различается и что именно инженер данных обязан передать команде машинного обучения, чтобы фичи в обучении и в проде совпадали.