Бэкап DuckDB: CHECKPOINT, EXPORT DATABASE и копия файла в S3

DuckDB хранит всю базу в одном файле, поэтому бэкап кажется простым: скопировал analytics.duckdb — и готово. На практике копия, снятая в неудачный момент, оказывается без последних данных или не открывается. Ниже — как делать бэкап DuckDB правильно: когда можно копировать файл, когда лучше выгрузить данные через EXPORT DATABASE, как автоматизировать это через cron или systemd, отправить в S3 и проверить, что из копии можно восстановиться.

Если вы ещё выбираете движок, сначала посмотрите обзор DuckDB. Здесь предполагается, что база уже есть и в ней лежат данные, которые жалко потерять.

Как DuckDB хранит данные: файл и WAL

У базы DuckDB два файла:

  • analytics.duckdb — основной файл базы;
  • analytics.duckdb.wal — журнал предзаписи (write-ahead log). Пока изменения не перенесены в основной файл, они живут только здесь.

Перенос изменений из WAL в основной файл называется чекпоинтом. DuckDB делает его автоматически, когда WAL вырастает до порога (настройка checkpoint_threshold), и при штатном закрытии базы. Отсюда первое правило: копия одного .duckdb без .wal в момент, когда WAL не пуст, не содержит последних изменений.

Второе правило связано с конкурентным доступом. Открыть файл на запись может только один процесс. Несколько процессов могут открыть базу только на чтение (READ_ONLY), и то лишь когда нет процесса-писателя. Внутри одного процесса с базой могут работать несколько потоков. Поэтому внешний скрипт бэкапа не может просто подключиться к базе, которую держит открытой ваш сервис: он получит ошибку блокировки.

Отсюда три рабочих способа:

  1. Остановить писателя (или дождаться, пока он закроет базу), затем копировать файл.
  2. Сделать бэкап изнутри процесса-писателя: CHECKPOINT и COPY FROM DATABASE в отдельный файл.
  3. Выгрузить данные в переносимый формат через EXPORT DATABASE.

Способ 1: закрыть базу и скопировать файл

Самый надёжный вариант для пакетных сценариев: ETL отработал, процесс завершился, файл никто не держит. При штатном закрытии DuckDB делает чекпоинт, и .wal либо исчезает, либо пустеет.

#!/usr/bin/env bash
set -euo pipefail

DB=/srv/analytics/analytics.duckdb
OUT=/var/backups/duckdb
TS=$(date +%Y-%m-%d_%H%M)

mkdir -p "$OUT"

# Принудительный чекпоинт: сработает только если файл никем не открыт на запись
duckdb "$DB" -c "CHECKPOINT;"

if [ -s "$DB.wal" ]; then
  echo "WAL не пуст — база ещё используется, бэкап отменён" >&2
  exit 1
fi

cp "$DB" "$OUT/analytics_$TS.duckdb"
gzip -f "$OUT/analytics_$TS.duckdb"

Если файл держит другой процесс, команда duckdb "$DB" -c ... завершится ошибкой блокировки, а set -e остановит скрипт. Это лучше, чем молча скопировать несогласованный файл.

Копировать файл системным cp, пока сервис пишет в базу, не стоит. Блокировка DuckDB рекомендательная, cp её не замечает, и вы получите снимок файла посреди записи страниц.

Способ 2: бэкап изнутри процесса через COPY FROM DATABASE

Если сервис работает постоянно и остановить его нельзя, бэкап делает сам сервис. Начиная с DuckDB 0.10 есть команда COPY FROM DATABASE, которая копирует все схемы, таблицы и представления из одной подключённой базы в другую.

CHECKPOINT;
ATTACH '/var/backups/duckdb/analytics_backup.duckdb' AS b;
COPY FROM DATABASE analytics TO b;
DETACH b;

Обратите внимание на имя источника. В DuckDB база по умолчанию называется по имени файла без расширения: для analytics.duckdb это analytics, а не main (main — схема по умолчанию). Проверить можно запросом SELECT current_database();.

Целевой файл должен быть новым. Если там уже лежат таблицы с теми же именами, копирование упадёт с конфликтом, поэтому удаляйте или переименовывайте старый файл перед запуском.

Пример на Python для сервиса, который держит соединение открытым:

import os
from datetime import datetime

import duckdb

con = duckdb.connect("/srv/analytics/analytics.duckdb")

def backup(con: duckdb.DuckDBPyConnection, out_dir: str) -> str:
    ts = datetime.now().strftime("%Y-%m-%d_%H%M")
    target = os.path.join(out_dir, f"analytics_{ts}.duckdb")
    if os.path.exists(target):
        os.remove(target)
    src = con.execute("SELECT current_database()").fetchone()[0]
    con.execute("CHECKPOINT")
    con.execute(f"ATTACH '{target}' AS b")
    try:
        con.execute(f"COPY FROM DATABASE {src} TO b")
    finally:
        con.execute("DETACH b")
    return target

