← все статьи
8 мин

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 не просто «достаточно хорош». Он лучше.

← все статьи Следующая →Docker Compose в 2026: 12 паттернов для production