Несколько лет назад я трудился в проекте, где основной биллинг работал на 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. Выражаю особую благодарность своему тимлиду Михаилу Левицкому за консультацию при написании статьи.

Комментарии (8)


  1. Sleuthhound
    24.12.2025 07:46

    Кажеться, что слишком много телодвижений + бэкап должен быть в таком формате чтобы максимально быстро можно было на нем запустить пг и тут возникает вопрос: А если за сутки у вас архивируется 100Tb wal файлов и инкрементальный бэкап пусть даже 1 раз в сутки, то скажем востановиться на 23:47:12 это нужно накатить примерно 50Tb валов до нужной точки - сколько Вы будите накатывать их? Как решается такая проблема?


    1. polkiloo
      24.12.2025 07:46

      Это отличный вопрос.

      Действительно это серьезное ограничение для использования данной технологии в таких масштабах.

      Однако, у нас есть несколько идей как можно сделать поднятие временного экземпляра существенно быстрее - в следующих статьях расскажем про наши исследования.


      1. Sleuthhound
        24.12.2025 07:46

        Кажеться, что отстающая реплика на N часов более интересное решение, хотя и более накладное с точки зрения потребления ресурсов.


        1. polkiloo
          24.12.2025 07:46

          Это отличное решение, проверенное временем.

          Однако, используя экземпляр поднятый на резервной копии, можно заглянуть в прошлое сильно больше чем на час.

          Заглянуть можно на столько, насколько позволяет глубина хранения РК - например можно вытащить данные по состоянию на несколько лет назад.

          Кроме того, данная технология не ограничивается в сценариях использования только flashback восстановлением.


  1. bigtrot
    24.12.2025 07:46

    Насколько я понял из статьи, что архив создается после порчи данных. Если это так, то тогда не понятно, как получить данные до их порчи.


    1. polkiloo
      24.12.2025 07:46

      Спасибо за находку, действительно ошибка в тексте - архив создается до порчи данных.


      1. bigtrot
        24.12.2025 07:46

        В чем преимущество Вашего подхода по сравнению с восстановление на заданный момент времени штатным подходом и после этого восстановить данные?


        1. polkiloo
          24.12.2025 07:46

          Значительное преимущество по времени восстановления на конкретную точку будет при следующих условиях:

          1. Размер базовой резервной копии > 1 Tb

          2. Суммарный размер WAL сегментов для консистетного восстановления < 50 Gb - точка во времени близка к базовой копии.

          В таком случае время проигрывания WAL незначительно отличается, а для дампа отдельной БД на 10Gb не нужно поднимать кластер более 1 Tb

          Для таких операций можно выделить специальную recovery area с быстрыми nvme дисками малого размера.