Обычный CHECKPOINT может не выполниться, если в этот момент идут другие транзакции. Есть FORCE CHECKPOINT, но он прерывает активные транзакции — применяйте его только там, где это допустимо. Сам COPY FROM DATABASE работает в рамках транзакции и видит согласованное состояние базы, так что чекпоинт здесь нужен в первую очередь для того, чтобы основной файл не отставал от WAL.

Способ 3: EXPORT DATABASE в Parquet

EXPORT DATABASE выгружает схему и данные в каталог: файлы schema.sql, load.sql и по файлу данных на таблицу.

EXPORT DATABASE '/var/backups/duckdb/export_2026-10-01' (FORMAT parquet);

Восстановление в новую базу:

duckdb /srv/analytics/restored.duckdb -c "IMPORT DATABASE '/var/backups/duckdb/export_2026-10-01';"

Чем этот способ полезен:

  • Переносимость между версиями. Формат хранения файлов DuckDB менялся между версиями. Начиная примерно с 0.10/1.0 разработчики обещают обратную совместимость (новая версия читает файлы старой), но если вы прыгаете через несколько версий или откатываетесь на более старую, Parquet и SQL-схема надёжнее. Перед обновлением DuckDB уточните детали совместимости в документации своей версии.
  • Читаемость. Parquet открывают pandas, Polars, Spark, ClickHouse — данные не заперты в одном движке.

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

Автоматизация: cron и systemd timer

Для cron достаточно строки в crontab -e:

15 3 * * * /usr/local/bin/duckdb-backup.sh >> /var/log/duckdb-backup.log 2>&1

С systemd вы получаете журнал и запуск пропущенных заданий после перезагрузки. Файл /etc/systemd/system/duckdb-backup.service:

[Unit]
Description=DuckDB backup

[Service]
Type=oneshot
User=analytics
ExecStart=/usr/local/bin/duckdb-backup.sh

И /etc/systemd/system/duckdb-backup.timer:

[Unit]
Description=Daily DuckDB backup

[Timer]
OnCalendar=*-*-* 03:15:00
Persistent=true

