Инкрементальный бэкап pgBackRest после 2 000 транзакций занял 103,9 МБ при полном 108,1 МБ: файлы таблицы изменились, и копия взяла их целиком. Тот же инкремент в блочном режиме — 29,3 МБ, у WAL-G — 6,6 МБ. WAL под такой бэкап — тот же, что в статье про базовый бэкап и PITR; как выбирать частоту бэкапов — в статье про RPO и RTO.
Цепочка full, incr, incr и размер последнего инкремента
Стенд
Образ на базе postgres:18 с пакетом pgbackrest; WAL-G — релизный бинарник, смонтированный в контейнер. Два репозитория pgBackRest получают одни и те же данные: repo1 обычный, repo2 с repo2-block=y и repo2-bundle=y (блочный инкремент работает только вместе с bundle, руководство). Каждый бэкап делается в оба репозитория подряд при неизменной базе, поэтому разница в размерах — это только формат.
S=dba-pgbackrest-walg-incremental
printf 'FROM postgres:18\nRUN apt-get update && apt-get install -y --no-install-recommends pgbackrest curl ca-certificates && rm -rf /var/lib/apt/lists/*\n' > Dockerfile
docker build -t $S:local .
curl -sL -o wal-g https://github.com/wal-g/wal-g/releases/download/v3.0.9/wal-g-pg-24.04-amd64 && chmod +x wal-g
docker run -d --name $S --memory 2g -e POSTGRES_PASSWORD=pg -v $PWD/wal-g:/usr/local/bin/wal-g:ro $S:local -c shared_buffers=256MB
docker exec $S sh -c 'mkdir -p /var/lib/pgbackrest /var/spool/pgbackrest /var/log/pgbackrest && chown postgres: /var/lib/pgbackrest /var/spool/pgbackrest /var/log/pgbackrest
cat > /etc/pgbackrest/pgbackrest.conf <<EOF
[global]
repo1-path=/var/lib/pgbackrest/plain
repo1-retention-full=2
repo2-path=/var/lib/pgbackrest/block
repo2-block=y
repo2-bundle=y
repo2-retention-full=2
compress-type=lz4
start-fast=y
process-max=2
archive-async=y
spool-path=/var/spool/pgbackrest
[main]
pg1-path=/var/lib/postgresql/18/docker
EOF'
docker exec $S psql -U postgres -Atq -c "alter system set archive_mode=on" -c "alter system set archive_command='pgbackrest --stanza=main archive-push %p'" -c "alter system set wal_level=replica" -c "alter system set max_wal_size='2GB'"
docker restart $S
docker exec -u postgres $S pgbackrest --stanza=main stanza-create
docker exec -u postgres $S pgbackrest --stanza=main check
docker exec $S pgbench -U postgres -i -s 60 -q postgres
docker exec $S psql -U postgres -Atc "select pg_size_pretty(pg_database_size('postgres'))" -c "select pg_size_pretty(pg_relation_size('pgbench_accounts'))"
905 MB
768 MB
pb ниже — оболочка над docker exec -u postgres $S pgbackrest --stanza=main --log-level-console=warn. Шаг нагрузки — pgbench -c4 -j2 -t 500 (2 000 транзакций, каждая обновляет одну строку pgbench_accounts) и CHECKPOINT.
Опыт 1: размер full, diff и incr в двух репозиториях
pb --type=full --repo=1 backup; pb --type=full --repo=2 backup
# затем три раза: нагрузка, CHECKPOINT, и пара бэкапов типов diff, incr, incr
for step in diff incr incr; do
docker exec $S pgbench -U postgres -c4 -j2 -t 500 postgres
docker exec $S psql -U postgres -Atqc checkpoint
for r in 1 2; do pb --type=$step --repo=$r backup; done
done
pb info | grep -E 'backup:|database size|repo[12]:' | tail -6
| Бэкап | backup size repo1, по файлам, МБ | backup size repo2, блоки и bundle, МБ |
|---|---|---|
| full | 101,8 | 102,3 |
| diff | 98,2 | 28,6 |
| incr 1 | 98,2 | 27,9 |
| incr 2 | 98,3 | 28,0 |
Строки info для последнего incr в обоих репозиториях:
incr backup: 20261028-003109F_20261028-003204I
database size: 921MB, database backup size: 898.5MB
repo1: backup set size: 102.7MB, backup size: 98.3MB
incr backup: 20261028-003117F_20261028-003206I
database size: 921MB, database backup size: 898.5MB
repo2: backup size: 28MB
database backup size в info — это объём файлов, которые бэкап признал изменёнными: 898,5 МБ из 921 МБ в обоих репозиториях, то есть pgbench_accounts (769 МБ) и её первичный ключ (128 МБ). Сумма backup size цепочки: repo1 396,5 МБ, repo2 186,8 МБ. В repo1 каждый инкремент почти равен полному, потому что файл, у которого изменились размер или время, копируется целиком:
// If delta is disabled and size/timestamp are equal then the file is not copied
if (!file.delta && fileSizeEqual && file.timestamp == filePrior.timestamp)
file.copy = false;
Это manifestBuildIncr в src/info/manifest/manifest.c (строки 521–523): файл остаётся на ссылке на предыдущий бэкап только при равенстве размера и времени изменения, а любой записанный байт сдвигает время. Блочный режим сравнивает содержимое: каждый блок хэшируется (xxHashOne) и сверяется с картой блоков предыдущего бэкапа (src/command/backup/blockIncr.c, строки 124–133):
// Get block checksum
const Buffer *const checksum = xxHashOne(this->checksumSize, this->block);
// Does the block exist in the input map?
const BlockMapItem *const blockMapItemIn =
this->blockMapPrior != NULL && this->blockNo < blockMapSize(this->blockMapPrior) ?
blockMapGet(this->blockMapPrior, this->blockNo) : NULL;
// If the block is new or has changed then write it
if (blockMapItemIn == NULL || memcmp(blockMapItemIn->checksum, bufPtrConst(checksum), this->checksumSize) != 0)
Размер блока записан в манифесте, поле bi в единицах по 8 192 байта (константа BLOCK_INCR_SIZE_FACTOR). Строки файла таблицы (16399) и её индекса (16407) из манифеста последнего инкремента repo2:
docker exec $S sh -c 'grep -h "base/5/16399=\|base/5/16407=" /var/lib/pgbackrest/block/backup/main/20261028-003117F_20261028-003206I/backup.manifest'
pg_data/base/5/16399={"bi":10,"bic":7,"bim":90071,"checksum":"5c28a8c72eb5b3ccb65e1f1f0f61fab4451b5720","checksum-page":true,"group":"postgres","rck":"38cec6011ac3f23a0fb9372628b25faaa80c726c","repo-size":9955212,"size":806576128,"timestamp":1793147524}
pg_data/base/5/16407={"bi":4,"bic":7,"bim":38354,"checksum":"27a15bc43207212b5266b13fd37681e06dc54f2d","checksum-page":true,"group":"postgres","rck":"198d23be0317fe49a3f0704d876820c9c7d40bb4","repo-size":19293813,"size":134791168,"timestamp":1793147524}
Таблица режется на блоки по 10 × 8 192 = 80 КиБ, индекс — по 4 × 8 192 = 32 КиБ (размер зависит от размера и возраста файла, функция manifestBuildBlockIncrSize). В инкремент попало 9,96 МБ таблицы и 19,3 МБ индекса. Индекс вдвое тяжелее таблицы, потому что pgbench_accounts создана с fillfactor=100, и HOT-обновление почти не срабатывает: n_tup_hot_upd 508 из 10 000 n_tup_upd, остальные обновления добавляют записи в B-tree. "checksum-page":true значит, что при копировании pgBackRest сверил контрольные суммы страниц файла.
Доля изменённых блоков таблицы предсказуема. В файле 806 576 128 / 81 920 ≈ 9 846 блоков; 6 000 случайных строк за три шага нагрузки заденут в среднем 1 − e^(−6000/9846) ≈ 45,6 % блоков. Лог restore --delta ниже показывает для этого файла bi 351.9MB/769.2MB, то есть 45,7 %.
Опыт 2: restore в пустой каталог и restore —delta
Каждый репозиторий: полный набор восстанавливается в пустой /restore, поверх него --delta до последнего инкремента, затем последний инкремент отдельно в пустой /restore2. Каждое измерение — один запуск на базе 920 МБ в кэше страниц.
pb --repo=$r --set=$FULL --pg1-path=/restore restore
pb --repo=$r --set=$LATEST --pg1-path=/restore --delta restore
pb --repo=$r --set=$LATEST --pg1-path=/restore2 restore
| Операция | repo1, по файлам | repo2, блоки и bundle |
|---|---|---|
| full в пустой каталог | 2,02 с | 1,92 с |
| последний incr в пустой каталог | 1,79 с | 2,00 с |
--delta поверх восстановленного full | 1,11 с | 1,41 с |
С --log-level-console=detail лог --delta показывает, что именно переписано:
2026-10-28 00:40:40.551 P01 DETAIL: restore file /restore/base/5/16399 (769.2MB, 100.00%) checksum 5c28a8c72eb5b3ccb65e1f1f0f61fab4451b5720
2026-10-28 00:40:55.365 P01 DETAIL: restore file /restore/base/5/16399 (bi 351.9MB/769.2MB, 100.00%) checksum 5c28a8c72eb5b3ccb65e1f1f0f61fab4451b5720
Первая строка — repo1, вторая — repo2. Из 982 файлов repo1 переписал 204 (остальные 778 exists and matches backup), среди них таблицу целиком; repo2 переписал 195 файлов, а таблицу — 351,9 МБ из 769,2 МБ. --delta быстрее восстановления последнего инкремента в пустой каталог в обоих репозиториях (1,11 против 1,79 с и 1,41 против 2,00 с), но блочный --delta не обогнал файловый: 1,41 против 1,11 с. Проверка содержимого: кластер из repo2 с --type=immediate --target-action=promote на порту 5433 вернул select sum(abalance), (select count(*) from pgbench_history) from pgbench_accounts = -165471|2000, и то же вернул источник.
Опыт 3: archive-push, verify и expire
С archive-async=y archive_command не ждёт загрузки в репозиторий: фоновый процесс забирает из очереди .ready все готовые сегменты за один запуск. Восемь переключений WAL подряд (insert и pg_switch_wal()):
docker exec $S psql -U postgres -Atqc "create table t(i int)"
docker exec $S sh -c 'for i in 1 2 3 4 5 6 7 8; do psql -U postgres -Atq -c "insert into t values (1)" -c "select pg_switch_wal()" >/dev/null; done; sleep 4; grep -E "push [0-9]+ WAL" /var/log/pgbackrest/main-archive-push-async.log | tail -2'
2026-10-28 00:41:16.492 P00 INFO: push 1 WAL file(s) to archive: 000000010000000000000043
2026-10-28 00:41:16.799 P00 INFO: push 7 WAL file(s) to archive: 000000010000000000000044...00000001000000000000004A
pg_stat_archiver.archived_count вырос с 74 до 82 при failed_count 0. Очередь — это список .ready-файлов PostgreSQL (archivePushReadyList), а лимит её размера считает archivePushDrop в src/command/archive/push/push.c (строки 75–88) как размер первого готового сегмента, умноженный на число готовых. Если archive-push-queue-max превышен, pgbackrest help archive-push archive-push-queue-max обещает: «pgBackRest will notify PostgreSQL that the WAL was successfully archived, then DROP IT» и предупреждение в журнале PostgreSQL. Сегмент отбрасывается ради диска, и поток архива рвётся; без этой опции при недоступном репозитории WAL копится в pg_wal.
verify читает репозиторий и сверяет контрольные суммы. Для проверки в repo1 перезаписаны 4 байта файла таблицы в полном бэкапе (printf XXXX | dd of=.../pg_data/base/5/16399.lz4 bs=1 seek=100000 conv=notrunc):
pb --repo=2 --log-level-console=info verify | tail -1
pb --repo=1 --log-level-console=info verify | tail -7
2026-10-28 00:41:28.567 P00 INFO: verify command end: completed successfully (702ms)
2026-10-28 00:41:29.120 P01 INFO: invalid checksum '20261028-003109F/pg_data/base/5/16399.lz4'
2026-10-28 00:41:29.474 P00 INFO: stanza: main
status: error
backup: 20261028-003109F, status: invalid, total files checked: 981, total valid files: 980
checksum invalid: 1
2026-10-28 00:41:29.474 P00 INFO: verify command end: completed successfully (834ms)
Строка completed successfully относится к самой команде: echo $? после verify с порчей в repo1 вернул 0, а порчу показывает только блок status: error, поэтому скрипту нужно разбирать вывод. Затем два полных бэкапа в repo1 при repo1-retention-full=2 (вторая команда выполнена дважды, показан вывод после второго запуска):
pb --repo=1 info | grep 'wal archive'
pb --repo=1 --type=full --log-level-console=info backup | grep -E 'expire|remove'
pb --repo=1 info | grep -E 'wal archive|full backup'
wal archive min/max (18): 000000010000000000000001/00000001000000000000004A
2026-10-28 00:41:40.525 P00 INFO: repo1: expire full backup set 20261028-003109F, 20261028-003109F_20261028-003140D, 20261028-003109F_20261028-003152I, 20261028-003109F_20261028-003204I
2026-10-28 00:41:40.588 P00 INFO: repo1: 18-1 remove archive, start = 000000010000000000000031, stop = 00000001000000000000004A
wal archive min/max (18): 00000001000000000000004B/00000001000000000000004E
full backup: 20261028-004135F
full backup: 20261028-004138F
Третий по счёту полный бэкап вытеснил первый вместе с diff и двумя incr, и WAL старше самого старого из оставшихся полных бэкапов удалён из архива. Первый бэкап с испорченным файлом исчез вместе с ними.
Опыт 4: WAL-G на той же базе
WAL-G читает настройки из окружения. Полный бэкап и две дельты с WALG_DELTA_MAX_STEPS=3; рядом по тем же состояниям базы идут полный бэкап и два incr в оба репозитория pgBackRest:
wg() { docker exec -u postgres -e WALG_FILE_PREFIX=/var/lib/walg -e WALG_DELTA_MAX_STEPS=3 -e PGUSER=postgres -e PGHOST=/var/run/postgresql -e PGDATABASE=postgres $S wal-g "$@"; }
pb --type=full --repo=1 backup; pb --type=full --repo=2 backup
wg backup-push /var/lib/postgresql/18/docker
for i in 1 2; do
docker exec $S pgbench -U postgres -c4 -j2 -t 500 postgres
docker exec $S psql -U postgres -Atqc checkpoint
for r in 1 2; do pb --type=incr --repo=$r backup; done
wg backup-push /var/lib/postgresql/18/docker
done
docker exec $S sh -c 'cd /var/lib; du -sb pgbackrest/plain/backup/main/20261028-004340F* pgbackrest/block/backup/main/20261028-004342F* walg/basebackups_005/base_*[0-9A-F]'
108097360 pgbackrest/plain/backup/main/20261028-004340F
103822919 pgbackrest/plain/backup/main/20261028-004340F_20261028-004356I
103879865 pgbackrest/plain/backup/main/20261028-004340F_20261028-004403I
108554190 pgbackrest/block/backup/main/20261028-004342F
29390852 pgbackrest/block/backup/main/20261028-004342F_20261028-004358I
29257899 pgbackrest/block/backup/main/20261028-004342F_20261028-004405I
104704785 walg/basebackups_005/base_000000010000000000000054
6739325 walg/basebackups_005/base_00000001000000000000005A_D_000000010000000000000054
6630524 walg/basebackups_005/base_000000010000000000000060_D_00000001000000000000005A
Дельта WAL-G в 4,4 раза меньше блочного инкремента pgBackRest (6,6 против 29,3 МБ) и в 15,7 раза меньше файлового (103,9 МБ). Лог backup-push называет базу дельты: Delta backup from base_000000010000000000000054 with LSN 0/54000028. Страница попадает в дельту по условию на LSN из заголовка (SelectNewValidPage в internal/databases/postgres/incremental_page_reader.go, строки 164–166):
if isNew || (pageHeader.lsn() >= pageReader.Lsn) {
pageReader.Blocks = append(pageReader.Blocks, blockNo)
}
Единица здесь — страница 8 КБ против 80 и 32 КиБ у блока pgBackRest. Без битовой карты (WALG_USE_WAL_DELTA в опыте не задавалась) initialize вызывает FullScanInitialize, то есть читает все страницы файла. Восстановление последнего бэкапа в пустой каталог: wal-g backup-fetch /restore LATEST (full и две дельты) заняло 2,52 с, pgbackrest restore последнего incr — 1,52 с (repo1) и 1,61 с (repo2).
Что из этого следует
- Инкремент по файлам на базе, где обновления разбросаны по таблице, почти равен полному бэкапу: 103,9 МБ против 108,1 МБ. Блочный режим включай сразу вместе с bundle; в руководстве pgBackRest отмечено, что он работает лучше, когда включён для всех типов бэкапа.
- Размер инкремента определяет не только таблица. При
fillfactor=100обновления делают индекс тяжелее таблицы (19,3 против 10,0 МБ). restore --deltaпереписывает только отличающееся (204 файла из 982 или 352 МБ из 769 МБ таблицы), но на 920 МБ в кэше выигрыш в секунды; для сетевого репозитория эти секунды не измерялись.archive-push-queue-maxзащищает диск ценой разрыва потока архива. Ставь его вместе с алертом на предупреждениеdropped WAL fileи наpg_stat_archiver.failed_count.verifyнаходит повреждённый файл в репозитории (1 из 981), но не доказывает, что бэкап восстановится; проверка содержимого — отдельное восстановление, как в статье про PITR. Логическая копия, pg_dump, не зависит от формата файлов и от WAL.
Чек-лист
- Блочный режим и bundle (
repo1-block=y,repo1-bundle=y) включены в конфигурации репозитория с первого полного бэкапа. - После каждого нового типа нагрузки сравни
backup sizeинкремента и полного бэкапа; близкие значения означают, что режим не работает. archive-async=yс заданнымиspool-pathиarchive-push-queue-max, алерт на предупреждение об отброшенном WAL.pgbackrest verifyпо расписанию, разбор вывода (status: error), а не только кода возврата; раз в период пробное восстановление в отдельный каталог.- Убери стенд:
docker rm -f -v dba-pgbackrest-walg-incremental; docker rmi dba-pgbackrest-walg-incremental:local.