Data Layout: Z-ordering, Hilbert curves и file skipping

Пространственные кривые, bit-interleaving и многомерная кластеризация данных - хирургический инструмент Senior Data Engineer для ускорения аналитических запросов без раздутого партиционирования

optimization data-layout z-order iceberg delta-lake

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.