
Несколько лет назад я трудился в проекте, где основной биллинг работал на Oracle. Однажды коллега захотел поправить тестовые начисления в таблице abon_charges и выполнил такой запрос:
UPDATE abon_charges SET amount = 0 WHERE service_id = 123 AND v_abon_id = v_abon_id;
На первый взгляд — ничего страшного. Но v_abon_id = v_abon_id истинно для любой строки. Oracle это не игнорирует. Условие становится:
WHERE service_id = 123 AND TRUE
Так запрос обнулил абсолютно все суммы для service_id=123 за десятки месяцев. В таблице было около 1,8 млн строк по этой услуге.
С такой неприятностью в Oracle может помочь механизм Oracle Flashback. Вкратце: находим проблемную транзакцию, в отдельной сессии включаем чтение таблицы на момент до обновления, снимаем копию в отдельную таблицу и отдаём её нашему виновнику для решения проблемы :).
Мы починили всё без простоя и полного восстановления всего кластера. С тех пор мне всегда хотелось иметь такой «точечный флэшбэк» и в PostgreSQL. Особенно в системах, где восстановление базы на несколько терабайтов может занимать часы. И вот недавно мне довелось организовать такое решение в нашем продукте Platform V CopyWala. Это инструмент для бэкапа от СберТеха, который работает с PostgreSQL. Покажу, как всё устроено.
Как работает FUSE и зачем он нужен
FUSE (Filesystem in Userspace) — механизм Linux, который позволяет реализовывать файловую систему в пользовательском пространстве. Программе передаются системные вызовы (open, read, readdir и т. д.) и она сама решает, что отдавать. Таким образом ОС кажется, что она работает с настоящим каталогом с данными.
Почему это интересно для PostgreSQL
FUSE-драйвер может смонтировать его как виртуальный PGDATA. Тогда можно запустить PostgreSQL так:
pg_ctl start -D <fuse-directory>
И PostgreSQL честно подумает, что работает на локальной файловой системе, хотя в реальности читает архив.
В нашем продукте для резервного копирования и восстановления данных Platform V CopyWala мы добавили драйвер CopyWala Fuse. Он умеет монтировать в произвольный каталог наш собственный формат архива и предоставлять ту самую виртуальную PGDATA, с которой может работать экземпляр PostgreSQL. Покажу, как воспроизвести псевдо-flashback в PostgreSQL с помощью postgres_fdw и Copywala Fuse.
Шаг 1. Поднимаем окружение
Нам понадобится два контейнера для эмуляции аварии. Один мастер (для примера используем контейнер, но вы можете использовать свой экземпляр) и одна реплика, которую мы поднимем с помощью Copywala Fuse. Структура проекта будет выглядеть следующим образом:
project/ ├─ docker-compose.yml └─ backups_dir/ ├─ copywala // бинарь копивалы (не ниже v.1.2.0) ├─ config.yaml // конфиг для запуска копивалы └─ test-agent/backups/<backup>.cwl // архив, сделанный на шаге 3
docker-compose.yml:
version: '3.9' services: master_ct: image: postgres:16 container_name: master_ct environment: POSTGRES_USER: cwdbuser POSTGRES_PASSWORD: cwdbpass POSTGRES_DB: cwdb ports: - "5432:5432" volumes: - "./backups_dir:/backups_dir" replica_ct: image: postgres:16 container_name: replica_ct environment: POSTGRES_USER: cwdbuser POSTGRES_PASSWORD: cwdbpass POSTGRES_DB: cwdb ports: - "5433:5432" volumes: - "./backups_dir:/backups_dir"
Шаг 2. Наполняем мастер и портим данные для теста
docker compose up -d docker exec -it master_ct pgbench -i -s 10 -U cwdbuser -d cwdb
pgbench — это замечательная утилита для нагрузочного тестирования и наполнения данных в БД. Она доступна «из коробки», так что проблем с ней быть не должно.
После генерации у вас появится следующая структура таблиц:
cwdb=# \dt List of tables Schema | Name | Type | Owner --------+------------------+-------+---------- public | pgbench_accounts | table | cwdbuser public | pgbench_branches | table | cwdbuser public | pgbench_history | table | cwdbuser public | pgbench_tellers | table | cwdbuser (4 rows)
Снимаем эталонную checksum:
SELECT md5(string_agg(md5(t::text), ',' ORDER BY aid)) FROM pgbench_accounts t; md5 ---------------------------------- 65061aa9af380081c3d77f665f90808b (1 row)
Она нам понадобится в будущем.
Создаём резервную копию:
docker exec -it master_ct /backups_dir/copywala init test-agent -c \ /backups_dir/config.yaml docker exec -it master_ct /backups_dir/copywala create-backup -c \ /backups_dir/config.yaml
Портим данные:
DELETE FROM pgbench_accounts WHERE aid IN ( SELECT aid FROM pgbench_accounts TABLESAMPLE SYSTEM(1); );
Шаг 3. Монтируем архив через FUSE и запускаем PostgreSQL без restore
В контейнере replica_ct:
docker exec -it replica_ct bash su - postgres mkdir -p /fuse_pg_data # директория в которую будем монтировать нашу резервную копию pg_ctl stop
Монтируем архив, созданный на втором шаге, и запускаем экземпляр применительно к смонтированной директории:
/backups_dir/copywala fuse /fuse_pg_data /backups_dir/test-agent/backups/<backup>.cwl & pg_ctl start -D /fuse_pg_data
После успешного запуска видим в журнале:
INF start fuse mounting mount_path=/fuse_pg_data cache_swap_size=268435456 cache_dir=/tmp
Здесь стоит рассказать немного про параметры запуска команды copywala fuse. cache_swap_size — это размер буфера, в который пишутся данные при работе со смонтированной директорией. При переполнении буфера данные будут сбрасываться на диск в директорию, указанную в cache_dir.
Экземпляр готов, подчеркну, без restore, напрямую из архива. Теперь в смонтированной директории мы видим следующее:

