Технологии

SQLite заблокирована: журнал WAL и конкурентный доступ

Ошибка «database is locked» почти никогда не про SQLite. Разбираем модель конкурентного доступа, показываем, какие настройки убирают ошибку, а какие только маскируют, и приводим реальный случай с зависшим сайтом.

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 (журнал упреждающей записи) меняет ровно одну вещь и меняет её радикально: читатели больше не блокируют писателя, а писатель — читателей. Пишущий добавляет данные в отдельный файл журнала, читающие продолжают видеть согласованный снимок. Ограничение остаётся одно: писатель по-прежнему один в каждый момент времени.

Отсюда — исчерпывающий список того, чем вообще может быть вызвана ошибка блокировки:

  1. база не в режиме WAL;
  2. второй писатель ждёт первого дольше, чем ему разрешено ждать;
  3. транзакция записи держится слишком долго;
  4. переход «читаю → пишу» внутри одной транзакции упёрся в чужую блокировку;
  5. файл базы лежит на файловой системе, где блокировки работают неправильно.

Разберём каждую.

#Причина 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 вырос до гигабайт нет контрольных точек размер файла рядом с базой

Последняя строка — отдельный сюжет. Файл журнала растёт, пока есть хоть одно долгоживущее читающее соединение: контрольная точка не может его усечь. Симптом — «база вроде маленькая, а на диске занято втрое больше». Лечится не настройкой, а закрытием долгоживущих читателей.

#Если ничего не помогло

Порядок дальнейших действий, честно и по убыванию вероятности.

  1. Убедитесь, что процесс один. Второй экземпляр приложения — самая частая причина всех странностей разом.
  2. Проверьте целостность: PRAGMA integrity_check;. Ответ, отличный от ok, переводит задачу в другую плоскость — восстановление из копии, а не настройка.
  3. Померьте, сколько длится ваша самая долгая транзакция записи. Не оцените, а померьте: добавьте замер времени ожидания блокировки и времени внутри блока. Без этих двух чисел разговор о блокировках — гадание.
  4. Проверьте, не упёрлись ли вы в объём. Медленные запросы на большой таблице удлиняют транзакции и превращаются в блокировки; где у SQLite реальные пределы, разобрано в статье про SQLite и FTS5 на миллионах строк.
  5. И только теперь рассматривайте переезд на серверную СУБД. Признак, что он оправдан, ровно один: вам действительно нужны несколько одновременных писателей с разных машин. Всё остальное — не про SQLite.

#Частые вопросы

#Правда ли, что SQLite не годится для веб-приложения?

Для веб-приложения с одним пишущим процессом и умеренным потоком записи — годится с большим запасом. Проблемы начинаются там, где несколько машин пишут в одну базу: это сценарий, для которого SQLite не предназначена вовсе.

#Помогает ли увеличить busy_timeout до минуты?

Помогает спрятать симптом и ухудшить впечатление: вместо быстрой ошибки пользователь получит минутное зависание. Значение в несколько секунд разумно; всё, что больше, — признак, что чинить надо длительность транзакций.

#Что делать с растущим файлом -wal?

Найти долгоживущее читающее соединение и закрыть его. Принудительная контрольная точка помогает разово, но не устраняет причину. Полный сброс журнала делается на остановленном приложении.

#Нужно ли включать WAL вручную после восстановления из копии?

Проверьте прагму после восстановления. Режим журнала хранится в файле, и копия, снятая некорректным способом, может приехать в другом режиме. Это один из аргументов в пользу проверки восстановления, а не только создания копии.

#Влияет ли количество потоков приложения на блокировки?

Прямо: каждый поток со своим соединением — потенциальный писатель. Сорок фоновых воркеров, пишущих отметку живости каждые три секунды, создают заметный поток записи сами по себе. Уменьшение частоты таких служебных записей часто даёт больше, чем любая настройка базы.

#Может ли ошибка блокировки появиться из-за медленного старта приложения?

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

Хотите ИИ, который помнит вас?

Persona держит контекст между чатами и работает на вашем железе — если развернуть её у себя и подключить локальную модель.

Создать аккаунт