Базы данных Объектные хранилища: S3, MinIO, слои хранения и lakehouse
0%

Объектные хранилища: S3, MinIO, слои хранения и lakehouse

Объектные хранилища: S3, MinIO, слои хранения и lakehouse

Есть смешной способ понять, насколько S3 изменил индустрию: посмотрите, что лежит под современными базами данных. Под Snowflake — S3. Под BigQuery — Colossus, тот же класс системы. Kafka с версии 3.9 умеет выгружать старые сегменты в S3. ClickHouse умеет держать части MergeTree на S3. Elasticsearch умеет искать прямо по снапшотам в S3. pgBackRest льёт бэкапы PostgreSQL в S3. Databricks, Iceberg, Trino, DuckDB, Spark, Flink — всё это в конечном счёте пишет байты в объектное хранилище.

Это не мода. Это следствие одного экономического факта: объектное хранилище — единственный способ хранить петабайт данных, платя за него 20 000 $ в месяц вместо 200 $ 000, и при этом не думать о дисках, RAID, репликации и ёмкости. Всё остальное в этой статье — про то, чем именно вы платите за эту цену, потому что бесплатно не бывает.

И платите вы конкретной вещью: S3 — это не файловая система и не база данных. Это сетевое key-value хранилище с большими неизменяемыми значениями, задержкой в десятки миллисекунд и практически без транзакционных примитивов. Каждая ошибка, которую вы совершите с ним в проде, будет следствием того, что вы забыли одно из этих трёх слов.

Модель данных: bucket, key, object — и чего в ней нет

Формально всё просто. Есть бакет (пространство имён, привязанное к региону), внутри него — объекты. Объект — это тройка: ключ (UTF-8 строка до 1024 байт), тело (до 5 ТиБ) и набор метаданных (системных вроде Content-Type и пользовательских x-amz-meta-*, суммарно до 2 КБ). При включённом версионировании к этому добавляется versionId.

А теперь — список того, чего здесь нет, и это гораздо важнее:

Плоское пространство ключей S3 и его следствия

  • Нет каталогов. logs/2026/07/app.parquet — это одна строка. Символ / не имеет служебного смысла; иерархию рисует консоль, выполняя LIST с delimiter=/. Следствие: «удалить каталог» — это перечислить все ключи с префиксом и удалить их по одному (пачками по 1000 через DeleteObjects).
  • Нет переименования. mv = COPY + DELETE. Копирование внутри региона делает сам сервис (данные не идут через ваше приложение), но стоит запросов и времени, пропорциональных объёму. Переименование «каталога» на 10 млн ключей — это 20 млн запросов и часы работы.
  • Нет частичной перезаписи и нет append. Объект неизменяем. Изменить один байт — переписать объект целиком. Именно поэтому все форматы данных поверх S3 устроены как LSM: пишем новые файлы, старые помечаем мёртвыми, периодически схлопываем.
  • Нет транзакций между объектами. Записать два объекта атомарно нельзя. Отсюда — целая индустрия табличных форматов, которой посвящена вторая половина статьи.
  • Нет fsync и нет неопределённости. Когда PUT вернул 200 OK с ETag, данные уже долговечны и реплицированы. Это как раз хорошая новость.

Консистентность: что изменилось в декабре 2020

Первые 14 лет S3 был eventually consistent для перезаписей и удалений: после PUT поверх существующего ключа последующий GET мог вернуть старое тело. На этом построена целая мифология и куча защитного кода в Hadoop-экосистеме (печально известный S3Guard поверх DynamoDB).

В декабре 2020 AWS включил строгую read-after-write консистентность для всех операций во всех регионах, без доплаты и без падения производительности: GET, LIST, HEAD после PUT/DELETE немедленно видят результат (анонс AWS). S3Guard был удалён из Hadoop.

Но границы важны:

Что Гарантия
GET/HEAD после PUT/DELETE того же ключа Строгая read-after-write
LIST после PUT/DELETE Строгая (список отражает результат)
Атомарность отдельного PUT Полная: клиент видит либо старую версию, либо новую, никогда не «полуфайл»
Атомарность между двумя объектами Отсутствует
Конфигурация бакета (policy, ACL, lifecycle) Eventually consistent
Cross-Region Replication Асинхронная, лаг от секунд до минут (SLA только с S3 RTC)
Условная запись If-None-Match: * Есть с августа 2024 — атомарный «создай, если нет»
Условная запись/удаление If-Match: <etag> Есть с ноября 2024 — атомарный compare-and-swap

Последние две строки — тихая революция, которую многие пропустили. До 2024 года на S3 не было никакого примитива взаимного исключения, и Delta Lake на AWS требовал внешний DynamoDB-лог для сериализации коммитов. Теперь If-None-Match: * даёт ровно то, что нужно табличным форматам: «создать файл коммита версии N, и пусть проиграет тот, кто опоздал».

import boto3
from botocore.exceptions import ClientError

s3 = boto3.client("s3")