Что происходит внутри
Когда мы вызываем ls в корне смонтированной директории, СopyWala Fuse получает syscall = getdents(readdir), а обработчик идёт в мета-информацию архива, читая только корневую структуру директорий и файлов, и приводит их в необходи��ый формат текущей ОС для вывода.
Важно! Мы читаем не сами файлы, а только их мета-информацию, благодаря чему можем быстро пройтись по древовидной структуре и получить доступ к любому файлу почти мгновенно.
Разберём другой случай, когда нам нужно получить данные какого-либо файла.

При попытке чтения мы также получаем мета-информацию о местоположении файла в архиве и запрашиваем не весь файл, а только те части (срез offset + size), которые были запрошены на стороне клиента.
Шаг 4. «Флэшбэк» через postgres_fdw
Переходим на мастер и подключаемся к таблице, развёрнутой на нашей Fuse-реплике:
CREATE EXTENSION IF NOT EXISTS postgres_fdw; CREATE SERVER replica_server FOREIGN DATA WRAPPER postgres_fdw OPTIONS (host 'replica_ct', port '5432', dbname 'cwdb'); CREATE USER MAPPING FOR cwdbuser SERVER replica_server OPTIONS (user 'cwdbuser', password 'cwdbpass'); CREATE SCHEMA IF NOT EXISTS replica_import; # добавляем таблицу во временную схему для исключения конфликта имён IMPORT FOREIGN SCHEMA public LIMIT TO (pgbench_accounts) FROM SERVER replica_server INTO replica_import; ALTER FOREIGN TABLE replica_import.pgbench_accounts RENAME TO pgbench_accounts_replica;
Сравниваем количество строк:
SELECT count(*) FROM pgbench_accounts; -- 999900 SELECT count(*) FROM pgbench_accounts_replica; -- 1000000
Восстанавливаем повреждённые строки:
INSERT INTO pgbench_accounts SELECT r.* FROM pgbench_accounts_replica r LEFT JOIN pgbench_accounts m USING (aid) WHERE m.aid IS NULL;
Проверяем checksum — должна совпасть с эталонной.
SELECT md5(string_agg(md5(t::text), ',' ORDER BY aid)) FROM pgbench_accounts t; md5 ---------------------------------- 65061aa9af380081c3d77f665f90808b (1 row)
Что ещё можно делать с FUSE в PostgreSQL?
Использование FUSE как «виртуального PGDATA» открывает массу возможностей.
Гранулярное восстановление через pg_dump. Можно вытащить одну таблицу:
pg_dump -t orders_replica > orders.sqlи вернуть её в боевую базу.
Экспресс-аудит любых исторических копий. Смонтировать архив → открыть psql → посмотреть состояние данных.
Исследование резервной копии. Проанализировать реальное состояние кластера на момент бэкапа, без restore.
Тестирование миграций на реальных данных. Поднимаем Postgres из архива → гоняем миграции → не портим боевой кластер.
Снижение RTO для локальных и dev-сред.Не нужно поднимать тяжёлые стенды.
Спасибо за внимание! Готов ответить на вопросы и послушать ваши мнения о том, где ещё может быть полезен Fuse. Больше о том, как мы развиваем инструмент, пишем в нашем сообществе про базы данных — приходите. И прошу в комментарии!
P. S. Выражаю особую благодарность своему тимлиду Михаилу Левицкому за консультацию при написании статьи.
