Skip to content
Learning Platform

Зачем вообще нужен table format

Object storage вроде S3, GCS или MinIO — это не файловая система. Это key-value хранилище, где ключ — длинная строка, а значение — байты объекта. Никаких директорий там физически нет: «папка» — это иллюзия, которую даёт префикс ключа и операция листинга по этому префиксу. Атомарного «переименуй директорию» там тоже нет. Эти два факта — отсутствие настоящих директорий и отсутствие атомарного rename — ломают всё, на чём годами держался data lake поверх Hive.

Apache Iceberg — это table format: спецификация того, как разложить набор файлов данных и метаданных так, чтобы поверх «глупого» хранилища объектов появились настоящая таблица, транзакции и история. Не движок запросов и не формат файла. Данные по-прежнему лежат в Parquet, ORC или Avro. Iceberg добавляет дерево метаданных и протокол коммита, превращающие груду файлов в таблицу с ACID и time travel. Эта статья — самодостаточный бесплатный разбор того, как именно он это делает, до уровня указателей и байтов; если же хочется потрогать манифесты руками, тот же материал побайтово разбирается в курсе Modern storage formats (вводные уроки открыты бесплатно).

Шаг 1: чем плох Hive table format на object storage

Чтобы оценить Iceberg, надо понять, что он чинит. В Hive table format таблица — это директория, а партиция — поддиректория вида country=RU/dt=2026-06-01/. Где лежат данные таблицы, нигде явно не записано: чтобы это узнать, движок листит директории. Отсюда три фундаментальные боли на S3.

Листинг дорогой и неконсистентный. Чтобы прочитать таблицу, надо рекурсивно перечислить все объекты под префиксом. На таблице с десятками тысяч партиций это тысячи LIST-запросов к S3, каждый с задержкой в десятки миллисекунд. Планирование запроса само по себе занимает минуты — ещё до чтения первого байта данных. Хуже того: листинг возвращает ровно то, что физически лежит под префиксом прямо сейчас, поэтому состояние таблицы определяется содержимым бакета, а не явным манифестом.

Нет атомарности на уровне таблицы. Запись новой партиции — это серия PUT отдельных файлов. Читатель, листящий директорию в середине записи, увидит половину файлов: грязное чтение в чистом виде. Никакого commit-барьера, который бы делал набор файлов видимым «всё-или-ничего», нет.

Rename не атомарен и не бесплатен. Классический трюк Hive — писать во временную директорию, а в конце атомарно её переименовать. На HDFS rename — это правка указателя в namenode, мгновенная и атомарная. На S3 rename не существует как операция: это COPY каждого объекта плюс DELETE оригинала. Для тысяч файлов это дорого, медленно и в середине процесса оставляет таблицу в полусобранном состоянии. Именно на этом «коммите через rename» молча рассыпается надёжность Hive-таблиц на облачном хранилище.

Корень всех трёх проблем один: в Hive состояние таблицы выводится из листинга директорий, а не задаётся явно. Iceberg переворачивает это.

Шаг 2: дерево метаданных — состояние задаётся явно

Iceberg никогда не листит директории, чтобы понять, что лежит в таблице. Вместо этого он держит явное, иммутабельное дерево метаданных. Сверху вниз цепочка такая:

catalog (указатель на текущий metadata.json)
        |
        v
metadata.json  ── схемы, partition specs, список snapshots, current-snapshot-id
        |
        v
manifest list (Avro) ── один файл на snapshot; список manifest'ов + их partition-границы
        |
        v
manifest files (Avro) ── списки data-файлов + статистика (min/max, null-counts, row-count)
        |
        v
data files (Parquet/ORC/Avro) ── собственно строки

Разберём уровни, потому что вся механика ACID и планирования живёт именно здесь.

Catalog хранит ровно одну вещь на таблицу: указатель на текущий файл metadata.json. Это может быть строка в Postgres (JDBC-каталог), запись в Hive Metastore, объект в REST-каталоге или AWS Glue. Важна не реализация, а то, что у каталога есть одна обновляемая ячейка — «вот актуальная версия таблицы».

metadata.json — корень дерева на конкретный момент. Это иммутабельный JSON-файл, описывающий всю таблицу: историю schema evolution (массив схем со стабильными column-id), все исторические partition spec, массив всех snapshots и поле current-snapshot-id. Каждый коммит порождает новый файл metadata.json — старый не трогается.

Manifest list — один Avro-файл на snapshot. Это «оглавление» снимка: список manifest-файлов, входящих в него, и для каждого — агрегированные границы значений партиционирующих колонок (partition field summaries). По этим границам читатель может выкинуть целый manifest, даже не открыв его.