def try_commit(bucket: str, version: int, payload: bytes) -> bool:
    """Атомарно застолбить номер версии коммита.

    If-None-Match: '*' означает «выполни PUT, только если ключа ещё нет».
    Ровно один из конкурирующих писателей получит 200, остальные — 412.
    Это единственный примитив взаимного исключения, который даёт S3."""
    try:
        s3.put_object(
            Bucket=bucket,
            Key=f"_delta_log/{version:020d}.json",
            Body=payload,
            IfNoneMatch="*",          # условная запись
        )
        return True
    except ClientError as e:
        if e.response["Error"]["Code"] == "PreconditionFailed":
            return False              # кто-то занял эту версию раньше — читаем и повторяем
        raise

Важная оговорка для тех, кто пишет на MinIO, Ceph или R2: условные записи — часть S3 API, но реализованы они не везде и не одинаково. Проверяйте свой бэкенд явным интеграционным тестом с двумя параллельными писателями, а не документацией.

Механика API: то, на чём реально теряют деньги и время

Multipart upload

Одиночный PUT ограничен 5 ГиБ. Всё, что больше (а на практике — всё, что больше ~100 МБ), заливается частями: от 5 МиБ каждая (кроме последней), максимум 10 000 частей, максимум 5 ТиБ на объект. Части можно грузить параллельно и повторять по отдельности при сбое.

Красный блок — не теория. Незавершённые multipart-загрузки не видны ни в консоли, ни в ListObjectsV2, но занимают место и попадают в счёт. Регулярно встречающийся сценарий: бакет на 40 ТБ в биллинге при 12 ТБ реальных данных. Ищутся они через ListMultipartUploads, лечатся правилом жизненного цикла:

{
  "Rules": [
    {
      "ID": "abort-incomplete-mpu",
      "Status": "Enabled",
      "Filter": { "Prefix": "" },
      "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 }
    }
  ]
}

ETag: не хеш содержимого

Для объекта, залитого одним PUT без SSE-KMS, ETag — это MD5 тела. Для multipart — это MD5 от конкатенации бинарных MD5 всех частей, с суффиксом -<число частей>. То есть ETag зависит от того, каким размером частей вы заливали файл. Использовать его как контрольную сумму содержимого нельзя: тот же файл, залитый другим клиентом с другим multipart_chunksize, даст другой ETag. Если нужна честная проверка целостности — включайте ChecksumAlgorithm=CRC32C или SHA256, S3 посчитает и сохранит их отдельно.

Range GET и почему это основа всей аналитики

GET с заголовком Range: bytes=1048576-2097151 читает произвольный кусок объекта. Это выглядит как мелочь, но именно на этом стоит весь колоночный анализ поверх объектного хранилища: движок читает футер Parquet-файла (последние килобайты), из него узнаёт смещения нужных колонок и row group’ов, и вытягивает только их — иногда 2% от файла.

import pyarrow.parquet as pq
import pyarrow.fs as fs

s3 = fs.S3FileSystem(region="eu-central-1")

# Читаем ТОЛЬКО две колонки и только те row group'ы, где min/max
# статистика в футере допускает наличие подходящих строк.
# Физически это несколько Range GET, а не скачивание файла целиком.
tbl = pq.read_table(
    "my-lake/events/dt=2026-07-16/part-0001.parquet",
    filesystem=s3,
    columns=["user_id", "amount"],
    filters=[("amount", ">", 1000)],   # предикат уходит в pushdown по статистике
)

Задержка: главный ориентир

Операция Типичное значение (S3 Standard, тот же регион)
TTFB для GET небольшого объекта 20–60 мс (p50), 100–200 мс (p99)
TTFB для GET из Glacier Instant Retrieval те же миллисекунды
Пропускная способность одного потока 60–120 МБ/с
Пропускная способность при 32 параллельных Range GET 3–8 ГБ/с (упор в сеть инстанса)
LIST на 1000 ключей 40–150 мс
Лимит на разделяемый префикс 3 500 PUT/COPY/POST/DELETE, 5 500 GET/HEAD в секунду
S3 Express One Zone, GET 2–8 мс TTFB
Restore из Glacier Flexible (Standard) 3–5 часов
Restore из Deep Archive (Standard / Bulk) ~12 часов / до 48 часов

Из этой таблицы следует главное правило производительности: латентность вы не победите, победить можно только параллелизмом. 100 объектов по 10 МБ, скачанных последовательно, — это 100 × (50 мс + 100 мс) ≈ 15 секунд. Те же объекты в 32 потока — меньше секунды. Все библиотеки-обёртки (AWS CRT, s3transfer, object_store в Rust) существуют ради этого.

Экономика: где на самом деле формируется счёт

Хранение — это обычно не главная статья расходов. Ниже — реальные цены us-east-1 (порядок величин стабилен годами; актуальные смотрите на странице цен S3).

Класс $/ГБ·мес Мин. срок Мин. billable размер Retrieval PUT $/1000 GET $/1000 Задержка доступа
Standard 0.023 0 $ 0.005 0.0004 мс
Intelligent-Tiering 0.023 → 0.0036 128 КБ 0 $ 0.005 0.0004 мс
Standard-IA 0.0125 30 дней 128 КБ 0.01 $/ГБ 0.010 0.0010 мс
One Zone-IA 0.010 30 дней 128 КБ 0.01 $/ГБ 0.010 0.0010 мс, но 1 AZ
Glacier Instant Retrieval 0.004 90 дней 128 КБ 0.03 $/ГБ 0.020 0.010 мс
Glacier Flexible Retrieval 0.0036 90 дней 40 КБ 0.01 $/ГБ (Standard), 0 $ (Bulk) 0.030 3–5 ч
Glacier Deep Archive 0.00099 180 дней 40 КБ 0.02 $/ГБ (Standard) 0.050 12–48 ч
Express One Zone 0.11 1 час 0 $ 0.0025 0.0002 2–8 мс

