Бэкап SQLite прошёл проверку. Второй заказ в него не попал

—

от автора

Копия базы открылась, проверка целостности ответила ok. После копирования SHA-256 файла raw.db совпадал с SHA-256 основного live.db. При этом соединение SQLite видело два заказа, а отдельная копия — один. Справка о здоровье выдана, пациент недоукомплектован.

Я получил этот результат при проверке на отдельной локальной базе. Создал две контрольные записи, сохранил изменения и сделал две копии разными способами. Рабочих клиентских данных в этой проверке не было. Зато были настоящие файлы, зафиксированные транзакции и вполне настоящий результат: второй заказ в копию одного основного файла не попал.

В двух запусках 20 и 21 октября я выполнил десять независимых прогонов с новыми базами. Во всех десяти источник содержал две записи, обычная копия файла — одну, а копия через механизм SQLite — две. Проверка целостности во всех трёх случаях возвращала ok. Ни одной строке не понадобилось физически умереть на диске: я просто неправильно определил, что именно надо считать базой при копировании.

За это и люблю проверки восстановления. Сначала кажется, что осталось подтвердить очевидное. Через полчаса выясняется, что очевидное было моей личной инициативой и в документацию SQLite не входило.

Что я проверял

Среда была скромная: Windows 11, Python 3.12.14, SQLite 3.53.1. База live.db содержала таблицу orders с номером и суммой заказа, а также таблицу restore_marker. В ней лежала служебная запись, по которой я мог узнать, дошла ли до копии конкретная транзакция. Ожидаемое новое значение этой записи я сохранял отдельно от файлов базы.

У первого заказа был номер 1 и условная сумма 100, у второго — номер 2 и сумма 200. Эти числа нужны для однозначного сравнения содержимого. Я не измерял на них производительность и не пытался выдать два заказа за модель нагрузки интернет-магазина. Для проверки полноты двух заранее известных строк достаточно; для прогноза нагрузки они годятся примерно как спичка для приёмки дизель-генератора.

База работала в режиме WAL, Write-Ahead Log. В этом режиме SQLite записывает изменённые страницы в отдельный журнал live.db-wal. Новые читающие транзакции через SQLite уже видят подтверждённые изменения, хотя соответствующие страницы ещё могут не попасть в основной live.db. Ранее начатый читатель при этом сохраняет свой прежний снимок. Позднее операция checkpoint переносит подходящие страницы из журнала в основной файл.

Отсюда и ловушка. Для приложения актуальное состояние читается через SQLite с учётом журнала. Для shutil.copyfile(live, raw) существует только файл live.db, который я указал. Команда честно исполняет поручение, даже если поручение написал человек, которому стоило ещё раз выпить кофе и прочитать про WAL.

Как получилось расхождение

Сначала я создал таблицы, добавил первый заказ и записал исходный маркер baseline. Затем выполнил PRAGMA wal_checkpoint(TRUNCATE) и проверил результат: операция завершилась без признака блокировки. К этому моменту первый заказ находился в основном файле. Его SHA-256 я сохранил для последующего сравнения.

После этого вставил второй заказ и обновил маркер на новый случайный токен в той же транзакции. Вызвал commit(), записал ожидаемый токен в отдельный файл и оставил исходное соединение открытым. Между этой транзакцией и копированием я не запускал ручной checkpoint. Автоматический checkpoint у пишущего соединения на время проверки был отключён.

Именно у этого соединения: настройка wal_autocheckpoint=0 не запрещает другим соединениям выполнять checkpoint и не замораживает базу целиком. Других писателей в проверке не было. Мне требовалось устойчивое состояние, в котором новые изменения уже подтверждены, но ещё находятся в WAL. Сценарии с параллельной записью я сюда не подмешивал.

Теперь я скопировал только основной live.db в raw.db. В этот момент WAL занимал 8272 байта. Хеш live.db перед второй транзакцией, хеш того же основного файла после транзакции и хеш raw.db совпали. Содержимое основного файла совпало в двух контрольных точках, а raw.db содержал те же байты. Само сравнение хешей не рассказывает о возможных промежуточных состояниях файла.

