Data Layout: Z-ordering, Hilbert curves и file skipping
Пространственные кривые, bit-interleaving и многомерная кластеризация данных - хирургический инструмент Senior Data Engineer для ускорения аналитических запросов без раздутого партиционирования
Data Layout: Z-ordering, Hilbert curves и file skipping¶
Мы добрались до вершины инженерного искусства в оптимизации Data Lakehouse. Если партиционирование - это грубый топор, который рубит данные на огромные изолированные куски, то механизмы Data Layout - это хирургический скальпель. Z-ordering, кривые Гильберта и File Skipping позволяют Spark отсекать ненужные файлы при чтении по нескольким колонкам сразу, не плодя при этом бесконечные вложенные папки на S3.
В современном Modern Data Stack (Delta Lake, Apache Iceberg) эта тема отличает Senior-разработчика от всех остальных. Именно здесь математика пространственных кривых превращается в реальные секунды ускорения продакшн-запросов.
1. Проклятие многомерности: крах линейной сортировки¶
Начнём с фундаментальной проблемы, которая делает Z-ordering необходимым инструментом.
Почему ORDER BY ломается при многоколоночной фильтрации¶
Представьте таблицу событий с тремя важными аналитическими колонками: country, date и age. Аналитик хочет мгновенно получать ответы на запросы типа WHERE age = 30 AND country = 'RU'. Естественная реакция инженера - отсортировать таблицу: ORDER BY country, date, age.
Что происходит физически? Данные записываются в файлы в лексикографическом порядке: сначала все строки с country = 'DE', внутри них - по возрастанию date, внутри каждой даты - по возрастанию age. Это идеально для запросов типа WHERE country = 'DE' AND date = '2024-01-01'. Но для запроса WHERE age = 30 это катастрофа.
Почему? Потому что age - третья колонка в ключе сортировки. Для каждой комбинации (country, date) значение age = 30 встречается в каждом файле. Min/Max диапазоны по колонке age для каждого файла будут примерно одинаковы: [18, 75]. Планировщик Spark, видя это, не может пропустить ни одного файла - приходится читать всё.
Диаграмма: провал лексикографической сортировки. Три файла содержат данные для разных стран, но колонка age разбросана по всем файлам одинаково. Планировщик вынужден читать каждый файл, потому что диапазон age в каждом файле включает значение 30. Ни один файл не пропускается.
Это и есть проклятие многомерности в линейных структурах: любая линейная сортировка идеально работает только для одной (первой) колонки ключа. Вторая колонка работает хуже, третья - почти не работает.
Постановка задачи: равноправная многоколоночная локальность¶
Нам нужен алгоритм, который:
- Отображает многомерное пространство значений в одномерный порядок записи в файлы
- Обеспечивает, чтобы значения, близкие в любом измерении, оказывались в одном файле
- Создаёт узкие диапазоны
[Min, Max]для каждой колонки в каждом файле
Именно эту задачу решают пространственные кривые - Z-Order и кривые Гильберта.
2. Механика File Skipping: как Spark читает метаданные до открытия файлов¶
Прежде чем понять, как Z-ordering улучшает File Skipping, нужно разобраться, как File Skipping работает изнутри.
Анатомия метаданных в Parquet и Iceberg/Delta¶
Каждый Parquet-файл хранит внутри себя метаданные - Footer и Column Statistics для каждого Row Group:
- null_count - количество NULL-значений в колонке
- value_counts - общее количество значений
- lower_bound (Min) - минимальное значение в колонке для этого Row Group
- upper_bound (Max) - максимальное значение в колонке для этого Row Group
Apache Iceberg поднимает эти метаданные на уровень манифестов. Каждый Manifest File содержит агрегированные статистики по всем Data Files, которые в нём перечислены. Планировщик читает манифесты - лёгкие Avro-файлы - без открытия самих Parquet-файлов.
Delta Lake хранит аналогичную информацию в своём Transaction Log (JSON-файлы в директории _delta_log/). Каждая запись add в логе содержит stats с minValues и maxValues для каждой колонки.
Диаграмма: механика File Skipping. Планировщик Spark читает только метаданные из манифестов (лёгкие файлы), сравнивает предикаты запроса с диапазонами [Min, Max] каждого файла и полностью пропускает файлы, где нужные значения точно отсутствуют. Это происходит до начала выполнения самих Task. Задачи создаются только для файлов, которые не были пропущены.
Концепция взаимного перекрытия (Overlap) - враг File Skipping¶
Overlap - это ситуация, когда диапазоны [Min, Max] разных файлов по одной колонке сильно пересекаются.
Пример: если у нас 100 файлов, и каждый содержит user_id от 1 до 1,000,000 (Full Range), то любой запрос WHERE user_id = 42 должен прочитать все 100 файлов, потому что 42 входит в диапазон каждого файла. Overlap = 100%.
После Z-ordering: каждый файл содержит user_id из узкого диапазона - файл 1 хранит user_id от 1 до 10,000, файл 2 - от 10,001 до 20,000 и так далее. Запрос WHERE user_id = 42 читает только 1 из 100 файлов. Overlap ≈ 1%.
Задача Z-ordering - минимизировать Overlap сразу по нескольким колонкам одновременно.
3. Математика под капотом: Z-Ordering и кривые Гильберта¶
Z-Order (кривая Мортона): чередование битов¶
Z-ordering был изобретён Гаем Мортоном в 1966 году для организации данных в географических информационных системах. Его ключевая идея - Bit-Interleaving: чередование битов нескольких числовых значений для получения единого Z-индекса.
Принцип Bit-Interleaving¶
Возьмём два значения: x = 5 (в двоичном: 101) и y = 3 (в двоичном: 011).
Традиционная конкатенация дала бы нам 101011 - сначала все биты x, потом все биты y. Но это создаёт ту же проблему, что и лексикографическая сортировка: первая переменная доминирует.
Bit-Interleaving чередует биты поочерёдно:
- Берём старший бит
x:1 - Берём старший бит
y:0 - Берём второй бит
x:0 - Берём второй бит
y:1 - Берём третий бит
x:1 - Берём третий бит
y:1
Z-индекс = 100111 = 39 в десятичной системе.
Диаграмма: Bit-Interleaving - сердце Z-ordering. Биты двух значений (x=5 и y=3) чередуются поочерёдно: сначала старший бит x, затем старший бит y, и так далее. Результирующий Z-индекс (39) кодирует оба измерения в один порядковый номер. Главное свойство: значения, близкие одновременно по x и по y, получают близкие Z-индексы - они записываются в один файл.
Визуализация Z-кривой в двумерном пространстве¶
Если разложить все Z-индексы для двумерной сетки 4×4 и нарисовать линию, соединяющую их по порядку, получится характерная Z-образная форма. Именно отсюда и происходит название алгоритма:
y=3: 12 13 14 15
y=2: 8 9 10 11
y=1: 4 5 6 7
y=0: 0 1 2 3
x=0 x=1 x=2 x=3
Обход по Z-индексу (0→1→2→3→4→5→6→7→8→...) рисует Z-образные траектории внутри каждого квадранта. Точки, близкие в двумерном пространстве, получают близкие Z-индексы.
Строки таблицы с Z-индексами 0–3 записываются в File 1, строки с Z-индексами 4–7 - в File 2, и так далее. Поскольку строки с x=0, y=0 (Z=0) и x=1, y=0 (Z=1) физически соседние, они попадают в один файл. Это означает узкий диапазон min/max по обеим колонкам.
Слабое место Z-Order: скачки на границах¶
У Z-кривой есть известный недостаток - резкие скачки при переходе между Z-зонами. Например, в сетке 4×4 строка с координатами (0, 1) имеет Z-индекс 2, а соседняя строка (1, 1) имеет Z-индекс 3. Но (0, 2) имеет Z-индекс 8, хотя физически это ближайший сосед сверху. Скачок с 3 до 8 означает, что между ними записываются строки (1, 2), (2, 1) и (3, 1) - пространственно удалённые точки.
Эти «скачки» ухудшают локальность данных на границах Z-зон и снижают эффективность File Skipping по сравнению с теоретическим максимумом.
Кривые Гильберта: решение проблемы скачков¶
Кривая Гильберта - пространственно-заполняющая кривая второго порядка. В отличие от Z-кривой, она построена рекурсивно и не имеет «скачков»: каждая последующая точка кривой всегда является ближайшим соседом предыдущей в N-мерном пространстве.
Диаграмма: Z-Order vs кривая Гильберта. Оба алгоритма решают одну задачу - отображение многомерного пространства в одномерный порядок записи - но делают это по-разному. Кривая Гильберта обеспечивает более качественную пространственную локальность ценой более сложного вычисления индекса. Оба подхода дают несравнимо лучший File Skipping по нескольким колонкам, чем классическая лексикографическая сортировка.
Почему Гильберт побеждает Z-Order¶
Математически доказано, что кривая Гильберта минимизирует максимальную длину скачка при обходе многомерного пространства. Для практики дата-инженера это означает:
- Лучшая локальность: строки с похожими значениями по обеим колонкам с большей вероятностью окажутся в одном файле
- Более узкие диапазоны: Min/Max в каждом файле будут теснее, что позволяет Spark пропускать больше файлов
- Устойчивость к кардинальности: на данных с высокой кардинальностью (миллионы уникальных значений) превосходство Гильберта над Z-Order становится ещё более заметным
Apache Iceberg начал поддерживать кривые Гильберта с версии 1.0+ через процедуру rewrite_data_files с параметром sort_order = 'HILBERT(col1, col2)'.
4. Практика: настройка Data Layout в Delta Lake и Iceberg¶
Delta Lake: OPTIMIZE ZORDER¶
from pyspark.sql import SparkSession
import pyspark.sql.functions as F
import time
spark = SparkSession.builder \
.appName("Data-Layout-ZOrder") \
.config("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension") \
.config("spark.sql.catalog.spark_catalog",
"org.apache.spark.sql.delta.catalog.DeltaCatalog") \
.getOrCreate()
path = "/tmp/delta_zorder_demo"
# 1. Создаём синтетическую таблицу событий
# 5 миллионов строк с тремя аналитическими колонками
df = spark.range(1, 5_000_000) \
.withColumn("user_id", (F.col("id") * 7) % 100_000) \
.withColumn("country",
F.when(F.col("id") % 3 == 0, "RU")
.when(F.col("id") % 3 == 1, "US")
.otherwise("DE")) \
.withColumn("event_type",
F.when(F.col("id") % 5 == 0, "PURCHASE")
.when(F.col("id") % 5 == 1, "CLICK")
.otherwise("VIEW")) \
.withColumn("ts", F.to_timestamp(
F.lit("2024-01-01").cast("timestamp") +
(F.col("id") % 365 * 86400)))
df.write.format("delta").mode("overwrite").save(path)
# 2. Тест ДО оптимизации
# Запрос по двум некластеризованным колонкам - Full Scan
start = time.time()
count_before = spark.read.format("delta").load(path) \
.filter("user_id = 42 AND country = 'RU'") \
.count()
elapsed_before = time.time() - start
print(f"До Z-Order: {count_before} строк за {elapsed_before:.2f} сек")
# Spark UI: все файлы прочитаны, min/max overlap ~100%
# 3. Применяем Z-Order по двум колонкам
# OPTIMIZE перезаписывает файлы с новой пространственной кластеризацией
from delta.tables import DeltaTable
deltaTable = DeltaTable.forPath(spark, path)
optimize_start = time.time()
deltaTable.optimize().executeZOrderBy("user_id", "country")
optimize_elapsed = time.time() - optimize_start
print(f"OPTIMIZE ZORDER завершён за {optimize_elapsed:.2f} сек")
# 4. Тест ПОСЛЕ оптимизации - тот же самый запрос
start_opt = time.time()
count_after = spark.read.format("delta").load(path) \
.filter("user_id = 42 AND country = 'RU'") \
.count()
elapsed_after = time.time() - start_opt
print(f"После Z-Order: {count_after} строк за {elapsed_after:.2f} сек")
print(f"Ускорение: {elapsed_before / elapsed_after:.1f}x")
# Spark UI: files scanned упал с N до 1-3, bytesRead - в 10-20 раз меньше
В Spark UI после Z-ordering посмотрите на секцию SQL Metrics для вашего Job:
number of files read- должно упасть в 10–50 разscan time- время на чтение файловsize of files read- объём прочитанных байт
SQL-синтаксис для Delta¶
-- Оптимизация всей таблицы
OPTIMIZE events_delta
ZORDER BY (user_id, country);
-- С указанием конкретной партиции - ускоряет OPTIMIZE для больших таблиц
-- Позволяет точечно оптимизировать только свежие данные
OPTIMIZE events_delta
WHERE date = '2024-01-01'
ZORDER BY (user_id, country);
Apache Iceberg: rewrite_data_files с Z-Order и Hilbert¶
В Iceberg Z-ordering и Hilbert-clustering - это часть процедуры rewrite_data_files, которую мы разбирали в уроке про компакцию.
from pyspark.sql import SparkSession
import pyspark.sql.functions as F
spark = SparkSession.builder \
.appName("Iceberg-Data-Layout") \
.config("spark.sql.extensions",
"org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions") \
.config("spark.sql.catalog.local", "org.apache.iceberg.spark.SparkCatalog") \
.config("spark.sql.catalog.local.type", "hadoop") \
.config("spark.sql.catalog.local.warehouse", "/tmp/iceberg_warehouse") \
.getOrCreate()
# Создаём Iceberg-таблицу с партиционированием по дате
spark.sql("""
CREATE TABLE IF NOT EXISTS local.db.events (
id BIGINT,
user_id BIGINT,
country STRING,
event_type STRING,
ts TIMESTAMP
) USING iceberg
PARTITIONED BY (days(ts))
""")
# Имитируем накопление мелких файлов (20 микро-батчей)
for batch in range(20):
batch_df = spark.range(batch * 250_000, (batch + 1) * 250_000) \
.withColumn("user_id", (F.col("id") * 7) % 100_000) \
.withColumn("country",
F.when(F.col("id") % 3 == 0, "RU").otherwise("US")) \
.withColumn("event_type",
F.when(F.col("id") % 5 == 0, "PURCHASE").otherwise("VIEW")) \
.withColumn("ts", F.current_timestamp())
batch_df.writeTo("local.db.events").append()
# Z-Order через Iceberg - strategy='sort' + sort_order='zorder(...)'
spark.sql("""
CALL local.system.rewrite_data_files(
table => 'local.db.events',
strategy => 'sort',
sort_order => 'zorder(user_id, country)',
options => map(
'target-file-size-bytes', '134217728',
'max-concurrent-file-group-rewrites', '5'
)
)
""")
# Hilbert Curve - лучший выбор для данных с высокой кардинальностью
# Доступен начиная с Apache Iceberg 1.0+
spark.sql("""
CALL local.system.rewrite_data_files(
table => 'local.db.events',
strategy => 'sort',
sort_order => 'hilbert(user_id, country)',
options => map(
'target-file-size-bytes', '134217728'
)
)
""")
# Проверяем насколько узкими стали диапазоны Min/Max после оптимизации
# Чем уже диапазон - тем эффективнее будет File Skipping
spark.sql("""
SELECT
file_path,
record_count,
lower_bounds['user_id'] AS user_id_min,
upper_bounds['user_id'] AS user_id_max,
lower_bounds['country'] AS country_min,
upper_bounds['country'] AS country_max
FROM local.db.events.files
ORDER BY lower_bounds['user_id']
LIMIT 10
""").show(truncate=False)
Вывод системной таблицы local.db.events.files показывает, насколько узкими стали диапазоны [user_id_min, user_id_max] и [country_min, country_max] после Z-ordering. До оптимизации каждый файл содержал user_id от 0 до 99,999 (Full Range). После - каждый файл содержит user_id из диапазона шириной ~1,000–5,000 значений. Это и есть разница между 0% и 95% File Skipping.
Сравнение workflow: Delta Lake vs Iceberg¶
Диаграмма: сравнение workflow в Delta Lake и Iceberg. Оба формата следуют одному шаблону: запись → накопление файлов → Z-ordering/Hilbert → удаление старых файлов. Ключевое различие - атомарность: Iceberg использует Snapshot Isolation (читатели видят старые файлы во время компакции), Delta Lake обеспечивает ACID через Transaction Log. Конечный результат одинаков - узкие диапазоны в файлах и эффективный File Skipping.
5. Жёсткие ограничения и правила проектирования¶
Правило 1: ограничение количества колонок¶
Максимум 2–4 колонки для Z-order/Hilbert. Это не рекомендация - это математически обусловленное ограничение.
Причина: с ростом числа измерений N количество ячеек в N-мерной гиперкубической решётке экспоненциально возрастает. При фиксированном размере файла (128 MB = ~1M строк) данные в каждой ячейке многомерного пространства становятся редкими. Файлы начинают содержать строки из удалённых регионов пространства - пространственная локальность деградирует.
Практические последствия: при Z-order по 8 колонкам эффективность File Skipping по каждой отдельной колонке приближается к случайной раскладке. Вы тратите время на OPTIMIZE, но не получаете выигрыша.
Как выбирать колонки для Z-order:
- Возьмите 10–20 наиболее частых аналитических запросов к таблице
- Найдите 2–3 колонки, которые чаще всего используются в
WHERE-предикатах (особенно в комбинации через AND) - Убедитесь, что эти колонки не являются партиционными - нет смысла делать Z-ordering по партиционной колонке, там уже работает Directory Pruning
Правило 2: кардинальность имеет значение¶
Z-ordering работает лучше всего для колонок с высокой и средней кардинальностью (десятки тысяч - миллионы уникальных значений):
| Кардинальность | Примеры | Рекомендация |
|---|---|---|
| Очень низкая (< 100) | country, status |
partitionBy |
| Низкая (100–10,000) | city, product_category |
Z-Order |
| Средняя (10K–1M) | user_id, device_id |
Z-Order (идеально) |
| Высокая (> 1M) | session_id, event_id |
Z-Order + Hilbert |
| UUID/GUID | Первичные ключи | Малоэффективен |
Для очень низкой кардинальности классическое partitionBy даёт 100% Directory Pruning с нулевыми накладными расходами. Z-order для таких колонок - избыточная сложность.
Для UUID-колонок (полностью уникальных значений) Z-ordering не помогает, потому что невозможно создать узкий диапазон [Min, Max] при поиске одного конкретного UUID - он всегда будет попадать в диапазон единственного файла.
Правило 3: комбинирование Partitioning и Z-Order¶
Самая эффективная стратегия - двухуровневая оптимизация: грубое партиционирование по дате/месяцу + тонкий Z-order по аналитическим колонкам внутри каждой партиции.
Диаграмма: двухуровневая оптимизация - партиционирование + Z-Order. Первый уровень - Directory Pruning по партиционной колонке date - мгновенно отбрасывает ненужные директории без открытия файлов. Второй уровень - Z-ordering по user_id и country - обеспечивает File Skipping внутри выбранной партиции. Результат: из 9 файлов читается только 1. Без двухуровневой схемы пришлось бы читать 2–3 файла (меньше, чем без оптимизации, но больше, чем с комбинированной схемой).
Рекомендованная схема для Event Table в продакшне:
# Уровень 1: партиционирование по дате (Directory Pruning)
# Уровень 2: Z-Order по user_id + event_type (File Skipping)
df.write \
.format("delta") \
.partitionBy("date") \
.mode("overwrite") \
.save("/warehouse/events")
# После накопления файлов - точечный Z-Order внутри партиций
# Указываем WHERE date для обработки только свежих данных
from delta.tables import DeltaTable
DeltaTable.forPath(spark, "/warehouse/events") \
.optimize() \
.where("date = '2024-01-01'") \
.executeZOrderBy("user_id", "event_type")
Правило 4: Z-order - не серебряная пуля для OR-запросов¶
Вопрос на засыпку:
«Мы сделали Z-Order по колонкам
(user_id, date). Аналитик пишет:WHERE user_id = 500 OR date = '2024-05-23'. Сработает ли File Skipping?»
Правильный ответ: нет, или сработает очень плохо.
Условие OR означает: «верни строку, если выполняется хотя бы одно из условий». Планировщик не может пропустить файл, если хотя бы одно из условий OR теоретически может выполниться в этом файле. Поскольку Z-ordering создаёт узкие диапазоны для комбинации значений, а не для отдельных условий, большинство файлов придётся прочитать.
Для эффективного File Skipping предикаты должны соединяться через AND:
-- Хорошо: оба условия через AND - Z-order работает в полную силу
SELECT * FROM events
WHERE user_id = 42 AND country = 'RU';
-- Плохо: условие OR - Z-order практически не помогает
SELECT * FROM events
WHERE user_id = 42 OR country = 'RU';
-- Приемлемо: IN по одной колонке + AND по второй
SELECT * FROM events
WHERE user_id IN (42, 43, 44) AND country = 'RU';
6. Когда Data Layout не помогает¶
Прежде чем запускать OPTIMIZE ZORDER, убедитесь, что это вообще нужно:
Диаграмма: дерево решений - нужен ли Z-Order. Прежде чем запускать дорогостоящий OPTIMIZE, убедитесь, что он применим. Маленькие таблицы, Full Table Scan, UUID-ключи и часто перезаписываемые данные - это случаи, когда Z-ordering не принесёт пользы, зато потратит CPU-время на перезапись файлов.
Антипаттерны Data Layout¶
Антипаттерн 1: Z-order без предварительной компакции
Если таблица содержит 100,000 файлов по 1 MB, запуск OPTIMIZE ZORDER попытается переписать все эти файлы, создавая колоссальную нагрузку. Правильная последовательность: сначала компакция (Bin-Pack для уменьшения числа файлов), потом Z-order.
# Неправильно: Z-Order сразу на мелких файлах
deltaTable.optimize().executeZOrderBy("user_id", "country")
# Правильно: сначала обычная компакция без Z-Order
spark.sql("OPTIMIZE events") # только слияние мелких файлов
# Затем Z-Order на уже нормальных файлах
spark.sql("OPTIMIZE events ZORDER BY (user_id, country)")
Антипаттерн 2: Z-order по колонке с низкой кардинальностью
# Плохо: country имеет всего 50 уникальных значений
# partitionBy справится лучше
deltaTable.optimize().executeZOrderBy("country")
# Хорошо: country уже партиционирована
# Z-Order - по аналитическим колонкам с высокой кардинальностью
deltaTable.optimize().executeZOrderBy("user_id", "device_type")
Антипаттерн 3: Z-order по слишком многим колонкам
# Плохо: 7 колонок - локальность деградирует до случайной раскладки
deltaTable.optimize() \
.executeZOrderBy("user_id", "country", "device", "os",
"version", "channel", "segment")
# Хорошо: 2-3 самые важные аналитические колонки
deltaTable.optimize() \
.executeZOrderBy("user_id", "country")
Антипаттерн 4: оптимизация без анализа запросов
Z-ordering нельзя настроить «вслепую». Обязательно проверяйте в Spark UI:
# Смотрим план выполнения - ищем строку PartitionFilters и PushedFilters
df.filter("user_id = 42 AND country = 'RU'").explain("extended")
# Метрики File Skipping доступны через системные таблицы Delta
spark.sql("DESCRIBE DETAIL events_delta") \
.select("numFiles", "sizeInBytes") \
.show()
# Для Iceberg - проверяем диапазоны Min/Max в системной таблице files
spark.sql("""
SELECT
count(*) AS total_files,
min(lower_bounds['user_id']) AS global_min,
max(upper_bounds['user_id']) AS global_max,
avg(upper_bounds['user_id'] - lower_bounds['user_id']) AS avg_range_width
FROM local.db.events.files
""").show()
# avg_range_width после Z-Order должен быть значительно уже, чем до
7. Итоги: сравнительная таблица¶
Финальное сравнение трёх подходов¶
Представьте таблицу events с 1 миллиардом строк, партиционированную по дате. Аналитик делает запрос: WHERE user_id = 42 AND country = 'RU' AND device_type = 'mobile'. Внутри одной партиции (date=2024-01-01) - 1000 файлов по 128 MB.
Без оптимизации (случайная раскладка): все 1000 файлов прочитаны. Overlap ≈ 100% по всем трём колонкам. Читаем 128 GB.
После Lexicographic Sort по (user_id, country, device_type): user_id=42 фильтрует до 10 файлов. Но country и device_type внутри этих 10 файлов всё ещё перемешаны - читаем 10 файлов = 1.28 GB.
После Z-Order по (user_id, country): строки с user_id ≈ 42 и country = 'RU' сконцентрированы в 1–2 файлах. Читаем 256 MB.
После Hilbert по (user_id, country): аналогично Z-Order, но с более качественной локальностью - с высокой вероятностью 1 файл = 128 MB.
Это разница между 128 GB прочитанных данных (Full Scan) и 128 MB (1 файл) - ускорение в 1000 раз.
Сводная таблица инструментов¶
| Стратегия | Кардинальность | Колонок | Запросы | Стоимость | Частота |
|---|---|---|---|---|---|
partitionBy |
< 10,000 | 1–2 | Exact match | Нет | При записи |
| Lexicographic Sort | Любая | 1 | Диапазон по 1 колонке | Низкая | Еженедельно |
| Z-Order | > 1,000 | 2–4 | AND по нескольким | Высокая | Еженедельно |
| Hilbert | > 10,000 | 2–4 | AND по нескольким | Высокая | Ежемесячно |
| Bucketing | > 10,000 | 1 (join key) | Join | Низкая | При записи |
Пять золотых правил Data Layout¶
Data Layout через Z-ordering и кривые Гильберта - это последний рубеж оптимизации, который применяется поверх правильного партиционирования и компакции. Его смысл - математически обоснованное упорядочивание строк в файлах так, чтобы предикаты аналитических запросов по нескольким колонкам отсекали максимальное количество файлов.
Главные правила для запоминания:
- 2–4 колонки - не больше, иначе пространственная локальность деградирует
- Высокая кардинальность - Z-order для колонок с тысячами уникальных значений, не для
countryиstatus - AND, не OR - File Skipping работает только для конъюнктивных предикатов
- Сначала компакция, потом Z-order - не запускайте Z-order на таблице с миллионами мелких файлов
- Смотрите Spark UI -
files scannedиbytes read- единственные достоверные метрики эффективности
Сочетание partitionBy(date) + OPTIMIZE ZORDER BY (user_id, country) - это канонический шаблон для Event Table в production Lakehouse, который работает одинаково хорошо и в Delta Lake, и в Apache Iceberg.