Базы данных SQLite и встраиваемые БД: где они сильнее «взрослых» серверов
0%

SQLite и встраиваемые БД: где они сильнее «взрослых» серверов

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).

Три свойства, которые делают его особенным:

  1. Public domain. Не MIT, не BSD — авторы отказались от прав вообще. Никаких юридических вопросов при встраивании во что угодно.
  2. Тестирование уровня авионики. 100% MC/DC-покрытие ветвлений (стандарт DO-178B), объём тестового кода примерно в 590 раз больше объёма самой библиотеки, отдельные наборы для fuzz, out-of-memory и I/O-ошибок (sqlite.org/testing.html). Это не «хорошо покрытый проект», это другая лига.
  3. Формат файла стабилен с 2004 года, авторы публично обязались поддерживать его до 2050-го (sqlite.org/lts.html). База, записанная SQLite 3.0, читается сегодняшней версией. Для архивных форматов это решающий аргумент — Библиотека Конгресса США рекомендует SQLite как формат долговременного хранения.

Как устроен файл: страницы, B-tree, overflow

Вся база — один файл, который логически представляет собой массив страниц одинакового размера (по умолчанию 4096 байт с версии 3.12.0). Страница номер N лежит по смещению (N-1) * page_size. Никакого каталога экстентов, никаких табличных пространств.

Раскладка файла SQLite: страницы, B-tree, overflow

Каждая таблица — это 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 до системного вызова

Ключевые вещи, которые стоит запомнить про эту схему:

  • 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 — разбор в статье про транзакции и изоляцию).

WAL: кадры, wal-index и снимки читателей

Практические следствия 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 — не компромисс, а лучшее инженерное решение:

  1. Клиентские приложения. Мобильные, десктопные, CLI. Здесь сервер физически невозможен, а самописный формат файла проиграет по надёжности с разгромным счётом.
  2. Формат файла приложения. Проекты Fossil SCM, Adobe Lightroom, .sqlite-каталоги — «SQLite as an application file format» (sqlite.org/appfileformat.html). Вы получаете транзакционность, частичное обновление и запросы вместо «прочитать 200 МБ XML целиком».
  3. Тесты. База в памяти (:memory:), создаваемая за микросекунды, — на порядок быстрее и надёжнее контейнера с Postgres. Оговорка: диалекты различаются, поэтому для приложений на серверной СУБД такой подход опасен — тестируйте на том же движке, что в проде.
  4. Edge и IoT. Нет постоянной сети, ограничены ресурсы, нужна локальная очередь с гарантией доставки.
  5. Read-heavy сайты и внутренние сервисы. Кэш, конфигурация, feature-флаги, аналитика продукта на одном хосте. Задержка на чтение падает с миллисекунды до микросекунд, инфраструктура исчезает.
  6. Аналитика на ноутбуке. DuckDB по Parquet заменяет связку «поднять Spark-кластер» для датасетов до сотен гигабайт.
  7. Аудиторские и архивные данные. Стабильность формата до 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 несерьёзный».

Источники

Что дальше

Мы несколько раз упирались в вопрос «почему планировщик выбрал скан вместо индекса» и «какой индекс здесь правильный». Следующая статья разбирает это системно и уже для всех движков: Индексы и планы выполнения: B-tree, hash, GIN, покрывающие индексы.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов