Перейти к содержимому
TelegramGitHub

Data Engineering Design Patterns, глава 8: Паттерны хранения данных

Главная мысль: Быстрый запрос начинается с правильной раскладки данных. Раскладка уменьшает объём чтения, а значит, время и стоимость. Но у каждой оптимизации есть цена: статичность, место на диске или стоимость записи.

Добавить вычислительные ресурсы можно всегда. Но это дорого и делается уже под давлением. Паттерны этой главы работают заранее: они меняют способ хранения, а не размер кластера.

ПаттернКогда применять
Horizontal PartitionerЗапросы читают часть строк по времени или по бизнес-ключу с малым числом значений
Vertical PartitionerВ строке есть изменяемые и неизменяемые атрибуты, или у групп атрибутов разные правила доступа
BucketКолонка с большим числом значений часто стоит в фильтре или в JOIN
SorterЗапросы часто фильтруют или сортируют по одним и тем же колонкам
Metadata EnhancerНужно пропускать ненужные файлы или блоки, не читая их
Dataset MaterializerОдин тяжёлый запрос выполняется снова и снова
ManifestЛистинг файлов в object store медленный
NormalizerВажнее всего согласованность данных и дешёвые обновления
DenormalizerВажнее всего скорость чтения, JOIN слишком дорогие

Первый вопрос про хранилище: как разделить набор данных, чтобы читать нужную часть. Есть два паттерна: горизонтальный и вертикальный.

  • Проблема: Задача считает скользящие агрегаты за 4 дня. Данных всё больше, фильтр “старше 4 дней” читает всё. Больше ресурсов значит больше затрат.
  • Как работает:
    • Выбираем ключ партиционирования (partition key).
    • Каждое значение ключа получает свою физическую папку или partition.
    • Ключ бывает временной (время запуска задачи или event time) или бизнес-ключ (страна, партнёр). Ключи можно вкладывать: год, месяц, день, страна.
    • Примеры: PARTITIONED BY в BigQuery и Databricks, partitionBy в Spark, свой partitioner в Kafka.
  • Цена:
    • Слишком много partition: много метаданных и мелких файлов. Партиционирование по user_id даст миллион папок. Берите ключи с малым числом значений.
    • Skew: partition бывают разного размера. В микробатче самый большой partition задерживает весь батч.
    • Ключ трудно менять. Нужно переписать данные. Исключение: Apache Iceberg меняет схему партиционирования на уровне метаданных, старые файлы остаются как есть.

Это основа для идемпотентности, например для Fast Metadata Cleaner из главы 4.

-- PostgreSQL: одна партиция на день
CREATE TABLE visits (
visit_id TEXT, event_time TIMESTAMP, page TEXT,
PRIMARY KEY (visit_id, event_time)
) PARTITION BY RANGE (event_time);
CREATE TABLE visits_20250101 PARTITION OF visits
FOR VALUES FROM ('2025-01-01') TO ('2025-01-02');

Sharding — частный случай. Данные делятся между машинами, а не только между папками.

Паттерн с тем же именем есть в главе 7. Там он нужен, чтобы дёшево удалять PII.

  • Проблема: В событии визита есть атрибуты, которые разные в каждом событии (время, страница). Есть и атрибуты, которые одинаковы для всего визита (IP, браузер). Вторые не хочется хранить по сто раз.
  • Как работает:
    • Делим атрибуты на группы по смыслу.
    • Выбираем ключ, который их соединяет (visit_id).
    • Пишем каждую группу в свою таблицу или папку.
    • Для разных групп можно задать разный срок хранения и доступ.
  • Цена:
    • Связанные атрибуты лежат в разных местах. Нужна документация.
    • Чтобы увидеть всю строку, нужен JOIN. Помогает view или Dataset Materializer.
    • Продюсер пишет в несколько мест. Это сложнее и дороже.
HorizontalVertical
Что делимСтроки целикомКолонки одной строки
КритерийЗначение ключаГруппа атрибутов
Чтение полной строкиОдна partitionJOIN по ключу

