Data Engineering Design Patterns, глава 8: Паттерны хранения данных
Главная мысль: Быстрый запрос начинается с правильной раскладки данных. Раскладка уменьшает объём чтения, а значит, время и стоимость. Но у каждой оптимизации есть цена: статичность, место на диске или стоимость записи.
Добавить вычислительные ресурсы можно всегда. Но это дорого и делается уже под давлением. Паттерны этой главы работают заранее: они меняют способ хранения, а не размер кластера.
Все паттерны главы
Заголовок раздела «Все паттерны главы»| Паттерн | Когда применять |
|---|---|
Horizontal Partitioner | Запросы читают часть строк по времени или по бизнес-ключу с малым числом значений |
Vertical Partitioner | В строке есть изменяемые и неизменяемые атрибуты, или у групп атрибутов разные правила доступа |
Bucket | Колонка с большим числом значений часто стоит в фильтре или в JOIN |
Sorter | Запросы часто фильтруют или сортируют по одним и тем же колонкам |
Metadata Enhancer | Нужно пропускать ненужные файлы или блоки, не читая их |
Dataset Materializer | Один тяжёлый запрос выполняется снова и снова |
Manifest | Листинг файлов в object store медленный |
Normalizer | Важнее всего согласованность данных и дешёвые обновления |
Denormalizer | Важнее всего скорость чтения, JOIN слишком дорогие |
Партиционирование
Заголовок раздела «Партиционирование»Первый вопрос про хранилище: как разделить набор данных, чтобы читать нужную часть. Есть два паттерна: горизонтальный и вертикальный.
Horizontal Partitioner
Заголовок раздела «Horizontal Partitioner»- Проблема: Задача считает скользящие агрегаты за 4 дня. Данных всё больше, фильтр “старше 4 дней” читает всё. Больше ресурсов значит больше затрат.
- Как работает:
- Выбираем ключ партиционирования (
partition key). - Каждое значение ключа получает свою физическую папку или partition.
- Ключ бывает временной (время запуска задачи или
event time) или бизнес-ключ (страна, партнёр). Ключи можно вкладывать: год, месяц, день, страна. - Примеры:
PARTITIONED BYв BigQuery и Databricks,partitionByв Spark, свой partitioner в Kafka.
- Выбираем ключ партиционирования (
- Цена:
- Слишком много partition: много метаданных и мелких файлов. Партиционирование по
user_idдаст миллион папок. Берите ключи с малым числом значений. Skew: partition бывают разного размера. В микробатче самый большой partition задерживает весь батч.- Ключ трудно менять. Нужно переписать данные. Исключение: Apache Iceberg меняет схему партиционирования на уровне метаданных, старые файлы остаются как есть.
- Слишком много partition: много метаданных и мелких файлов. Партиционирование по
Это основа для идемпотентности, например для 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 — частный случай. Данные делятся между машинами, а не только между папками.
Vertical Partitioner
Заголовок раздела «Vertical Partitioner»Паттерн с тем же именем есть в главе 7. Там он нужен, чтобы дёшево удалять PII.
- Проблема: В событии визита есть атрибуты, которые разные в каждом событии (время, страница). Есть и атрибуты, которые одинаковы для всего визита (IP, браузер). Вторые не хочется хранить по сто раз.
- Как работает:
- Делим атрибуты на группы по смыслу.
- Выбираем ключ, который их соединяет (
visit_id). - Пишем каждую группу в свою таблицу или папку.
- Для разных групп можно задать разный срок хранения и доступ.
- Цена:
- Связанные атрибуты лежат в разных местах. Нужна документация.
- Чтобы увидеть всю строку, нужен
JOIN. Помогает view илиDataset Materializer. - Продюсер пишет в несколько мест. Это сложнее и дороже.
| Horizontal | Vertical | |
|---|---|---|
| Что делим | Строки целиком | Колонки одной строки |
| Критерий | Значение ключа | Группа атрибутов |
| Чтение полной строки | Одна partition | JOIN по ключу |
Организация записей
Заголовок раздела «Организация записей»Партиционирование грубое. Оно не работает для колонок с большим числом значений. Следующие два паттерна работают внутри 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: она только применяет логику к уже записанным данным.
- Схема 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;Оптимизация чтения
Заголовок раздела «Оптимизация чтения»Metadata Enhancer
Заголовок раздела «Metadata Enhancer»- Проблема: Аналитики читают только малую часть partition, но платят за чтение всего файла.
- Как работает:
- В файл или в отдельную таблицу пишется статистика: min, max, число
NULL, число строк. - В Parquet статистика лежит в footer каждого файла. Движок смотрит footer и решает, нужен ли файл.
- Delta Lake, Iceberg и Hudi добавляют статистику в commit log. Тогда
COUNT(*)и фильтры поNULLчасто считаются без чтения данных. - В реляционных базах статистика нужна планировщику запросов.
- В файл или в отдельную таблицу пишется статистика: min, max, число
- Цена:
- Запись немного медленнее: нужно считать статистику.
- Статистика устаревает. В базах она обновляется по порогу изменений. Тогда запускают
ANALYZE TABLE, но это добавляет нагрузку.
Dataset Materializer
Заголовок раздела «Dataset Materializer»- Проблема: 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 tUSING ( 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_idWHEN MATCHED THEN UPDATE SET visits_count = t.visits_count + i.visitsWHEN NOT MATCHED THEN INSERT (user_id, visits_count) VALUES (i.user_id, i.visits);Manifest
Заголовок раздела «Manifest»- Проблема: Листинг файлов в 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 и мелких файлах. Некоторые системы ограничивают размер или срок записей.
Представление данных
Заголовок раздела «Представление данных»Normalizer
Заголовок раздела «Normalizer»- Проблема: В таблице визитов неизменяемые атрибуты (устройство, браузер) повторяются в каждой строке. Это место на диске и медленные обновления.
- Как работает:
- Определяем бизнес-сущности (визиты, устройства, браузеры).
- Описываем атрибуты каждой сущности.
- Описываем связи между сущностями.
- Два варианта: нормальные формы (
1NF,2NF,3NF) для транзакционных систем иsnowflake schemaдля аналитики. 1NF: атомарные значения, первичный ключ.2NF: каждая колонка зависит от всего ключа.3NF: нет транзитивных зависимостей между неключевыми колонками.- В
snowflake schemaизмерения сами описываются подизмерениями.
- Цена:
- Сложность модели.
- Много
JOIN. В распределённой системе они дорогие из-за обмена по сети. Помогаютcolocationиbroadcastмалых таблиц (в Spark порог задаётspark.sql.autoBroadcastJoinThreshold). - История значений измерений нужна отдельно. Решается техниками
SCD(см.Static Joiner, глава 5).
Denormalizer
Заголовок раздела «Denormalizer»- Проблема: Типичный запрос соединяет 8 таблиц. Данных много, аналитики ждут.
- Как работает:
- Данные из соединяемых таблиц кладём в одну строку. Тогда
JOINне нужен. - Вариант 1: обычные колонки,
One Big Table. - Вариант 2: вложенные структуры (
STRUCT). - Вариант 3:
star schema. Это промежуточный путь: измерения плоские, без подизмерений. - Нормализованный слой можно оставить как источник и строить поверх него денормализованный. Порядок задаёт паттерн последовательности из главы 6.
- Данные из соединяемых таблиц кладём в одну строку. Тогда
- Цена:
- Обновление дороже: одно значение повторено во многих строках. Если таблица считается снимком, обновлять её не нужно.
- Данные дублируются. Часто помогает кодирование через словарь.
One Big Tableлегко превратить в мусорную корзину. Проверка: если в названии таблицы просятся “и” или “с”, в ней слишком много несвязанных атрибутов.
-- Нормализованно: нужно два JOINSELECT v.visit_id, u.login, d.typeFROM visits vLEFT JOIN users u ON v.user_id = u.idLEFT 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.
Вопросы для самопроверки
Заголовок раздела «Вопросы для самопроверки»- Почему партиционирование по
user_idобычно плохая идея? Какой паттерн лучше подходит? - Чем
Vertical Partitionerотличается отHorizontal Partitioner? Когда нужен вертикальный? - Почему при составном ключе сортировки фильтр только по второй колонке мало помогает? Что помогает?
- Что вы выиграете и что потеряете, если заменить нормализованную модель на
One Big Table?
Предыдущая: Паттерны безопасности данных. Следующая: Паттерны качества данных.