Затем сделал backup.db через source.backup(target) — интерфейс резервного копирования SQLite. Источник к этому моменту не имел незавершённой транзакции записи. После завершения копирования я отдельно открыл источник и обе копии, прочитал строки заказов, маркер и результат PRAGMA integrity_check. Вот что получилось.

Источник live.db: заказы (1, 100) и (2, 200), новый маркер, integrity_check = ok.

Копия основного файла raw.db: только заказ (1, 100), прежний маркер baseline, integrity_check = ok.

Копия через backup(), файл backup.db: заказы (1, 100) и (2, 200), новый маркер, integrity_check = ok.

Второй заказ оставался на источнике. В raw.db он не исчез после копирования — он туда вообще не попал. Это различие пригодилось в расследовании: искать повреждённые страницы было бессмысленно. Я аккуратно сохранил более раннее состояние основного файла.

Схема: почему второй заказ не попал в копию
Подтверждённый второй заказ находится в WAL, а копия только основного файла содержит первый заказ.

Подтверждённый второй заказ находится в WAL, а копия только основного файла содержит первый заказ.

Почему три успешные проверки не заметили проблему

Успешный вызов копирования означает, что операция с указанным файлом завершилась. Совпадение SHA-256 подтверждает равенство проверяемого содержимого. PRAGMA integrity_check проверяет внутреннюю структуру открытой базы. В этой проверке каждый механизм сообщил правду в пределах своей задачи.

Подмена произошла у меня в голове. К утверждению «скопировали тот же основной файл» я незаметно приписал «получили всё, что приложение уже подтвердило». Но часть подтверждённых изменений находилась в WAL, который я не копировал. Контрольная сумма не могла возразить: список файлов для задания резервного копирования она не составляет.

С integrity_check история такая же. Открытая старая база была структурно исправна. В ней существовали таблицы и первый заказ; внутреннего повреждения, которое надо было бы обнаружить, не было. Проверка структуры не знает, что до снятия копии приложение уже увидело вторую запись.

Размер файлов тоже не помог. После завершения первого подробного прогона все три основных файла занимали по 16 384 байта. Количество строк при этом различалось. Размер полезен для поиска грубых отклонений, но в данном случае он не отличил полную копию от отстающей.

Хеши я сравнивал до закрытия исходного соединения. Это существенная оговорка: после его закрытия SQLite может перенести страницы из WAL, и основной live.db уже будет содержать другое состояние. Сравнивать его позднейший хеш с зафиксированным раньше значением и объявлять результат ошибкой копирования было бы неверно.

При осмотре папки после окончания программы отдельного live.db-wal уже не было. Без записи размера журнала в момент копирования легко было бы уверенно заявить: «Какой WAL, здесь его нет». Поэтому в результатах проверки я сохранил состояние именно в нужный момент. Посмертный осмотр каталога не всегда объясняет, что лежало рядом с открытой базой минутой раньше.

Схема: что подтверждают три зелёные проверки
Копирование, контрольная сумма и integrity_check не подтверждают наличие нужного заказа в резервной копии.

Копирование, контрольная сумма и integrity_check не подтверждают наличие нужного заказа в резервной копии.

Полный скрипт проверки

В опубликованном коде есть обе таблицы, внешний ожидаемый маркер, создание двух копий и приёмка результата. Он создаёт новую папку в текущем каталоге и работает только с файлами в ней. Для этого листинга используется Python 3.12: параметр autocommit в нём задан явно. Окончательный код проверен на Python 3.12.14 и SQLite 3.53.1. Функция connect() выбирает LEGACY_TRANSACTION_CONTROL, чтобы сценарий не зависел от будущего изменения режима транзакций по умолчанию.

