SQLite предложили ввести «издания» по примеру Rust

SQLite, движок реляционной базы данных в виде библиотеки, встроенной прямо в приложение, а не отдельного серверного процесса. Автор блога mort.coffee называет его фактическим стандартом для локального хранения данных и отмечает, что даже серверный софт держит на нём базы (в тексте приведён пример агрегатора lobste.rs). При этом, по его мнению, дефолтные настройки SQLite опасны, и он разбирает четыре конкретных проблемы.

Первая, по умолчанию SQLite игнорирует ограничения внешних ключей (foreign key constraints), хотя все остальные известные автору РСУБД проверяют их «из коробки». Это усугубляется тем, что SQLite умеет повторно использовать ROWID, внутренний числовой идентификатор строки. Автор приводит сквозной пример: пользователь Bob создаёт аккаунт с id=1 и пишет пост; аккаунт удаляют, но забывают удалить его посты; новый пользователь Alice получает id=1 из-за повторного использования ROWID, и в выборке её имя автоматически «приклеивается» к старому посту Bob, без единой ошибки в логах. Включение PRAGMA foreign_keys = ON; превращает такую операцию удаления в ошибку FOREIGN KEY constraint failed.

Вторая проблема, слабая типизация столбцов. Столбец, объявленный как INTEGER, на деле получает не строгий тип, а «предпочтительный» тип (affinity): SQLite пытается привести вставляемое значение к нужному типу, но если это не удаётся, просто сохраняет как есть. В примере из текста в столбец duration_sec INTEGER без ошибки попадает строка «Way too long, I mean come on» вместо числа. У SQLite есть решение, STRICT-таблицы, которые дают настоящую ошибку типа при вставке, но включать их приходится вручную для каждой таблицы отдельно, общей PRAGMA для этого нет. Автор также ссылается на комментарии к обсуждению: пользователь 'zie' с lobste.rs отмечает, что STRICT-таблицы заодно меняют правила разбора имён типов (что закрывает лазейку с самодокументирующими названиями столбцов вроде DATETIME или COLOR), а пользователь 'masklinn' указывает, что нужный автору механизм псевдонимов типов уже описан в стандарте SQL99 как CREATE DOMAIN, то есть SQLite нужно лишь реализовать существующий стандарт.

Третья проблема, поведение при параллельной записи. SQLite допускает много одновременных читателей, но только одного писателя; по умолчанию, если два процесса одновременно пытаются писать, второй сразу получает ошибку SQLITE_BUSY вместо ожидания снятия блокировки. Автор пишет, что именно это приводило к реальным падениям его софта, пока он не начал вручную писать циклы повторных попыток; правильное исправление, PRAGMA busy_timeout = 5000;, которая заставляет SQLite ждать освобождения блокировки до пяти секунд перед тем, как вернуть ошибку.

Четвёртая проблема, производительность: по умолчанию отключён журнальный режим Write-Ahead Log (WAL), а также не выставлен synchronous = NORMAL, что автор называет главным фактором медленной записи «из коробки». Включение PRAGMA journal_mode = WAL; вместе с PRAGMA synchronous = NORMAL; резко ускоряет запись без риска повреждения данных (за подробным разбором тюнинга автор отсылает к отдельному материалу Sylvain Kerkour про оптимизацию SQLite для серверов).

Причину, по которой все эти дефолты годами остаются такими, автор называет прямо, обратная совместимость: смена дефолта рискует сломать старый софт и отпугнуть разработчиков от будущих обновлений SQLite. В качестве решения он предлагает завести в SQLite систему «изданий» по аналогии с языком Rust, где издание можно указать за конкретным проектом или базой, так новые проекты получали бы безопасные настройки по умолчанию, а существующие базы и приложения продолжали бы работать по старым правилам без изменений. Предзагруженный текст обрывается на этом месте до подробного описания механизма самих «изданий», поэтому дальше конкретики о том, как именно должна быть устроена такая система для SQLite, в материале нет.