Партиционирование грубое. Оно не работает для колонок с большим числом значений. Следующие два паттерна работают внутри partition.

  • Проблема: Колонка user_id стоит в 80% фильтров. Как partition она даст слишком много папок.
  • Как работает:
    • Выбираем колонки для bucket и число bucket.
    • Номер bucket: hash(key) % число_buckets.
    • Строки с разными ключами лежат в одном bucket.
    • Bucket pruning: фильтр по ключу читает только нужные bucket.
    • Shuffle elimination: если две таблицы разбиты одинаково, JOIN идёт по парам bucket без обмена данными по сети.
    • Примеры: CLUSTERED BY ... INTO 50 BUCKETS в Athena, bucketBy в Spark. Идею популяризировал Hive.
  • Цена:
    • Схема bucket неизменяема. Смена колонки или числа требует полной перезаписи (backfill).
    • Число bucket трудно угадать. Данные растут, bucket становятся большими.
    • В Athena нельзя делать INSERT в таблицу с bucket: она только применяет логику к уже записанным данным.
  • Проблема: Таблицы разбиты по неделям. Чистка стала проще, но запросы не стали быстрее. Все фильтруют по event_time.
  • Как работает:
    • Выбираем колонки сортировки.
    • Хранилище записывает строки в этом порядке.
    • Метаданные блоков (min/max) позволяют пропускать блоки, которые не подходят под фильтр.
    • Лексикографическая сортировка: CLUSTER BY в BigQuery, clustered tables в Snowflake.
    • Кривая Z-order: размещает рядом строки по нескольким колонкам сразу. Есть в Delta Lake (executeZOrderBy) и Iceberg. В Redshift похожая вещь называется interleaved sort keys.
  • Цена:
    • Читателю лучше, писателю хуже. Новые записи лежат несортированными, пока их не пересортируют.
    • При составном ключе фильтр должен включать ведущие колонки. Фильтр только по второй колонке почти не помогает. Здесь выручает Z-order.
    • Смена ключа может потребовать пересортировать всю таблицу.
-- BigQuery: partition по дню, сортировка по двум колонкам
CREATE TABLE visits_raw (...)
PARTITION BY DATE(event_time)
CLUSTER BY visit_id, page;
  • Проблема: Аналитики читают только малую часть partition, но платят за чтение всего файла.
  • Как работает:
    • В файл или в отдельную таблицу пишется статистика: min, max, число NULL, число строк.
    • В Parquet статистика лежит в footer каждого файла. Движок смотрит footer и решает, нужен ли файл.
    • Delta Lake, Iceberg и Hudi добавляют статистику в commit log. Тогда COUNT(*) и фильтры по NULL часто считаются без чтения данных.
    • В реляционных базах статистика нужна планировщику запросов.
  • Цена:
    • Запись немного медленнее: нужно считать статистику.
    • Статистика устаревает. В базах она обновляется по порогу изменений. Тогда запускают ANALYZE TABLE, но это добавляет нагрузку.
  • Проблема: View над несколькими таблицами пересчитывается при каждом чтении. Потребители жалуются на задержку.
  • Как работает:
    • Выбираем набор данных и пишем запрос (SELECT, UNION, JOIN).
    • Сохраняем результат как materialized view или как обычную таблицу.
    • У materialized view есть автообновление (enable_refresh в BigQuery, AUTO REFRESH в Redshift) или ручное (REFRESH MATERIALIZED VIEW в PostgreSQL).
    • Таблица даёт больше свободы: можно добавить partition, bucket, sorting. Но обновлять её нужно самим.
    • Инкрементальный вариант: берём строки с insertion_time позже прошлого запуска и добавляем их через MERGE. Это связка Incremental Loader и Merger.
  • Цена:
    • Каждое обновление запускает запрос. Тяжёлый запрос грузит базу.
    • Автообновление идёт не сразу после изменения. Задержка возможна.
    • Права доступа на view нужно согласовать с правами на исходные таблицы.
    • Дубликат данных занимает место. Можно материализовать только часть.