Открыть полный Python-скрипт
from contextlib import closingfrom pathlib import Pathimport hashlibimport shutilimport sqlite3import tempfileimport uuidEXPECTED_ORDERS = [(1, 100), (2, 200)]def connect(path, *, uri=False):    return sqlite3.connect(        path, uri=uri, autocommit=sqlite3.LEGACY_TRANSACTION_CONTROL    )def readonly(path):    uri = Path(path).resolve().as_uri() + "?mode=ro"    return connect(uri, uri=True)def sha256(path):    with Path(path).open("rb") as stream:        return hashlib.file_digest(stream, "sha256").hexdigest()def inspect_orders(path):    with closing(readonly(path)) as db:        db.execute("BEGIN")        integrity = db.execute("PRAGMA integrity_check").fetchall()        orders = db.execute(            "SELECT id, amount FROM orders ORDER BY id"        ).fetchall()        marker = db.execute(            "SELECT token FROM restore_marker WHERE name = ?", ("probe",)        ).fetchone()        db.rollback()        return integrity, orders, markerdef verify_orders(path, expected_token):    integrity, orders, marker = inspect_orders(path)    if integrity != [("ok",)]:        raise ValueError("integrity check failed")    if marker is None or marker[0] != expected_token:        raise ValueError("expected marker is missing")    if orders != EXPECTED_ORDERS:        raise ValueError("order contents do not match")    return ordersroot = Path(tempfile.mkdtemp(prefix="wal-backup-", dir="."))live = root / "live.db"raw = root / "raw.db"backup = root / "backup.db"receipt = root / "expected_marker.txt"with closing(connect(live)) as source:    mode = source.execute("PRAGMA journal_mode=WAL").fetchone()[0]    if mode.lower() != "wal":        raise RuntimeError("WAL was not enabled")    source.execute("PRAGMA wal_autocheckpoint=0")    source.executescript("""        CREATE TABLE orders(id INTEGER PRIMARY KEY, amount INTEGER NOT NULL);        CREATE TABLE restore_marker(name TEXT PRIMARY KEY, token TEXT NOT NULL);        INSERT INTO orders VALUES(1, 100);        INSERT INTO restore_marker VALUES('probe', 'baseline');    """)    source.commit()    checkpoint = source.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()    if checkpoint is None or checkpoint[0] != 0:        raise RuntimeError("checkpoint did not complete")    before = sha256(live)    expected_token = str(uuid.uuid4())    source.execute("INSERT INTO orders VALUES(2, 200)")    source.execute(        "UPDATE restore_marker SET token = ? WHERE name = ?",        (expected_token, "probe"),    )    source.commit()    receipt.write_text(expected_token, encoding="utf-8")    wal_size = Path(str(live) + "-wal").stat().st_size    shutil.copyfile(live, raw)    hashes_match = before == sha256(live) == sha256(raw)    if not hashes_match:        raise RuntimeError("main-file hashes differ in this run")    with closing(connect(backup)) as target:        source.backup(target)    expected_token = receipt.read_text(encoding="utf-8")    print("main-file hashes match:", hashes_match)    print("WAL bytes at copy:", wal_size)    for name, path in [("live", live), ("raw", raw), ("backup", backup)]:        integrity, orders, marker = inspect_orders(path)        check = "ok" if integrity == [("ok",)] else repr(integrity)        state = "current" if marker == (expected_token,) else "previous"        print(name, "orders:", orders, "integrity:", check, "marker:", state)    try:        verify_orders(raw, expected_token)    except ValueError as error:        print("raw rejected:", error)    else:        raise RuntimeError("stale copy was accepted")    verify_orders(backup, expected_token)    print("backup accepted: True")

Вывод этого запуска

main-file hashes match: TrueWAL bytes at copy: 8272live orders: [(1, 100), (2, 200)] integrity: ok marker: currentraw orders: [(1, 100)] integrity: ok marker: previousbackup orders: [(1, 100), (2, 200)] integrity: ok marker: currentraw rejected: expected marker is missingbackup accepted: True

В строках вывода current означает совпадение маркера с ожидаемым токеном из отдельного файла. У raw.db остаётся прежнее значение. Для краткости сам случайный токен не печатается: для вывода о расхождении важен результат сравнения, а не конкретный набор букв.

Размер WAL 8272 байта относится к этим запускам с данной схемой и короткими записями. Он не служит порогом опасности для чужих баз. Существенный результат здесь — наличие подтверждённых изменений за пределами основного файла в момент копирования. Даже маленький журнал оказался достаточно большим, чтобы испортить мои ожидания.

Что теперь проверяет приёмка

В функции verify_orders() три условия. Сначала база должна пройти проверку структуры. Затем в ней должен найтись ожидаемый маркер. Наконец, строки SELECT id, amount FROM orders ORDER BY id должны точно совпасть с двумя заранее известными строками, включая суммы. Одного COUNT(*) для этой задачи я не оставил: правильное число строк ещё не означает правильное содержимое.