Точки пересечения стоимости классов хранения S3

Разберём этот график словами, потому что он ломает интуицию.

Standard-IA дешевле Standard, только если вы читаете меньше, чем весь датасет раз в месяц. Точка безубыточности: 23.55 $ (Standard, 1 ТБ) против 12.80 $ + 10.24 $·f. Пересечение при f ≈ 1.05. Если вы честно не знаете f — не гадайте, включите Intelligent-Tiering: он сам двигает объекты между уровнями по факту доступа, беря 0.0025 $ за 1000 объектов в месяц за мониторинг. Для объектов крупнее ~1 МБ это выгодно почти всегда; для миллиардов мелких объектов плата за мониторинг может превысить экономию — считайте.

Deep Archive монетарно дешевле Glacier IR всегда. У него и хранение в 4 раза дешевле, и retrieval дешевле (0.02 $ против 0.03 $). Разница не в деньгах, а в трёх других осях: 12 часов ожидания restore, минимальный срок хранения 180 дней и 0.05 $ за 1000 PUT. Последнее убивает: залить 100 млн мелких файлов в Deep Archive стоит 5 000 $ только за запросы, при стоимости хранения в 99 $/месяц. Архивные классы созданы для больших объектов; мелочь нужно предварительно паковать в tar/zip.

Скрытые статьи, которые взрывают счёт

  1. Egress в интернет. 0.09 $/ГБ за первые 10 ТБ (первые 100 ГБ в месяц бесплатно). Раздача 50 ТБ статики в месяц — 4 $ 500. Это в 195 раз дороже, чем хранить эти же 50 ТБ. Именно egress, а не хранение, продал Cloudflare R2 половине индустрии.
  2. NAT Gateway. Классика: приложение в приватной подсети ходит в S3 через NAT — 0.045 $ за каждый обработанный гигабайт, плюс почасовая плата. Лечится Gateway VPC Endpoint для S3, который бесплатен. Проверьте это в первый же день; счета на десятки тысяч долларов приходили именно отсюда.
  3. SSE-KMS. Каждый PUT/GET шифрованного объекта — это вызов KMS, 0.03 $ за 10 000 запросов. При миллиарде GET в месяц это 3 000 $ сверху и заодно риск упереться в квоту KMS. Лечится S3 Bucket Keys: одна ключевая операция на бакет-уровне сокращает обращения к KMS примерно на 99%.
  4. Lifecycle-переходы. Перевод объекта в другой класс — платный запрос (0.01 $ за 1000 переходов). 500 млн мелких логов, переезжающих в IA, — это 5 000 $ разом за то, что должно было сэкономить деньги.
  5. LIST в аналитике. ListObjectsV2 отдаёт 1000 ключей за запрос и стоит как PUT. Таблица из 4 млн файлов, которую движок перечисляет при каждом запросе, — 4000 запросов и десятки секунд только на планирование. Это одна из главных причин появления Iceberg.
  6. Версионирование без lifecycle. Включили версионирование, приложение перезаписывает объекты — старые версии копятся вечно и оплачиваются. Обязателен NoncurrentVersionExpiration.
{
  "Rules": [
    {
      "ID": "logs-tiering",
      "Status": "Enabled",
      "Filter": { "And": { "Prefix": "logs/", "ObjectSizeGreaterThan": 131072 } },
      "Transitions": [
        { "Days": 30,  "StorageClass": "STANDARD_IA" },
        { "Days": 120, "StorageClass": "GLACIER_IR" },
        { "Days": 365, "StorageClass": "DEEP_ARCHIVE" }
      ],
      "Expiration": { "Days": 2555 }
    },
    {
      "ID": "kill-old-versions",
      "Status": "Enabled",
      "Filter": { "Prefix": "" },
      "NoncurrentVersionExpiration": { "NoncurrentDays": 30, "NewerNoncurrentVersions": 3 },
      "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 }
    }
  ]
}

Обратите внимание на ObjectSizeGreaterThan: 131072 в первом правиле — оно защищает от перевода объектов меньше 128 КБ в IA, где они всё равно тарифицируются как 128 КБ, и переход только увеличивает счёт.

Долговечность, версии и защита от шифровальщика

S3 Standard спроектирован на 11 девяток годовой долговечности (99.999999999%): при хранении 10 млн объектов ожидаемая потеря — один объект раз в 10 000 лет. Достигается это erasure coding с распределением фрагментов минимум по трём зонам доступности внутри региона.