[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now duckdb-backup.timer
systemctl list-timers duckdb-backup.timer

DuckDB часто живёт на ноутбуке аналитика под Windows. Там задачу создаёт планировщик:

$action  = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-File C:\scripts\duckdb-backup.ps1"
$trigger = New-ScheduledTaskTrigger -Daily -At 3:15am
Register-ScheduledTask -TaskName "DuckDB backup" -Action $action -Trigger $trigger

Если бэкап делает сам сервис (способ 2), расписание можно держать внутри приложения, а внешний планировщик использовать только для выгрузки готовых файлов в хранилище.

Загрузка в S3 и ротация

Копия на том же диске не спасёт от потери сервера. Отправляйте файлы в объектное хранилище. Yandex Object Storage:

aws s3 cp "$OUT/analytics_$TS.duckdb.gz" \
  s3://my-backups/duckdb/ \
  --endpoint-url https://storage.yandexcloud.net

Selectel:

aws s3 cp "$OUT/analytics_$TS.duckdb.gz" \
  s3://my-backups/duckdb/ \
  --endpoint-url https://s3.ru-1.storage.selcloud.ru

То же через rclone с remote в ~/.config/rclone/rclone.conf:

[yos]
type = s3
provider = Other
endpoint = https://storage.yandexcloud.net
access_key_id = ...
secret_access_key = ...
rclone copy "$OUT" yos:my-backups/duckdb --include "*.gz"

Локальную ротацию решает find:

find /var/backups/duckdb -name "analytics_*.duckdb.gz" -mtime +14 -delete

В бакете удобнее настроить правило жизненного цикла (lifecycle) на префикс duckdb/, чтобы старые объекты удалялись автоматически. Подробнее про pg_dump-аналог этой схемы — в статье бэкап PostgreSQL через pg_dump в S3.

Проверка бэкапа

Файл в бакете ещё не значит, что из него можно восстановиться. Минимальная проверка — открыть копию только на чтение и посчитать строки:

gunzip -k analytics_2026-10-01_0315.duckdb.gz
duckdb -readonly analytics_2026-10-01_0315.duckdb -c "
  SELECT table_name, estimated_size FROM duckdb_tables() ORDER BY table_name;
  SELECT count(*) FROM events;
"

В Python то же самое:

import duckdb

con = duckdb.connect("analytics_2026-10-01_0315.duckdb", read_only=True)
for (name,) in con.execute("SELECT table_name FROM duckdb_tables()").fetchall():
    print(name, con.execute(f'SELECT count(*) FROM "{name}"').fetchone()[0])

Сравнивайте результат с предыдущей копией: если в таблице events вчера было больше строк, чем сегодня, это повод разобраться до того, как понадобится восстановление. Встроенного аналога PRAGMA integrity_check из SQLite у DuckDB нет, поэтому полное чтение таблиц — самая практичная проверка. Общий порядок тестового восстановления описан в статье как проверить, что бэкап рабочий.

Восстановление

  1. Остановите процесс, который пишет в базу.
  2. Переименуйте текущий файл: mv analytics.duckdb analytics.duckdb.broken.
  3. Уберите старый WAL рядом с ним: mv analytics.duckdb.wal analytics.duckdb.wal.broken. Если его оставить, DuckDB попытается применить журнал от другой базы к восстановленному файлу.
  4. Распакуйте копию на место основного файла и откройте её на чтение для проверки.
  5. Запустите сервис.

Для экспорта вместо шагов 4–5 создайте новую базу через IMPORT DATABASE. Восстановление серверных баз разобрано отдельно в статье как восстановить базу из бэкапа.

Частые ошибки

  • Копируют только .duckdb, пока жив процесс-писатель. Последние изменения остаются в .wal, а сам файл может быть снят в середине записи.
  • Запускают бэкап-скрипт рядом с работающим сервисом. Второй процесс не может открыть базу на запись, а на чтение — пока есть писатель. Бэкап должен делать либо сам сервис, либо скрипт после его остановки.
  • Пишут COPY FROM DATABASE main TO b. main — это схема, а не база; используйте имя из current_database().
  • Восстанавливают файл, но оставляют чужой .wal.
  • Обновляют DuckDB и только потом проверяют, открываются ли старые бэкапы. Держите хотя бы один экспорт в Parquet перед обновлением.

Те же принципы для SQLite (файл плюс -wal, согласованная копия через .backup и VACUUM INTO) разобраны в статье как сделать бэкап SQLite.

Как это сделать в dbsend

dbsend хранит версии DuckDB-файлов и показывает, что изменилось между ними. Сервис не подключается к вашей базе: файл готовите вы, CLI его загружает. Для DuckDB CLI отправляет файл как есть, поэтому перед загрузкой сделайте CHECKPOINT и закройте соединения (или загружайте копию, полученную через COPY FROM DATABASE).

npm install --global @dbsend/sdk
dbsend login --key sqv_pk_…
dbsend init --database <id> --engine duckdb
dbsend backup /var/backups/duckdb/analytics_backup.duckdb -l "nightly"
dbsend log

Что вы получаете: каждая загрузка становится версией vN, одинаковый файл не создаёт новую версию, dbsend извлекает из .duckdb список таблиц, DDL и оценку числа строк. На платных тарифах доступны сравнение любых двух версий (схема, таблицы, строки, размер) и алерты в Telegram, email или webhook, если бэкап не пришёл по расписанию. Файлы хранятся в S3 в России с шифрованием AES-256-GCM. Восстановление — это скачивание исходного файла с проверкой контрольной суммы:

dbsend restore <snapshotId> ./analytics_restored.duckdb --yes

Чего dbsend не делает: не запускает DuckDB у вас на сервере, не делает чекпоинт за вас и не принимает экспорт-каталоги Parquet как DuckDB-базу (каталог можно заархивировать и загрузить как generic_file, но тогда будет только размер и контрольная сумма). Подробнее — на странице бэкапы DuckDB и в документации CLI. Если с базой работает ИИ-агент, посмотрите, как делать бэкап перед изменениями через MCP.

Частые вопросы

Можно ли делать бэкап DuckDB, не останавливая приложение?

Да, но изнутри того же процесса, который держит базу открытой. Выполните CHECKPOINT, подключите новый файл через ATTACH и скопируйте данные командой COPY FROM DATABASE. Внешний скрипт к базе с активным писателем не подключится.

Нужно ли сохранять файл .wal?

Если перед копированием сделан чекпоинт и база закрыта, .wal пуст или отсутствует, и сохранять его не нужно. Если WAL не пуст, копия одного .duckdb неполная — лучше повторить бэкап после чекпоинта, чем пытаться копировать пару файлов по отдельности.

Что лучше: копия файла или EXPORT DATABASE?

Копия файла быстрее делается и быстрее восстанавливается, но привязана к формату хранения DuckDB. Экспорт в Parquet медленнее, зато переносится между версиями и читается другими инструментами. Удобная схема: частые копии файла плюс экспорт раз в сутки и перед обновлением DuckDB.

Как проверить, что бэкап DuckDB не битый?

Откройте его с флагом -readonly или read_only=True, выведите список таблиц из duckdb_tables() и посчитайте строки в ключевых таблицах. Сравните с предыдущей копией: резкое падение числа строк заметнее всего именно так.