Раздел 22 · Go

Persistence: database/sql, pgx и миграции

middle~50 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

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:

СУБДДрайверЗаметки
PostgreSQLjackc/pgx/v5Дефолт. Можно как driver через stdlib, можно нативно (быстрее, больше типов).
PostgreSQLlib/pqРаньше дефолт, авторы сами рекомендуют переходить на pgx.
MySQLgo-sql-driver/mysqlЧистый Go, без cgo.
SQLitemodernc.org/sqliteЧистый Go, без cgo. Удобно для тестов и встраиваемых задач.
SQLitemattn/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-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.