SQLite в production
- title
- SQLite в production
- type
- summary
- summary
- Режим WAL может блокировать короткоживущих читателей, плюс разбор pragma-шаблона с исправлением двух ошибок
- tags
- databases, sqlite, concurrency, operations
- created
- 2026-07-29
- updated
- 2026-07-29
- lang
- ru
- translation_of
- sqlite-in-production
- source_updated
- 2026-07-29
- translated
- 2026-09-01
- translator
- lllm/antigravity/gemini-3.7-flash-medium
Два июльских материала 2026 года об использовании SQLite в роли основной базы данных на сервере приложений. Их полезно сопоставить: авторы расходятся в оценке того, где именно кроются риски. Заметка Хайнека Шлавака (Hynek Schlawack, TIL от 26 июля) - это отчёт из первых рук о том, как режим WAL приводил к ошибкам database is locked у читателей на базе, в которую никто не писал днями. Руководство по настройке от Micrologics, вышедшее девятью днями ранее, представляет собой стандартный шаблон pragma-настроек и исходит из противоположного: включите WAL, задайте busy timeout - и читатели перестанут быть проблемой.
Читатели блокируются, а read-only системы всё равно пишут на диск
Совет, который Хайнек приводит как общепринятый, состоит из трёх пунктов: режим WAL, ненулевой busy timeout и BEGIN IMMEDIATE для любой пишущей транзакции. Он признаёт, что для целевой нагрузки этот совет верен: для веб-приложения с долгоживущими соединениями из пула. Его собственная нагрузка переворачивает каждое условие из этой формулы. Записи в ряде случаев происходят реже одного раза в месяц. Чтение происходит десятки и сотни раз в секунду из независимых процессов без пула соединений: каждый открывает базу с флагом SQLITE_OPEN_READONLY, выполняет один SELECT и закрывает файл.
Режим WAL выбрали для подстраховки, чтобы редкий пишущий процесс не блокировал читателей. Но сработал механизм жизненного цикла соединений в WAL, а не путь записи. Соединения координируются через индекс в разделяемой памяти -shm, и открытие или закрытие пустой WAL-базы может на короткое время требовать эксклюзивных блокировок. Новый читатель, попадающий в такое окно, получает ошибку SQLITE_BUSY, хотя данные приложения в этот момент не пишутся и не писались уже несколько дней. Поскольку в C-библиотеке SQLite значение busy_timeout по умолчанию равно 0, ошибка сразу возвращается вызывающему коду вместо повторной попытки.
Список файлов подтверждает происходящее:
-rw-r----- 1 root root 294912 Jul 21 09:49 /vmws/config/config.db
-rw-r----- 1 root root 32768 Jul 24 18:26 /vmws/config/config.db-shm
-rw-r----- 1 root root 0 Jul 24 18:26 /vmws/config/config.db-wal
Команда ls выполнена 24 июля. К самой базе последний раз обращались 21 июля, тогда как у файлов -shm и -wal стоят отметки времени минутной давности - их оставили читатели. Файл -wal имеет нулевой размер, но всё равно генерирует дисковую активность.
Воспроизводящий скрипт на чистой стандартной библиотеке Python запускает 64 процесса, каждый из которых делает 100 циклов open/select/close. Процессы синхронизируются через барьер, чтобы открывать базу одновременно. Тест проведён для трёх конфигураций:
A) WAL, no busy timeout 7 / 6400 locked
B) WAL, 1s busy timeout 0 / 6400 locked
C) DELETE, no busy timeout 0 / 6400 locked
Сценарий A на MacBook Pro 2023 года с Python 3.14 стабильно выдаёт от 1 до 10 ошибок; сценарии B и C не дают ни одной. Хайнек решил проблему переводом баз в режим журнала DELETE. Это возвращает блокировку читателей пишущими процессами - компромисс, вполне приемлемый при ежемесячных записях и работающий до сих пор. В заключение он замечает, что именно нулевой таймаут по умолчанию в C-библиотеке позволил заметить ошибку: "ура вещам, которые ломаются громко".
Шаблон, рекомендованный вторым источником
Micrologics приводит последовательность инициализации для каждого соединения, к которой сходится большинство статей про SQLite в production:
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;
PRAGMA cache_size = -64000; -- ~64 MB; negative means KiB, positive means pages
PRAGMA mmap_size = 1073741824; -- 1 GB mapped
PRAGMA foreign_keys = ON;
PRAGMA journal_size_limit = 67108864;
synchronous = NORMAL отключает fsync при каждом коммите и синхронизирует данные в критические моменты вроде контрольных точек (checkpoint); в режиме WAL при сбое это грозит потерей недавно зафиксированных транзакций, но не целостности базы. cache_size по умолчанию составляет около 2 МБ, чего для рабочего набора сервера недостаточно. mmap_size отображает файл базы данных в адресное пространство процесса, превращая чтение в адресную арифметику вместо системных вызовов read(), а кэшированием занимается дисковый кэш ядра; если база меньше лимита, она отображается целиком.
В части транзакций руководство даёт верные рекомендации. Транзакция DEFERRED (по умолчанию) вообще не берёт блокировок: она начинается как чтение и переходит в запись только при первой операции изменения данных. Ситуация, когда два соединения читают, а затем оба пытаются записать, приводит к классическому сбою. IMMEDIATE сразу берёт зарезервированную блокировку, оставляя доступ читателям, а EXCLUSIVE блокирует всё. Правило простое: транзакция с любой записью должна начинаться с BEGIN IMMEDIATE.
Для создания контрольных точек есть четыре режима, различающихся готовностью блокировать операции:
PASSIVEпереносит всё, что может, никого не блокируя, и прерывается, если читатель всё ещё удерживает старую страницу в WAL.FULLблокирует новых писателей и ждёт существующих читателей, чтобы перенести весь WAL целиком.RESTARTдействует какFULLи дополнительно сбрасывает WAL, чтобы следующие записи шли с начала файла.TRUNCATEдействует какRESTARTи усекает файл WAL на диске до нулевого размера.
Практический вывод: при непрерывном потоке читателей автоматический checkpoint никогда не завершится, и WAL будет расти бесконечно. Решением служит вызов PRAGMA wal_checkpoint(PASSIVE) по расписанию из фонового потока в сочетании с journal_size_limit.
Два ошибочных утверждения в шаблоне
Руководство представляет Litestream как слой VFS. Это не так. Litestream - отдельный процесс, который читает файл WAL и передаёт фреймы в объектное хранилище; SQLite о его существовании не знает. Полноценной VFS на базе FUSE является LiteFS - к ней одной из этой пары применима такая формулировка. В итоге заголовок раздела ("Custom VFS Layers for the Cloud Era") описывает два инструмента, работающих на совершенно разных уровнях, как единое архитектурное решение.
Директиве PRAGMA auto_vacuum = INCREMENTAL также не место в инициализации каждого соединения. Режим auto-vacuum задаётся до создания первой таблицы либо меняется постфактум через полный VACUUM; вызов этой pragma при подключении к заполненной базе ничего не делает. Комментарий в шаблоне ("Optimize index page allocation and query plans") не соответствует ни назначению auto-vacuum, ни возможностям pragma, выставляемых на уровне соединения.
Итог сопоставления
Случай Хайнека служит контрпримером не к отдельным pragma, а к контексту применения шаблона. Набор директив рассчитан на пул соединений - Micrologics прямо говорит о проектировании "пула соединений и логики транзакций" вокруг ограничения на одного писателя. В модели с процессами формата "открыл - прочитал - закрыл" инициализация из восьми pragma выполняется на каждый SELECT, и именно включённый режим WAL начинает порождать ошибки. Значение busy_timeout = 5000 замаскировало бы проблему Хайнека, но скрыло бы тот факт, что фактически read-only система непрерывно выполняет запись в два вспомогательных файла.
Более широкий тезис материала Micrologics касается локального хранилища: на NVMe встроенная в процесс база исключает сетевые задержки, а postgresql остаётся правильным выбором для распределённой записи между регионами или объёмов данных свыше нескольких терабайт. Примерно на этой же границе подключается duckdb-quack-protocol - изначально внутрипроцессный движок, добавляющий сетевой протокол, поскольку зоопарк обходных путей стал сложнее самого протокола. В обоих случаях решается один и тот же вопрос: когда именно in-process подход перестаёт себя окупать.
О настройке структуры данных в SQLite без скрытых ловушек см. sqlite-strict-tables: та же схема, где мягкие значения по умолчанию удобны ровно до тех пор, пока тихо не пропустят целый класс ошибок. В database-index-gotchas разобран аналогичный класс проблем для планировщика запросов. Материалы charles-leifer-blog служат постоянным источником информации о внутреннем устройстве SQLite от автора, который разбирает исходные патчи.