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.