Ключевые факты

  • SQLite по умолчанию НЕ проверяет внешние ключи (PRAGMA foreign_keys выключена), и из-за повторного использования ROWID новый пользователь может «унаследовать» посты удалённого пользователя без единой ошибки.
  • Столбцы SQLite нестрого типизированы: даже INTEGER-столбец может хранить произвольный текст, если для таблицы явно не включён режим STRICT, а STRICT приходится включать вручную для каждой таблицы отдельно.
  • Без PRAGMA busy_timeout SQLite сразу возвращает ошибку SQLITE_BUSY при параллельной записи вместо ожидания снятия блокировки, автор связывает это с реальными падениями своего софта.
  • По умолчанию отключён журнальный режим WAL и не выставлен synchronous=NORMAL, из-за чего запись в SQLite заметно медленнее, чем при включении этих двух PRAGMA.
  • Раз дефолты не меняют из-за обратной совместимости, автор предлагает завести в SQLite систему «изданий» по примеру Rust: новые проекты получают безопасные настройки, старые базы и приложения не ломаются.

Почему это важно

SQLite, не просто библиотека для мобильных и десктопных приложений: по словам автора, даже серверный софт вроде агрегатора lobste.rs хранит данные в SQLite. При этом ключевые защитные механизмы, проверка внешних ключей, строгая типизация столбцов, разумное ожидание блокировки при записи, быстрый журнальный режим, по умолчанию выключены. Разработчик, который взял SQLite «из коробки» без чтения документации по PRAGMA, рискует получить тихую порчу данных или неожиданные падения, и не сразу поймёт причину.

Кому это важно

Разработчикам, использующим SQLite как встраиваемую базу данных для приложений, десктопного и мобильного софта, CLI-утилит, а также как основу для серверных сервисов с умеренной нагрузкой (пример из текста, lobste.rs). В первую очередь уязвимы те, кто не настраивал PRAGMA вручную и полагается на настройки по умолчанию.

Как это применить

Автор перечисляет конкретные PRAGMA, которые стоит включать в любом новом проекте на SQLite уже сегодня: PRAGMA foreign_keys = ON; для проверки внешних ключей; создание таблиц с ключевым словом STRICT, чтобы столбец INTEGER не принимал произвольный текст; PRAGMA busy_timeout = 5000;, чтобы SQLite ждал освобождения блокировки вместо мгновенной ошибки SQLITE_BUSY; и связку PRAGMA journal_mode = WAL; с PRAGMA synchronous = NORMAL; для заметного ускорения записи. Долгосрочно автор предлагает системное решение, механизм «изданий» по аналогии с Rust, при котором новые издания SQLite получали бы безопасные настройки по умолчанию, а существующие базы и приложения продолжали бы работать по старым правилам без изменений.

Можно ли доверять

Это личный пост одного разработчика в его блоге, а не заявление команды SQLite. Технические факты о дефолтном поведении foreign_keys, типовой аффинности столбцов и STRICT-таблицах опираются на официальную документацию SQLite и легко проверяются. А вот сама идея «изданий», частная инициатива автора и участников обсуждения на Hacker News и lobste.rs (в тексте упомянуты комментаторы 'zie' и 'masklinn'), а не предложение самой команды SQLite; официального ответа разработчиков SQLite в источнике нет.

Риски и подводные камни

Главный риск уже реализовался в примере из текста, а не гипотетический: удаление пользователя и повторное использование его ROWID новым пользователем приводит к тому, что чужие данные молча «приписываются» новому владельцу без единой ошибки в логах. Дополнительный риск, ручное управление PRAGMA: достаточно забыть включить один из параметров (foreign_keys, STRICT, busy_timeout, WAL) в одном месте кода, и защита не сработает. Наконец, судьба самого предложения об «изданиях» неясна: команда SQLite годами не меняла дефолты именно из опасений сломать обратную совместимость, и нет гарантии, что предложенный автором механизм будет вообще рассмотрен.