Холодное хранилище для PostgreSQL: pg_lake + S3
Марк Нефёдов, Big Data EngineerКакая была потребность
У продуктовых команд регулярно возникает один и тот же конфликт. Есть данные, которые нельзя удалять — например, история ответов внешних источников, которую нужно хранить 3+ лет, потому что этого требует закон. И есть цена хранения: боевой PostgreSQL живёт на быстрых локальных SSD, и архив занимает там место наравне с горячими данными, за которыми ходят в реальном времени.
Дальше начинается снежный ком: база пухнет, дольше идут бэкапы и восстановление, тяжелее автовакуум, дороже поднять новую реплику, больнее переживается failover. При этом сами архивные данные читаются редко — статистика, разборы, ответы на запросы.
Классические варианты нам не подходили. Просто удалить — нельзя. Выгрузить в DWH — данные уезжают в другой контур: отдельный пайплайн, отдельные доступы, отдельный диалект, и приложение уже не может просто сделать SELECT и приджойнить архив к живым данным. Хотелось третьего: убрать данные с дорогого диска, но оставить их «под рукой» — доступными обычным SQL за минуты, а не через заявку и ETL.
Что сделали
Собрали холодное хранилище для PostgreSQL на двух вещах: внутренней платформе объектного хранения STaaS и open-source расширении pg_lake от Snowflake Labs.
Идея pg_lake в том, что таблица создаётся как обычная — только USING iceberg. Физически её данные лежат в объектном хранилище в виде Parquet-файлов и Iceberg-метаданных, а PostgreSQL остаётся владельцем каталога, схемы и указателя на текущую версию метаданных. Для приложения это по-прежнему таблица в его же базе: тот же коннект, тот же SQL, те же джойны с горячими таблицами. Под капотом в под с PostgreSQL добавляется sidecar с движком DuckDB, который и выполняет чтение из S3; общаются они через unix-сокет.
Для сервиса включение выглядит как флаг у его хранилища: платформа сама заводит бакет, доставляет доступы через Vault и настраивает расширение. Дальше разработчик просто пишет SQL.
Что пришлось доработать в самом расширении
Это, пожалуй, самая интересная часть. pg_lake — молодой проект, и на демо-объёмах он ведёт себя отлично. Наши масштабы вскрыли проблемы, которые на маленьких таблицах не видны в принципе.
Главная — рост памяти при обслуживании Iceberg-таблицы. При обходе снапшотов и манифестов во время VACUUM расширение копировало путь каждого файла в транзакционный контекст памяти, хотя хэш-таблица, куда путь клали, и так копирует ключ. Плюс не освобождались списки манифестов и сами манифесты сразу после того, как они уже записаны. На таблице с большим числом снапшотов это выливалось в несколько гигабайт в одном контексте и приводило к OOM самого PostgreSQL. Причём подкрутить лимиты снаружи было нельзя: параметры, ограничивающие число компакций и удалений за один вакуум, не ограничивают предварительный поиск referenced-файлов. Мы починили это у себя, выкатили на свой форк, а фикс отправили в апстрим — pg_lake#454. Следом опубликовали продолжение — масштабирование очистки Iceberg-метаданных при TRUNCATE: pg_lake#457.
Мы вообще очень много всего починили, все наши изменения в проекте открыты и лежат здесь: https://github.com/Snowflake-Labs/pg_lake/pulls?q=author%3Amarknefedov+
Отдельно стоит сказать, почему мы вообще пошли в апстрим, а не остались на форке. Форк — это разовое обезболивание: он живёт ровно до следующего релиза, после чего его нужно переносить руками. Отправленный в upstream фикс — это то, что дальше едет к нам само, вместе с обновлением версии, и заодно достаётся всем остальным, кто наступит на те же грабли на своих объёмах.
Как это работает на реальном сервисе
Первым на решение переехал сервис Автотеки, хранящий историю ответов внешних источников. Его основная таблица партиционирована по дням.
Логика простая: дневные партиции старше окна удержания (у сервиса это 45 дней) фоновый мигратор копирует в холодную Iceberg-таблицу, а затем отцепляет и удаляет из горячей базы. Чтения при этом не ломаются — статистические запросы сами разбивают запрошенный диапазон по границе «горячее / холодное» и склеивают результат, так что вызывающему коду вообще не нужно знать, где физически лежат данные.
Отдельно закладывались на то, что процесс может упасть в любой момент. Перенос идёт маленькими сегментами (источник + сутки), копирование сегмента и отметка о его завершении делаются одним атомарным шагом, а удалить горячую партицию можно только после того, как все её сегменты подтверждённо доехали. Поэтому любой сбой — это в худшем случае повторная работа, но не потеря данных.
Что в итоге изменилось
Самая наглядная цифра — соотношение объёмов. У базы этого сервиса 400 ГБ локального диска на реплику. В холодном слое на S3 сейчас лежит 540 ГБ данных — 2.9 млрд строк в 85 тысячах Parquet-файлов. В обычных таблицах PostgreSQL тот же объём занял бы порядка 2.1 ТБ: строка весит около 736 байт в heap против 199 байт в Parquet, то есть примерно вчетверо больше, плюс индексы.
И это ещё без учёта того, что реплик три. Каждый гигабайт в горячей базе оплачивается трижды — на каждой реплике свой локальный диск. Гигабайт в холодном слое хранится один раз: Iceberg-файлы не ездят по репликам PostgreSQL, их общий источник — объектное хранилище, а через WAL едет только SQL-каталог.
Иначе говоря, речь не про «освободили немного места». Этих данных на диске базы просто не могло бы поместиться — их пришлось бы или удалять, или увозить в DWH и терять прямой SQL-доступ.
Что получилось в сумме:
- в горячем слое остаётся окно в 45 дней (около 53 ГБ), всё старше живёт на S3;
- сервис пишет около 1.6 млн строк в сутки, это примерно 1.1 ГБ в день — то есть без переноса диск съедался бы за считанные месяцы;
- архив при этом доступен обычным SQL из того же приложения, без похода в DWH;
- расширение стало стабильнее — и у нас, и в upstream;
- решение стало платформенным: включить холодное хранилище теперь может любой сервис на PostgreSQL 17 в k8s, а не только тот, кто первым в это пошёл.