Онлайн-бэкап SQLite на выделенном сервере с проверкой восстановления
Задание резервного копирования завершилось успешно, но после восстановления в базе не оказалось последних записей. В файловых сервисах, использующих SQLite в режиме WAL, причины таких сбоев особенно легко понять неверно. Успешное копирование файла означает лишь, что перенос байтов завершён, но не подтверждает согласованность базы данных. На выделенном сервере, где непрерывно обновляются файловые индексы, состояния заданий или списки резервных копий, создание копии и проверка её пригодности должны быть двумя отдельными этапами.
1. Разграничьте WAL и резервное копирование
В режиме WAL зафиксированные изменения могут сначала находиться в файле -wal и лишь затем переноситься в основной файл базы при выполнении контрольной точки. Если выполнить cp service.db backup.db, пока приложение работает, эти изменения могут не попасть в копию. Последовательное копирование основного файла, WAL и файла разделяемой памяти тоже не даёт снимка на один и тот же момент времени.
Интерфейс онлайн-резервного копирования читает согласованные данные через соединение с базой, а не копирует вслепую меняющиеся файлы. Он подходит для систем, в которых нельзя останавливать запись, но создаёт дополнительную нагрузку на чтение и запись в целевое хранилище. Если можно закрыть все соединения, резервное копирование в офлайн-режиме проще. Однако запись нужно остановить и в фоновых заданиях, а не только при обработке пользовательских запросов.
WAL — не удалённая резервная копия, а зеркалирование — не резервное копирование. Надёжная копия должна находиться вне области отказа исходной базы и проходить независимую проверку чтением.
Выделенные серверы OnuCloud в стандартной комплектации оснащены двумя накопителями NVMe по 1 ТБ: каждый подключён напрямую, аппаратного RAID нет. Запись копии на второй накопитель позволяет частично разделить нагрузку чтения и записи, но оба накопителя остаются в одной машине. Это не заменяет хранение копии на удалённой площадке.
2. Подготовьте воспроизводимый пример с непрерывной записью
Следующий эксперимент рассчитан на Debian 12 и другие среды, в которых доступны Python 3 и модуль стандартной библиотеки sqlite3. Сначала проверьте модуль и создайте отдельный временный каталог. Все действия затрагивают только экспериментальные файлы: рабочая база данных не используется, изменять системные службы не требуется.
python3 -c 'import sqlite3; print(sqlite3.sqlite_version)'
umask 077
export LAB_DIR="$(mktemp -d)"
printf '%s\n' "$LAB_DIR"
В примере используются две таблицы: events хранит отдельные записи, а totals — накопленный итог. Каждая транзакция одновременно добавляет запись и увеличивает итоговое значение. Поэтому в копии сумма значений всех записей должна совпадать с накопленным итогом. Такая проверка содержательнее, чем проверка наличия файла, и показывает, что целостность структуры и корректность бизнес-данных — разные критерии приёмки.
Приведённый ниже скрипт содержит и поток записи, и процедуру резервного копирования. Каталог передаётся из предыдущего шага. При каждом повторном запуске создавайте новый каталог, чтобы не смешивать данные с предыдущим экспериментом. Рабочие базы обычно устроены сложнее: для них нужно отдельно определить межтабличные связи и требования к версии приложения, а не переносить эту счётную модель без изменений.
3. Создайте и проверьте копию через онлайн-интерфейс
Скрипт дожидается первой зафиксированной транзакции, затем выполняет онлайн-резервное копирование, пока поток записи продолжает работу. Сначала данные записываются во временный файл. Он переименовывается в итоговую экспериментальную копию только после успешной проверки целостности, внешних ключей и бизнес-инвариантов. Ошибка на любом этапе приводит к неуспешному завершению скрипта: новая копия не публикуется.
python3 - <<'PY'
import os
import sqlite3
import threading
import time
from pathlib import Path
base = Path(os.environ["LAB_DIR"])
source = base / "source.db"
pending = base / "backup.pending.db"
published = base / "backup.db"
db = sqlite3.connect(source)
mode = db.execute("PRAGMA journal_mode=WAL").fetchone()[0]
if mode.lower() != "wal":
raise RuntimeError("WAL mode unavailable")
db.executescript("""
CREATE TABLE events (
n INTEGER PRIMARY KEY,
amount INTEGER NOT NULL
);
CREATE TABLE totals (
id INTEGER PRIMARY KEY CHECK (id = 1),
balance INTEGER NOT NULL
);
INSERT INTO totals VALUES (1, 0);
""")
db.close()
stop = threading.Event()
ready = threading.Event()
errors = []
def writer():
conn = None
try:
conn = sqlite3.connect(source, timeout=5)
while not stop.is_set():
with conn:
conn.execute("INSERT INTO events(amount) VALUES (1)")
conn.execute(
"UPDATE totals SET balance = balance + 1 WHERE id = 1"
)
ready.set()
time.sleep(0.01)
except Exception as exc:
errors.append(exc)
ready.set()
finally:
if conn is not None:
conn.close()
worker = threading.Thread(target=writer)
worker.start()
try:
if not ready.wait(5):
raise RuntimeError("Writer did not become ready")
if errors:
raise RuntimeError("Writer failed") from errors[0]
src = sqlite3.connect(source, timeout=5)
dst = sqlite3.connect(pending, timeout=5)
try:
src.backup(dst, pages=128, sleep=0.01)
finally:
dst.close()
src.close()
finally:
stop.set()
worker.join()
if errors:
raise RuntimeError("Writer failed") from errors[0]
check = sqlite3.connect(pending)
try:
result = check.execute("PRAGMA integrity_check").fetchall()
if result != [("ok",)]:
raise RuntimeError("Integrity check failed")
if check.execute("PRAGMA foreign_key_check").fetchall():
raise RuntimeError("Foreign key check failed")
count, amount = check.execute(
"SELECT COUNT(*), COALESCE(SUM(amount), 0) FROM events"
).fetchone()
balance = check.execute(
"SELECT balance FROM totals WHERE id = 1"
).fetchone()[0]
if not (count > 0 and count == amount == balance):
raise RuntimeError("Business invariant failed")
finally:
check.close()
os.replace(pending, published)
print(f"Verified rows: {count}")
print(published)
PY
Параметр pages=128 задаёт число страниц базы данных, обрабатываемых за один шаг, а не количество байтов. Параметр sleep в основном используется при повторных попытках из-за занятости базы и не является ограничителем постоянной скорости передачи. В рабочей среде нужно фиксировать длительность и причины ошибок, а также устанавливать предельное время выполнения задания. Непрерывная интенсивная запись может затянуть резервное копирование. Превышение времени следует считать ошибкой, а не помечать незавершённый файл как успешную копию.
Переименование здесь лишь отделяет ещё не принятую копию от проверенной и не гарантирует сохранность данных при отключении питания. Для рабочего процесса резервного копирования границы гарантированного сохранения нужно проектировать с учётом синхронизации файлов, синхронизации каталогов и семантики целевого хранилища.
4. Проверяйте восстановление изолированно, не подменяя копию исходной базой
К моменту завершения резервного копирования исходная база уже может содержать новые записи. Поэтому нельзя требовать, чтобы число строк в копии совпадало с результатом более позднего запроса к исходной базе. Сначала проверьте внутреннюю согласованность копии, затем оцените её актуальность по временному окну резервного копирования. Онлайн-резервное копирование не является непрерывной репликацией и не обеспечивает автоматически восстановление на любой момент времени.
Создайте рабочий экземпляр для восстановления из принятой копии
Экспериментальная копия уже закрыта, поэтому её можно скопировать в другой каталог в офлайн-режиме. Следующий пример открывает рабочий экземпляр для восстановления только на чтение и проверяет структуру таблиц и ключевые связи. Не направляйте тестовое соединение к source.db: иначе вы будете проверять исходную базу.
mkdir "$LAB_DIR/restore"
cp "$LAB_DIR/backup.db" "$LAB_DIR/restore/service.db"
python3 - <<'PY'
import os
import sqlite3
from pathlib import Path
path = Path(os.environ["LAB_DIR"]) / "restore" / "service.db"
conn = sqlite3.connect(path.as_uri() + "?mode=ro", uri=True)
try:
print(conn.execute("PRAGMA integrity_check").fetchall())
print(conn.execute("PRAGMA foreign_key_check").fetchall())
print(conn.execute("""
SELECT
(SELECT COALESCE(SUM(amount), 0) FROM events),
(SELECT balance FROM totals WHERE id = 1)
""").fetchone())
finally:
conn.close()
PY
Критерии успеха: проверка целостности возвращает ok, проверка внешних ключей не возвращает строк, а два итоговых значения совпадают. В этом примере внешних ключей нет, поэтому пустой результат подтверждает лишь то, что процедура проверки выполняется. В рабочей базе следует сохранить реальные ограничения и проверить их соблюдение.
При приёмке в рабочей среде также нужно открыть этот экземпляр совместимой версией приложения и прочитать файловый индекс или список заданий, отключив отправку почты, обратные вызовы и автоматическое выполнение задач. Возможность открыть базу ещё не означает, что все внешние файлы на месте.
5. Превратите разовый эксперимент в критерии приёмки резервных копий
Перед вводом процесса в эксплуатацию сохраняйте как минимум четыре вида подтверждений: путь к исходной базе и версию приложения, время начала и окончания резервного копирования, результаты проверок и результаты изолированного чтения. Временные отметки нужны для оценки актуальности данных, но не должны трактоваться как точная граница зафиксированных транзакций. Если системе требуется определённая точка восстановления, нужно отдельно спроектировать учёт порядковых номеров фиксации транзакций и цели восстановления.
Ограничьте доступ к каталогу резервных копий, чтобы вместе с копией не раскрыть токены, пользовательские данные или параметры заданий. После передачи копии на удалённую площадку сверьте контрольные суммы файлов и повторите тест восстановления. Совпадение контрольных сумм подтверждает только целостность передачи и не заменяет приёмку базы данных.
При сбое сохраняйте предыдущую проверенную копию, а файл-кандидат обрабатывайте отдельно. Не перезаписывайте успешно принятую копию пустым файлом. Если база ссылается на вложения, сегменты индекса или другие базы, онлайн-копирование одной базы не обеспечивает атомарность между ресурсами: потребуется остановка записи на уровне приложения либо координация с помощью манифеста версий.
Итоговый критерий приёмки — не «команда копирования завершилась успешно», а «копия существует независимо, структурные проверки пройдены, бизнес-связи соблюдены, приложение может прочитать данные изолированно». Только при одновременном выполнении этих четырёх условий резервная копия имеет подтверждённую ценность для восстановления.
Часто задаваемые вопросы
Можно ли в режиме WAL копировать только основной файл SQLite?
Это ненадёжный способ онлайн-бэкапа: подтверждённые транзакции могут оставаться в WAL. Раздельное копирование файлов также не гарантирует единый момент состояния. Нужен API онлайн-бэкапа либо копирование после закрытия всех соединений.
Достаточно ли результата ok от integrity_check?
Нет. Проверка в основном оценивает внутреннюю структуру базы. Отдельно выполнить foreign_key_check, проверить ключевые таблицы, бизнес-инварианты и чтение приложением в изолированной копии.
Выделенный физический сервер AMD EPYC 7543P
от $472 в месяц