Read in:
Русский

Полгода я винил SQLite в собственном баге

О чём это: история про ошибку SQLITE_BUSY, которую я полгода ловил, валил на SQLite и в итоге нашёл в собственном пуле соединений. Наполовину боевая история, наполовину — довод в пользу того, чтобы держать базу внутри одного процесса. Читать, если вы хоть раз видели «database is locked» и думали, что зря выбрали SQLite.

Ошибка была моя. Где-то полгода trip2g под нагрузкой сыпал SQLITE_BUSY и database is locked, и примерно столько же я раз за разом решал, что виноват SQLite. Нет. Это я настроил пул соединений так, что он врал мне, сколько у меня писателей. В тот день, когда я наконец это понял, я перестал бояться держать базу внутри собственного процесса и начал получать от этого удовольствие.

Я пришёл сюда после многих лет на Postgres. В Postgres ты не думаешь, сколько у тебя писателей. Открываешь пул на тридцать соединений, все пишут когда хотят, а сервер разруливает. Поэтому когда я перевёл trip2g на один встроенный SQLite-файл, модель показалась неправильной на ощупь. Один писатель за раз, даже в режиме WAL? Это читалось как ограничение, которое надо обходить, а не контракт, под который надо проектировать. Я месяцами относился к нему как к первому и всё это время тихо злился на движок.


Каждый фикс делал ошибку реже

Первый шаг, ещё в августе, был самым тупым из возможных. Я поставил SetMaxOpenConns(1) на общий пул. BUSY исчез: соединение стало физически одно, гонкам стало не за что цепляться. И сломался вход. Долгая запись занимала единственное соединение, а запрос, которому надо было всего лишь прочитать куку сессии, стоял за ней в очереди. Я оставил в коде // TODO: it breaks sign in, вернул пул на 10, потом на 25 — и BUSY вернулся сразу же.

В тот же день я добавил хелпер WithRetry: он ловил busy-ошибку и повторял запрос с небольшим джиттером. Работало как пластырь. Ретраи только прятали конкуренцию. Я насыпал и прагм: busy_timeout, _txlock=immediate, WAL, кэш побольше. Каждое изменение делало ошибку реже. Ни одно не убивало её. Я читал это как «SQLite капризничает». Всё было наоборот. SQLite вёл себя идеально предсказуемо, а переменной был я.

Вывод: баг, который с каждым фиксом становится реже, но не умирает, — это обычно один баг, а не много.


Баг был в том, что «настроено» — это не «применено»

То, что наконец вскрыло проблему, оказалось мелким и слегка унизительным. У меня в конфиге стоял busy_timeout = 20000. Двадцать секунд. А база возвращала database is locked мгновенно, вообще без ожидания. Двадцатисекундный таймаут, срабатывающий за ноль секунд, должен был остановить меня ещё за месяцы до этого. Не остановил, потому что я не спросил почему.

Спросила модель Anthropic, с которой я тогда работал в паре, — Fable 5. Она прочитала расширенный код результата вместо заголовочного числа, заметила, что в скобках стоит 517, а не простая 5, и пошла читать исходники драйвера. trip2g работает на modernc.org/sqlite, чистом Go-драйвере. А я написал строку подключения в стиле mattn, который откуда-то смутно помнил: _busy_timeout, _journal, _timeout. modernc не понимает ни одного из них. Он поддерживает _pragma, _txlock и _time_format, а всё остальное молча выбрасывает. Ни ошибки, ни предупреждения. Каждая прагма, которую я считал выставленной, летела на пол.

Был ещё фолбэк, который на старте выполнял разовый PRAGMA busy_timeout = 20000, — так я и убедил себя, что всё применилось. Но database/sql — это пул, а не соединение. Разовый Exec настраивает ровно то одно соединение, которое ему попалось. В пуле чтения до 25 соединений. Значит, моё значение таймаута несло одно соединение из двадцати пяти, а остальные двадцать четыре поднимались с голыми дефолтами SQLite: busy_timeout=0, foreign_keys=OFF. Стартовый лог писал «прагмы настроены» — и про одно соединение из двадцати пяти это была правда.

517 — вторая половина истории. Обычный отложенный BEGIN начинает жизнь как читатель и берёт снапшот на чтение. Когда такая транзакция потом пытается писать, а между делом другое соединение успело закоммитить, она апгрейдится на снапшоте, который уже устарел, — и SQLite сразу возвращает SQLITE_BUSY_SNAPSHOT (517). busy_timeout тут не поможет никогда: сколько ни жди, снапшот не перестанет быть устаревшим. Лекарство — _txlock=immediate: тогда пишущая транзакция берёт write-lock прямо на BEGIN и вежливо встаёт в очередь, а не падает. Эта прагма была в строке всё это время. Драйвер игнорировал её вместе со всеми остальными.

Вывод: «настроено» и «применено» — разные слова. Зелёный стартовый лог рассказывает про одно соединение. Остальные скажут правду, только если их зафиксировать и спросить.


А фоновые задачи тихо воровали писателя

Даже когда прагмы наконец легли на каждое соединение, оставался второй баг, и этот был чисто архитектурным. Месяцами раньше я обернул каждую GraphQL-мутацию в пишущую транзакцию. Удобно. Каждая мутация по умолчанию атомарна. Но это значит, что на всё время мутации единственное пишущее соединение принадлежит одному запросу. Всё, что мутация запускает и что тоже хочет писать, теперь просит соединение, которое этот же запрос уже держит.

