sqlite3.OperationalError: database is locked — и приложение падает на записи, которая вчера работала. Или, что хуже, не падает, а зависает: пользователь жалуется, что «сайт подвисает», хотя процессор простаивает, диск свободен, а в логах ничего.
Эта ошибка почти никогда не означает, что SQLite не справилась. Она означает, что кто-то держит блокировку записи дольше, чем другой готов ждать. Разница принципиальна: в первом случае надо менять базу, во втором — код.
#Быстрая проверка
Две команды отсекают половину случаев.
# 1. в каком режиме журнала работает база
sqlite3 /var/lib/persona/persona.db "PRAGMA journal_mode;"
# ждём: wal. Если delete или truncate — половина проблемы здесь
# 2. кто держит файл открытым (Linux)
lsof /var/lib/persona/persona.db
# Windows: handle64.exe persona.db либо Resource Monitor → Диск → Дескрипторы
Если во второй команде обнаружился ваш собственный второй экземпляр приложения — расследование окончено, дальше можно не читать. Осиротевший процесс, оставшийся с прошлого деплоя, держит соединения и мешает новому; как этот сценарий вообще возникает и почему деплой его не замечает, разобрано в статье про мониторинг личного сервиса.
#Модель доступа, из которой следует всё остальное
SQLite — не «база без конкурентности», но её конкурентность устроена жёстко: много читателей, один писатель. В журнальном режиме по умолчанию всё ещё жёстче — писатель блокирует и читателей тоже.
Режим WAL (журнал упреждающей записи) меняет ровно одну вещь и меняет её радикально: читатели больше не блокируют писателя, а писатель — читателей. Пишущий добавляет данные в отдельный файл журнала, читающие продолжают видеть согласованный снимок. Ограничение остаётся одно: писатель по-прежнему один в каждый момент времени.
Отсюда — исчерпывающий список того, чем вообще может быть вызвана ошибка блокировки:
- база не в режиме WAL;
- второй писатель ждёт первого дольше, чем ему разрешено ждать;
- транзакция записи держится слишком долго;
- переход «читаю → пишу» внутри одной транзакции упёрся в чужую блокировку;
- файл базы лежит на файловой системе, где блокировки работают неправильно.
Разберём каждую.
#Причина 1. База не в режиме WAL
Признак: любая запись вызывает ошибку у всех остальных, включая чтение.
Проверка: PRAGMA journal_mode; возвращает не wal.
Решение: включить WAL. Важная деталь: режим журнала — свойство файла базы, а не соединения, он сохраняется между запусками. Но выставлять его надо на каждом соединении в коде, иначе легко получить базу, созданную одним процессом в одном режиме, а открытую другим — в другом.
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
synchronous = NORMAL в паре с WAL — стандартный компромисс: заметно быстрее, чем FULL, а риск ограничен потерей последних транзакций при отключении питания, не порчей базы. Учтите, что WAL добавляет рядом с базой два файла — -wal и -shm, — и это ломает наивное резервное копирование; корректный способ — в гайде про резервное копирование SQLite без порчи данных.
#Причина 2. Нулевое время ожидания
Признак: ошибка возникает мгновенно, без задержки. Под нагрузкой — часто, в покое — никогда.
Проверка: посмотрите, выставляете ли вы busy_timeout. По умолчанию во многих драйверах он равен нулю: второй писатель получает отказ немедленно, вместо того чтобы подождать.
Решение:
PRAGMA busy_timeout = 5000; -- рабочие соединения
У нас это был первый настоящий фикс. Ситуация: около сорока фоновых воркеров, каждый обновляет свою отметку живости раз в три секунды, плюс один медленный вызов языковой модели, удерживающий транзакцию записи. Без времени ожидания вторые писатели умирали сразу, ошибки летели пачками, и снаружи это выглядело как «сайт зависает».
Отдельное значение — на старте приложения: там мы ставим 30 секунд, потому что при одновременном запуске нескольких процессов один из них применяет миграции, а остальным правильнее подождать, чем гонки устраивать. Про сам порядок применения схемы — в статье про миграции базы данных без потери данных.
Важно понимать границу: busy_timeout не лечит долгие транзакции, он лишь даёт очереди возможность выстроиться. Если корень в причине 3, время ожидания только превратит быструю ошибку в медленную.
#Причина 3. Долгая транзакция записи
Признак: ошибка приходит примерно через ваше время ожидания. Симптом — «подвисает», а не «падает».
Проверка: найдите в коде все места, где между началом транзакции и её завершением происходит что-то, кроме самих INSERT / UPDATE / DELETE. Сетевой вызов. Обращение к языковой модели. Расчёт эмбеддинга. Чтение большого файла.
Решение — правило, а не настройка: внутри транзакции записи не должно быть ни одного сетевого или модельного вызова. Тяжёлая работа делается до или после блока.
# неправильно: транзакция держится всё время ответа модели
async with write_transaction() as conn:
answer = await llm.generate(prompt) # секунды, иногда десятки
await conn.execute("INSERT INTO messages ...", (answer,))
# правильно: считаем снаружи, пишем внутри
answer = await llm.generate(prompt)
async with write_transaction() as conn:
await conn.execute("INSERT INTO messages ...", (answer,))
Одно такое место способно положить всю запись в приложении. Именно это и стояло за жалобой на зависания.
#Причина 4. Повышение блокировки посреди транзакции
Признак: ошибка появляется под конкуренцией и почти невоспроизводима на стенде.
Механика: транзакция началась как читающая, потом в ней случилась запись. SQLite вынуждена повысить блокировку с разделяемой до эксклюзивной, и если за это время другой писатель успел вклиниться — вторая сторона получит отказ, причём не по таймауту: повышение блокировки при активном втором писателе — ситуация с гарантированным взаимоблокированием, и ждать здесь бессмысленно.
Решение: начинать транзакции записи явно, сразу забирая блокировку.
BEGIN IMMEDIATE;
-- только INSERT / UPDATE / DELETE
COMMIT;
BEGIN IMMEDIATE берёт блокировку записи сразу, в самом начале. Второй писатель честно ждёт своей очереди по busy_timeout вместо того, чтобы наткнуться на тупик посередине. У нас соединения для записи открываются в режиме автофиксации, и BEGIN IMMEDIATE выдаётся явно — драйвер сам его не поставит.
#Причина 5. База на сетевом диске
Признак: ошибки хаотичные, встречаются повреждения, поведение отличается между машинами.
Проверка: посмотрите, где физически лежит файл базы. Сетевая шара, смонтированный облачный диск, папка синхронизации — все три опасны.
Решение: перенести базу на локальный диск. Никакие настройки этого не лечат: механизм блокировок SQLite опирается на корректную работу блокировок файловой системы, а сетевые протоколы их эмулируют по-разному. Порядок переезда с приёмкой — в гайде про перенос экземпляра на другой диск или машину.
#Таблица: симптом → причина → проверка
| Симптом | Вероятная причина | Проверка |
|---|---|---|
| Ошибка сразу, без паузы | нулевой busy_timeout |
значение прагмы в коде |
| Ошибка ровно через N секунд | долгая транзакция | сетевые вызовы внутри блока |
| Запись блокирует чтение | база не в WAL | PRAGMA journal_mode |
| Только под нагрузкой, не повторяется | повышение блокировки | явный BEGIN IMMEDIATE |
| Хаос и повреждения | сетевой диск | физический путь к файлу |
Файл -wal вырос до гигабайт |
нет контрольных точек | размер файла рядом с базой |
Последняя строка — отдельный сюжет. Файл журнала растёт, пока есть хоть одно долгоживущее читающее соединение: контрольная точка не может его усечь. Симптом — «база вроде маленькая, а на диске занято втрое больше». Лечится не настройкой, а закрытием долгоживущих читателей.
#Если ничего не помогло
Порядок дальнейших действий, честно и по убыванию вероятности.
- Убедитесь, что процесс один. Второй экземпляр приложения — самая частая причина всех странностей разом.
- Проверьте целостность:
PRAGMA integrity_check;. Ответ, отличный отok, переводит задачу в другую плоскость — восстановление из копии, а не настройка. - Померьте, сколько длится ваша самая долгая транзакция записи. Не оцените, а померьте: добавьте замер времени ожидания блокировки и времени внутри блока. Без этих двух чисел разговор о блокировках — гадание.
- Проверьте, не упёрлись ли вы в объём. Медленные запросы на большой таблице удлиняют транзакции и превращаются в блокировки; где у SQLite реальные пределы, разобрано в статье про SQLite и FTS5 на миллионах строк.
- И только теперь рассматривайте переезд на серверную СУБД. Признак, что он оправдан, ровно один: вам действительно нужны несколько одновременных писателей с разных машин. Всё остальное — не про SQLite.
#Частые вопросы
#Правда ли, что SQLite не годится для веб-приложения?
Для веб-приложения с одним пишущим процессом и умеренным потоком записи — годится с большим запасом. Проблемы начинаются там, где несколько машин пишут в одну базу: это сценарий, для которого SQLite не предназначена вовсе.
#Помогает ли увеличить busy_timeout до минуты?
Помогает спрятать симптом и ухудшить впечатление: вместо быстрой ошибки пользователь получит минутное зависание. Значение в несколько секунд разумно; всё, что больше, — признак, что чинить надо длительность транзакций.
#Что делать с растущим файлом -wal?
Найти долгоживущее читающее соединение и закрыть его. Принудительная контрольная точка помогает разово, но не устраняет причину. Полный сброс журнала делается на остановленном приложении.
#Нужно ли включать WAL вручную после восстановления из копии?
Проверьте прагму после восстановления. Режим журнала хранится в файле, и копия, снятая некорректным способом, может приехать в другом режиме. Это один из аргументов в пользу проверки восстановления, а не только создания копии.
#Влияет ли количество потоков приложения на блокировки?
Прямо: каждый поток со своим соединением — потенциальный писатель. Сорок фоновых воркеров, пишущих отметку живости каждые три секунды, создают заметный поток записи сами по себе. Уменьшение частоты таких служебных записей часто даёт больше, чем любая настройка базы.
#Может ли ошибка блокировки появиться из-за медленного старта приложения?
Может — при одновременном запуске нескольких процессов, когда один применяет миграции. Именно для этого случая время ожидания на старте ставят намного больше рабочего. Про то, из чего вообще складывается запуск, — в гайде про холодный старт приложения, а про место базы в общей эксплуатации сервиса — в статье о том, как поднять свой ИИ-сервис.