EnglishРусский Map

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 от автора, который разбирает исходные патчи.