SQLite и встраиваемые БД: где они сильнее «взрослых» серверов
Почти вся индустрия рефлекторно начинает проект с «поднимем Postgres». Это разумный дефолт — мы разбирали его в статье про PostgreSQL. Но у этого дефолта есть цена, которую редко считают: отдельный процесс, отдельный сетевой хоп на каждый запрос, отдельный жизненный цикл, отдельный бэкап, отдельный мониторинг, отдельный счёт от облака. Для огромного класса задач эта цена — чистый убыток, а встраиваемая БД даёт не «упрощённый», а строго лучший результат: меньше задержка, меньше отказов, меньше кода.
Эта статья — о том, как встраиваемые БД устроены изнутри, где они выигрывают у серверных с разгромным счётом, и — что важнее — где они ломаются и почему.
Что вообще значит «встраиваемая»
Серверная СУБД — это отдельная программа. Ваше приложение общается с ней по протоколу через сокет: сериализация запроса, системный вызов, переключение контекста, планирование ОС, десериализация ответа. Встраиваемая БД — это библиотека, которую вы линкуете в свой процесс. Запрос — это вызов функции. Данные приходят по указателю.
Аналогия: серверная БД — это склад через дорогу с кладовщиком и накладными; встраиваемая — полка в вашей комнате. Пока вам нужен один товар за раз и вы единственный, кто с полки берёт, поход через дорогу — чистые накладные расходы. Как только к полке выстраивается очередь из тридцати человек с разных этажей, кладовщик внезапно становится необходим.
Численно разница такая: круговой обход к локальному PostgreSQL через unix-сокет — это порядка 50–150 мкс на тривиальный SELECT 1, через TCP по сети внутри датацентра — 0.3–1 мс. Вызов sqlite3_step() по уже подготовленному запросу с попаданием в page cache — 1–10 мкс. Разница в 1–2 порядка. Для запроса, который делает одну выборку, она незаметна. Для эндпоинта, делающего 200 мелких запросов в цикле (привет, N+1), — это разница между 4 мс и 200 мс.
Официальная документация SQLite прямо формулирует свою нишу: «SQLite competes with fopen()» — он конкурирует не с PostgreSQL, а с самодельным форматом файла (sqlite.org/whentouse.html).
Почему именно SQLite заслуживает отдельного разговора
SQLite — вероятно, самая распространённая СУБД на планете: он в каждом Android и iOS, в каждом браузере, в macOS, в Windows 10+, в самолётах Airbus, в Python-стандартной библиотеке. Оценки авторов — более триллиона активных баз (sqlite.org/mostdeployed.html).
Три свойства, которые делают его особенным:
- Public domain. Не MIT, не BSD — авторы отказались от прав вообще. Никаких юридических вопросов при встраивании во что угодно.
- Тестирование уровня авионики. 100% MC/DC-покрытие ветвлений (стандарт DO-178B), объём тестового кода примерно в 590 раз больше объёма самой библиотеки, отдельные наборы для fuzz, out-of-memory и I/O-ошибок (sqlite.org/testing.html). Это не «хорошо покрытый проект», это другая лига.
- Формат файла стабилен с 2004 года, авторы публично обязались поддерживать его до 2050-го (sqlite.org/lts.html). База, записанная SQLite 3.0, читается сегодняшней версией. Для архивных форматов это решающий аргумент — Библиотека Конгресса США рекомендует SQLite как формат долговременного хранения.
Как устроен файл: страницы, B-tree, overflow
Вся база — один файл, который логически представляет собой массив страниц одинакового размера (по умолчанию 4096 байт с версии 3.12.0). Страница номер N лежит по смещению (N-1) * page_size. Никакого каталога экстентов, никаких табличных пространств.
Каждая таблица — это B+tree, ключом которого выступает rowid (64-битное целое). Каждый индекс — отдельное B-tree, ключом которого выступает кортеж индексируемых колонок плюс rowid в конце. Первая страница содержит 100-байтовый заголовок и корень системной таблицы sqlite_schema, где хранится DDL всех объектов в виде текста.
Отсюда сразу следуют практические выводы:
- Поиск по первичному ключу-целому — это спуск по одному дереву. Поиск по вторичному индексу — спуск по дереву индекса плюс отдельный спуск по дереву таблицы за строкой. Классический «index lookup + table access», подробно в статье про индексы и планы выполнения.
WITHOUT ROWIDменяет всё. Таблица становится B-tree, ключом которого является объявленныйPRIMARY KEY, а строка хранится прямо в листе. Для таблиц с естественным составным ключом и короткими строками это убирает целый уровень косвенности и один индекс.- Порядок колонок в строке имеет значение. SQLite декодирует запись слева направо; чтобы добраться до 40-й колонки, надо пропарсить 39 предыдущих. Ставьте часто читаемые и мелкие колонки в начало, толстые BLOB — в конец.
-- Обычная таблица: скрытый rowid + отдельное B-tree для PK
CREATE TABLE events_a (
id INTEGER PRIMARY KEY, -- ЭТО алиас rowid, отдельного индекса нет
user_id INTEGER NOT NULL,
ts INTEGER NOT NULL,
payload BLOB
);
-- WITHOUT ROWID: строка живёт в листе индекса первичного ключа
CREATE TABLE events_b (
user_id INTEGER NOT NULL,
ts INTEGER NOT NULL,
payload BLOB,
PRIMARY KEY (user_id, ts)
) WITHOUT ROWID;
-- Выборка "все события пользователя за период" читает подряд лежащие
-- страницы одного дерева — без единого лишнего спуска.
Записи длиннее порога (примерно page_size - 35 байт для листа таблицы) режутся: начало остаётся в листе, хвост уходит в цепочку overflow-страниц. Практический эффект: если вы храните 200-килобайтные JSON-блобы в таблице, по которой часто делаете SELECT id, status FROM ..., вы всё равно тащите overflow-цепочки только тогда, когда явно читаете эту колонку — но фрагментация файла растёт, а VACUUM становится дорогим. Толстые блобы лучше выносить в отдельную таблицу.
Слои: от SQL до системного вызова
выбор плана по sqlite_stat1"] C --> B["Байткод VDBE
EXPLAIN показывает именно его"] B --> BT["B-tree layer
курсоры, поиск, вставка, split"] BT --> PG["Pager
page cache, транзакции, журнал, блокировки"] PG --> V["VFS
абстракция ОС: open/read/write/fsync/lock"] V --> OS1["unix-VFS"] V --> OS2["win32-VFS"] V --> OS3["свой VFS: память, шифрование,
объектное хранилище, WASM/OPFS"]
Ключевые вещи, которые стоит запомнить про эту схему:
- VDBE — настоящая виртуальная машина.
EXPLAIN SELECT ...печатает её ассемблер. Для отладки обычно нуженEXPLAIN QUERY PLAN— человекочитаемая сводка, но при разборе тонких случаев (например, «почему сортировка не убралась») полныйEXPLAINбесценен. - VFS — точка расширения. Именно через неё живут шифрующие обёртки (SQLCipher), браузерная сборка на OPFS, чтение баз прямо из S3 по HTTP Range-запросам (
sql.js-httpvfs) и in-memory базы. Если вам нужна «SQLite поверх чего-то странного» — вы пишете VFS, а не патчите ядро. - Pager — место, где живут все гарантии долговечности. Всё, что мы дальше обсуждаем про журналы и блокировки, — это он.
Транзакции: rollback journal против WAL
Изначальный механизм — rollback journal. Перед изменением страницы её исходная копия пишется в файл -journal, затем правится основной файл. При падении оставшийся журнал воспроизводится в обратную сторону. Модель блокировок — файловая, с пятью состояниями:
Главная беда этой модели — писатель на этапе PENDING → EXCLUSIVE полностью останавливает читателей, а долгий читатель полностью останавливает писателя.
WAL (write-ahead log, с версии 3.7.0, 2010) переворачивает схему: изменённые страницы дописываются в конец файла -wal, основной файл не трогается до checkpoint. Читатель в момент старта транзакции запоминает mxFrame — номер последнего кадра, зафиксированного на этот момент, — и читает мир строго по состоянию на этот кадр. Это и есть snapshot isolation, только реализованный копированием страниц, а не версионированием строк (сравните с MVCC в PostgreSQL — разбор в статье про транзакции и изоляцию).
писатель его не потревожил R2->>WAL: BEGIN → фиксирует mxFrame = 4 R2->>WAL: чтение стр.7 → находит кадр 3 R1->>WAL: END (снимок отпущен) W->>DB: wal_checkpoint: перенос кадров 1..4 в main.db Note over WAL,DB: Checkpoint не может обогнать самого
старого живого читателя
Практические следствия WAL, которые кусают в проде:
- Читатели не блокируют писателя, писатель не блокирует читателей. Но писатель ровно один. Это фундаментальное свойство, а не настройка. Все записи в базу сериализуются.
- База — это три файла:
main.db,main.db-wal,main.db-shm. Скопировать только первый — почти гарантированно получить устаревшие или битые данные. Копируйте черезVACUUM INTOили backup API. -shm— это разделяемая память. Значит, все процессы, работающие с базой, обязаны быть на одном хосте. WAL принципиально не работает по NFS/SMB. Это самая частая причина «SQLite повредился» — см. каталог способов испортить базу на sqlite.org/howtocorrupt.html.- Checkpoint не обгоняет старого читателя. Одна забытая открытая транзакция на чтение (например, курсор в фоновом отчёте) — и
-walрастёт гигабайтами, потому что переносить кадры некуда.
Продакшн-конфигурация: PRAGMA, которые надо ставить всегда
Дефолты SQLite выбраны под совместимость с 2004 годом, а не под ваш сервер. Вот боевой набор для серверного приложения:
PRAGMA journal_mode = WAL; -- параллельные читатели; персистентно, ставится один раз
PRAGMA synchronous = NORMAL; -- fsync только на checkpoint, не на каждый commit
PRAGMA busy_timeout = 5000; -- 5 с ждать освобождения писателя вместо мгновенного SQLITE_BUSY
PRAGMA foreign_keys = ON; -- ВНЕЗАПНО: по умолчанию ВЫКЛЮЧЕНЫ, и это per-connection
PRAGMA cache_size = -64000; -- 64 МБ page cache (минус = килобайты, плюс = страницы)
PRAGMA temp_store = MEMORY; -- временные B-tree для сортировок в RAM
PRAGMA mmap_size = 268435456; -- 256 МБ через mmap: чтение без copy в user space
PRAGMA wal_autocheckpoint = 1000; -- checkpoint каждые ~4 МБ WAL (дефолт, обычно ок)
Разбор нетривиальных пунктов:
synchronous = NORMAL в режиме WAL безопасен. Официальная документация (sqlite.org/pragma.html#pragma_synchronous) прямо утверждает: в WAL-режиме NORMAL не может привести к повреждению базы, риск — только потеря нескольких последних транзакций при отключении питания (не при падении процесса!). Для 95% приложений это правильный обмен: FULL делает fsync на каждый commit и режет пропускную способность записи в 10–50 раз. Для платёжного реестра — оставляйте FULL.
foreign_keys выключены по умолчанию и настраиваются на соединение. Это классическая ловушка: разработчик проверил в CLI (где включил вручную), а пул соединений в приложении их не включает. Ставьте в callback инициализации соединения.
mmap_size имеет цену. При включённом mmap ошибка ввода-вывода приходит не кодом возврата, а сигналом SIGBUS, который обрушит процесс. На локальном NVMe это приемлемо; на сетевом или подозрительном хранилище — не включайте.
PRAGMA optimize стоит запускать периодически (раз в несколько часов или при закрытии долгоживущего соединения): он при необходимости досчитает статистику ANALYZE по таблицам, где она устарела.
Транзакции: BEGIN IMMEDIATE вместо BEGIN
Это, пожалуй, самая важная практическая деталь всей статьи.
BEGIN; -- DEFERRED: блокировка берётся лениво
SELECT balance FROM acct WHERE id = 1; -- взяли read-снимок
UPDATE acct SET balance = ... WHERE id = 1; -- пытаемся апгрейдиться до писателя
Если между SELECT и UPDATE кто-то другой успел записать, апгрейд снимка невозможен, и SQLite возвращает SQLITE_BUSY_SNAPSHOT немедленно, не вызывая busy-handler — ждать бессмысленно, снимок уже устарел. Ваш busy_timeout = 5000 здесь не поможет, и вы получите загадочные «database is locked» под нагрузкой.
Правильно: если транзакция будет писать — открывайте её сразу как пишущую.
BEGIN IMMEDIATE; -- сразу берём write-lock, busy_timeout работает штатно
SELECT balance FROM acct WHERE id = 1;
UPDATE acct SET balance = ... WHERE id = 1;
COMMIT;
Практическая архитектура для веб-приложения на SQLite: два пула соединений — один на N читателей (WAL позволяет параллельно), один писатель на одно соединение с BEGIN IMMEDIATE. Так устроены production-обвязки вроде better-sqlite3 (Node), rusqlite + deadpool (Rust), Litestack (Rails).
import sqlite3
import threading
def connect(path: str, readonly: bool = False) -> sqlite3.Connection:
"""Соединение с боевыми настройками. Одно соединение — один поток."""
uri = f"file:{path}?mode={'ro' if readonly else 'rwc'}"
conn = sqlite3.connect(uri, uri=True, isolation_level=None, timeout=5.0)
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA synchronous = NORMAL")
conn.execute("PRAGMA busy_timeout = 5000")
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA cache_size = -64000")
conn.execute("PRAGMA temp_store = MEMORY")
return conn
# Единственный писатель на процесс, защищённый мьютексом:
# конкуренция всё равно сериализуется в БД, но так мы не тратим
# время на SQLITE_BUSY-ретраи и держим предсказуемую задержку.
_write_lock = threading.Lock()
_writer = connect("app.db")
def transfer(src: int, dst: int, amount: int) -> None:
with _write_lock:
_writer.execute("BEGIN IMMEDIATE") # не DEFERRED!
try:
row = _writer.execute(
"SELECT balance FROM acct WHERE id = ?", (src,)
).fetchone()
if row is None or row[0] < amount:
raise ValueError("недостаточно средств")
_writer.execute("UPDATE acct SET balance = balance - ? WHERE id = ?", (amount, src))
_writer.execute("UPDATE acct SET balance = balance + ? WHERE id = ?", (amount, dst))
_writer.execute("COMMIT")
except Exception:
_writer.execute("ROLLBACK")
raise
Типизация: динамическая по умолчанию, строгая по требованию
SQLite исторически использует type affinity: объявленный тип колонки — это лишь подсказка о предпочтительном представлении, а положить туда можно что угодно.
CREATE TABLE t (n INTEGER, s TEXT);
INSERT INTO t VALUES ('не число', 42); -- ПРОЙДЁТ
SELECT typeof(n), typeof(s) FROM t; -- text | integer
Для скриптов это удобство, для продакшна — источник тихих багов. С версии 3.37.0 (2021) есть STRICT:
CREATE TABLE t (
id INTEGER PRIMARY KEY,
n INTEGER NOT NULL,
s TEXT NOT NULL,
ts INTEGER NOT NULL DEFAULT (unixepoch()),
meta TEXT CHECK (json_valid(meta)) -- JSON храним как TEXT + проверка
) STRICT;
INSERT INTO t (n, s) VALUES ('не число', 'ok'); -- Error: cannot store TEXT value in INTEGER column
Используйте STRICT во всех новых схемах. Допустимые типы в strict-таблицах: INT, INTEGER, REAL, TEXT, BLOB, ANY. Обратите внимание: нет BOOLEAN, DATETIME, VARCHAR(n) — их и раньше не было по-настоящему, STRICT просто перестаёт делать вид.
Отдельная боль — отсутствие типа даты. Канон: хранить Unix-время в INTEGER (секунды или миллисекунды) либо ISO-8601 в TEXT. Первое компактнее и корректно сортируется числами; второе читается глазами. Функции unixepoch(), datetime(ts,'unixepoch'), strftime() работают с обоими.
Планы выполнения и индексы
Планировщик SQLite (NGQP, с 3.8.0) — стоимостной, но опирается на очень скромную статистику. ANALYZE заполняет sqlite_stat1 строками вида «в индексе idx на каждое различное значение первой колонки приходится в среднем K строк». Со сборкой SQLITE_ENABLE_STAT4 появляются гистограммы (sqlite_stat4), но в стандартном amalgamation их нет.
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
status TEXT NOT NULL,
total INTEGER NOT NULL,
created INTEGER NOT NULL
) STRICT;
EXPLAIN QUERY PLAN
SELECT id, total FROM orders WHERE user_id = 42 AND status = 'paid' ORDER BY created DESC;
-- SCAN orders
-- USE TEMP B-TREE FOR ORDER BY ← полный перебор + сортировка на диске
CREATE INDEX orders_user_status_created
ON orders (user_id, status, created DESC);
EXPLAIN QUERY PLAN
SELECT id, total FROM orders WHERE user_id = 42 AND status = 'paid' ORDER BY created DESC;
-- SEARCH orders USING INDEX orders_user_status_created (user_id=? AND status=?)
-- ← сортировка исчезла: индекс уже выдаёт нужный порядок
Идиомы, которые дают больше всего:
-- 1. Покрывающий индекс: все нужные колонки в индексе → таблица не читается вообще.
CREATE INDEX orders_cover ON orders (user_id, status, created DESC, total);
-- В плане появится "USING COVERING INDEX orders_cover"
-- 2. Частичный индекс: индексируем только горячее подмножество.
-- Для 2% незакрытых заказов индекс будет в 50 раз меньше.
CREATE INDEX orders_open ON orders (created) WHERE status IN ('new', 'processing');
-- 3. Индекс по выражению: под конкретный предикат.
CREATE INDEX orders_day ON orders (date(created, 'unixepoch'));
-- 4. Явное указание, если планировщик ошибся (использовать редко и осознанно):
SELECT * FROM orders INDEXED BY orders_open WHERE status = 'new';
Что важно знать про особенности именно SQLite:
LIKE 'abc%'использует индекс только приcase_sensitive_likeили при колонкеCOLLATE NOCASE— из-за того, что дефолтныйLIKEрегистронезависим для ASCII. Для префиксного поиска надёжнееx >= 'abc' AND x < 'abd'или FTS5.- Один индекс на таблицу в одном соединении в скане (SQLite умеет
OR-оптимизацию через union индексов, но не bitmap-scan как PostgreSQL). Составные индексы важнее, чем в Postgres. ANALYZEне запускается сам. После массовой загрузки данных обязательно вызовите его, иначе планировщик будет принимать решения по эвристикам «из воздуха».- Инструмент
sqlite3_analyzerи команда.expertв CLI подсказывают недостающие индексы.
Сложность операций стандартна для B-tree: поиск/вставка/удаление — $O(\log_B n)$ обращений к страницам, где $B$ — число ключей в странице (для 4 КБ и коротких ключей это сотни), последовательное сканирование диапазона — $O(k/B)$ страниц на $k$ строк.
Расширения: FTS5, JSON, R-Tree, векторы
Встраиваемость не означает бедность. Стандартная сборка включает:
-- Полнотекстовый поиск: FTS5 с BM25-ранжированием
CREATE VIRTUAL TABLE docs_fts USING fts5(
title, body,
content = 'docs', content_rowid = 'id', -- внешнее содержимое: не дублируем данные
tokenize = "unicode61 remove_diacritics 2"
);
SELECT d.id, d.title, bm25(docs_fts) AS rank
FROM docs_fts JOIN docs d ON d.id = docs_fts.rowid
WHERE docs_fts MATCH 'облач* NEAR(миграция, 5)'
ORDER BY rank
LIMIT 20;
-- JSON: функции json_* и бинарный JSONB (с 3.45.0, 2024)
SELECT json_extract(meta, '$.plan') AS plan, count(*)
FROM accounts
WHERE json_extract(meta, '$.active') = 1
GROUP BY 1;
-- Генерируемая колонка + индекс поверх JSON-поля
ALTER TABLE accounts ADD COLUMN plan TEXT
GENERATED ALWAYS AS (json_extract(meta, '$.plan')) VIRTUAL;
CREATE INDEX accounts_plan ON accounts(plan);
-- R-Tree: пространственные диапазонные запросы
CREATE VIRTUAL TABLE geo USING rtree(id, min_lat, max_lat, min_lon, max_lon);
SELECT id FROM geo WHERE min_lat <= 55.8 AND max_lat >= 55.7
AND min_lon <= 37.7 AND max_lon >= 37.5;
Отдельно стоит знать про sqlite-vec — расширение векторного поиска (преемник sqlite-vss), позволяющее хранить эмбеддинги и искать ближайших соседей прямо в файле базы. Для локального RAG на десятки тысяч документов этого достаточно, и не нужно поднимать отдельный сервис; когда объём переваливает за миллионы векторов — переезжайте на специализированные решения из статьи про векторные базы.
Границы: где SQLite ломается
Честный список, во многом совпадающий с официальным «When to use» (sqlite.org/whentouse.html):
| Ограничение | Суть | Обход / когда критично |
|---|---|---|
| Один писатель | Все INSERT/UPDATE/DELETE сериализуются глобально |
Батчинг в одну транзакцию; очередь записи. Критично при >нескольких тысяч независимых записей/с |
| Один хост | -shm — разделяемая память; WAL по сети не работает |
Litestream/LiteFS/rqlite или переезд на сервер |
| Нет сети и пользователей | Нет ролей, GRANT, TLS, аутентификации | Доступ через ваше приложение; ОС-права на файл |
Ограниченный ALTER TABLE |
Только RENAME, ADD COLUMN, DROP COLUMN (с 3.35.0) |
Миграция «12 шагов»: новая таблица → копирование → rename |
| Нет параллелизма запросов | Один SELECT = один поток |
Для аналитики берите DuckDB |
| Слабая статистика | Нет гистограмм без STAT4 | ANALYZE, составные индексы, INDEXED BY |
RIGHT/FULL JOIN |
Появились только в 3.39.0 (2022) | Проверяйте версию в целевом окружении |
| Практический потолок размера | Формально 281 ТБ, реально комфортно до сотен ГБ | Дальше — стоимость VACUUM, бэкапа, отсутствие партиционирования |
Отдельно: миф «SQLite не выдержит нагрузку веб-сайта» верен только наполовину. Сайт с читающей нагрузкой SQLite тянет колоссальную — сама sqlite.org работает на SQLite. Ломается всё именно на конкурентной записи. Простое правило-ориентир: если ваш профиль — до сотен записей в секунду и любое количество чтений с одного хоста, SQLite подойдёт; если тысячи независимых писателей — не подойдёт, никакие настройки не спасут.
Долговечность, бэкапы и «SQLite по сети»
# ПРАВИЛЬНО: консистентный снимок работающей базы (и заодно дефрагментация)
sqlite3 app.db "VACUUM INTO '/backup/app-$(date +%F-%H%M).db'"
# ПРАВИЛЬНО: инкрементальный backup API через CLI
sqlite3 app.db ".backup '/backup/app.db'"
# НЕПРАВИЛЬНО: cp/rsync живого файла — потеря -wal и/или разорванная страница
cp app.db /backup/ # так не делайте
# Проверка целостности (полная — читает всю базу, quick_check дешевле)
sqlite3 app.db "PRAGMA integrity_check;"
sqlite3 app.db "PRAGMA foreign_key_check;"
# Ручной checkpoint и усечение WAL перед архивированием
sqlite3 app.db "PRAGMA wal_checkpoint(TRUNCATE);"
Экосистема «SQLite как серверная БД» за последние годы выросла и заслуживает знания:
- Litestream — демон, который непрерывно стримит WAL-кадры в S3/GCS. Даёт point-in-time recovery с окном в секунды и стоит доли доллара в месяц. Не даёт read-реплик.
- LiteFS — FUSE-файловая система, перехватывающая транзакции и реплицирующая их на другие узлы. Даёт read-реплики с одним лидером-писателем.
- rqlite и dqlite — SQLite под Raft-консенсусом. Настоящая отказоустойчивость ценой сетевой задержки на запись; тема пересекается с материалом про репликацию и шардирование.
- libSQL / Turso, Cloudflare D1 — форки и managed-сервисы, добавляющие сетевой протокол и edge-репликацию.
Ключевая мысль: это разные компромиссы, а не «улучшенный SQLite». Litestream — про дешёвое восстановление. rqlite — про HA и линеаризуемость. Ни один не убирает ограничение «один писатель».
Ландшафт встраиваемых БД
SQLite — не единственный вариант, и по многим задачам не лучший.
DuckDB: «SQLite для аналитики»
DuckDB (CWI, Амстердам; версия 1.0 — июнь 2024) — встраиваемая колоночная СУБД с векторизованным исполнением. Тот же принцип «библиотека в вашем процессе», но противоположная точка в пространстве компромиссов: колоночное хранение, многопоточное исполнение, оптимизация под сканы и агрегации.
import duckdb
# Запрос прямо по Parquet-файлам в S3 — без загрузки в БД
con = duckdb.connect()
con.sql("INSTALL httpfs; LOAD httpfs;")
con.sql("""
SELECT date_trunc('day', ts) AS d,
count(*) AS events,
approx_count_distinct(user_id) AS uniq
FROM read_parquet('s3://logs/2026/07/*.parquet')
WHERE status >= 500
GROUP BY 1 ORDER BY 1
""").show()
# И даже так: JOIN между Parquet, pandas DataFrame и таблицей SQLite
con.sql("ATTACH 'app.db' AS app (TYPE sqlite)")
con.sql("SELECT u.plan, count(*) FROM read_parquet('s3://logs/*.parquet') l "
"JOIN app.users u ON u.id = l.user_id GROUP BY 1")
Правило выбора между ними максимально простое: SQLite — когда вы трогаете единицы строк за запрос; DuckDB — когда вы трогаете миллионы. Агрегация по 100 млн строк, где SQLite думает минуты, у DuckDB занимает секунды. Обратно: точечный SELECT ... WHERE id = ? в DuckDB медленнее. Идея колоночного хранения подробно разбирается в статье про ClickHouse и OLAP.
RocksDB и LMDB: когда SQL не нужен
Если ваш доступ — исключительно «дай значение по ключу» или «пройди диапазон ключей», SQL-слой становится накладными расходами. Здесь живут два принципиально разных движка:
- RocksDB (Meta, форк LevelDB) — LSM-дерево. Запись идёт в memtable и последовательно на диск; чтение может потребовать просмотра нескольких уровней. Отличная пропускная способность записи, встроенное сжатие, но write amplification и compaction-паузы. Это фундамент MyRocks, TiKV, Kafka Streams, ранних версий CockroachDB.
- LMDB (Symas/OpenLDAP, Howard Chu) — memory-mapped B+tree с copy-on-write. Чтение — буквально разыменование указателя в mmap, без копирования и без блокировок; MVCC с одним писателем. Феноменально быстрое чтение, но файл занимает место с запасом и write amplification на мелких записях выше, чем у LSM. Идейный потомок в Go —
bbolt, на котором работает etcd.
Компромисс LSM против B-tree — один из фундаментальных в мире хранилищ; подробнее в статье про графовые и key-value БД.
Честное сравнение
| Ось | SQLite | DuckDB | RocksDB | LMDB | PostgreSQL |
|---|---|---|---|---|---|
| Модель данных | Реляционная, SQL, динамическая типизация (+STRICT) |
Реляционная, SQL, колоночная | Упорядоченный key-value, байты | Упорядоченный key-value, байты | Реляционная, SQL, строгие типы |
| Профиль нагрузки | OLTP, точечные операции | OLAP, сканы и агрегации | Запись-интенсивный KV | Чтение-интенсивный KV | Универсальный OLTP |
| Консистентность | ACID, serializable (один писатель) | ACID, snapshot | Атомарность батча, без SQL-транзакций | ACID, single-writer MVCC | ACID, полный набор уровней изоляции |
| Конкурентность | N читателей + 1 писатель, 1 хост | Многопоточное чтение, 1 писатель | Многопоточно, 1 процесс | N читателей + 1 писатель | Тысячи соединений, много хостов |
| Масштабирование | Вертикально; Litestream/rqlite для HA | Вертикально; отлично параллелит ядра | Вертикально; шардинг руками | Вертикально; предел = RAM+диск | Реплики, шардинг, пулеры |
| Эксплуатация | Нулевая: файл + библиотека | Нулевая: файл + библиотека | Нужен тюнинг compaction и уровней | Почти нулевая, но нужен map_size |
Полноценный: бэкапы, vacuum, мониторинг, апгрейды |
| Стоимость | 0 $ лицензия, 0 $ инфраструктура | 0 $ | 0 $ | 0 $ | 0 $ лицензия, но 50 $–2000/мес managed + время SRE |
| Когда НЕ брать | Много конкурентных писателей, доступ по сети, аналитика на сотнях млн строк | Точечный OLTP, частые мелкие UPDATE, многопользовательская запись | Нужны запросы сложнее «по ключу», нужны индексы | Датасет много больше RAM, тяжёлая запись мелкими порциями | Однопользовательское приложение, мобильный клиент, CLI-инструмент, тест-окружение |
Где встраиваемая БД объективно сильнее сервера
Сведём воедино сценарии, в которых выбор SQLite/DuckDB — не компромисс, а лучшее инженерное решение:
- Клиентские приложения. Мобильные, десктопные, CLI. Здесь сервер физически невозможен, а самописный формат файла проиграет по надёжности с разгромным счётом.
- Формат файла приложения. Проекты Fossil SCM, Adobe Lightroom,
.sqlite-каталоги — «SQLite as an application file format» (sqlite.org/appfileformat.html). Вы получаете транзакционность, частичное обновление и запросы вместо «прочитать 200 МБ XML целиком». - Тесты. База в памяти (
:memory:), создаваемая за микросекунды, — на порядок быстрее и надёжнее контейнера с Postgres. Оговорка: диалекты различаются, поэтому для приложений на серверной СУБД такой подход опасен — тестируйте на том же движке, что в проде. - Edge и IoT. Нет постоянной сети, ограничены ресурсы, нужна локальная очередь с гарантией доставки.
- Read-heavy сайты и внутренние сервисы. Кэш, конфигурация, feature-флаги, аналитика продукта на одном хосте. Задержка на чтение падает с миллисекунды до микросекунд, инфраструктура исчезает.
- Аналитика на ноутбуке. DuckDB по Parquet заменяет связку «поднять Spark-кластер» для датасетов до сотен гигабайт.
- Аудиторские и архивные данные. Стабильность формата до 2050-го и рекомендация Библиотеки Конгресса — сильные аргументы в регуляторных задачах.
Типичные ошибки в проде
- Оставить
journal_mode = delete(дефолт). Любой читатель блокирует писателя. ОдинPRAGMA journal_mode = WALчасто убирает 90% жалоб на «database is locked». BEGINвместоBEGIN IMMEDIATEв пишущих транзакциях. ДаётSQLITE_BUSY_SNAPSHOT, который не лечится таймаутом. Разобрано выше.busy_timeout = 0. Дефолт. Первый же конфликт — исключение вместо ожидания.- Соединение, разделяемое между потоками без синхронизации. SQLite собирается в режиме
serializedи не упадёт, но вы получите неявную сериализацию и загадочные результаты при чередующихся курсорах. Правило: одно соединение — один поток. cpживой базы в бэкап. Пропущенный-wal= потеря последних транзакций, а иногда и битый файл.- База на NFS/SMB/сетевом томе. Сломанные advisory-локи → повреждение. Единственный корректный вариант — локальный диск.
- Забытый долгий читатель.
-walрастёт неограниченно, потому что checkpoint не может обогнать старый снимок. Мониторьте размер-wal. - Отсутствие
ANALYZEпосле загрузки данных. Планировщик выбирает полные сканы там, где есть отличный индекс. - Вставка построчно без транзакции. Каждая строка — отдельный commit с fsync. Разница между 500 вставок/с и 500 000 вставок/с — это одна обёртка
BEGIN ... COMMITвокруг цикла. - Хранение больших BLOB в основной таблице. Раздувает файл и overflow-цепочки; выносите в отдельную таблицу или на файловую систему/объектное хранилище.
Быстрый замер, который стоит прогнать самому, чтобы почувствовать масштаб эффекта транзакций:
import sqlite3, time, os
def bench(n=200_000, wrap_in_txn=True, sync="NORMAL"):
if os.path.exists("bench.db"):
os.remove("bench.db")
c = sqlite3.connect("bench.db", isolation_level=None)
c.execute("PRAGMA journal_mode=WAL")
c.execute(f"PRAGMA synchronous={sync}")
c.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT) STRICT")
rows = [(i, f"value-{i}") for i in range(n)]
t0 = time.perf_counter()
if wrap_in_txn:
c.execute("BEGIN IMMEDIATE")
c.executemany("INSERT INTO t VALUES (?, ?)", rows)
c.execute("COMMIT")
else:
for r in rows: # каждый INSERT — отдельная транзакция
c.execute("INSERT INTO t VALUES (?, ?)", r)
dt = time.perf_counter() - t0
print(f"txn={wrap_in_txn} sync={sync}: {n/dt:,.0f} строк/с")
bench(wrap_in_txn=True) # порядок сотен тысяч строк/с
bench(n=20_000, wrap_in_txn=False) # порядок тысяч строк/с
bench(n=20_000, wrap_in_txn=False, sync="FULL") # ещё на порядок меньше
Порядки величин на обычном NVMe: батч в одной транзакции — сотни тысяч строк в секунду; построчно с synchronous=NORMAL — единицы тысяч; построчно с FULL — сотни. Все три числа — про один и тот же движок; разница целиком в том, как вы его используете.
Мини-итог
- Встраиваемая БД — это библиотека в вашем процессе. Она выигрывает у сервера там, где сетевой хоп и отдельная эксплуатация не окупаются: клиенты, формат файла, edge, тесты, read-heavy одиночный хост.
- Файл SQLite — массив страниц; каждая таблица и индекс — B-tree. Отсюда
WITHOUT ROWID, overflow-страницы и важность составных индексов. - WAL даёт snapshot-изоляцию и «читатели не мешают писателю», но требует одного хоста и оставляет ровно одного писателя. Это главный ограничитель, и он архитектурный, а не настроечный.
- Минимальный продакшн-набор:
WAL,synchronous=NORMAL,busy_timeout,foreign_keys=ON,STRICT-таблицы,BEGIN IMMEDIATEдля записи, регулярныйANALYZE, бэкап черезVACUUM INTO. - DuckDB — не конкурент SQLite, а его аналитический двойник: единицы строк против миллионов. RocksDB и LMDB — для чистого key-value, где SQL лишний.
- Переходите на серверную СУБД, когда появляются конкурентные писатели, доступ по сети или требование к отказоустойчивости на нескольких узлах, — а не потому что «SQLite несерьёзный».
Источники
- SQLite: Appropriate Uses For SQLite — официальный разбор границ применимости.
- SQLite: Write-Ahead Logging — устройство WAL, checkpoint, ограничения.
- SQLite: Database File Format — побайтовое описание формата.
- SQLite: How To Corrupt An SQLite Database File — каталог способов потерять данные.
- SQLite: How SQLite Is Tested — методология тестирования.
- SQLite: The Next-Generation Query Planner — как работает NGQP.
- SQLite: PRAGMA statements — полный справочник настроек.
- DuckDB documentation и статья Raasveldt, Mühleisen «DuckDB: an Embeddable Analytical Database» (SIGMOD 2019).
- Litestream, LiteFS, rqlite — репликация и HA поверх SQLite.
- Howard Chu, «LMDB: Lightning Memory-Mapped Database» — symas.com/lmdb.
- RocksDB Wiki — тюнинг LSM и compaction.
- Martin Kleppmann, «Designing Data-Intensive Applications», глава 3 — LSM против B-tree.
Что дальше
Мы несколько раз упирались в вопрос «почему планировщик выбрал скан вместо индекса» и «какой индекс здесь правильный». Следующая статья разбирает это системно и уже для всех движков: Индексы и планы выполнения: B-tree, hash, GIN, покрывающие индексы.