Что 11 девяток не покрывают:

  • Ваш DELETE. Удаление по ошибке или по злому умыслу — это корректно выполненная операция. Durability тут ни при чём.
  • Ваш баг. Приложение, перезаписавшее объекты мусором, — тоже корректная операция.
  • Гибель региона. 11 девяток — внутри региона. Кросс-региональная катастрофа — это CRR, и её вы настраиваете сами.
  • One Zone-IA. Здесь данные в одной AZ. Потеря AZ = потеря данных. Класс годится только для того, что можно пересоздать.

Практический минимум защиты выглядит так:

# 1. Версионирование: DELETE ставит delete marker, тело остаётся восстановимым
aws s3api put-bucket-versioning --bucket prod-data \
  --versioning-configuration Status=Enabled

# 2. Object Lock в режиме COMPLIANCE: объект нельзя удалить или изменить
#    до истечения срока НИКОМУ, включая root-аккаунт. Ровно это ломает
#    сценарий шифровальщика «получил админ-ключи → стёр бэкапы».
aws s3api put-object-lock-configuration --bucket prod-backups \
  --object-lock-configuration '{
    "ObjectLockEnabled":"Enabled",
    "Rule":{"DefaultRetention":{"Mode":"COMPLIANCE","Days":30}}
  }'

# 3. Блокировка любого публичного доступа на уровне аккаунта
aws s3control put-public-access-block --account-id 123456789012 \
  --public-access-block-configuration \
  BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

Разница между GOVERNANCE и COMPLIANCE принципиальна: GOVERNANCE можно обойти с правом s3:BypassGovernanceRetention (защита от дурака), COMPLIANCE не может обойти никто и никак до истечения срока (защита от злоумышленника с вашими ключами). Для бэкапов берите COMPLIANCE — и заранее подумайте о счёте, потому что удалить их досрочно вы тоже не сможете.

Про полную картину резервного копирования и восстановления БД см. https://courses.digitable.life/post/databases/08-replication-and-sharding/ — там же про то, почему бэкап без регулярной проверки восстановления бэкапом не является.

MinIO, Ceph и остальные: S3 у себя

S3 API стал де-факто стандартом, и локальных реализаций много. Ключевой вопрос при выборе — не «поддерживает ли оно S3 API» (заявляют все), а «какие именно части и с какими гарантиями».

MinIO

MinIO — это один Go-бинарник, который превращает набор дисков в S3-совместимое хранилище. Архитектурно он строится на erasure set: группа из 4–16 дисков, внутри которой объект режется на data- и parity-шарды по Рида — Соломона. Дефолт — EC:4 (четыре шарда чётности), то есть выдерживается потеря половины дисков в наборе на чтение.

# Продакшн-раскладка: 4 узла × 4 диска = 16 дисков в одном erasure set.
# ВАЖНО: диски передаются одной фигурной нотацией — MinIO сам
# распределяет erasure set так, чтобы шарды не оказались на одном узле.
minio server --console-address ":9001" \
  http://minio-{1...4}.internal:9000/data/disk{1...4}

# Категорически НЕЛЬЗЯ: RAID под MinIO. Erasure coding уже даёт
# избыточность; RAID сверху удваивает расход места и мешает MinIO
# видеть отказ конкретного диска. Отдавайте сырые XFS-разделы.

Что важно знать про MinIO перед выбором:

  • Лицензия — AGPLv3. Если вы встраиваете MinIO в продукт, который отдаёте наружу как сервис, AGPL распространяется на ваш код. Для внутреннего использования это не проблема, для вендора — юридический вопрос уровня «сходите к юристу».
  • В 2025 году коммерческая политика ужесточилась: из community-сборок вырезали веб-консоль управления, оставив только базовый объектный браузер. Ставка сделана на коммерческий AIStor. Часть сообщества мигрировала на форки и альтернативы; учитывайте это в планировании на годы вперёд.
  • Расширение только пулами. Нельзя добавить один диск. Расширяются серверными пулами (ещё N узлов такой же конфигурации), и старые данные автоматически не перебалансируются — новые записи идут преимущественно в свободный пул.
  • Функциональные пробелы. Нет классов хранения в смысле S3 (есть tiering в удалённый S3), нет Intelligent-Tiering, поведение ListObjectsV2 и conditional writes отличается в деталях. Это ломает софт, написанный «по AWS».

Честное сравнение

AWS S3 MinIO (self-hosted) Ceph RGW Cloudflare R2 GCS Azure Blob
Модель Managed Свой софт, своё железо Свой софт, своё железо Managed Managed Managed
$/ГБ·мес (горячее) 0.023 железо: 0.004 $–0.010 амортизированно железо 0.015 0.020 0.018
Egress в интернет 0.09 $/ГБ сеть ДЦ сеть ДЦ 0 $ 0.12 $/ГБ 0.087 $/ГБ
Классы хранения 8 классов tiering во внешний S3 пулы + lifecycle Standard + Infrequent Access 4 класса 4 уровня
Долговечность 11 девяток (заявлено) зависит от вашей схемы EC зависит от CRUSH-правил 11 девяток 11 девяток 11+ девяток
Условные записи (If-Match) Да Частично, зависит от версии Частично Да Да (generation) Да (ETag)
Object Lock / WORM Да, COMPLIANCE Да Да Да Да (retention policy) Да (immutability)
Порог входа по эксплуатации Нулевой Средний Очень высокий Нулевой Нулевой Нулевой
Сильная сторона Экосистема и надёжность Простота, скорость на NVMe Единый файл/блок/объект Отсутствие egress Интеграция с BigQuery Интеграция с MS-стеком
Когда НЕ брать Тяжёлый egress, регуляторика на локальность Вендор-риск лицензии, нужна команда Нет выделенной команды хранения Нужны глубокие архивные классы Нужен именно AWS-стек Не-Azure окружение