Писать внутри мутации хотело многое. Сохранение заметки ставило в очередь задачу публикации в Telegram, и постановка в очередь использовала глобальный контекст приложения вместо контекста запроса — теряла транзакцию и шла искать собственное пишущее соединение. Фоновые задачи, созданные внутри транзакции, на деле создавались вне её области — это давало новые 517 и оставляло дубликаты задач после отката. Крон-запуски и вложенные админ-мутации открывали свои транзакции и упирались в то же пишущее соединение, которое держала мидлвара. Им понадобилась директива @skipTx, чтобы выходить из общей обёртки.

Хуже всех была сама очередь. goqite опрашивает SQLite по таймеру, бесконечно стреляя DELETE и UPDATE по своей таблице. Я держал её на пишущем пуле. И поллер очереди с приложением встали в вечное противостояние за единственный пишущий слот, а никакой busy_timeout не спасает от соперника, который стучится не переставая. Первого мая я выдал goqite отдельное соединение, отдельно от пишущего пула. Вот на этом — вместе с работой по @skipTx накануне — сага и заканчивается. Девять месяцев от первого коммита про BUSY до последнего, и шесть из них, плохих, я смотрел, как мой собственный код дерётся сам с собой за одного писателя.


Что изменилось, когда я перестал нянчить базу

Вот часть, которую я не предвидел. Как только писатель стал честным, я вообще перестал нянчить базу.

Годами на Postgres я проводил для каждого нового арендатора маленький ритуал. Сделать CREATE DATABASE внутри существующего кластера или поднять новый сервер? Кто ещё живёт в этом инстансе? Если я ошибусь в WHERE tenant_id = ?, чьи данные утекут в чью страницу? В trip2g каждая база знаний — это собственный SQLite-файл, которым владеет собственный процесс. Колонки tenant_id нет нигде, потому что нечего различать. Вся база и есть арендатор. Баг в multitenant-коде или неверный параметр физически не дотянется до данных соседа: строки соседа лежат в другом файле, за другим процессом, обычно на другом хосте. Я изолирую меш баз на сетевом уровне, а не аккуратным условием, зарытым в запрос. Самая прочная граница, какую я могу провести, оказалась той, которую мне больше не надо помнить проводить.

Я и с N+1 помирился. На Postgres двести мелких запросов на одну страницу — это грех, потому что каждый из них сетевой round trip. SQLite не сервер. Он работает в том же адресном пространстве, что и приложение, поэтому запрос — это вызов функции, а не сообщение по проводу. Официальная страница говорит прямо: много маленьких запросов в SQLite — это эффективно. Дэвид Кроушоу разворачивает версию покрупнее в своих заметках про программирование в один процесс: не используй N компьютеров там, где хватит одного. Я читал оба текста ещё до всей этой истории. Поверил только после того, как перестал воевать с писателем.


Один процесс до самого низа

То, что я защищаю теперь, — это не только база. Это форма. trip2g — это один бинарник на Go. Очередь задач — goqite, а это просто ещё одна таблица в том же SQLite-файле, так что не нужно держать ни Redis, ни брокер. Полнотекстовый поиск, кэш страниц, очередь и данные — всё живёт в одном процессе. Go кросс-компилирует этот процесс в статический бинарник почти под любую платформу, так что он запускается на маленькой VM, старом ноутбуке или на любом железе, что под рукой, и рядом ничего ставить не надо. Я могу посадить арендатора на отдельную машину ровно по той же причине, по какой всё это влезает на дохлое железо: движущаяся часть всего одна.


Спасибо

Ничего этого не было бы без людей, которые сделали из однопроцессного SQLite серьёзный способ выпускать софт. Д. Ричард Хипп и команда SQLite построили движок, и те самые точные расширенные коды, которые я игнорировал, 5 и 517, — единственная причина, по которой баг вообще можно было найти. Дэвид Кроушоу написал заметки про один процесс и раннюю Go-обвязку и дал мне мысль, что один писатель — это замысел, а не рана. Ян Мерцл поддерживает чистый Go-драйвер modernc, на котором trip2g реально работает, вместе с механизмом _pragma, который и оказался всем фиксом целиком. Маркус Вюстенберг написал goqite, и перенос её на собственное соединение — тот коммит, что наконец закрыл дело. А Бен Джонсон сделал Litestream и LiteFS — то, что позволяет одному файлу вести себя как надёжное, реплицируемое, восстановимое продакшен-хранилище. Без Litestream и компании никакого trip2g бы не было. Я говорю это буквально.


Что я сказал бы себе прежнему

Реши, где живёт твой единственный писатель, в первый же день. Одно пишущее соединение, открываемое через BEGIN IMMEDIATE, щедрый busy_timeout на редкое честное ожидание и отдельный пул чтения, который разворачивается так широко, как хочешь. Всё, что пишет по расписанию, — очереди и кроны в первую очередь, — получает собственное соединение, чтобы никогда не конкурировать с запросом. Клади прагмы в DSN, а не в разовый Exec, а потом зафиксируй горсть соединений и проверь, что они действительно применились.

BUSY никогда не был «трудным характером» SQLite. Это SQLite терпеливо, полгода, говорил мне, что мой пул врёт о том, сколько у меня писателей. Мне надо было просто перестать спорить и наконец услышать.