По умолчанию SQLite использует журнал отката: писатель блокирует базу на время транзакции, читатели ждут. Режим WAL меняет схему и позволяет читать во время записи. Это не «включаем и забываем»: у него свои правила контрольных точек, свой профиль надёжности и ограничения по файловым системам. Разберём, как WAL работает, какие pragma задать и как не получить распухший файл журнала.
Что меняется в файлах
В режиме WAL рядом с основной базой появляются два файла: app.db-wal с последними страницами и app.db-shm с разделяемой памятью. Читатель видит снимок на момент начала транзакции, писатель дописывает изменения в WAL. При контрольной точке страницы переносятся в основной файл, и журнал может быть переиспользован.
Отсюда главное свойство: читатели и писатель не блокируют друг друга. Но писатель по-прежнему один. Две одновременные записи невозможны, вторая получит SQLITE_BUSY или подождёт, если задан busy_timeout.
Включение
Режим задаётся pragma и сохраняется в файле базы. Проверить текущее значение можно той же командой без аргумента. Для нового приложения разумно включать WAL сразу после создания файла.
sqlite3 app.db "PRAGMA journal_mode=WAL;"
sqlite3 app.db "PRAGMA journal_mode;"
sqlite3 app.db "PRAGMA synchronous=NORMAL;"
sqlite3 app.db "PRAGMA wal_autocheckpoint=1000;"
Значение synchronous=NORMAL в WAL считается безопасным компромиссом: база не повреждается при сбое питания, но последние зафиксированные транзакции могут пропасть. Для финансовых операций ставят FULL, принимая падение скорости. autocheckpoint задаёт порог в страницах, после которого запускается автоматическая контрольная точка.
Один писатель и очередь
Приложение должно открывать соединение с таймаутом и начинать изменяющие транзакции явно. Режим BEGIN IMMEDIATE берёт блокировку записи сразу, поэтому конфликт возникает в начале, а не в середине работы. Это проще обрабатывать повтором.
import sqlite3
con = sqlite3.connect("app.db", timeout=5.0, isolation_level=None)
con.execute("PRAGMA journal_mode=WAL")
con.execute("PRAGMA synchronous=NORMAL")
con.execute("PRAGMA busy_timeout=5000")
con.execute("PRAGMA foreign_keys=ON")
Параметр isolation_level=None переводит драйвер в режим автокоммита, и транзакции открываются вручную. Для пакетной вставки это удобно: одна транзакция на тысячу строк работает в разы быстрее, чем тысяча отдельных. При ошибке SQLITE_BUSY стоит повторить операцию с небольшой задержкой, а не увеличивать таймаут бесконечно.
Контрольные точки
Контрольная точка переносит страницы из WAL в основной файл. PASSIVE не мешает читателям и может завершиться частично. FULL ждёт читателей, TRUNCATE дополнительно обрезает журнал до нуля, RESTART ждёт завершения всех читателей. Возвращаемые значения показывают, сколько страниц перенесено и осталось.
sqlite3 app.db "PRAGMA wal_checkpoint(PASSIVE);"
sqlite3 app.db "PRAGMA wal_checkpoint(FULL);"
sqlite3 app.db "PRAGMA wal_checkpoint(TRUNCATE);"
sqlite3 app.db "PRAGMA wal_checkpoint(RESTART);"
Частая ошибка — длинные читающие транзакции. Они держат снимок и не дают контрольной точке продвинуться, поэтому WAL растёт. Если файл журнала измеряется гигабайтами, ищите забытый курсор или открытую транзакцию в другом процессе. Помогает и journal_size_limit, но он ограничивает размер после точки, а не во время работы.
Надёжность и сбои
WAL не отменяет правила про fsync. При synchronous=FULL каждая фиксация доходит до диска, при NORMAL часть записей может остаться в кеше ОС. База при этом остаётся целой: незавершённые страницы не попадут в основной файл. После аварийного завершения SQLite восстановит состояние по журналу при следующем открытии.
Проверка целостности выполняется отдельно и на большой базе занимает время. Её стоит запускать после сбоя питания или подозрительных ошибок ввода-вывода.
sqlite3 app.db "PRAGMA integrity_check;"
sqlite3 app.db "PRAGMA quick_check;"
sqlite3 app.db "PRAGMA page_count;"
sqlite3 app.db "PRAGMA page_size;"
quick_check быстрее, но проверяет меньше. Для регулярного мониторинга достаточно quick_check раз в сутки, а полный integrity_check — перед ответственным обновлением.
Резервное копирование
Копировать файл базы при работающем приложении нельзя: получится несогласованный снимок. Правильный путь — команда .backup или VACUUM INTO. Обе создают целостную копию с учётом WAL. Для регулярных бэкапов удобно делать снимок, проверять его и только потом отправлять в хранилище.
sqlite3 app.db ".backup 'backup.db'"
sqlite3 app.db "VACUUM INTO 'backup-compact.db'"
sqlite3 backup.db "PRAGMA integrity_check;"
sqlite3 app.db "PRAGMA wal_checkpoint(TRUNCATE);"
VACUUM INTO заодно пересобирает файл и убирает фрагментацию, но требует свободного места. Для баз в сотни гигабайт такой подход может быть тяжёлым, тогда используют инкрементальные методы и внешние инструменты.
Ограничения
WAL плохо работает на сетевых файловых системах: блокировки и разделяемая память требуют корректной поддержки, которой нет у NFS и многих SMB-монтирований. Держите базу на локальном диске. Режим только для чтения тоже имеет нюанс: для открытия нужен доступ к -shm, иначе SQLite не сможет создать разделяемую память.
Ещё одно ограничение — версия формата. Старые сборки SQLite могут не понимать базу, созданную новой версией. Перед обновлением приложения проверяйте PRAGMA user_version и держите совместимую версию библиотеки.
Мониторинг в продакшене
Достаточно нескольких метрик: размер -wal, число ошибок SQLITE_BUSY, длительность контрольных точек и свободное место. Размер журнала удобно снимать вместе с выводом wal_checkpoint(PASSIVE): если поле checkpointed перестало расти, значит читатель держит старый снимок. Ещё одна полезная привычка — логировать время каждой транзакции: медленная запись часто означает не диск, а ожидание блокировки.
sqlite3 app.db "PRAGMA wal_checkpoint(PASSIVE);"
ls -lh app.db app.db-wal
sqlite3 app.db "PRAGMA journal_size_limit;"
sqlite3 app.db "PRAGMA busy_timeout;"
Если журнал растёт даже при коротких транзакциях, проверьте, не открывает ли приложение соединение на каждый запрос и не держит ли его в пуле без закрытия. Иногда виноват ORM, который оставляет незавершённую транзакцию после исключения. Явный rollback в обработчике ошибок решает проблему быстрее, чем увеличение таймаутов.
Чек-лист
- База на локальном диске, не на NFS.
- journal_mode=WAL зафиксирован в миграции.
- busy_timeout задан на уровне соединения.
- Изменения начинаются с BEGIN IMMEDIATE.
- Длинные чтения закрывают транзакции.
- Размер -wal под мониторингом.
- Бэкап через .backup или VACUUM INTO.
Итог
WAL превращает SQLite в предсказуемую базу для многопоточного приложения: читатели не ждут писателя, а сбой не разрушает файл. Но выигрыш появляется только при дисциплине — таймаутах, явных транзакциях, контроле журнала и правильном бэкапе. Настройка занимает несколько pragma, а понимание компромиссов экономит часы отладки. Для локальных приложений и небольших сервисов это почти всегда правильный выбор.