Отдельно про Ceph RGW: это не «MinIO, но сложнее», это принципиально другая система — единый кластер RADOS, отдающий одновременно объекты, блочные устройства (RBD) и файловую систему (CephFS). Мощнейшая штука, но её эксплуатация — отдельная профессия: PG-раскладка, CRUSH-правила, resharding индекса бакета (когда в бакете десятки миллионов объектов, шардированный индекс становится главной операционной болью). Без выделенных инженеров хранения не стоит.

Экономика self-hosted честно. 1 ПБ полезной ёмкости при EC 8+4 (накладные расходы 1.5×) — это 1.5 ПБ сырых дисков, около 75 дисков по 20 ТБ, 6–8 узлов с сетью 25 GbE. Капитальные затраты порядка 120 $–180 тыс., амортизация за 5 лет плюс ДЦ, питание и люди — примерно 0.004 $–0.008 за ГБ в месяц. На S3 Standard тот же петабайт стоит 21 000 $ в месяц, то есть 1.26 млн $ за пять лет. Разница огромна — но она целиком уходит в вашу команду и ваш риск. Self-hosted выигрывает при стабильных больших объёмах и наличии инженеров; проигрывает при непредсказуемом росте и маленькой команде.

Слои хранения: почему все разделили storage и compute

Классическая база держит данные на локальных дисках узла. Это даёт минимальную задержку, но связывает ёмкость и вычисления: нужен ещё терабайт — покупайте узел с процессорами, которые вам не нужны; нужен ещё один аналитик — тоже покупайте узел с дисками, которые не нужны.

Объектное хранилище разрывает эту связь.

Что даёт эта схема:

  • Независимое масштабирование. Ноль вычислительных узлов ночью — платите только за хранение. Пик отчётности — 200 узлов на час.
  • Много движков на одних данных. Spark пишет, Trino читает, DuckDB на ноутбуке читает те же файлы. Никакого ETL между системами.
  • Бесплатное восстановление. Узел упал — поднимите новый, состояния на нём нет.
  • Дешёвое хранение истории. Данные пятилетней давности лежат в Glacier IR по 4 $/ТБ.

Что она забирает:

  • Задержка. Локальный NVMe — 100 мкс, S3 — 50 мс. Разница в 500 раз. Отсюда обязательный кэш для интерактивных нагрузок.
  • Отсутствие транзакций «из коробки» — ровно та проблема, которую решает табличный слой.