Manifest file — Avro-файл со списком конкретных data-файлов. Для каждого файла хранятся его путь, формат, к какой партиции относится и — ключевое — column-level статистика: row_count, нижние и верхние границы (lower_bounds/upper_bounds) по колонкам, счётчики null. Именно эта статистика позволяет планировать запрос без чтения данных.

Data files — обычные Parquet-файлы. Иммутабельны: Iceberg никогда не дописывает в существующий файл и не правит его in-place.

Ключевое свойство всего дерева: оно построено только из иммутабельных файлов плюс один обновляемый указатель в каталоге. Запомните это — на нём держится весь следующий шаг.

Шаг 3: snapshot и атомарный commit — откуда берётся ACID и time travel

snapshot — это полное, иммутабельное состояние таблицы в момент времени, заданное одним manifest list. Запись в таблицу не меняет существующие файлы — она создаёт новый snapshot:

  1. Пишутся новые data-файлы (новые объекты в S3).
  2. Пишется новый manifest, ссылающийся на актуальный набор data-файлов.
  3. Пишется новый manifest list для нового snapshot.
  4. Пишется новый metadata.json, где этот snapshot становится current-snapshot-id, а предыдущий остаётся в истории.
  5. Каталог атомарно меняет указатель со старого metadata.json на новый.

Вся транзакция сводится к одному действию: атомарная замена одного указателя в каталоге. Это реализуется через compare-and-swap: «переключи указатель на новый metadata, только если текущий по-прежнему равен тому, что я прочитал в начале». В JDBC-каталоге это UPDATE ... WHERE metadata_location = :ожидаемый. Это optimistic concurrency control: писатели не блокируют друг друга, а на коммите проверяют, что никто не вклинился. Если два писателя гонятся, CAS выигрывает один; проигравший перечитывает свежий metadata, переигрывает свою операцию поверх и повторяет CAS.

Отсюда напрямую следуют гарантии:

  • Атомарность. Пока указатель не переключён, новые файлы для читателей не существуют. Переключение указателя одной операцией делает весь набор изменений видимым разом. Нет состояния «половина записана».
  • Изоляция (snapshot isolation). Читатель резолвит таблицу через текущий metadata.json в момент старта запроса и работает с этим snapshot до конца. Параллельная запись создаёт новый snapshot, но читатель его не видит — он держит консистентный снимок.
  • Долговечность. Каждый snapshot — это иммутабельные файлы. Откатить ничего не нужно: при сбое до CAS просто остаются «осиротевшие» файлы, которые подберёт уборка.

Time travel же оказывается бесплатным побочным эффектом. Раз каждый snapshot иммутабелен и остаётся в истории metadata.json, пока на него ссылаются, можно прочитать таблицу как она выглядела на любой исторический snapshot — по его id или по timestamp:

-- состояние на конкретный snapshot
SELECT * FROM db.orders VERSION AS OF 3821550127947089009;

-- состояние на момент времени
SELECT * FROM db.orders FOR TIMESTAMP AS OF '2026-05-28 09:00:00';

Никакой особой машинерии для time travel нет: вы просто говорите движку резолвить дерево не от current-snapshot-id, а от исторического. Та же иммутабельность даёт мгновенный откат (rollback переключает указатель на прошлый snapshot) и аудит — кто и что записал в каждый коммит. На уровне V2-спеки snapshots ещё нумеруются монотонными sequence numbers, что задаёт порядок и нужно для row-level операций ниже.

Шаг 4: hidden partitioning и эволюция партиций

В Hive, чтобы партиционирование сработало, инженер должен материализовать производный столбец и сам помнить о нём в каждом запросе. Партиционировали по event_day, выведенному из event_ts? Тогда WHERE event_ts >= '2026-06-01' не отсечёт ни одной партиции — фильтр стоит по event_ts, а партиции нарезаны по event_day. Чтобы pruning сработал, придётся писать WHERE event_day = '2026-06-01'. Забыл — получил полное сканирование. Это вечный источник тихих просадок производительности.

Iceberg вводит hidden partitioning. В partition spec вы объявляете не отдельный столбец, а transform над реальным столбцом: day(event_ts), bucket(16, user_id), truncate(10, name). Само значение партиции вычисляется и хранится в метаданных; отдельной колонки в данных нет. Когда вы пишете WHERE event_ts >= '2026-06-01', Iceberg сам применяет тот же transform к границам предиката и отсекает партиции. Запрос остаётся на естественном столбце — таблица сама знает, как из него получить партицию.