Проверки нескольких запросов выполняются в одной читающей транзакции внутри inspect_orders(). Это удерживает согласованное представление базы для данного чтения. В моей проверке параллельных изменений не было, но зависеть от случайного момента каждого отдельного SELECT мне тоже не хотелось. Соединение закрывается через closing(), в том числе при исключении.

Условия записаны обычными if с исключениями. Утверждения assert в приёмочном коде могут исчезнуть при оптимизированном запуске Python. Очень удобный способ улучшить статистику успешных проверок, если перестать интересоваться данными. Я предпочёл оставить статистике менее творческую работу.

Открытие через mode=ro решает ещё одну конкретную задачу: неверный путь не должен незаметно создать пустую базу. Path.resolve().as_uri() превращает абсолютный путь в корректный URI, а параметр mode=ro требует открыть базу для чтения. Это не обещание, что SQLite никогда не понадобятся сопутствующие файлы WAL или shared memory; условия чтения WAL-базы остаются частью работы SQLite. Здесь важно, что отсутствующий основной файл не создаётся вместо нужной копии.

Ожидаемый токен читается из expected_marker.txt, а не из проверяемой базы. Если взять оба сравниваемых значения из одной старой копии, она прекрасно сойдётся сама с собой. В этой проверке независимость простая: ожидаемое значение находится за пределами копируемых файлов базы. Никакой устойчивости к потере всего компьютера такой способ хранения сам по себе не даёт.

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

Почему я использовал backup()

У меня был конкретный дефект: копирование одного основного файла давало отстающую копию. Вызов source.backup(target) сформировал копию через SQLite и сохранил обе подтверждённые строки вместе с новым маркером. Проверяющий код отклонил raw.db и принял backup.db. Так я проверил исправление на данных, на которых обычное копирование уже показало расхождение.

Приём «добавить -wal к маске файлов» я не выдаю за проверенное исправление. Последовательное копирование меняющихся файлов само по себе не обеспечивает единый момент состояния: между чтениями источник может измениться. В моём запуске записи на время копирования отсутствовали, поэтому он не проверяет все последствия конкурентной записи. Исправление оценивалось в тех условиях, которые описаны в листинге.

Здесь важно и место commit(). Я завершил транзакцию записи до source.backup(target). Само слово backup не освобождает от проверки условий вызова, результата и ошибок. В этом коде ошибка копирования не превращается в сообщение об успешной приёмке: исключение останавливает дальнейшее выполнение.

Это локальная проверка конкретного механизма, а не готовый регламент хранения поколений копий. В ней нет атомарной публикации последнего бэкапа, удалённого хранилища и восстановления после потери питания. Я не измерял время подъёма сервиса и не делал выводов о терабайтных базах. Для такой работы два заказа пришлось бы хотя бы научить платить за электричество.

Что осталось от зелёного статуса

После проверки я разделяю три утверждения: файл успешно скопирован, открытая база структурно исправна, нужные подтверждённые данные присутствуют. В данном случае первые два были истинными даже для raw.db. Последнее условие проверили внешний маркер и сравнение содержимого строк. Именно на нём неполная копия перестала получать зелёный статус.

Вся неприятность поместилась в несколько файлов. Не было отказа массива, переполненной стойки и героического восстановления до рассвета. Было моё неверное ожидание, что основной файл SQLite уже содержит всё, что видит открытое соединение. Проверить это на отдельной базе оказалось значительно приятнее, чем впервые узнать такую подробность из разговора с владельцем настоящих заказов.

Теперь сообщение «бэкап успешно создан» для меня означает начало следующей проверки. Я открываю копию отдельно и смотрю, что она действительно помнит. В этот раз память у неё нашлась только после смены способа копирования. Зелёная лампочка пережила, самооценка тоже — с небольшим восстановлением из резервной копии.

Основа материала — реально выполненные локальные проверки SQLite 20 и 21 октября 2026 года и повторная проверка окончательного листинга 29 октября. Контрольные данные созданы для этих проверок. История относится к проверке резервной копии на отдельных файлах; авария действующего сервиса здесь не заявлена.

ссылка на оригинал статьи https://habr.com/ru/articles/1101568/