Так работают сегодня практически все: ClickHouse умеет MergeTree на диске типа s3 (см. https://courses.digitable.life/post/databases/13-clickhouse-and-olap/), Kafka с KIP-405 выгружает старые сегменты в объектное хранилище, Elasticsearch делает searchable snapshots для «холодных» индексов (https://courses.digitable.life/post/databases/14-timeseries-and-search/), а векторные базы вроде Milvus изначально спроектированы вокруг S3 как слоя хранения (https://courses.digitable.life/post/databases/15-vector-databases/).

<!-- ClickHouse: политика хранения «горячее на NVMe, холодное в S3».
     Части MergeTree старше месяца автоматически переезжают в объектное
     хранилище и остаются полностью читаемыми — просто медленнее. -->
<clickhouse>
  <storage_configuration>
    <disks>
      <s3_cold>
        <type>s3</type>
        <endpoint>https://my-bucket.s3.eu-central-1.amazonaws.com/ch/</endpoint>
        <use_environment_credentials>true</use_environment_credentials>
        <!-- Локальный кэш поверх S3 — без него интерактивные запросы
             упрутся в 50 мс латентности на каждую засечку. -->
        <data_cache_enabled>true</data_cache_enabled>
        <data_cache_max_size>200000000000</data_cache_max_size>
      </s3_cold>
    </disks>
    <policies>
      <hot_to_cold>
        <volumes>
          <hot><disk>default</disk></hot>
          <cold><disk>s3_cold</disk><prefer_not_to_merge>true</prefer_not_to_merge></cold>
        </volumes>
        <move_factor>0.15</move_factor>
      </hot_to_cold>
    </policies>
  </storage_configuration>
</clickhouse>

Lakehouse: как превратить ведро с файлами в таблицу

«Data lake» первого поколения — это каталог с Parquet-файлами и Hive Metastore, который помнит, где какие партиции. У этой конструкции ровно четыре смертельные болезни:

  1. Нет атомарности. Job пишет 500 файлов и падает на 300-м. Читатель видит полтаблицы. Классический костыль — писать во временный каталог и «переименовывать», но на S3 rename — это копирование, а не атомарная операция.
  2. LIST вместо метаданных. Планировщик обходит хранилище, чтобы понять, какие файлы читать. На 4 млн файлов это тысячи запросов и десятки секунд.
  3. Нет эволюции схемы. Переименовали колонку — старые файлы стали нечитаемыми, потому что Hive сопоставляет колонки по имени и позиции.
  4. Нет UPDATE/DELETE. А GDPR требует удалять данные пользователя. Приходится переписывать целые партиции.

Табличные форматы — Apache Iceberg, Delta Lake, Apache Hudi — решают всё это одним приёмом: они добавляют поверх файлов слой метаданных, а атомарность коммита сводят к одной атомарной операции над одним указателем.

Как устроен коммит в Iceberg

Ключевая идея: вся работа делается вне транзакции, и только в конце происходит одна атомарная подмена указателя. Это оптимистический контроль параллелизма — тот же принцип, что в снапшотной изоляции обычных СУБД (см. https://courses.digitable.life/post/databases/07-transactions-and-isolation/), только точка сериализации вынесена в каталог.

Дерево метаданных Iceberg выглядит так:

metadata/v42.metadata.json          ← схема, партиционирование, список снапшотов
  └─ snap-8291...avro (manifest list) ← перечень манифестов + границы партиций
       ├─ manifest-a.avro            ← файлы + min/max по КАЖДОЙ колонке + число строк
       └─ manifest-b.avro
data/dt=2026-07-16/00001.parquet
data/dt=2026-07-16/00002.parquet

Планировщик запроса читает metadata.json, затем manifest list, отбрасывает манифесты по границам партиций, затем читает нужные манифесты и отбрасывает файлы по min/max. LIST по хранилищу не выполняется никогда. На таблице из миллионов файлов планирование занимает сотни миллисекунд вместо десятков секунд.

-- Spark SQL поверх Iceberg. Обратите внимание на три вещи,
-- невозможные в «просто Parquet в каталоге».

-- 1) Скрытое партиционирование: пользователь фильтрует по ts,
--    Iceberg сам понимает, что это партиция по дням.
CREATE TABLE lake.events (
    event_id   BIGINT,
    user_id    BIGINT,
    ts         TIMESTAMP,
    amount     DECIMAL(12,2),
    country    STRING
) USING iceberg
PARTITIONED BY (days(ts), bucket(64, user_id))
TBLPROPERTIES (
    'format-version'                      = '2',
    'write.target-file-size-bytes'        = '536870912',  -- целимся в 512 МБ
    'write.parquet.compression-codec'     = 'zstd',
    'write.delete.mode'                   = 'merge-on-read',
    'history.expire.max-snapshot-age-ms'  = '604800000'   -- 7 дней истории
);

-- 2) Настоящий DELETE по строкам — раньше это означало
--    переписать всю партицию.
DELETE FROM lake.events WHERE user_id = 42;   -- GDPR-запрос

-- 3) Time travel и откат: снапшоты живы, пока их не удалили.
SELECT count(*) FROM lake.events TIMESTAMP AS OF '2026-07-15 00:00:00';
CALL lake.system.rollback_to_snapshot('lake.events', 8291056432104123456);

-- Эволюция схемы без переписывания данных: Iceberg сопоставляет
-- колонки по стабильным ID, а не по имени или позиции.
ALTER TABLE lake.events RENAME COLUMN country TO country_code;
ALTER TABLE lake.events ADD COLUMN channel STRING AFTER amount;

-- Обслуживание — обязательное, не опциональное.
CALL lake.system.rewrite_data_files(
    table => 'lake.events',
    strategy => 'binpack',
    options => map('min-input-files','8','target-file-size-bytes','536870912')
);
CALL lake.system.expire_snapshots('lake.events', TIMESTAMP '2026-07-12 00:00:00', 100);
CALL lake.system.remove_orphan_files(table => 'lake.events');

Последние три вызова — не украшение. Без rewrite_data_files стриминговая запись за месяц наплодит сотни тысяч мелких файлов и запросы деградируют в разы. Без expire_snapshots метаданные растут бесконечно, а удалённые данные продолжают оплачиваться. Без remove_orphan_files в бакете копятся файлы от упавших job’ов, которые не видит ни одна таблица, но за которые вы платите. Lakehouse без регламентного обслуживания — это медленно тухнущая система.

Iceberg против Delta Lake против Hudi

Iceberg Delta Lake Hudi
Происхождение Netflix, 2018; Apache Databricks, 2019; Linux Foundation Uber, 2016; Apache
Метаданные Дерево manifest’ов (Avro) Лог _delta_log: JSON-коммиты + Parquet-чекпойнты каждые 10 Timeline из инстант-файлов
Сериализация коммита CAS в каталоге (REST/Glue/Nessie/Polaris) PUT If-None-Match на файл версии (раньше — DynamoDB на S3) Блокировка (ZooKeeper/DynamoDB/HMS)
Партиционирование Скрытое: пользователь не пишет партиционный предикат Явное (+ liquid clustering в новых версиях) Явное
Эволюция схемы По ID колонок — безопасны rename, reorder, drop По имени + column mapping По имени
Удаления Position/equality delete files (v2), deletion vectors (v3) Deletion vectors Copy-on-write / merge-on-read
Upsert-нагрузка Хорошо Хорошо Лучше всех (индекс по ключу записи)
Экосистема движков Максимально широкая: Trino, Spark, Flink, ClickHouse, DuckDB, Snowflake, BigQuery, Redshift Сильнее всего в Databricks; вне его — через delta-rs и UniForm Уже Spark/Flink
Когда брать Мультидвижковая архитектура, открытый стандарт, ставка «по умолчанию» Вы уже в Databricks Частые upsert’ы и CDC-приём с низкой задержкой
Когда НЕ брать Данных меньше нескольких ТБ — накладные расходы не окупятся Нужна независимость от вендора Нужна широкая поддержка движков

