Persistence: database/sql, pgx и миграции
После HTTP-сервиса почти всегда возникает вопрос “куда класть данные”. В Go дефолт, реляционная БД через database/sql. Этот пакет, абстрактный интерфейс, под него подключают драйвер для конкретной СУБД (Postgres, MySQL, SQLite). Поверх можно навернуть pgx, sqlc, goose. Но прежде чем брать инструменты, разберёмся в том, что из коробки.
Цель урока: научиться открывать соединение с правильным пулом, писать запросы с параметрами (без SQL-инъекций), сканить результат в структуры, корректно использовать транзакции, готовить миграции и тестировать слой данных без сюрпризов на проде.
1. Архитектура: database/sql, драйвер, СУБД
database/sql, не ORM. Это унифицированный API над любым SQL-драйвером:
[ твой код ] -> database/sql -> драйвер -> СУБД
Драйвер знает протокол конкретной СУБД, database/sql про это не знает. Если нужно сменить Postgres на MySQL, в коде меняется драйвер плюс синтаксис запросов (плейсхолдеры разные), а слой database/sql остаётся тот же.
Шорт-лист драйверов на 2026:
| СУБД | Драйвер | Заметки |
|---|---|---|
| PostgreSQL | jackc/pgx/v5 | Дефолт. Можно как driver через stdlib, можно нативно (быстрее, больше типов). |
| PostgreSQL | lib/pq | Раньше дефолт, авторы сами рекомендуют переходить на pgx. |
| MySQL | go-sql-driver/mysql | Чистый Go, без cgo. |
| SQLite | modernc.org/sqlite | Чистый Go, без cgo. Удобно для тестов и встраиваемых задач. |
| SQLite | mattn/go-sqlite3 | Через cgo, но самый зрелый. |
Драйверы регистрируют себя в database/sql через init(). Поэтому импортируют их анонимно:
import (
"database/sql"
_ "github.com/jackc/pgx/v5/stdlib" // Postgres
// или _ "modernc.org/sqlite"
// или _ "github.com/go-sql-driver/mysql"
)
_ означает: импортируй ради side-effect (регистрации), но напрямую не использую. Если убрать _, компилятор ругнётся на unused import, потому что в коде ты обращаешься к драйверу не по имени, а по строке "pgx" в sql.Open.
2. sql.DB, это пул, а не одно соединение
Главная структура, sql.DB. Это пул соединений, а не одно соединение. Поэтому:
- открывать его нужно один раз на процесс;
- закрывать в самом конце через
defer db.Close(); - передавать в репозитории (или собрать обёртку), не пересоздавать в каждом обработчике;
- безопасно вызывать из любых горутин.
package main
import (
"context"
"database/sql"
"log/slog"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
func openDB(ctx context.Context, dsn string) (*sql.DB, error) {
db, err := sql.Open("pgx", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
db.SetConnMaxLifetime(5 * time.Minute)
db.SetConnMaxIdleTime(2 * time.Minute)
pingCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
if err := db.PingContext(pingCtx); err != nil {
db.Close()
return nil, err
}
return db, nil
}
Что важно понимать:
sql.Openне подключается к серверу. Он только проверяет аргументы и создаёт пустой пул. Реальное соединение устанавливается лениво на первом запросе. Поэтому ошибка вида “сервер недоступен” вылезет не изOpen, а изPingContextили первогоQueryContext.PingContextв старте сервиса, обязательная практика. Без него ты узнаешь о проблеме на первом запросе клиента, что поздно для health-check.SetMaxOpenConns. Дефолт, без лимита. Под нагрузкой это легко открывает 1000 соединений и убивает Postgres (там обычно лимит 100). Бери 25 как стартовое значение, под бенчмарк подкручивай.SetMaxIdleConnsобычно равенMaxOpenConns, иначе пул будет постоянно открывать-закрывать.SetConnMaxLifetime, защита от “зомби-соединений” с TLS-сессиями, NAT-таймаутами и DB failover. 5 минут, разумный дефолт для cloud-окружения.SetConnMaxIdleTimeдобавлен в Go 1.15, закрывает простаивающие соединения.
Пингу всегда давай контекст с таймаутом, иначе на startup можно повиснуть навсегда.
3. Контекст, всегда
В database/sql есть пары методов: с контекстом и без. Без контекста, не используй, кроме скриптов на одну страницу:
// 👎 не пиши
row := db.QueryRow("SELECT ...")
// ✅ пиши
row := db.QueryRowContext(ctx, "SELECT ...")
Контекст:
- передаёт дедлайн и cancellation в драйвер. Если контекст отменён, активный запрос рубится на сервере (через
pg_cancel_backendдля Postgres); - легко наследуется из HTTP:
r.Context()уже содержитrequest id, отмену клиента, дедлайн запроса.
func (s *Server) GetUser(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
user, err := s.repo.ByID(ctx, r.PathValue("id"))
if err != nil { /* ... */ }
// ...
}
Per-query timeout, типа 3 секунды, держит хвост латентности под контролем. Без него один тяжёлый запрос блокирует соединение на минуты.
4. Чтение: QueryContext, QueryRowContext, ExecContext
Три точки входа для отправки запросов:
db.ExecContext(ctx, query, args...) // INSERT, UPDATE, DELETE, DDL
db.QueryContext(ctx, query, args...) // SELECT с несколькими строками
db.QueryRowContext(ctx, query, args...) // SELECT с одной строкой
4.1. Одна строка
type User struct {
ID int64
Email string
Name string
}
func (r *Repo) ByID(ctx context.Context, id int64) (User, error) {
const q = `SELECT id, email, name FROM users WHERE id = $1`
var u User
err := r.db.QueryRowContext(ctx, q, id).
Scan(&u.ID, &u.Email, &u.Name)
switch {
case errors.Is(err, sql.ErrNoRows):
return User{}, ErrUserNotFound
case err != nil:
return User{}, fmt.Errorf("user by id: %w", err)
}
return u, nil
}
Что важно:
- Плейсхолдеры, не интерполяция. Postgres использует
$1, $2, ..., MySQL/SQLite,?. Никогда не лей параметры черезfmt.Sprintf, это прямой путь к SQL-injection. sql.ErrNoRows, специальная ошибка при отсутствии строки. Идиома: оборачивать в свою domain-ошибку (ErrUserNotFound), чтобы handler не зависел от типа БД.- Порядок переменных в
Scanдолжен совпадать с порядком колонок вSELECT. ПоэтомуSELECT *в проде, плохая привычка: схема таблицы поменяется, а скан поедет.
4.2. Несколько строк
func (r *Repo) Top(ctx context.Context, limit int) ([]User, error) {
const q = `SELECT id, email, name FROM users ORDER BY created_at DESC LIMIT $1`
rows, err := r.db.QueryContext(ctx, q, limit)
if err != nil {
return nil, fmt.Errorf("query top users: %w", err)
}
defer rows.Close()
users := make([]User, 0, limit)
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Email, &u.Name); err != nil {
return nil, fmt.Errorf("scan: %w", err)
}
users = append(users, u)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows: %w", err)
}
return users, nil
}
Что критично:
defer rows.Close(), обязательно. Если не закрыть, соединение из пула останется “грязным” и не вернётся.rows.Err()после цикла, обязательно.rows.Next()возвращаетfalseи при честном конце, и при ошибке (например, обрыв соединения посреди стриминга).rows.Err(), единственный способ их различить.- Префаллокация слайса,
make([]User, 0, limit), экономит несколько append-realloc.
4.3. Запись через ExecContext
func (r *Repo) Insert(ctx context.Context, u User) (int64, error) {
const q = `INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id`
var id int64
err := r.db.QueryRowContext(ctx, q, u.Email, u.Name).Scan(&id)
if err != nil {
return 0, fmt.Errorf("insert user: %w", err)
}
return id, nil
}
Postgres любит RETURNING id через QueryRowContext. У MySQL/SQLite нет RETURNING, там работают через ExecContext:
res, err := r.db.ExecContext(ctx, `INSERT ...`, args...)
if err != nil { /* ... */ }
id, err := res.LastInsertId()
n, err := res.RowsAffected()
LastInsertId для Postgres вернёт ноль, не используй.
5. NULL и nullable-поля
Проблема: в Go string не может быть nil, в SQL NULL отдельный тип. Вариантов два.
(а) sql.NullString и компания
var desc sql.NullString
err := row.Scan(&desc)
// desc.Valid == true -> desc.String, иначе значение не задано
if !desc.Valid {
return "----", nil
}
return desc.String, nil
Аналогично есть NullInt64, NullBool, NullTime, NullFloat64. Их минус: в JSON они сериализуются криво ({"String":"x","Valid":true}). Поэтому в API-структуру всё равно перекладывают.
(б) Указатель
type User struct {
ID int64
Name string
Email *string // nil, если NULL
}
*string сканится из NULL в nil, в JSON отлично сериализуется (null или строка). Минус, надо разыменовывать.
В пет-проекте бери указатели для DTO, sql.Null*, для внутренних типов когда нужен Valid-флаг.
Postgres в песочнице нет, но database/sql есть. Тест подкладывает фейковый драйвер, а QueryRowContext, Scan и sql.ErrNoRows работают настоящие. Прочитай строку с nullable-колонками и не перепутай NULL с нулём.
Не могу найти задачу 22-golang/21-persistence-and-sql/scan-nulls. Проверь путь в src/content/tasks/.
6. pgx, нативный драйвер для Postgres
pgx живёт двумя жизнями. Через pgx/v5/stdlib, это драйвер для database/sql (наш импорт _ "github.com/jackc/pgx/v5/stdlib"). Через github.com/jackc/pgx/v5 напрямую, это родной API без database/sql:
import "github.com/jackc/pgx/v5/pgxpool"
pool, err := pgxpool.New(ctx, dsn)
defer pool.Close()
var name string
err = pool.QueryRow(ctx, "SELECT name FROM users WHERE id = $1", 42).
Scan(&name)
Что даёт нативный pgx:
- Богатые типы:
pgtype.UUID,pgtype.JSONB,pgtype.Numeric, массивы Postgres, диапазоны. Всё типизировано. - Batch:
BatchплюсSendBatch, отправляет пачку запросов одним round-trip-ом. На N+1 это даёт x10-50. - CopyFrom: bulk-загрузка в Postgres через бинарный протокол COPY. Для миллионов строк, единственный нормальный путь.
- Listen/Notify: подписка на pub/sub Postgres через нативный канал.
- Чуть быстрее за счёт меньшего числа аллокаций по сравнению с
database/sql/stdlib.
Минус один: код к нему привязан, на MySQL не переедешь без переписывания. Но если ты уже точно на Postgres, бери pgxpool напрямую и не страдай.
премиум-урок
Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.