SQLite как production-база: когда это не шутка
«SQLite — это для прототипов». Эту фразу произносят уверенно, как аксиому. Между тем SQLite обслуживает production-трафик в Apple, Airbus, Adobe и сотнях тысяч мобильных приложений. В 2025 году Turso построила на нём распределённую базу данных с edge-репликацией. Проблема не в SQLite — проблема в том, что большинство разработчиков просто не знает, как его правильно готовить.
Почему миф живёт
SQLite по умолчанию работает в режиме WAL=OFF, с синхронным journal mode и без connection pooling. Если взять голый файл и начать в него писать из нескольких потоков — получишь SQLITE_BUSY на каждом третьем запросе и запись по 50 RPS. Именно это видят разработчики в первый раз. И именно это не имеет ничего общего с правильно настроенным SQLite.
Другая причина — путаница между ограничениями SQLite и ограничениями конкретного use case. SQLite — это embedded база. Она живёт в процессе приложения. Это и слабость (нет сетевого протокола, нет горизонтального масштабирования записи), и сила (нет сетевых задержек, нет сериализации, нет round-trip).
WAL mode: первое, что нужно включить
Write-Ahead Logging — режим, который меняет SQLite из «файла с мьютексом» в настоящую конкурентную базу. В WAL mode читатели не блокируют писателей и наоборот. Несколько читателей работают параллельно без блокировок вообще.
-- Включить один раз при инициализации БД
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL; -- безопасно с WAL, быстрее FULL
PRAGMA cache_size = -64000; -- 64 MB page cache (отрицательное = KB)
PRAGMA temp_store = MEMORY;
PRAGMA mmap_size = 268435456; -- 256 MB memory-mapped I/O
PRAGMA busy_timeout = 5000; -- ждать 5 сек вместо мгновенного BUSY
После включения WAL mode производительность чтения на типичном read-heavy сервисе вырастает в 3–5 раз. Запись — в 2–3 раза. Это не магия, это просто правильные настройки.
Важный момент: PRAGMA synchronous = NORMAL с WAL безопасен. Вы можете потерять транзакцию при сбое питания, но не повредить базу — WAL восстановит консистентное состояние при следующем открытии.
Python + aiosqlite + SQLAlchemy: правильный connection pool
Одна из главных ошибок — использование одного соединения на весь процесс или, наоборот, создание нового соединения на каждый запрос. SQLite поддерживает несколько читателей одновременно, но только одного писателя. Правильная стратегия: один write-соединение, пул read-соединений.
import asyncio
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
# Отдельные движки для чтения и записи
WRITE_ENGINE = create_async_engine(
"sqlite+aiosqlite:///./app.db",
connect_args={
"check_same_thread": False,
"timeout": 5,
},
pool_size=1, # SQLite: только один writer
max_overflow=0,
echo=False,
)
READ_ENGINE = create_async_engine(
"sqlite+aiosqlite:///./app.db",
connect_args={
"check_same_thread": False,
"timeout": 5,
},
pool_size=10, # Несколько reader-соединений
max_overflow=5,
echo=False,
)
async def _init_pragmas(conn):
"""Применить PRAGMA при каждом новом соединении."""
await conn.execute(text("PRAGMA journal_mode = WAL"))
await conn.execute(text("PRAGMA synchronous = NORMAL"))
await conn.execute(text("PRAGMA cache_size = -64000"))
await conn.execute(text("PRAGMA temp_store = MEMORY"))
await conn.execute(text("PRAGMA mmap_size = 268435456"))
await conn.execute(text("PRAGMA busy_timeout = 5000"))
# Регистрируем event на connect
from sqlalchemy import event
@event.listens_for(READ_ENGINE.sync_engine, "connect")
def on_read_connect(dbapi_conn, _):
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA journal_mode = WAL")
cursor.execute("PRAGMA synchronous = NORMAL")
cursor.execute("PRAGMA cache_size = -64000")
cursor.execute("PRAGMA busy_timeout = 5000")
cursor.close()
ReadSession = sessionmaker(READ_ENGINE, class_=AsyncSession, expire_on_commit=False)
WriteSession = sessionmaker(WRITE_ENGINE, class_=AsyncSession, expire_on_commit=False)
@asynccontextmanager
async def get_read_session():
async with ReadSession() as session:
yield session
@asynccontextmanager
async def get_write_session():
async with WriteSession() as session:
async with session.begin():
yield session
Litestream: репликация без сервера
Главный аргумент против SQLite в production — «а что если диск умрёт?». Litestream снимает этот аргумент полностью. Это бинарник, который в фоне непрерывно реплицирует WAL-файл в S3, R2, GCS или любое S3-совместимое хранилище. Задержка репликации — секунды. Recovery — минуты.
# litestream.yml
dbs:
- path: /app/data/app.db
replicas:
- type: s3
bucket: my-app-backups
path: db/app.db
region: us-east-1
sync-interval: 1s # реплицировать каждую секунду
retention: 72h # хранить 72 часа WAL-сегментов
retention-check-interval: 1h
# Запуск — Litestream оборачивает ваш процесс
litestream replicate -config litestream.yml -- ./myapp
# Восстановление на новом сервере
litestream restore -config litestream.yml /app/data/app.db
В связке с Docker это выглядит так: Litestream как sidecar-процесс через s6-overlay или как init-контейнер в Kubernetes. Точка восстановления — последняя транзакция перед падением. RPO близко к нулю, RTO — 5–10 минут.
Метрики из реального сервиса (analytics dashboard, ~200K записей в день, 2M чтений): репликация в R2 — $0.03/мес за хранилище, задержка репликации — 1–3 секунды, restore с 6 месяцами данных — 4 минуты.
Реальные метрики: latency и throughput
Цифры с production-сервиса — read-heavy API (95% чтение, 5% запись), VPS с NVMe SSD, 4 vCPU, 8 GB RAM, база 2 GB:
# Бенчмарк: простые SELECT по первичному ключу
# SQLite WAL + mmap + cache:
# p50: 0.18 ms
# p95: 0.41 ms
# p99: 0.89 ms
# RPS: 14 000 req/s (10 concurrent readers)
# Сравнение с PostgreSQL на том же железе:
# p50: 0.9 ms (сетевой round-trip + парсинг протокола)
# p95: 2.1 ms
# p99: 4.8 ms
# RPS: 8 500 req/s
# Сложные JOIN-запросы (5 таблиц, агрегация):
# SQLite: p50 = 2.3 ms, p99 = 12 ms
# PostgreSQL: p50 = 1.8 ms, p99 = 9 ms
# (Postgres выигрывает на сложных запросах — query planner лучше)
Для простых lookup-операций SQLite быстрее PostgreSQL в 4–5 раз — за счёт отсутствия сетевого стека. На сложных аналитических запросах PostgreSQL начинает выигрывать, особенно если данных много и нужен параллельный plan execution.
Embedded analytics: отдельный use case
Ещё один паттерн — embedded analytics прямо в продукте. Каждый tenant получает свою SQLite-базу. Изоляция данных бесплатная, бэкапы независимые, запросы не конкурируют между арендаторами.
import aiosqlite
from pathlib import Path
TENANT_DB_DIR = Path("/app/data/tenants")
async def get_tenant_db(tenant_id: str) -> aiosqlite.Connection:
db_path = TENANT_DB_DIR / f"{tenant_id}.db"
conn = await aiosqlite.connect(db_path)
conn.row_factory = aiosqlite.Row
await conn.execute("PRAGMA journal_mode = WAL")
await conn.execute("PRAGMA synchronous = NORMAL")
await conn.execute("PRAGMA cache_size = -32000")
await conn.execute("PRAGMA busy_timeout = 3000")
return conn
async def get_tenant_stats(tenant_id: str, period_days: int = 30):
async with await get_tenant_db(tenant_id) as db:
async with db.execute("""
SELECT
DATE(created_at) AS day,
COUNT(*) AS events,
COUNT(DISTINCT user_id) AS unique_users
FROM events
WHERE created_at >= DATE('now', ? || ' days')
GROUP BY day
ORDER BY day DESC
""", (f"-{period_days}",)) as cursor:
return await cursor.fetchall()
Такой подход используется в Basecamp, Notion (для offline-копий) и во многих SaaS с data isolation требованиями.
Когда SQLite не подходит
Честный разбор требует честного «нет». SQLite — неправильный выбор в следующих ситуациях:
- Write-heavy нагрузка — один writer не масштабируется горизонтально. Если у вас 1000+ записей в секунду от разных процессов, смотрите в сторону PostgreSQL или MySQL.
- Несколько инстансов приложения — SQLite предполагает один процесс на одном сервере. Если у вас 3 реплики за load balancer, каждая должна иметь доступ к одному файлу — это либо shared NFS (медленно и ненадёжно), либо Litestream только для чтения реплик (асинхронная репликация, eventual consistency).
- Сложные concurrent транзакции — SQLite не поддерживает row-level locking. Вся база блокируется на запись. Если у вас паттерн «много одновременных транзакций с пересечением», PostgreSQL с MVCC справится лучше.
- Данные больше RAM — SQLite работает отлично, пока рабочий набор данных помещается в page cache. Если база 100 GB и запросы случайные — производительность упадёт из-за disk I/O.
SQLite — это не упрощённый PostgreSQL. Это другой инструмент с другой моделью конкурентности. Правильно настроенный WAL-mode SQLite с Litestream-репликацией закрывает 80% задач, которые обычно отдают PostgreSQL. Разница — в стоимости инфраструктуры, операционной сложности и скорости разработки. Для read-heavy сервисов до 50M запросов в день на одном сервере — SQLite не просто «достаточно хорош». Он лучше.