По состоянию на 2026 год Iceberg победил как отраслевой стандарт: его поддержали Snowflake, BigQuery, Redshift, Confluent и даже Databricks (купив Tabular и запустив UniForm), AWS выпустил управляемые S3 Tables. Если вы выбираете сегодня с чистого листа и не сидите в Databricks — берите Iceberg, это ставка с наименьшим риском.

DuckDB как проверка реальностью

Хороший тест зрелости lakehouse: можно ли прочитать вашу таблицу с ноутбука без кластера.

-- Установка и чтение таблицы Iceberg напрямую из S3, без Spark,
-- без Hadoop, одним процессом на ноутбуке.
INSTALL iceberg; LOAD iceberg;
INSTALL httpfs;  LOAD httpfs;

CREATE SECRET (TYPE s3, PROVIDER credential_chain, REGION 'eu-central-1');

-- Планирование идёт по манифестам: DuckDB прочитает только те
-- Parquet-файлы, чья min/max статистика допускает совпадение.
SELECT country_code, date_trunc('day', ts) AS d, sum(amount) AS revenue
FROM iceberg_scan('s3://my-lake/warehouse/events')
WHERE ts >= '2026-07-01' AND amount > 100
GROUP BY 1, 2
ORDER BY revenue DESC
LIMIT 20;

Если это работает — вы построили открытый lakehouse. Если для чтения нужен конкретный кластер конкретного вендора — вы построили проприетарное хранилище, которое просто лежит в вашем бакете.

Практика продакшена

Проблема мелких файлов и как считать целевой размер

Стоимость чтения одного файла ≈ TTFB (≈50 мс) + размер/пропускная_способность. Для файла в 1 МБ это 50 мс накладных на ~10 мс полезной работы — КПД 17%. Для файла в 512 МБ — 50 мс на 5 секунд, КПД 99%.

Отсюда рабочее правило: целевой размер файла в lakehouse — 128 МБ до 1 ГБ, оптимум обычно 256–512 МБ. Стриминговая запись мелкими батчами неизбежно нарушает это правило, поэтому компакция обязательна. Дополнительный аргумент — Parquet row group по умолчанию 128 МБ: файл меньше этого просто не даёт движку возможности параллелить чтение внутри файла.

Считать деградацию можно прямо: таблица из 2 млн файлов по 5 МБ вместо 20 тыс. файлов по 500 МБ — это в 100 раз больше GET-запросов (0.0004 $/1000 → 0.80 $ против 0.008 $ на полный скан) и, что важнее, в 100 раз больше раундтрипов: при 64 потоках это 2 000 000/64 × 50 мс ≈ 26 минут только латентности против 15 секунд.

Раскладка ключей и параллелизм

Лимит 3 500 PUT в секунду действует на разделяемый префикс, и S3 делит пространство автоматически по мере роста нагрузки — но не мгновенно, а за минуты-часы. При резком старте высоконагруженной записи вы получите 503 SlowDown. Два приёма:

# Плохо: все ключи начинаются с растущей даты — запись всегда бьёт
# в один «горячий» хвост отсортированного пространства.
key = f"events/2026-07-16/{uuid4()}.parquet"

# Хорошо: короткий хеш-префикс равномерно размазывает запись
# по разделяемым префиксам с первой секунды.
h = hashlib.blake2b(str(uuid4()).encode(), digest_size=2).hexdigest()  # 4 символа
key = f"events/{h}/2026-07-16/{uuid4()}.parquet"

Важная оговорка: для аналитики это ухудшает читаемость префиксов, поэтому в lakehouse так делать не нужно — там нагрузка на запись распределяется естественно и планирование идёт по манифестам, а не по LIST. Хеш-префикс нужен для сценариев с очень высокой частотой записи мелких объектов (телеметрия, загрузка пользовательских файлов).

И всегда включайте адаптивные повторы — 503 SlowDown и 500 штатны для S3, это не аварии:

from botocore.config import Config

cfg = Config(
    retries={"max_attempts": 10, "mode": "adaptive"},  # экспоненциальная пауза + client-side rate limiting
    max_pool_connections=64,                            # без этого 64 потока встанут в очередь
    tcp_keepalive=True,
    connect_timeout=3,
    read_timeout=60,
)
s3 = boto3.client("s3", config=cfg)

Наблюдаемость

Три вещи, которые нужно включить в первый день, иначе диагностировать будет нечем:

  1. S3 Storage Lens — бесплатный дашборд по всем бакетам: объём, число объектов, распределение по классам, незавершённые MPU, доля неактуальных версий. Первое место, куда идти с вопросом «почему счёт вырос».
  2. S3 Inventory — ежедневный CSV/Parquet-отчёт со всеми объектами и их атрибутами. Дешевле, чем миллион LIST-запросов, и позволяет считать статистику по бакету обычным SQL.
  3. CloudTrail data events (выборочно, они платные) — кто удалил объект и когда. Без них расследование инцидента невозможно.

