На стенде с базой 1С файл данных в каталоге pg_dump с zstd обрезали, а pg_restore прочитал каталог с кодом 0 и без ошибок. Рассказываем, какая проверка ловит такой обрыв до загрузки и чем сверить загруженную базу с исходной.
Первую версию этой статьи мы выпустили по одиночным замерам: каждый способ копии сняли один раз. Следом нам прислали рецензию, и самый неприятный её вопрос был простой. Вы советуете directory с zstd, а обратно в базу такую копию загружали? Не загружали. Пришлось вернуться на стенд.
Здесь то, что получилось после возврата. На тестовом стенде «+Альянс» мы прогнали пять кругов: копии базы 1С в 14,8 ГБ четырьмя способами pg_dump, загрузка в пустую базу и сверка с исходной. Потом портили архивы и смотрели, какая проверка это замечает. По итогам вы выберете формат копии, прикинете по таблицам время копии и загрузки дампа, возьмёте скрипт, который не оставляет плохую копию под рабочим именем, и запросы для сверки загруженной базы.
Это лабораторный опыт: один стенд, одна база, четыре ядра. Свою базу придётся перемерить. Начать можно сегодня, в час без пользователей: четыре потока нагружают сервер, а наши замеры шли без нагрузки. Снимите копию командой pg_dump -Fd -j 4 -Z zstd -d база -f каталог, засеките время, загрузите её в пустую базу и сравните число строк по таблицам.
Коротко
- На тестовом стенде «+Альянс» (PostgreSQL 18.4 для 1С, 4 ядра, база 1С в 14,8 ГБ)
pg_dumpв формате directory с четырьмя потоками и zstd снимал копию за 156–179 секунд, тот же формат с gzip — за 179–198, custom и текст через gzip — за 260–314; на каждый способ по четыре замера. - Загрузка дампа в пустую базу PostgreSQL на этом стенде заняла от 358 до 565 секунд в 28 прогонах, дольше любой копии. В замер входит только команда загрузки: регистрацию базы в кластере 1С и вход в 1С мы не проверяли.
- Каталог directory с zstd чтением
pg_restore -f /dev/nullне проверить: на стенде обрезанный файл данных прошёл с кодом 0. Обрыв поймалzstd -tпо файлам, потерю файла — счёт файлов против оглавления. - Скрипт
kopiya-dir.shпишет копию в каталог.part, проверяет её по стадиям и только потом даёт рабочее имя. На базе теста Гилева в 81 МБ он прогнан восемь раз, на базе рабочего размера не запускался. - Отпечаток базы — число строк по каждой из 8 701 таблицы и определения индексов и ограничений — сошёлся с исходной базой у всех 28 успешных загрузок стенда; хеш содержимого таблиц считали у семи из них.
Какой формат pg_dump взять: четыре способа в пяти кругах
Начинать я советую с directory в четыре потока с zstd. Проверено это на одном стенде и одной базе. Копия этим способом снималась быстрее остальных в каждом круге, где сняты все четыре, и вышла на 14 % меньше того же формата с gzip. По времени загрузки от directory с gzip и от custom она не отличалась. Весь путь этот способ у нас прошёл: копия, восемь загрузок в пустую базу, сверка.
Замеры шли на тестовом стенде «+Альянс» 6 октября 2026 года, с 17:06 до 23:54 по Москве. Стенд — виртуальная машина Hyper-V: Debian 13.7, 4 ядра, 41 ГБ памяти, PostgreSQL 18.4 из сборки для 1С (Debian 18.4-1.1C), сервер 1С 8.3.27.1859. База — устаревшая копия рабочих данных 1С: 14 843 369 151 байт, 8 701 таблица, 20 117 индексов, 16 864 257 строк. Копии писались на отдельный диск. Перед каждым замером выполнялся CHECKPOINT и сбрасывался кэш ОС, время считали с точностью до секунды.
Круг на стенде — это один проход: копии, затем загрузка каждой в пустую базу и сверка. Порядок способов от круга к кругу менялся. Все копии одного круга сняты из одного снимка базы через ключ --snapshot, поэтому четыре архива хранят одно состояние. В скриптах копии из этой статьи такого ключа нет: он нужен был для сравнения.
Команды дословно, запускались они от пользователя postgres через bash -o pipefail -c; DB1 — псевдоним базы, $SNAP — имя снимка, $D — каталог круга на диске копий:
pg_dump --snapshot=$SNAP -d DB1 | gzip > $D/a.sql.gz
pg_dump --snapshot=$SNAP -Fc -d DB1 -f $D/a.dump
pg_dump --snapshot=$SNAP -Fd -j 4 -d DB1 -f $D/a.dir
pg_dump --snapshot=$SNAP -Fd -j 4 -Z zstd -d DB1 -f $D/a.zstd.dir
Время копии в секундах. Медиана посчитана по кругам 2, 3 и 5: только в них сняты все четыре способа.
| Способ | Круг 1 | Круг 2 | Круг 3 | Круг 4 | Круг 5 | Медиана кругов 2, 3, 5 | От и до | Размер копии, байт |
|---|---|---|---|---|---|---|---|---|
текстовый дамп через gzip | 275 | 314 | 302 | — | 260 | 302 | 260–314 | 1 811 243 343 |
custom (-Fc) | 285 | 265 | 290 | — | 286 | 286 | 265–290 | 1 839 431 604 |
directory, 4 потока (-Fd -j 4) | 198 | 187 | 183 | — | 179 | 183 | 179–198 | 1 837 125 126 |
directory, 4 потока, zstd (-Fd -j 4 -Z zstd) | сорвалась | 156 | 179 | 158 | 156 | 156 | 156–179 | 1 578 737 629 |
Все копии, кроме сорвавшейся, вернули код 0. Четвёртый круг мы добавили из-за этого срыва и сняли в нём только directory с zstd.
В кругах 2, 3 и 5 порядок по времени один: directory с zstd быстрее directory с gzip, а тот быстрее custom и текста. По медиане directory с zstd снимается на 15 % быстрее, чем directory с gzip. Разрыв между двумя directory составил 31, 4 и 23 секунды, так что в третьем круге они шли почти вровень. Custom и текст через gzip на этом стенде не различаются: в кругах 1 и 5 быстрее был текст, в кругах 2 и 3 custom. Одиночный замер из первой версии статьи, где текст через gzip вышел самым медленным, повтор не подтвердил.
Копия directory с zstd заняла 10,6 % от размера базы, три остальные — от 12,2 до 12,4 %. Размер от круга к кругу менялся на сотни байт, в таблице наименьший. Уровень сжатия мы не задавали ни у gzip, ни у zstd.
Параллельно pg_dump в PostgreSQL 18 пишет только в формат directory: с custom и текстовым дампом ключ -j не работает. С этим ключом утилита открывает на одно соединение больше, чем задано потоков, так что проверьте запас в max_connections. Каталог из -f она создаёт сама, и существовать заранее он не должен: ставьте в имя время запуска. Нагрузку на сервер базы ключ -j увеличивает, а наши замеры шли без пользователей, так что мешает ли параллельная копия работе в 1С, по этим цифрам сказать нельзя.
Сколько идёт загрузка дампа в PostgreSQL
Время загрузки дампа на стенде «+Альянс» — это время одной команды: psql или pg_restore заливает копию в уже созданную пустую базу. До рабочей базы 1С отсюда ещё несколько шагов, и прошли мы не все.
| Шаг | Что вышло на стенде |
|---|---|
создать пустую базу: createdb -T template0 | 0–1 секунда, 28 замеров |
| загрузить дамп | 358–565 секунд, 28 замеров |
собрать статистику: vacuumdb --analyze-only -j 4 | 53–64 секунды после загрузки directory с zstd, четыре замера |
| зарегистрировать базу в кластере 1С, запретить регламентные задания, войти в 1С | не проверяли |
Пустую базу под загрузку надо создавать из template0, а после загрузки разумно прогнать ANALYZE, чтобы у оптимизатора запросов была статистика. Утилиту vacuumdb для этого выбрали мы сами.
Время семи способов загрузки в секундах. Текст подавался в psql через zcat. Первые четыре способа идут без общей транзакции с остановкой на первой ошибке, три последних — одной транзакцией. Каталог directory с gzip одной транзакцией мы не загружали.
| Способ | Круг 1 | Круг 2 | Круг 3 | Круг 4 | Круг 5 | Медиана кругов 2, 3, 5 | От и до |
|---|---|---|---|---|---|---|---|
текст, psql -X -q -v ON_ERROR_STOP=1 | 565 | 552 | 540 | — | 451 | 540 | 451–565 |
custom, pg_restore -e -j 4 | 516 | 481 | 460 | — | 376 | 460 | 376–516 |
directory с gzip, pg_restore -e -j 4 | 482 | 460 | 472 | — | 387 | 460 | 387–482 |
directory с zstd, pg_restore -e -j 4 | — | 504 | 465 | 358 | 365 | 465 | 358–504 |
текст, psql -X -q -1 -v ON_ERROR_STOP=1 | 479 | 477 | 468 | — | 427 | 468 | 427–479 |
custom, pg_restore -1 | 512 | 481 | 499 | — | 407 | 481 | 407–512 |
directory с zstd, pg_restore -1 | — | 554 | 558 | 377 | 419 | 554 | 377–558 |
Все 28 загрузок вернули код 0, поток ошибок у каждой пуст.
Для окна простоя главное здесь вот что: каждая загрузка шла дольше любой копии. Самая быстрая загрузка заняла 358 секунд, самая долгая копия 314. Простой я бы планировал по времени загрузки, а не по времени ночной копии.
Вечерние и ночные круги различаются сильнее, чем способы. В кругах 1–3, с 17:06 до 21:04 по Москве, загрузки заняли 460–565 секунд, в кругах 4 и 5, с 22:06 до 23:54, от 358 до 451. Причину мы не установили: настройки PostgreSQL были те же, а стенд — виртуальная машина, и что в эти часы делали соседи по хосту, неизвестно.
Способы загрузки поэтому сравниваются только внутри одного круга, а секунды читаются как диапазон. Одиночные ночные замеры первой версии статьи дали 363–370 секунд; при повторе загрузки заняли от 358 до 565.
Загрузки из custom, directory с gzip и directory с zstd в четыре потока между собой не различаются. Внутри круга разница между ними 12–44 секунды, и в кругах 2, 3 и 5 лидер каждый раз другой. Какой из трёх архивов загружать, для времени на этом стенде оказалось неважно, так что выбирать между ними можно по времени копии и размеру.
Одна транзакция или остановка на первой ошибке: чем загружать
Режим загрузки определяет, что останется в базе, если загрузка споткнётся об ошибку. Одна транзакция (pg_restore -1, а у psql — ключ -1 вместе с ON_ERROR_STOP) при ошибке откатывает всё, что успела сделать, и мелкая ошибка в конце может стоить загрузки, которая шла много часов. Без общей транзакции с остановкой на первой ошибке (psql с ON_ERROR_STOP, pg_restore -e) в базе лежит то, что успело загрузиться. Есть и третий режим: без -e и без -1 утилита pg_restore после ошибки продолжает работу и в конце сообщает число ошибок. Время в третьем режиме на стенде не снималось.
Параллельная загрузка с одной транзакцией не сочетается: ключ -j у pg_restore нельзя ставить вместе с -1. Работает -j только с архивами custom и directory и только из файла или каталога, из конвейера он не читает. Ключ -1 у pg_restore сам включает остановку на ошибке.
Общего правила по скорости режимы на стенде «+Альянс» не дали. Текст одной транзакцией в каждом из четырёх кругов загружался быстрее того же текста без неё, на 24–86 секунд. Каталог directory с zstd одной транзакцией, наоборот, в каждом круге шёл медленнее четырёх потоков, на 19–93 секунды; у custom в двух кругах вышло вровень, в двух — медленнее на 31 и 39 секунд. Четыре потока pg_restore против текста одной транзакцией в кругах 1–3 шли вровень и только в круге 5 выиграли 9–15 %: устойчивого преимущества стенд не показал. Причин опыт не называет. Стенд работает с wal_level minimal, замера с replica нет, потоков везде четыре на четырёх ядрах. Для pg_restore -j хорошая отправная точка — число ядер сервера.
Ошибку посреди загрузки одна транзакция на стенде превратила в пустую базу. От сорванной копии первого круга остался неполный каталог directory с zstd: pg_restore -1 вернул на нём код 1, и таблиц в базе не оказалось. Загрузка в четыре потока тоже вернула 1, с сообщением «рабочий процесс неожиданно прервался», но оставила 8 701 таблицу без единой строки и без индексов. Неполный каталог zstd был у нас один, так что и случай этот единственный.
Обрыв без ошибки тот же режим пропускает. Текстовый дамп, оборванный по границе команды, psql -1 -v ON_ERROR_STOP=1 дважды загрузил на стенде с кодом 0: 1 октября мы обрезали файл сами, 3 октября оборвалась копия по расписанию. Оба раза база осталась без индексов. Кода загрузки поэтому мало, после неё нужна сверка.
Для пробной загрузки по расписанию я бы ставил одну транзакцию и сверку следом. К ключу -1 у psql обязателен ON_ERROR_STOP: без него тот же ключ на половине дампа маленькой базы вернул на стенде 0 при пустой базе.
Каталог directory с zstd: чем проверить копию до загрузки
Чтение архива командой pg_restore -f /dev/null для каталога directory с zstd как проверка не годится. На стенде «+Альянс» мы обрезали самый большой файл данных такого каталога в трёх местах: наполовину, без последних 64 КБ и без одного последнего байта. Все три раза pg_restore 18.4 прочитал каталог с кодом 0 и пустым потоком ошибок. SQL на выходе при этом получился короче целого: на 928 МБ, на 177 857 байт и на 46 785 байт. Тот же обрыв в каталоге с gzip дал код 1 и сообщение «не удалось закрыть файл данных».
Почему pg_restore не замечает обрыв файла zstd, мы не установили. Формата файлов данных в документации PostgreSQL 18 нет. Обрыв поймала другая проверка: zstd -t по обрезанному файлу вернул 1 во всех трёх случаях.
Какая проверка без базы что поймала на стенде:
| Что испорчено | pg_restore -l | pg_restore -f /dev/null | gzip -t или zstd -t по каждому файлу | Счёт файлов данных против TABLE DATA в оглавлении |
|---|---|---|---|---|
| custom: начало файла | ловит | ловит | — | — |
| custom: середина, хвост, один байт | пропускает | ловит | — | — |
| directory: нет файла данных | пропускает | ловит | пропускает | ловит |
| directory с gzip: файл обрезан | пропускает | ловит | ловит | пропускает |
| directory с zstd: файл обрезан | пропускает | пропускает | ловит | пропускает |
| directory: блок внутри файла затёрт | пропускает | ловит | ловит | пропускает |
directory: нет toc.dat | ловит | ловит | — | — |
Таблица собрана по одной базе и одному архиву каждого формата: custom в 1,84 ГБ, каталоги в 1,84 и 1,58 ГБ по 8 702 файла. В каталогах портили один файл за раз, самый большой или самый маленький.
Каталог directory с zstd до загрузки проверяется двумя шагами: zstd -tq по каждому файлу данных и счёт файлов *.dat.zst против записей TABLE DATA в оглавлении pg_restore -l. У целого каталога нашей базы оба числа равны 8 701. Равенство держится на том, что у каждой таблицы этой базы есть файл данных; на базе с большими объектами мы его не проверяли.
Одна ложная тревога у zstd -t была: он забраковал файл нулевой длины, которым мы заменили данные пустой таблицы, хотя каталог с таким файлом загрузился полностью.
Настоящая загрузка обрыв на один байт заметила, только заметил его сервер, а не pg_restore: при COPY он отверг последнюю, неполную строку таблицы config с ошибкой «нет данных для столбца». С ключом -e загрузка остановилась через 97 секунд с кодом 1, и в базе оказалось 1 841 662 строки из 16 864 257. Без -e она дошла до конца, тоже вернула 1 и оставила базу, где не хватило 40 624 строк: столько, сколько в таблице config в источнике.
Обрыв ровно по границе строки данных мы не получали. Пройдёт ли тогда загрузка с кодом 0 и неполной таблицей, неизвестно.
Код загрузки для каталога zstd поэтому полноту не доказывает. До загрузки нужны обе проверки, zstd -t по файлам и счёт файлов против оглавления, после неё — сверка с источником.
Custom-архиву чтение в /dev/null на стенде подошло лучше. На архиве в 1,84 ГБ pg_restore -f /dev/null вернул 1 на всех девяти видах порчи: от изменённого байта в середине («не удалось распаковать данные: incorrect data check») до хвоста без последних 64 КБ («не удалось прочитать входной файл: конец файла»). pg_restore -l семь случаев из девяти пропустил и показал оглавление целиком. Архив такого размера у нас был один. На custom-архиве в 5,3 МБ то же чтение пропускало потерю последних 64–512 КБ, а загрузка из архива без последних 64 КБ вышла полной. Почему граница у двух архивов разная, осталось невыясненным.
Чтение архива объектов в базе не создаёт. Ошибок сервера оно не увидит: нет расширения, не та версия, испорченная строка данных. Настоящая проверка копии любого формата — пробная загрузка и сверка с источником.
Проверки на архивах рабочего размера заняли на стенде от 99 до 169 секунд с холодным кэшем, по одному прогону на команду: zstd -tq по 8 701 файлу — 99 секунд, чтение pg_restore -f /dev/null — 112 секунд для custom и 130 для каталога zstd.
Скрипт копии: плохой файл не должен лежать под рабочим именем
3 октября 2026 года на стенде «+Альянс» оборвалась ежедневная копия по cron. Обрыв совпал по секунде с перезапуском PostgreSQL, который мы сами делали в соседнем опыте; копия по расписанию в план того опыта не входила. Скрипт копии на стенде код pg_dump не проверял и писал в журнал «End backup» при любом исходе. В журнале cron за ту же секунду записано «(CRON) info (No MTA installed, discarding output)»: без почтового агента вывод задания пропал.
Файл копии за 3 октября весил 95 МБ против 1,12 ГБ накануне, gzip -t вернул 0, строки dump complete в конце не было. База, залитая из него простым zcat | psql, получила 4 687 таблиц и ни одного индекса. Даже psql -1 -v ON_ERROR_STOP=1 отработал на этом файле с кодом 0: файл кончался целым блоком данных, и ошибок в потоке не было. Одна транзакция такой обрыв не ловит.
Скрипт копии стенда писали мы сами, так что камень летит в собственный огород. Проверка свободного места в нём брала из df -k второй столбец, то есть размер раздела вместо свободного места.
После рецензии мы написали два скрипта копии одной базы: kopiya-dir.sh для каталога directory с zstd и kopiya.sh для текстового дампа через gzip. Оба пишут копию под именем .part, проверяют её по стадиям и только после этого дают рабочее имя. Неудачная копия получает суффикс .ERROR, а в журнале остаётся имя стадии, на которой всё сломалось. Первый скрипт дословно, как он работал на стенде:
#!/bin/bash
# Копия одной базы в каталог directory + zstd:
# каталог .part, проверки по стадиям, затем имя или .ERROR
set -u -o pipefail
db=$1; dir=$2; logdir=${3:-$dir}; min_free_kb=${4:-5242880}; jobs=${5:-4}
PG_DUMP=${PG_DUMP:-pg_dump}
log=$logdir/backup-$(date +%F).log
say(){ echo "$(date '+%F %T') $*" >> "$log"; }
exec 9> "$logdir/.backup-$db.lock"
if ! flock -n 9; then say "SKIP $db stage=lock"; exit 75; fi
avail=$(df -Pk "$dir" | awk 'NR==2 {print $4}')
say "START $db Avail=${avail}K"
if [ "$avail" -lt "$min_free_kb" ]; then
say "FAIL $db stage=free_space Avail=${avail}K порог=${min_free_kb}K"; exit 1
fi
d=$dir/$db-$(date +%H%M%S).dir
$PG_DUMP -Fd -j "$jobs" -Z zstd -d "$db" -f "$d.part" 2>> "$log"
rc=$?
stage=; toc=-; files=-; bad=-
if [ "$rc" -ne 0 ]; then
stage=pg_dump
else
toc=$(pg_restore -l "$d.part" 2>> "$log" | grep -c ' TABLE DATA ')
files=$(find "$d.part" -name '*.dat.zst' | wc -l)
bad=0
for f in "$d.part"/*.dat.zst; do
zstd -tq "$f" 2>> "$log" || bad=$((bad+1))
done
if [ "$toc" -lt 1 ] || [ "$toc" -ne "$files" ]; then
stage=file_count
elif [ "$bad" -ne 0 ]; then
stage=zstd_test
fi
fi
if [ -z "$stage" ]; then
mv "$d.part" "$d"
say "OK $db files=$files bytes=$(du -sb "$d" | cut -f1)"
else
[ -e "$d.part" ] && mv "$d.part" "$d.ERROR"
say "FAIL $db stage=$stage pg_dump=$rc toc=$toc files=$files bad=$bad"
exit 1
fi
Вызов: sudo -u postgres bash kopiya-dir.sh <база> <каталог копий> <каталог журнала> [порог, КБ] [потоков].
Свободное место скрипт kopiya-dir.sh пишет в журнал при каждом запуске, туда же идёт поток ошибок pg_dump, pg_restore -l и zstd. Стадий после pg_dump две: file_count сравнивает число записей TABLE DATA в оглавлении с числом файлов *.dat.zst, zstd_test прогоняет zstd -tq по каждому файлу данных.
Переменная PG_DUMP нужна была опыту: через неё мы подставляли убитый и замедленный pg_dump и обёртки, которые портят каталог сразу после копии. Без неё скрипт вызывает обычный pg_dump. Порог свободного места в 5 ГБ — значение по умолчанию из примера, обоснования у него нет, подберите своё.
Скрипт kopiya-dir.sh прогнан на стенде восемь раз, на синтетической базе теста Гилева:
| Что подстроено | Код скрипта | Строка журнала |
|---|---|---|
| ничего | 0 | OK gilev files=189 bytes=4456051 |
pg_dump убит через 0,3 секунды | 1 | FAIL gilev stage=pg_dump pg_dump=137 toc=- files=- bad=- |
| базы с таким именем нет | 1 | FAIL net_takoy_bazy stage=pg_dump pg_dump=1 toc=- files=- bad=- |
имитация: после pg_dump самый большой файл данных обрезан на один байт | 1 | FAIL gilev stage=zstd_test pg_dump=0 toc=189 files=189 bad=1 |
имитация: после pg_dump самый большой файл данных удалён | 1 | FAIL gilev stage=file_count pg_dump=0 toc=189 files=188 bad=0 |
| порог места 999 999 999 999 КБ | 1 | FAIL gilev stage=free_space Avail=36895108K порог=999999999999K |
| второй запуск, пока идёт первый | 75 у второго, 0 у первого | SKIP gilev stage=lock, затем OK gilev files=189 bytes=4456051 |
| один поток вместо четырёх | 0 | OK gilev files=189 bytes=4456051 |
Сообщение pg_dump о несуществующей базе и сообщение zstd о преждевременном конце файла, Read error (39) : premature end, попали в дневной журнал. Каталогов .part после восьми прогонов не осталось. Копия из первого прогона прошла весь путь: каталог загружен в пустую базу с кодом 0, и отпечаток загруженной базы сошёлся с исходной — 189 таблиц, 135 128 строк, хеш содержимого по каждой таблице, определения 347 индексов и 996 ограничений.
На базе рабочего размера kopiya-dir.sh не запускался, и сколько займут его проверки на базе в 14,8 ГБ, неизвестно. Обрезанный и удалённый файлы — имитации: каталог портила подстановка сразу после pg_dump. Под cron скрипт не работал, только из сеанса SSH. Сообщение оболочки об убитом процессе ушло при этом в поток ошибок самого скрипта, мимо журнала. Цикла по базам, удаления старых копий и каталогов .ERROR в скрипте нет.
Условие [ "$toc" -lt 1 ] в kopiya-dir.sh не сработало ни разу: оглавление каждый раз, когда читалось, давало 189 записей. Равенство записей TABLE DATA и файлов *.dat.zst сошлось на двух базах, тестовой и базе в 14,8 ГБ, обе без больших объектов. Случая, когда рабочий процесс pg_dump вернул ошибку, а основной остался жив, у нас не было.
Скрипт для текстового дампа через gzip
Скрипт kopiya.sh со стенда «+Альянс» начинается так же, как kopiya-dir.sh: блокировка, журнал, проверка свободного места. Нет в нём только пятого параметра с числом потоков. Ниже фрагмент, которым он отличается, от записи копии до конца:
f=$dir/$db-$(date +%H%M%S).sql.gz
$PG_DUMP -d "$db" 2>> "$log" | gzip > "$f.part" 2>> "$log"
st=("${PIPESTATUS[@]}")
stage=
if [ "${st[1]}" -ne 0 ] && { [ "${st[0]}" -eq 0 ] || [ "${st[0]}" -eq 141 ]; }; then
stage=gzip
elif [ "${st[0]}" -ne 0 ]; then
stage=pg_dump
elif ! gzip -t "$f.part" 2>> "$log"; then
stage=gzip_test
elif [ "$(zcat "$f.part" | tail -n 5 | grep -c 'dump complete')" -lt 1 ]; then
stage=completion_marker
fi
if [ -z "$stage" ]; then
mv "$f.part" "$f"
say "OK $db $(stat -c%s "$f")"
else
mv "$f.part" "$f.ERROR"
say "FAIL $db stage=$stage pg_dump=${st[0]} gzip=${st[1]}"
exit 1
fi
Коды pg_dump и gzip скрипт kopiya.sh читает раздельно, сразу после конвейера, и по ним называет стадию. На базе теста Гилева он тоже прогнан восемь раз; стадии pg_dump, free_space и lock сработали так же, как у kopiya-dir.sh. Когда каталог копий на tmpfs в 1 МБ кончился посреди записи, в журнал попали «gzip: stdout: No space left on device» и строка FAIL gilev stage=gzip pg_dump=141 gzip=1. Код 141 у pg_dump здесь значит, что gzip умер первым и писать стало некуда, поэтому такую пару скрипт относит к стадии gzip.
Стадия completion_marker сработала только на имитации: обёртка отдала первые 3 000 строк дампа и код 0, gzip -t прошёл, и в журнале осталось «FAIL gilev stage=completion_marker pg_dump=0 gzip=0». Настоящего такого случая на стенде не было. Стадия gzip_test не сработала ни разу: ветка написана, но не проверена.
Под cron скрипт kopiya.sh тоже не запускался, база в прогонах была одна и маленькая, цикла по базам в нём нет. На большой базе gzip -t и zcat | tail прочитают файл копии ещё два раза. Загрузку из копии ни один из двух скриптов не делает: пройденные стадии kopiya.sh говорят, что pg_dump и gzip завершились без ошибки, контейнер цел и в конце стоит служебная строка, не больше.
Проверка строки «-- PostgreSQL database dump complete» — дополнительная, и держится она на нашем опыте. На страницах pg_dump и pg_restore документации PostgreSQL 18 этой строки нет. На стенде она стояла в конце целых текстовых дампов от pg_dump 15.6 и 18.4; у версии 18.4 после неё идёт ещё \unrestrict <ключ>, поэтому скрипт смотрит пять последних строк. Работает такая проверка только вместе с кодом pg_dump, gzip -t и пробной загрузкой.
По нашему опыту, gzip -t проверяет сжатый контейнер, а не полноту дампа. 3 октября мы убивали pg_dump через 0,3 секунды после старта: gzip закрыл файл штатно, gzip -t вернул 0, а в базу из такого файла дошло 15 007 строк из 135 121. Голый конвейер pg_dump | gzip без pipefail вернул тогда 0, с pipefail — код убитого pg_dump, 137.
Сравнения размера с прошлой копией в обоих скриптах нет. Файл за 3 октября уступал вчерашнему в 11,8 раза, и увидели мы это вручную. Ещё поищите в журнале cron своего сервера строку «No MTA installed»: если она там есть, вывод заданий пропадает так же, как на стенде 3 октября.
Чем сверить загруженную базу с исходной: отпечаток базы
В первой версии статьи слово «совпало» значило немного: сошлись число таблиц, число индексов и сумма строк. Равное число индексов не говорит, что индексы те же. Теперь загруженную базу на стенде «+Альянс» мы сверяем отпечатком. Отпечаток базы — это набор файлов, которые скрипт fp.sh снимает с базы одними и теми же запросами: счёт и хеш строк по каждой таблице и определения объектов.
| Что входит в отпечаток | Как снято | У базы стенда |
|---|---|---|
| строки по таблицам | count(*) по каждой таблице | 8 701 таблица, 16 864 257 строк |
| содержимое таблиц | сумма 64-битных хешей текстового вида каждой строки | 8 701 хеш |
| колонки | имя, тип, NOT NULL, значение по умолчанию, правило сортировки, права | 80 346 |
| индексы | таблица, pg_get_indexdef(), indisvalid, indisready, indislive, уникальность, первичный ключ | 20 117 |
| ограничения | pg_get_constraintdef(), convalidated | 73 444 |
Кроме этого, в отпечаток входят таблицы и последовательности с владельцем и правами, функции, типы, расширения, схемы и свойства базы.
Фрагмент fp.sh, который строит счёт строк, дословно. В $db лежит имя базы, в $out — каталог отпечатка, а $hash, равный единице, добавляет к счёту хеш содержимого. Команда psql в конце фрагмента кладёт в файл .stmts по одному запросу на таблицу. Отдельно от скрипта, с этими тремя переменными, заданными вручную, фрагмент на стенде не запускался.
NS="n.nspname <> 'information_schema' and n.nspname !~ '^pg_'"
if [ "$hash" = 1 ]; then
SEL="select format('select %L, count(*), coalesce(sum(hashtextextended(t::text,0)::numeric),0) from %I.%I t;', n.nspname||'.'||c.relname, n.nspname, c.relname)"
else
SEL="select format('select %L, count(*) from %I.%I t;', n.nspname||'.'||c.relname, n.nspname, c.relname)"
fi
psql -XAt -d "$db" -c "$SEL from pg_class c join pg_namespace n on n.oid=c.relnamespace where c.relkind='r' and $NS order by pg_relation_size(c.oid) desc" > "$out/.stmts"
Список из .stmts скрипт fp.sh выполняет в четыре сеанса psql, каждый в своей транзакции repeatable read. Определения объектов он снимает отдельным вызовом psql. Из этого вызова ниже приведены два запроса: про индексы и про ограничения. Они тоже фрагмент: сами по себе не запустятся, пока вместо $NS не подставлено условие n.nspname <> 'information_schema' and n.nspname !~ '^pg_'.
select format('%I.%I',n.nspname,ci.relname), format('%I.%I',n.nspname,ct.relname), pg_get_indexdef(i.indexrelid), i.indisvalid, i.indisready, i.indislive, i.indisunique, i.indisprimary, i.indisclustered, coalesce(ts.spcname,'')
from pg_index i join pg_class ci on ci.oid=i.indexrelid join pg_class ct on ct.oid=i.indrelid join pg_namespace n on n.oid=ci.relnamespace left join pg_tablespace ts on ts.oid=ci.reltablespace
where $NS order by 1;
select coalesce(format('%I.%I',n.nspname,c.relname),''), o.conname, o.contype, pg_get_constraintdef(o.oid), o.convalidated, o.condeferrable
from pg_constraint o join pg_namespace n on n.oid=o.connamespace left join pg_class c on c.oid=o.conrelid
where $NS order by 1,2;
Два отпечатка на стенде сравнивал второй скрипт, cmp.sh: счёт по таблицам двух баз он сводит через join, файлы с определениями сравнивает diff. Счёт строк без хеша одной командой есть во втором фрагменте грубой сверки, в конце раздела.
Исходную базу fp.sh читает в одном снимке: открывает транзакцию repeatable read и первой командой ставит set transaction snapshot с именем снимка, который держит сеанс копий. Счёт по живой базе 1С при этом не уплывает, пока идёт сверка. Снимок PostgreSQL доступен для импорта, пока открыта транзакция, которая его экспортировала, поэтому сеанс со снимком живёт до конца копий и сверки. К загруженной базе никто, кроме опыта, не подключался: в кластере 1С она не зарегистрирована.
У каждой из 28 успешных загрузок на стенде число строк совпало во всех 8 701 таблице, а в определениях объектов расхождений нет ни в одной категории. Индексов, у которых indisvalid или indisready отличны от истины, ноль. По содержимому сверка прошла у семи загрузок из 28: по одной на каждый из семи способов загрузки. Хеш совпал во всех таблицах каждой из семи. Остальные загрузки, 21 из 28, сверяли только по строкам и определениям.
Хеш в fp.sh — сумма 64-битных хешей текста строк, побайтовым сравнением он не является. В отпечаток не входят роли, настройки базы из ALTER DATABASE … SET, статистика планировщика и содержимое больших объектов.
Отпечаток со счётом и хешем снимался на стенде от 74 до 196 секунд, в четыре параллельных сеанса. Без хеша отпечаток исходной базы занимал от 10 до 22 секунд, загруженной — 5–17 секунд после загрузок без общей транзакции и 76–145 после загрузок одной транзакцией. Причину разницы мы не искали.
К себе я бы перенёс минимум: число строк в каждой таблице и определения индексов, снятые с источника и с загруженной базы до того, как в неё войдёт 1С.
Когда исходной базы уже нет: грубая сверка с дампом
Скрипт на awk из первой версии статьи годится для случая, когда сравнить загруженную базу можно только с дампом. Это грубая сверка по числу объектов и строк, без определений и содержимого. Работал он на текстовых дампах pg_dump 15.6 в формате COPY с десяти баз стенда «+Альянс»; на другие дампы я бы его без проверки не переносил.
Ядро скрипта грубой сверки, двумя фрагментами, как он отработал на стенде 1 октября 2026 года. Первому нужны $f — файл дампа, $a — псевдоним базы и $OUT — готовый каталог результатов; второму — $db, имя базы, и те же $a и $OUT. В скрипте $OUT задан до цикла по дампам, остальные три переменные задаёт цикл; отдельно от него фрагменты на стенде не запускались. Первый фрагмент считает в дампе строки между COPY … FROM stdin; и \.:
zcat "$f" | awk -v OUTD="$OUT" -v A="$a" '
/^COPY / { t=$2; gsub(/^public\./,"",t); gsub(/"/,"",t); c=0; inc=1; next }
inc && $0=="\." { print t, c > (OUTD "/" A ".dump.rows"); inc=0; next }
inc { c++; next }
/^CREATE TABLE / { ct++ }
/^CREATE (UNIQUE )?INDEX / { ci++ }
/^ ADD CONSTRAINT .* (PRIMARY KEY|UNIQUE)/ { cc++ }
END { print "create_table", ct+0, "create_index", ci+0, "pk_unique", cc+0 > (OUTD "/" A ".dump.ddl") }'
Второй фрагмент считает строки каждой таблицы в базе:
sudo -u postgres psql -XAt -d "$db" -c "select format('select %L, count(*) from public.%I;', c.relname, c.relname) from pg_class c where c.relnamespace='public'::regnamespace and c.relkind='r'" \
| sudo -u postgres psql -XAt -F' ' -d "$db" -f - > $OUT/$a.db.rows 2> $OUT/$a.db.err
Дальше скрипт сортирует оба списка и сводит их через join: в итог попадает таблица, где числа строк не равны или её нет с одной из сторон. Число таблиц и индексов базы он берёт из pg_class. Под архивы custom и directory скрипт пришлось бы переделывать, и этого мы не пробовали.
Десять баз стенда были залиты из этих дампов командой zcat | psql без ON_ERROR_STOP и без -1. Число таблиц, 79 608 на десять баз, и число индексов сошлись с дампами у каждой. По строкам целиком совпали три базы, в остальных семи разошлась 51 таблица. Расходились только служебные таблицы 1С: регистры сведений, binarydata, _commonsettings, константы, пара справочников с табличными частями.
У каждой из 51 разошедшейся таблицы по статистике pg_stat_user_tables выполнялись оба равенства: вставлено не меньше строк, чем было в дампе, и вставлено минус удалено — столько, сколько строк в базе. Счётчики шли с момента заливки, статистику не сбрасывали. Такая картина согласуется с работой 1С после заливки: в кластере во время опыта был открыт сеанс толстого клиента. Строгого доказательства у нас нет. В ту же сторону смотрит опыт 6 октября: в базе, которую 1С не открывала, расхождений по строкам не было ни у одной из 28 загрузок, но это другая база.
Десять дампов, 10,4 ГБ в сжатом виде и 38,4 млн строк, скрипт грубой сверки разобрал за 10 минут на четырёх ядрах. Строку с искажённым значением такая сверка пропустит: она считает строки и не смотрит в них.
Копия — снимок на момент запуска pg_dump
Дамп pg_dump хранит базу такой, какой она была в момент старта утилиты. Всё, что пользователи 1С сделали после старта, в копию не попадает.
На стенде «+Альянс» снимок на момент запуска видно по одной таблице. В ночь с 1 на 2 октября, пока шли первые замеры, база жила: в регистре сведений _inforg14646 строк стало 1 191 вместо 1 226. Три копии, загруженные в ту ночь, дали по 16 852 896 строк: ровно столько было в базе в начале опыта.
На минуту перед сбоем из дампа не вернуться. pg_dump создаёт логическую копию: в ней нет информации для воспроизведения журнала транзакций, и для восстановления на момент времени она не подходит. Эту задачу решают базовая копия pg_basebackup и архив журнала транзакций; в методике 1С они разобраны для PostgreSQL 9.6.
Точка, к которой вы откатитесь из дампа, — момент старта pg_dump для этой базы, а не время задания в расписании. Если скрипт копирует базы по очереди, у каждой свой момент: на стенде 3 октября последняя из десяти баз начала копироваться через 22 минуты после запуска задания cron. Ставьте копию туда, где потеря сделанного после старта приемлема. Если терять нельзя и нескольких часов работы, одного ночного дампа мало.
Где цифры стенда не перенесутся на вашу базу
Все цифры статьи сняты на одном стенде «+Альянс» и одной базе 1С в 14,8 ГБ. Стенд — четыре ядра и 41 ГБ памяти, и настроен он под опыты: shared_buffers 6 ГБ, wal_level minimal, synchronous_commit off. Потоков в замерах везде четыре. На другой базе и с другими настройками порядок способов может выйти другим.
Нагрузки пользователей во время замеров на стенде не было: в начале каждого круга к базе было одно-два подключения. Мешает ли копия работе, замеры не показывают. Сжатие шло с уровнем по умолчанию; lz4, другие уровни и формат tar мы не мерили.
Копия directory с zstd в первом круге сорвалась через 39 секунд с кодом 1: закрылся сеанс, который держал снимок, и pg_dump выдал «ошибка при выполнении запроса»: снимка уже не существовало. Почему сеанс закрылся, мы не установили. Это сбой обвязки нашего опыта, о самом pg_dump он ничего не говорит. В кругах 2–5 опыт запускался отвязанным от сеанса SSH, и сбой не повторился.
Загрузка в сборку PostgreSQL не для 1С осталась без проверки. В базах стенда стоят расширения mchar 2.2.1, fulleq 2.0 и fasttrun 2.0, а сам сервер — сборка для 1С (Debian 18.4-1.1C). Ключи pg_dump, pg_restore и psql в статье взяты из описания обычной PostgreSQL 18, где о сборке для 1С ничего нет.
Открывается ли загруженная база в 1С, мы не проверяли. Утилита 1С ibcmd версии 8.3.27.1859 на базе с пользователями без ключей --user и --password с ошибкой не упала. Она вывела «Для выполнения операции требуется аутентификация в информационной базе» и зациклилась на запросе имени. Выгрузка .dt за 12 минут 20 секунд написала 620 889 425 байт вывода, а сам файл остался пустым. Сама такая команда в наших прогонах не закончилась ни разу, так что в расписание её без учётных данных пользователя 1С не ставьте.
Резервные копии — часть нашей услуги «Сопровождение 1С»: мы настраиваем копии базы и проверяем восстановление. Замеры этой статьи сделаны на тестовом стенде компании, а не у клиентов.
Частые вопросы
Какой формат pg_dump выбрать для базы 1С, если ночное окно короткое?
Начните с directory в четыре потока со сжатием zstd. На стенде «+Альянс» с базой 1С в 14,8 ГБ такая копия снималась быстрее directory с gzip, custom и текста во всех трёх кругах, где сняты четыре способа, а по размеру оказалась на 14 % меньше, чем directory с gzip. Стенд и база у нас одни, поэтому снимите такую копию у себя, залейте в пустую базу и сравните оба времени с нынешним способом. До загрузки каталог проверяется парой: zstd -tq по каждому файлу и сравнение числа файлов данных с оглавлением. Когда база загружена, сверьте её с источником.
Можно ли проверить дамп, не загружая его в базу?
Частично. Custom-архив прочитайте командой pg_restore -f /dev/null и посмотрите код. На стенде «+Альянс» эта команда вернула 1 на каждой из девяти испорченных копий архива в 1,84 ГБ; архив был один, а на архиве в 5,3 МБ она же пропускала потерю хвоста размером от 64 до 512 КБ. Каталог directory с zstd проверяйте zstd -t по каждому файлу и счётом файлов против оглавления. У текстового дампа смотрите код pg_dump, gzip -t и строку dump complete в последних пяти строках; строка — признак по опыту стенда. Объектов в базе ни одна из этих проверок не создаёт, так что пробную загрузку они не заменяют.
Почему после загрузки строк в таблицах 1С не столько, сколько в дампе?
На стенде «+Альянс» такие расхождения согласуются с работой 1С после заливки, но строго это не доказано. Расходились служебные таблицы, в их числе регистры сведений и константы, а в кластере был открыт сеанс толстого клиента. Посмотрите на свою расходящуюся таблицу в pg_stat_user_tables. У наших счётчик вставок n_tup_ins был не меньше числа строк в дампе, а разность n_tup_ins − n_tup_del равнялась числу строк в базе; статистика при этом не сбрасывалась с самой заливки. Такая картина — признак работы 1С, а не проверка полноты. Надёжнее сверять базу до входа пользователей.
Есть ли у 1С рекомендации по резервному копированию на PostgreSQL?
Мы нашли три страницы 1С:ИТС о резервном копировании: 2007, 2008 и 2018 годов. Страница 2007 года приводит pg_dump в формате custom с ключом -b и сравнивает его с копированием файлов базы средствами ОС. Страница 2008 года говорит о клиент-серверном варианте 1С вообще и PostgreSQL по имени не называет. Страница 2018 года написана для PostgreSQL 9.6 и называет дамп логической копией, непригодной для восстановления на момент времени. О формате directory, потоках, zstd и проверке копии в этих трёх страницах ничего нет, свежей страницы о копировании на PostgreSQL мы не нашли.
Александр Жогов, основатель компании «+Альянс».
