COW vs MOR Deep-Dive
В предыдущем уроке мы увидели, что Hudi-таблица состоит из FileGroup → FileSlice, а FileSlice может содержать только base file (COW) или base + log files (MOR). Теперь разберём побайтово, как работает запись и чтение в каждом типе.
Выбор COW vs MOR — необратимое решение, закреплённое в hoodie.properties. Это не просто «быстрее пишем / быстрее читаем» — два типа имеют принципиально разные write paths, read paths, и наборы доступных запросов.
В Hudi 1.0 появились partial updates для MOR-таблиц — column-level merge, который обновляет только изменённые колонки. Это расширяет разрыв между COW и MOR: MOR получает преимущество не только в скорости записи, но и в эффективности хранения partial changes.
Write Path: COW (Copy-on-Write)
COW — «копирование при записи». При любом изменении (INSERT, UPDATE, DELETE) Hudi полностью перезаписывает затронутые base files:
Входящий батч (upsert)
Входящий батч записей для upsert. Каждая запись имеет record key (primary key) и precombine field (для разрешения дубликатов). Writer должен определить, в какой FileGroup попадает каждая запись.Index: record key → FileGroup
Индекс определяет, существует ли запись в таблице. Если да — возвращает File Group ID и partition path. Если нет — запись считается INSERT. Hudi поддерживает Bloom, Simple, HFile, Bucket индексы (подробнее — Урок 03).Прочитать base → merge → записать новый base
COW merge: для каждого затронутого FileGroup — прочитать текущий base file (Parquet), найти записи по record key, заменить на обновлённые, записать новый base file. Старый base file остаётся (до clean). Даже если обновлена 1 запись из 1M — перезаписывается весь файл.commit instant (новые base files)
Commit instant создаётся на timeline. Содержит: список новых base files, список заменённых base files, схему, статистику. Атомарный commit — либо все файлы записаны, либо rollback.Стоимость COW upsert
Допустим, FileGroup содержит 1 млн записей (base file ~100 MB). При обновлении 10 записей из этого FileGroup:
- Прочитать весь base file (100 MB read)
- Найти 10 записей по record key
- Заменить их значениями из батча (precombine определяет «победителя»)
- Записать новый base file (100 MB write)
Итого: 200 MB I/O для обновления 10 записей. Это write amplification — цена COW.
COW write amplification можно снизить через clustering — пересортировку данных так, чтобы часто обновляемые записи оказались в одних FileGroup. Тогда upsert затрагивает меньше FileGroup → меньше перезаписей. Подробнее — в Уроке 06.
Write Path: MOR (Merge-on-Read)
MOR — «слияние при чтении». При записи Hudi не трогает base files, а дописывает дельту в log files:
Входящий батч (upsert)
Входящий батч для upsert — аналогично COW. Тот же процесс index lookup для определения FileGroup. Разница начинается после тегирования.Index: record key → FileGroup
Index lookup идентичен COW: определяет FileGroup для каждой записи. Разница: для UPDATE не нужно читать base file. Записи сериализуются в Avro и дописываются в log file.Append Data Block в log file
MOR append: для каждого затронутого FileGroup — создать новый log block (Data Block с Avro-записями) и дописать в log file. Если log file не существует — создать. Не трогает base file. Скорость записи ≈ скорость append I/O.deltacommit instant (новые log blocks)
Deltacommit instant (не commit!) создаётся на timeline. Содержит: список затронутых log files, количество записей, схему. Timeline различает commit и deltacommit — это позволяет reader'у выбрать стратегию чтения.Стоимость MOR upsert
Тот же сценарий: 10 обновлённых записей из FileGroup с 1 млн строк:
- Сериализовать 10 записей в Avro (~5 KB)
- Дописать Data Block в log file (~5 KB write)
Итого: ~5 KB I/O вместо 200 MB. Write amplification отсутствует. Но есть read amplification — при чтении придётся мержить log с base.
HoodieLogFile: внутренняя структура
Log file — не просто append-only файл. Это структурированный контейнер из блоков:
Schema Evolution в Log Blocks
Каждый Data Block хранит свою Avro-схему в заголовке. Это поддерживает schema evolution внутри одного log file:
Block 1: schema v1 {order_id, amount, status}
Block 2: schema v2 {order_id, amount, status, priority} ← новая колонка
При merge reader проецирует записи из разных блоков в общую target schema, заполняя отсутствующие колонки default-значениями.
Delta Lake хранит схему в каждом commit. Iceberg — в metadata file с column-id mapping. Hudi хранит схему в каждом log block — это granular подход, который позволяет разным блокам внутри одного log-файла иметь разные схемы. Цена — дублирование schema string (~2-5 KB на блок).
Merge Strategy: как MOR сливает данные при чтении
Когда reader выполняет snapshot query на MOR-таблице, он должен смержить base file с цепочкой log files:
Snapshot Query (MOR)
Snapshot query на MOR-таблице. Reader получает список FileSlice (base + log files) от Timeline. Для каждого FileSlice выполняет merge.- Читаем base file (record_key → record)
- Применяем log blocks (overwrite по record_key)
- Precombine (разрешаем дубликаты)
Результат: merged records
Итог: объединённый набор записей. Reader возвращает их клиенту. Merge overhead зависит от: размера base file (hash map), количества log blocks, количества записей в log. Компакция обнуляет этот overhead.MOR merge загружает весь base file в memory (hash map по record key). Для FileGroup с 10M записей это может потребовать гигабайты RAM. Контролируйте размер FileGroup через hoodie.parquet.max.file.size (по умолчанию 128 MB) и количество записей через hoodie.copyonwrite.record.size.estimate.
Partial Updates (Hudi 1.0)
В Hudi 1.0 MOR получил partial updates — обновление только изменённых колонок:
Partial updates используют PartialUpdateAvroPayload — payload class, который при merge не перезаписывает null-колонки из log. Это идеально для wide tables (100+ колонок), где обновляются 2-3 поля.
Три типа запросов на MOR-таблице
MOR-таблица поддерживает три типа запросов — это уникальная особенность Hudi, которой нет ни в Delta Lake, ни в Iceberg:
Когда какой использовать
| Сценарий | Тип запроса | Почему |
|---|---|---|
| Дашборд аналитика | Read-Optimized | Допустимо отставание на 1-2 часа, важна скорость |
| Финансовый отчёт | Snapshot | Нужна точность до последней записи |
| ETL-пайплайн | Incremental | Обрабатываем только новые записи |
| Data science exploration | Read-Optimized | Массовое сканирование, merge на каждом файле — overkill |
| Real-time dashboard | Snapshot + частая compaction | Частая compaction снижает merge overhead |
Read-optimized query на MOR — идентичен чтению COW-таблицы: только base files, без merge. Если ваш основной use case — аналитические запросы с допустимым отставанием, MOR + read-optimized + фоновая compaction даёт вам быструю запись и быстрое чтение. Платите за compaction отдельно.
COW vs MOR: полное сравнение
Copy-on-Write (COW)
COW: Простая модель. Запись дорогая (перезапись base), чтение дешёвое (только base). Нет log файлов. Один тип запроса (snapshot = read-optimized). Не нужна compaction. Подходит для batch workloads с нечастыми обновлениями.Merge-on-Read (MOR)
MOR: Сложная модель. Запись дешёвая (append log), чтение дорогое (merge). Три типа запросов. Нужна compaction (async). Подходит для streaming/high-frequency upserts.Сравнительная таблица
| Параметр | COW | MOR |
|---|---|---|
| Write latency | Высокая (перезапись base) | Низкая (append log) |
| Read latency (snapshot) | Низкая (только base) | Высокая (base + merge logs) |
| Write amplification | Высокая | Низкая |
| Read amplification | Зависит от log size | |
| Instant type | commit | deltacommit |
| Типы запросов | 1 (snapshot = read-optimized) | 3 (snapshot, read-optimized, incremental) |
| Compaction нужна? | (критически) | |
| Partial updates (1.0) | ||
| Мелкие файлы | (base перезаписывается) | (log файлы, до compaction) |
| Подходит для | Batch, нечастые upserts | Streaming, частые upserts |
Compaction: мост между MOR и COW
Compaction — это процесс превращения MOR FileSlice в COW-подобный: merge log files в base file. После compaction FileSlice содержит только base — как в COW:
Стратегии compaction
# Стратегия: inline или async
hoodie.compact.inline=false
# Количество deltacommits до scheduled compaction
hoodie.compact.inline.max.delta.commits=5
# Compaction strategy
hoodie.compaction.strategy=org.apache.hudi.table.action.compact.strategy.LogFileSizeBasedCompactionStrategy
Без compaction log файлы неограниченно растут. Snapshot query на MOR с 1000 log blocks станет невыносимо медленным — merge каждого FileGroup потребует чтения всех 1000 блоков. Всегда настраивайте compaction для MOR-таблиц. Рекомендация: async compaction каждые 5-10 deltacommits.
Когда выбирать COW vs MOR
Частота обновлений?
Первый вопрос: как часто обновляются данные? Если обновления редкие (batch ETL раз в час или реже) — COW. Если частые (streaming каждые 5 минут) — MOR.Редкие (batch) → COW
Batch обновления (раз в час+): COW оптимален. Перезапись base при каждом batch — приемлемая цена за простоту. Нет compaction overhead. Читатели всегда видят актуальные данные без merge.Частые → Далее…
Частые обновления: зависит от read-требований. Если допустимо отставание — MOR + read-optimized. Если нужна real-time точность — MOR + частая compaction.Допустимо отставание?
Второй вопрос: допустимо ли отставание чтения? Read-optimized query на MOR видит данные на момент последней compaction. Если отставание на 10-30 минут допустимо — MOR идеален.→ MOR
Отставание допустимо: MOR — лучший выбор. Быстрая запись + read-optimized для аналитики + compaction в фоне. Streaming ingest + batch read — классический MOR pattern.→ MOR + compaction
Real-time точность: MOR с частой compaction (каждые 5 deltacommits). Или рассмотрите COW с microbatch (если batch не слишком частый). Snapshot query на MOR с малым количеством log blocks — приемлемый overhead.Антипаттерны
Не используйте COW для streaming ingest с частотой < 5 минут. Каждый batch будет полностью перезаписывать base files — write amplification на порядок выше, чем MOR append. При 100 FileGroup × 128 MB base = 12.8 GB перезаписи на каждый microbatch.
Не используйте MOR без compaction в production. Read amplification растёт линейно с количеством log blocks. После 100 deltacommits без compaction snapshot query может быть в 10-50x медленнее, чем после compaction.
Итоги
- COW: полная перезапись base при каждом upsert. Дорогая запись, дешёвое чтение. Тип instant —
commit - MOR: append в log файлы. Дешёвая запись, дорогое чтение (merge). Тип instant —
deltacommit - Log file — структурированный контейнер из блоков (Data, Delete, Rollback) с Avro-сериализацией и per-block schema
- Merge strategy: hash map по record key, последовательное применение log blocks, precombine для разрешения дубликатов
- Три типа запросов на MOR: snapshot (точный, дорогой), read-optimized (быстрый, отстаёт), incremental (CDC)
- Partial updates (Hudi 1.0): column-level merge для MOR — обновляем только изменённые колонки
- Compaction — обязательна для MOR. Async compaction каждые 5-10 deltacommits — recommended strategy
- Выбор: batch с нечастыми обновлениями → COW; streaming с частыми upserts → MOR
В следующем уроке мы разберём индексную подсистему Hudi — как именно происходит index lookup, который определяет маршрутизацию записей по FileGroup.
Hudi в Spark — production