Второе следствие иммутабельных метаданных — partition evolution. Раз каждый snapshot ссылается на конкретный partition spec, а metadata хранит все исторические spec, схему партиционирования можно сменить без переписывания старых данных. Старые файлы остаются под старым spec, новые пишутся под новый; manifest для каждого файла помнит, какому spec он принадлежит. На чтении движок планирует мульти-spec: к старым файлам применяет старый transform, к новым — новый. Перешли с month(ts) на day(ts), потому что данных стало больше? Никакого простоя и переписывания петабайтов — только новая запись в metadata.

Шаг 5: reader — планирование по статистике вместо листинга

Теперь становится понятно, почему планирование в Iceberg на порядки дешевле Hive. Читатель никогда не листит директории. Он спускается по дереву, на каждом уровне отсекая лишнее по статистике:

  1. Каталог отдаёт указатель на metadata.json → читатель резолвит current-snapshot-id и его manifest list.
  2. По partition-границам в manifest list читатель выбрасывает целые manifest-файлы, чьи диапазоны не пересекаются с предикатом запроса (manifest pruning).
  3. В оставшихся manifest читатель смотрит per-file lower_bounds/upper_bounds и выбрасывает data-файлы, чьи min/max не пересекаются с фильтром (data skipping). Запрос WHERE amount > 1000 пропустит файл, у которого upper_bound(amount) = 980, даже не открыв его.
  4. Остаётся точный список data-файлов на чтение. Только тут идёт обращение к S3 за самими данными.

Планирование — это чтение нескольких Avro-файлов метаданных и арифметика над границами, а не тысячи LIST-запросов. На больших таблицах это разница между минутами и десятками миллисекунд.

Последний внутренний выбор — как обновлять и удалять строки, раз data-файлы иммутабельны. Здесь две стратегии.

  • Copy-on-write (CoW). При UPDATE/DELETE Iceberg переписывает целиком каждый затронутый data-файл с применёнными изменениями и фиксирует новый snapshot. Запись дороже (переписывается весь файл ради нескольких строк), зато чтение максимально быстрое — лишней работы на reader нет.
  • Merge-on-read (MoR). Вместо переписывания пишется компактный delete-файл (позиционные или equality deletes), помечающий удалённые/изменённые строки. Запись дешёвая и быстрая, но reader на лету мерджит data-файлы с delete-файлами, и чтение дороже, пока фоновая компакция их не свернёт.

CoW оптимизирует под чтение, MoR — под запись. Это прямая ручка под нагрузку: редкие крупные батчи против частых мелких upsert’ов из стрима.

Hive vs Iceberg: сводка

СвойствоHive table formatApache Iceberg
Состояние таблицывыводится из листинга директорийзадано явно в дереве метаданных
Планирование запросатысячи LIST по S3чтение manifest + отсев по min/max
Атомарность коммитанет (серия PUT, rename)атомарный CAS указателя в каталоге
Коммит на S3COPY + DELETE каждого файлазамена одного указателя
Изоляция чтениягрязные чтения при записиsnapshot isolation
Time travelнетпо snapshot id или timestamp
Partition pruningнужен фильтр по производному столбцуhidden partitioning, фильтр по исходному
Смена партиционированияпереписать всю таблицуpartition evolution без переписи
Удаление/обновление строкпереписать партициюCoW или MoR (delete-файлы)

Соберём ментальную модель

Один тезис держит всё вместе: Iceberg заменяет «состояние таблицы = что лежит в бакете» на «состояние таблицы = на что указывает каталог». Из этой замены механически вытекает всё остальное. Иммутабельное дерево метаданных плюс один атомарно переключаемый указатель дают атомарность и изоляцию. Сохранение старых указателей даёт time travel и откат. Хранение transform’ов и всех partition spec в метаданных даёт hidden partitioning и эволюцию партиций. А per-file статистика в манифестах заменяет дорогой листинг на дешёвое планирование. Не пять отдельных фич — одно архитектурное решение, повёрнутое разными гранями.

Дальше начинается инженерия эксплуатации: компакция мелких файлов, expire snapshots (иначе история и осиротевшие файлы растут бесконечно), REST-каталоги, выбор CoW против MoR под конкретный паттерн нагрузки, сравнение с Delta Lake и Hudi. Но без модели «каталог → metadata → manifest list → manifest → data + один атомарный CAS» всё это — набор команд без понимания, почему они работают.

Если хочется системно и до самого железа — посмотрите направление Data Engineering: всё внутри-к-железу, с интерактивными песочницами и нарисованными от руки диаграммами.

Эта статья — бесплатный standalone-разбор. Если же хочется разобрать Iceberg побайтово — реальные Avro-манифесты, протокол коммита, CoW против MoR на живых данных — это часть курса Modern storage formats, где Iceberg идёт после Parquet, ORC и Delta Lake. Курс платный, но вводные уроки открыты бесплатно — начните с них.

Ещё в направлении · Data Engineering

Все материалы направления →