MERGE INTO visits_counter t
USING (
SELECT user_id, COUNT(*) AS visits
FROM visits
WHERE insertion_time > '2025-01-01 03:00:00'
GROUP BY user_id
) i ON t.user_id = i.user_id
WHEN MATCHED THEN UPDATE SET visits_count = t.visits_count + i.visits
WHEN NOT MATCHED THEN INSERT (user_id, visits_count) VALUES (i.user_id, i.visits);
  • Проблема: Листинг файлов в object store медленный. Для каждого чтения это много API-вызовов.
  • Как работает:
    • Продюсер записывает список созданных файлов в отдельный файл (manifest).
    • Читатель берёт список из manifest и не вызывает листинг.
    • Delta Lake, Iceberg и Hudi делают это сами: commit log служит manifest.
    • Ручной manifest нужен для интеграции систем. Пример: Delta Lake генерирует symlink_format_manifest для внешней таблицы в BigQuery.
    • Redshift COPY ... MANIFEST берёт список файлов для загрузки. Делайте отдельный manifest на каждый запуск. При повторе загрузятся те же файлы, а не новые. Вместе с очисткой данных запуска (глава 4) это даёт идемпотентную загрузку.
  • Цена:
    • Дополнительный шаг в пайплайне. Но создать manifest проще, чем листинг много раз.
    • Manifest может вырасти до гигабайт, особенно при streaming и мелких файлах. Некоторые системы ограничивают размер или срок записей.
  • Проблема: В таблице визитов неизменяемые атрибуты (устройство, браузер) повторяются в каждой строке. Это место на диске и медленные обновления.
  • Как работает:
    • Определяем бизнес-сущности (визиты, устройства, браузеры).
    • Описываем атрибуты каждой сущности.
    • Описываем связи между сущностями.
    • Два варианта: нормальные формы (1NF, 2NF, 3NF) для транзакционных систем и snowflake schema для аналитики.
    • 1NF: атомарные значения, первичный ключ.
    • 2NF: каждая колонка зависит от всего ключа.
    • 3NF: нет транзитивных зависимостей между неключевыми колонками.
    • В snowflake schema измерения сами описываются подизмерениями.
  • Цена:
    • Сложность модели.
    • Много JOIN. В распределённой системе они дорогие из-за обмена по сети. Помогают colocation и broadcast малых таблиц (в Spark порог задаёт spark.sql.autoBroadcastJoinThreshold).
    • История значений измерений нужна отдельно. Решается техниками SCD (см. Static Joiner, глава 5).
  • Проблема: Типичный запрос соединяет 8 таблиц. Данных много, аналитики ждут.
  • Как работает:
    • Данные из соединяемых таблиц кладём в одну строку. Тогда JOIN не нужен.
    • Вариант 1: обычные колонки, One Big Table.
    • Вариант 2: вложенные структуры (STRUCT).
    • Вариант 3: star schema. Это промежуточный путь: измерения плоские, без подизмерений.
    • Нормализованный слой можно оставить как источник и строить поверх него денормализованный. Порядок задаёт паттерн последовательности из главы 6.
  • Цена:
    • Обновление дороже: одно значение повторено во многих строках. Если таблица считается снимком, обновлять её не нужно.
    • Данные дублируются. Часто помогает кодирование через словарь.
    • One Big Table легко превратить в мусорную корзину. Проверка: если в названии таблицы просятся “и” или “с”, в ней слишком много несвязанных атрибутов.
-- Нормализованно: нужно два JOIN
SELECT v.visit_id, u.login, d.type
FROM visits v
LEFT JOIN users u ON v.user_id = u.id
LEFT JOIN devices d ON v.device_id = d.id;
-- Денормализованно: JOIN не нужен
SELECT visit_id, user_login, device_type FROM visits_one_big_table;
ТерминЗначение
PartitionФизически отдельное место хранения для значения ключа
CardinalityЧисло различных значений в колонке
SkewНеравномерный размер partition
BucketГруппа строк, выбранная по hash(key) % N
Z-orderКривая сортировки по нескольким колонкам сразу
Data skippingПропуск файлов или блоков по min/max статистике
Materialized viewСохранённый результат запроса с обновлением
ManifestФайл со списком файлов набора данных
Normal formПравила нормализации таблиц (1NF-3NF)
One Big TableШирокая денормализованная таблица
  • Партиционируйте по времени или по ключу с малым числом значений. Для user_id берите Bucket.
  • Проверяйте число partition. Тысячи мелких partition замедляют листинг.
  • В BigQuery и Snowflake комбинируйте partition по дате и clustering по частым фильтрам.
  • В Delta Lake и Iceberg периодически делайте OPTIMIZE и Z-order по колонкам фильтра.
  • Используйте Parquet: его статистика даёт data skipping бесплатно. Следите за свежей статистикой в хранилище.
  • Для тяжёлых витрин в dbt выбирайте table или incremental вместо view.
  • В хранилище часто строят нормализованный слой для консистентности и денормализованную витрину для BI.
  1. Почему партиционирование по user_id обычно плохая идея? Какой паттерн лучше подходит?
  2. Чем Vertical Partitioner отличается от Horizontal Partitioner? Когда нужен вертикальный?
  3. Почему при составном ключе сортировки фильтр только по второй колонке мало помогает? Что помогает?
  4. Что вы выиграете и что потеряете, если заменить нормализованную модель на One Big Table?

Предыдущая: Паттерны безопасности данных. Следующая: Паттерны качества данных.