Типичные ошибки

  1. Считать S3 файловой системой. Монтирование через s3fs/goofys даёт POSIX-интерфейс поверх модели, которая ему не соответствует. Работает до первого конкурентного доступа, append, или требования согласованных метаданных. Mountpoint for Amazon S3 честнее — он прямо отказывается поддерживать rename и произвольную запись.
  2. Забыть про незавершённые multipart-загрузки. Оплачиваются, невидимы, копятся годами.
  3. Включить версионирование без правила истечения неактуальных версий. Счёт растёт монотонно и навсегда.
  4. Перевести миллионы мелких объектов в архивный класс. Заплатить 5 000 $ за переходы, чтобы сэкономить 100 $ в месяц.
  5. Ходить в S3 через NAT Gateway вместо бесплатного Gateway Endpoint.
  6. Использовать SSE-KMS без Bucket Keys при высокой частоте запросов.
  7. Полагаться на ETag как на контрольную сумму содержимого.
  8. Строить lakehouse без регламента компакции и expire_snapshots. Через полгода запросы деградируют в десять раз, и никто не поймёт почему.
  9. Считать 11 девяток защитой от удаления. Нужен Object Lock, а не вера в durability.
  10. Не тестировать restore. Восстановление 300 ТБ из Deep Archive — это не «12 часов», это 12 часов на первый байт плюс сутки на выкачивание, плюс несколько тысяч долларов, о которых никто не предупредил.
  11. Игнорировать различия S3-совместимых реализаций. Код, отлаженный на MinIO, может упасть на реальном S3 (и наоборот) на conditional writes, семантике LIST или лимитах multipart.

Когда объектное хранилище — неправильный выбор

  • OLTP и любые точечные запросы с требованием единиц миллисекунд. 50 мс на объект — это приговор. Здесь ваш инструмент — https://courses.digitable.life/post/databases/02-postgresql/ или https://courses.digitable.life/post/databases/11-redis/.
  • Частые мелкие обновления. Изменение одного байта переписывает весь объект. Если рабочая нагрузка — «много мелких UPDATE», объектное хранилище не подходит ни в каком виде, даже под табличным форматом.
  • Строгие транзакции между несколькими сущностями. Даже с conditional writes вы получаете CAS над одним ключом, а не сериализуемость. Нужна серьёзная транзакционность в распределённой системе — смотрите https://courses.digitable.life/post/databases/18-newsql-and-distributed/.
  • POSIX-семантика как требование. Легаси, которому нужны rename, блокировки и произвольная запись, нужно ставить на настоящую сетевую ФС (EFS, CephFS, Lustre), а не эмулировать поверх S3.
  • Маленькие данные. До нескольких терабайт lakehouse со всей его машинерией — чистые накладные расходы. Одна PostgreSQL или ClickHouse справится быстрее, проще и дешевле.
  • Тяжёлая раздача контента напрямую из бакета. Egress съест бюджет. Нужен CDN перед бакетом — или провайдер без платы за egress.

Мини-итог

  • Объектное хранилище — это плоское key-value с большими неизменяемыми значениями, а не файловая система. Нет rename, нет append, нет частичной перезаписи, нет транзакций между объектами. Каждое архитектурное решение поверх S3 выводится из этих ограничений.
  • Консистентность с 2020 года строгая, а с 2024 появились условные записи (If-None-Match, If-Match) — единственный примитив взаимного исключения, и именно на нём стоят современные табличные форматы.
  • Счёт формируют не гигабайты, а запросы, egress, KMS, lifecycle-переходы и незавершённые multipart-загрузки. Считайте по формуле «хранение + retrieval·f + запросы», а не по цене за гигабайт.
  • Архивные классы дёшевы только для крупных объектов: минимальный billable размер, минимальный срок хранения и цена PUT превращают экономию на мелочи в убыток.
  • 11 девяток не защищают от вашего DELETE. Защищают версионирование, Object Lock в режиме COMPLIANCE и протестированное восстановление.
  • Разделение storage и compute даёт независимое масштабирование и мультидвижковость ценой 500-кратной разницы в задержке. Кэш поверх объектного слоя не опция, а часть архитектуры.
  • Lakehouse = Parquet (физический слой) + Iceberg/Delta (транзакционный слой) + каталог (точка сериализации коммита). Iceberg сегодня — ставка по умолчанию.
  • Lakehouse требует регламентного обслуживания: компакция мелких файлов, expire_snapshots, удаление осиротевших файлов. Без этого система тихо деградирует.

Источники

Что дальше

Мы разобрали хранилище, где данные — это неизменяемые байты, доступные по одному ключу. Следующая статья — про два семейства, которые двигаются в противоположные стороны от этой модели: графовые базы, где ценность именно в связях между записями, и встраиваемые key-value движки, где вся система умещается в библиотеку внутри вашего процесса: Графовые и key-value БД: Neo4j, Dgraph, etcd, RocksDB, LMDB.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов