SQLite в Go: PRAGMA, WAL, goose и один бинарник

GoSQLitegoosedatabase/sqlАрхитектура

Сайт хранит только лиды - одна таблица. Для этого SQLite идеален: один файл, ноль внешних сервисов, бэкап - cp db/*.db. Но дефолтные настройки SQLite не годятся для веб-приложения.

PRAGMA через DSN, а не через Exec

Ключевая ошибка - делать db.Exec("PRAGMA journal_mode=WAL") после sql.Open. database/sql держит пул соединений, и Exec применит PRAGMA только к одному из них. Остальные 7 соединений останутся с дефолтами.

Правильно - через DSN, применяется к каждому соединению пула:

func NewDB(path string) (*sql.DB, error) {
    dsn := fmt.Sprintf(
        "file:%s"+
            "?_pragma=busy_timeout(10000)"+
            "&_pragma=foreign_keys(ON)"+
            "&_pragma=journal_mode(WAL)"+
            "&_pragma=synchronous(NORMAL)"+
            "&_pragma=temp_store(MEMORY)"+
            "&_pragma=cache_size(-65536)"+
            "&_pragma=hard_heap_limit(16777216)"+
            "&_pragma=auto_vacuum(FULL)"+
            "&_pragma=journal_size_limit(67110000)"+
            "&_pragma=page_size(4096)",
        path,
    )
    db, err := sql.Open("sqlite3", dsn)
    db.SetMaxOpenConns(8)
    db.SetMaxIdleConns(8)
    db.SetConnMaxLifetime(30 * time.Minute)
    return db, err
}

Что даёт каждая PRAGMA:

  • busy_timeout(10000) - ждёт 10s при SQLITE_BUSY вместо мгновенной ошибки. Критично для конкурентных записей.
  • journal_mode(WAL) - читатели не блокируют писателя и наоборот. Без WAL любой INSERT лочит чтения.
  • foreign_keys(ON) - SQLite по умолчанию выключил FK, включаем явно.
  • synchronous(NORMAL) - безопасно с WAL, быстрее FULL без риска.
  • cache_size(-65536) - 64MB кэш в памяти (отрицательное = в KiB).
  • temp_store(MEMORY) - временные таблицы в RAM.
  • hard_heap_limit(16MB) - страховка от разрастания на минимальном VPS.
  • auto_vacuum(FULL) - файл не пухнет после DELETE.
  • page_size(4096) - под 4K страницы ОС.

Пул 8/8 - read-heavy профиль: 8 конкурентных читателей, WAL это позволяет. MaxLifetime 30m переоткрывает соединения, чтобы не держать stale WAL.

Миграции goose через embed.FS

Миграции вшиты в бинарь - на сервере не нужен отдельный goose cli:

// migrations.go
//go:embed migrations
var EmbeddedMigrations embed.FS

func runMigrations(db *sql.DB) error {
    goose.SetBaseFS(EmbeddedMigrations)
    goose.SetDialect("sqlite3")
    ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
    defer cancel()
    return goose.UpContext(ctx, db, "migrations")
}
-- migrations/20260816120000_lead_lead.sql
-- +goose Up
CREATE TABLE lead_lead (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    contact_method TEXT NOT NULL,
    contact TEXT NOT NULL,
    page_url TEXT NOT NULL DEFAULT '',
    message TEXT NOT NULL DEFAULT '',
    utm_source TEXT NOT NULL DEFAULT '',
    utm_medium TEXT NOT NULL DEFAULT '',
    utm_campaign TEXT NOT NULL DEFAULT '',
    utm_content TEXT NOT NULL DEFAULT '',
    utm_term TEXT NOT NULL DEFAULT '',
    consent_at TEXT NOT NULL,
    created_at TEXT NOT NULL
);
CREATE INDEX idx_lead_lead_created_at ON lead_lead(created_at);
-- +goose Down
DROP TABLE lead_lead;

Индекс по created_at - выборка лидов всегда с сортировкой по времени. TEXT NOT NULL DEFAULT '' вместо NULL - проще в Go, нет sql.NullString.

В Dockerfile миграции копируются отдельно и доступны по Caddy не отдается:

COPY --from=builder /app/migrations /app/migrations

Чистый database/sql без ORM

Одна операция - один ExecContext с плейсхолдерами ? (защита от инъекций):

func (s *Lead) Insert(ctx context.Context, lead model.Lead) error {
    _, err := s.db.ExecContext(ctx, `
        INSERT INTO lead_lead (name, contact_method, contact, page_url, message,
            utm_source, utm_medium, utm_campaign, utm_content, utm_term,
            consent_at, created_at)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
        lead.Name, lead.ContactMethod, lead.Contact, lead.PageURL, lead.Message,
        lead.UTM.Source, lead.UTM.Medium, lead.UTM.Campaign, lead.UTM.Content, lead.UTM.Term,
        lead.ConsentAt, lead.CreatedAt,
    )
    return err
}

Правила из AGENTS.md:

  • defer rows.Close() строго после if err != nil (нет утечек).
  • rows.Err() после for rows.Next().
  • Транзакции через db.BeginTx(ctx, nil) + defer tx.Rollback() + tx.Commit() в конце.
  • Никогда не склеивать строки в SQL, только ?.

CGO и сборка

mattn/go-sqlite3 - обёртка над C-кодом SQLite, требует CGO_ENABLED=1:

FROM golang:1.26-alpine AS builder
RUN apk add --no-cache build-base sqlite-dev
RUN CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o bin/tashirka cmd/tashirka/main.go

-ldflags="-s -w" режет debug-символы, бинарь ~15MB. На alpine:3.23 рантайме SQLite уже не нужен - драйвер статически слинкован.

Healthcheck через Ping

/health не просто 200 ok, а реальная проверка БД:

func Handler(db *sql.DB) echo.HandlerFunc {
    return func(c echo.Context) error {
        if err := db.Ping(); err != nil {
            return c.JSON(http.StatusServiceUnavailable, map[string]string{"status": "unhealthy"})
        }
        return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
    }
}

compose.yml дергает его каждые 30s - оркестратор видит реальный статус, а не просто живой процесс.

Проверка

sqlite3 db/tashirka.db "PRAGMA journal_mode; PRAGMA foreign_keys; PRAGMA busy_timeout;"
# wal|1|10000

sqlite3 db/tashirka.db "SELECT COUNT(*) FROM lead_lead;"
curl -s localhost:8000/health | grep -q '"status":"ok"'
go test ./internal/lead/storage -run TestLead -count=1

Итого

DSN для PRAGMA + WAL + пул 8 + goose через embed.FS + чистый database/sql = SQLite, который держит конкурентные запросы на VPS 1 CPU / 1GB RAM без Postgres и без ORM.