Индексная подсистема
В Уроке 02 мы видели, что и COW, и MOR начинают upsert с index lookup — определения, в каком FileGroup находится каждая запись. Этот шаг критичен: без индекса Hudi пришлось бы сканировать все base files во всех партициях для поиска каждой записи.
Hudi предлагает 6 типов индексов, включая новую систему secondary indexes в версии 1.0. Выбор индекса — одно из ключевых архитектурных решений при проектировании таблицы: он определяет скорость записи, потребление памяти, и поведение при upsert.
Индекс в Hudi — это не read-side оптимизация (как Z-order или Bloom filter на уровне Parquet). Это write-side структура: она используется при записи для маршрутизации записей по FileGroup. Read path не использует Hudi-индексы — он работает с Parquet statistics, partition pruning и column push-down.
Зачем нужен индекс: проблема tagging
При upsert Hudi должен пометить (tag) каждую входящую запись: INSERT (новая) или UPDATE (существующая). Для UPDATE нужно знать конкретный FileGroup, чтобы записать данные в правильный файл:
Батч: N записей (record_key, data)
Входящий батч содержит N записей. Каждая запись имеет record key (primary key). Writer должен определить для каждой записи: это INSERT или UPDATE? Если UPDATE — в какой FileGroup?Index: record_key → (partition, FileGroup, file)
Индекс принимает набор record keys и возвращает маппинг: record_key → (partition, FileGroup ID, file_name). Если record_key не найден — это INSERT. Если найден — UPDATE с привязкой к конкретному FileGroup.Без индекса tagging потребует full table scan — чтение всех base files для поиска каждого record key. При таблице с 1B записей (1000 FileGroup × 1M записей) это 1000 Parquet-файлов на каждый upsert. Индекс сводит это к O(1) или O(log N) lookup.
Bloom Filter Index
Bloom Filter Index — индекс по умолчанию в Hudi. Использует Bloom filter, встроенный в footer каждого Parquet base file:
Батч record keys
Batch записей для upsert. Каждая запись имеет record key. Bloom index начинает с partition pruning: если таблица партиционирована, определяем partition path для каждой записи.Определить partition для каждого key
Для каждой записи определяем partition (по partition fields). Это сужает поиск: вместо всех FileGroup таблицы — только FileGroup в нужных партициях. Если партиции нет — ищем по всей таблице.Читаем Bloom filter из Parquet footer (без чтения данных)
Для каждого base file в партиции читаем Bloom filter из Parquet footer (без чтения данных). Bloom filter — вероятностная структура: если говорит 'нет' — запись точно не в этом файле. Если 'да' — запись МОЖЕТ быть в файле (false positive возможен).Кандидаты: K файлов (Bloom сказал «возможно»)
Bloom filter отсеял большинство файлов. Осталось K кандидатов (файлы, где Bloom сказал 'возможно'). Для подтверждения: читаем Record Key range из Parquet statistics (min/max record_key). Ещё одна фильтрация.Читаем _hoodie_record_key из кандидатов → подтверждаем INSERT/UPDATE
Для оставшихся кандидатов: читаем колонку _hoodie_record_key из Parquet (column pruning). Это подтверждает или опровергает match. Только после этого запись tagged как INSERT или UPDATE.Характеристики Bloom Filter Index
# False Positive Probability (по умолчанию 0.000000001 — очень низкая)
hoodie.bloom.index.filter.type=DYNAMIC
hoodie.bloom.index.filter.dynamic.max.entries=100000
# Использовать min/max record key для доп. pruning
hoodie.bloom.index.use.metadata=true
# Параллелизм lookup
hoodie.bloom.index.parallelism=0
Bloom filter index лучше всего работает, когда record keys равномерно распределены по FileGroup (например, UUID). Если ключи монотонные (auto-increment ID), то все обновления попадают в одни и те же файлы, и Bloom filter не помогает — все файлы будут кандидатами.
Simple Index
Simple Index — brute-force подход: читает все record keys из всех base files в нужных партициях и делает join с входящим батчем:
Когда Simple Index лучше Bloom
Simple Index выигрывает в двух сценариях:
- Очень маленькие партиции (< 10 файлов): overhead Bloom pruning не окупается
- High update ratio (> 50% записей — обновления): Bloom даёт много кандидатов, и приходится всё равно читать ключи
hoodie.index.type=SIMPLE
HFile Record Index
HFile Record Index использует HFile (формат HBase) для хранения маппинга record_key → FileGroup. Это точный индекс без false positives и без full scan:
Батч record keys
Record keys для lookup. HFile index не читает base files — он обращается к отдельным HFile-файлам, которые содержат отсортированный маппинг record_key → (partition, FileGroup ID).HFile: sorted record_key → FileGroup Binary search: O(log N)
HFile — формат sorted key-value хранилища из HBase. Ключи отсортированы, поддерживается binary search. Block index на верхнем уровне позволяет найти нужный data block за O(log N). Каждый HFile покрывает один или несколько FileGroup.Record-Level Index (Hudi 1.0)
Hudi 1.0 представил Record-Level Index — эволюцию HFile Record Index, хранимую в metadata table:
# Включить Record-Level Index (Hudi 1.0+)
hoodie.metadata.record.index.enable=true
hoodie.index.type=RECORD_INDEX
Record-Level Index — рекомендуемый индекс для Hudi 1.0+ при больших таблицах (> 100M записей). Он сочетает точность HFile (нет false positives) с автоматическим обслуживанием (хранится в metadata table, обновляется транзакционно). Для маленьких таблиц (< 10M записей) Bloom filter по-прежнему достаточен.
Bucket Index
Bucket Index — hash-based индекс, который распределяет записи по FileGroup на основе хэша record key:
record_key
Record key каждой записи. Bucket Index вычисляет hash(record_key) mod N, где N — количество bucket'ов. Каждый bucket = один FileGroup. Маппинг детерминистичен: одинаковый ключ всегда попадает в один и тот же bucket.Характеристики Bucket Index
| Свойство | Значение |
|---|---|
| Lookup complexity | O(1) — чистый hash, нет I/O |
| False positives | |
| Дополнительная структура | — hash вычисляется на лету |
| Количество FileGroup | Фиксировано = N buckets |
| Resharding | Невозможно без пересоздания таблицы |
hoodie.index.type=BUCKET
hoodie.bucket.index.num.buckets=256
hoodie.bucket.index.hash.field=record_key
Количество bucket’ов нельзя изменить после создания таблицы. Если вы создали 256 bucket’ов, а данных стало в 100x больше — каждый bucket будет содержать огромный файл. Выбирайте N с запасом на рост. Ориентир: target file size / expected data per partition.
Consistent Hashing Bucket (Hudi 1.0)
Hudi 1.0 добавил consistent hashing вариант bucket index, который поддерживает динамическое масштабирование — добавление и удаление bucket’ов без полной пересортировки:
hoodie.index.type=BUCKET
hoodie.index.bucket.engine=CONSISTENT_HASHING
hoodie.bucket.index.max.num.buckets=512
hoodie.bucket.index.min.num.buckets=32
Secondary Indexes (Hudi 1.0)
Hudi 1.0 представил систему secondary indexes — индексов по произвольным колонкам (не только record key). Это read-side оптимизация, в отличие от primary index (write-side):
Secondary Index: status → FileGroups
Secondary index хранится в metadata table (.hoodie/metadata/). Формат: value → list of (partition, FileGroup, record position). Обновляется при каждом commit/deltacommit транзакционно.Pruned: только релевантные FileGroup
Результат: список FileGroup, содержащих записи с status='shipped'. Query engine читает только эти файлы. Data skipping: вместо 1000 файлов читаем 50.Expression Indexes (Hudi 1.0)
Expression Index — подвид secondary index, где индексируется выражение над колонками, а не сами колонки:
-- Создание secondary index (Hudi 1.0+)
CREATE INDEX idx_status ON orders USING secondary_index(status);
-- Expression index
CREATE INDEX idx_event_month ON events USING expression_index(month(event_date));
-- Column stats index
CREATE INDEX idx_amount_stats ON orders USING column_stats(amount);
Secondary и expression indexes в Hudi 1.0 — metadata-based: они хранятся в .hoodie/metadata/ table и обновляются атомарно вместе с данными. Это отличается от Iceberg (partition stats в manifest files) и Delta Lake (data skipping в checkpoint). Hudi-подход: индексы — это данные в таблице метаданных, транзакционно согласованные с основной таблицей.
Гайд по выбору индекса
Размер таблицы?
Первый вопрос: размер таблицы. Для маленьких таблиц (< 10M записей) Bloom filter достаточен и не требует настройки. Для больших — нужен более эффективный индекс.Паттерн ключей?
Второй вопрос: паттерн ключей. Случайные UUID → Record Index. Монотонные (timestamp-based) → Bucket Index. Неравномерное распределение → Record Index.Случайные → Record Index
Случайные ключи (UUID): Record-Level Index (Hudi 1.0). Точный lookup, block cache, автоматическое обслуживание. Лучший choice для большинства production workloads.Монотонные → Bucket
Монотонные ключи (timestamp, auto-increment): Bucket Index с consistent hashing. Hash разбивает монотонность, dynamic buckets обеспечивают масштабирование.Сравнительная таблица индексов
| Индекс | Lookup | False Positives | I/O на lookup | Доп. хранение | Resharding | Рекомендация |
|---|---|---|---|---|---|---|
| Bloom Filter | O(N files × filter check) | Чтение footer’ов | (в Parquet) | N/A | Default, < 10M записей | |
| Simple | O(N files × key scan) | Чтение ключей | N/A | Маленькие партиции | ||
| HFile | O(log N) | Чтение HFile блоков | HFile индексы | N/A | Устаревший (→ Record Index) | |
| Bucket (fixed) | O(1) | I/O | Предсказуемый размер данных | |||
| Bucket (consistent) | O(1) | I/O | Растущие данные | |||
| Record Index (1.0) | O(log N) | Block cache | Metadata table | Авто | Production default 1.0+ |
Для новых таблиц на Hudi 1.0+ рекомендуемая стратегия:
- Record-Level Index как primary index (write-side, для upsert tagging)
- Column Stats Index для read-side data skipping (range predicates)
- Expression Index для часто используемых предикатов с функциями
Эта комбинация покрывает и write-path (upsert маршрутизация) и read-path (query pruning).
Индекс и глобальная уникальность
По умолчанию Hudi-индекс — partition-scoped: он ищет record key только внутри одной партиции. Это значит, что одинаковый record key может существовать в разных партициях:
Когда нужен Global Index
Пример: таблица orders партиционирована по status (pending, processing, shipped). При обновлении заказа order_id=123 статус меняется с pending → shipped. С partition-scoped индексом:
- Hudi ищет
order_id=123в партицииshipped(целевой) - Не находит (запись в
pending) - Считает INSERT → создаёт дубликат
С global индексом Hudi найдёт запись в pending и выполнит cross-partition update: удалит из pending, вставит в shipped.
# Включить global index
hoodie.index.type=GLOBAL_BLOOM
# или
hoodie.index.type=GLOBAL_SIMPLE
# Record Index в Hudi 1.0 — глобальный по умолчанию
hoodie.metadata.record.index.enable=true
Global index значительно медленнее partition-scoped: при 1000 партиций lookup проверяет все 1000 вместо одной. Используйте его только когда запись может мигрировать между партициями. Для большинства use case (partition по дате, данные не мигрируют) partition-scoped индекс достаточен.
Сравнение с Delta Lake и Iceberg
| Аспект | Delta Lake | Apache Iceberg | Apache Hudi |
|---|---|---|---|
| Write-side index | (append-only log) | (manifest pruning) | Bloom, HFile, Bucket, Record Index |
| Read-side optimization | Z-order, liquid clustering | Hidden partitioning, sort orders | Secondary indexes, column stats, expression |
| Upsert без index | Full scan + merge | Full scan + merge | Impossible (index обязателен) |
| Index хранение | N/A | N/A | Parquet footer / metadata table |
| Глобальная уникальность | Через MERGE condition | Через MERGE condition | Global Index (встроенный) |
Ключевое отличие: Hudi требует индекс для upsert — это архитектурное решение, а не опция. Delta Lake и Iceberg выполняют MERGE через full scan + join (оптимизированный через statistics, но без dedicated index). Hudi’s approach: индекс делает upsert O(1) или O(log N) вместо O(N) — цена: дополнительная структура данных и обслуживание индекса.
Итоги
- Index lookup (tagging) — обязательный шаг перед каждым upsert: определяет INSERT vs UPDATE и целевой FileGroup
- Bloom Filter Index (default): вероятностный, footer-based, достаточен для < 10M записей
- Simple Index: brute-force чтение ключей, без false positives, для маленьких партиций
- HFile Record Index: точный lookup через sorted key-value HFile, block cache
- Bucket Index: O(1) hash-based lookup, фиксированное N (или dynamic с consistent hashing в 1.0)
- Record-Level Index (Hudi 1.0): HFile в metadata table, автоматическое обслуживание, до 4x быстрее Bloom — рекомендуемый для production
- Secondary Indexes (1.0): read-side индексы по произвольным колонкам, expression indexes, column stats
- Global vs partition-scoped: global нужен только при миграции записей между партициями
- Рекомендуемая комбинация для Hudi 1.0: Record Index (write) + Column Stats (read) + Expression Index (custom predicates)
В следующем уроке мы разберём контроль конкурентности — как Hudi обеспечивает ACID при нескольких writer’ах, и что изменил NBCC-протокол в версии 1.0.