voidlearn
 

6.7Работа с БД из Go

Пройди путь запроса от пула до строки результата: соединения ограничены, контекст должен дойти до драйвера, а rows нужно закрыть и проверить после итерации. Научись удерживать транзакцию в одном владельце, параметризовать значения и выкатывать изменение схемы без конфликта со старой версией приложения.

database/sql, pgx, sqlx, GORM - что выбрать

database/sql не драйвер, а абстракция стандартной библиотеки: пул соединений, интерфейсы Driver/Conn/Stmt, управление транзакциями. Говорить с PostgreSQL он не умеет, под ним всегда работает драйвер (lib/pq, pgx/v5/stdlib). Вокруг этого выбора и строится всё остальное.

ИнструментЧто этоПлюсыМинусыКогда брать
database/sql + драйверстандартная библиотеканоль зависимостей, полный контроль, единый API для любой СУБДмного рутины: Scan по полям, rows.Close, ErrNoRowsкогда важна переносимость между СУБД или минимум зависимостей
pgx (нативный режим)драйвер PostgreSQL со своим API и пуломбинарный протокол, нативные типы PG, CopyFrom, Batch, LISTEN/NOTIFY, свой быстрый пул pgxpoolтолько PostgreSQL, свой API - sqlmock и часть библиотек не подходятдефолт для нового сервиса на PostgreSQL
pgx через stdlibpgx как драйвер для database/sqlстандартный API + актуальный драйвертеряется часть возможностей pgxкогда нужен стандартный интерфейс, но не нужен lib/pq
sqlxтонкая обёртка над database/sqlStructScan, Get, Select, NamedExec, sqlx.In - убирает 80 % рутиныэто всё ещё ручной SQL; развивается медленнолюбимый компромисс: контроль над SQL без боли со сканированием
GORMполноценная ORMбыстрый старт, связи, хуки, автомиграции, soft deleteнепредсказуемый SQL, лёгкий N+1, магия рефлексии, тяжёлая отладка, автомиграции опасны на продеCRUD-админки, прототипы, команды без сильной SQL-экспертизы
sqlcгенератор кода из SQLпишешь SQL - получаешь типобезопасные функции и структуры; ошибки в SQL ловятся на компиляциикодогенерация в сборке, сложно с динамическими запросамисильный современный выбор, особенно в паре с pgx
squirrel / goquбилдеры запросовдинамические фильтры без склейки строкSQL становится Go-кодом и хуже читаетсятам, где реально много опциональных условий
Как отвечать на «что выбрать»

Не «GORM плохой», а размен: ORM экономит время на простом CRUD и отбирает контроль там, где он нужнее всего - в сложных запросах и в производительности. Рабочая формулировка: «Для сервиса на PostgreSQL беру pgx напрямую - ради бинарного протокола, CopyFrom и нативных типов; сканирование закрываю pgx.CollectRows или sqlc. ORM рассматриваю для админок, где 90 % занимает примитивный CRUD. И в любом варианте мне нужно уметь посмотреть, какой именно SQL уходит в базу.»

Чем конкретно хорош pgx

  • Бинарный протокол. lib/pq гоняет всё текстом: timestamptz сериализуется в строку и парсится обратно, int8 едет как «123456». pgx шлёт бинарное представление - отсюда меньше трафика и заметно меньше аллокаций на больших выборках.
  • Нативные типы PostgreSQL. jsonb, массивы, hstore, диапазоны, inet, uuid, композитные типы сканируются напрямую, без []byte и ручного json.Unmarshal.
  • CopyFrom использует протокол COPY, самый быстрый способ массовой вставки.
  • Batch включает pipelining: несколько разных запросов уходят одним сетевым обменом. На сети с RTT 1 мс сотня запросов укладывается в ~2 мс вместо 100 мс.
  • pgxpool даёт собственный пул с health-check соединений, ленивым подключением и настройками, которых нет в database/sql.
  • LISTEN/NOTIFY, курсоры, QueryRewriter, кастомные типы - всё, что не влезает в общий интерфейс database/sql.
  • Из бытового: pgconn.PgError с полями Code, ConstraintName, Detail, по которым нарушение уникальности точно отличается от нарушения внешнего ключа.
UUID: с Go 1.27 внешний пакет для этого не нужен

Раз уж речь про типы: годами стандартным ответом на «где взять UUID» был github.com/google/uuid. С Go 1.27 генератор въехал в стандартную библиотеку - пакет uuid, реализующий RFC 9562. Что в нём есть: uuid.New() (общий случай), uuid.NewV4() (случайный), uuid.NewV7(), uuid.Parse и uuid.MustParse, uuid.Nil(). Тип uuid.UUID устроен как [16]byte: сравнивается через ==, годится ключом мапы, умеет MarshalText/UnmarshalText, а значит и JSON.

Две практические детали. (1) Для первичного ключа бери NewV7(), а не NewV4(): v7 начинается с миллисекундного времени, поэтому такие ключи монотонны и ложатся в правый край B-tree, а не рвут его страницы по всему индексу (подробно разобрано в главе 6.4). (2) В database/sql его можно передавать напрямую - и как параметр запроса, и как приёмник в Scan. Интерфейсы driver.Valuer и sql.Scanner тип при этом не реализует: поддержка зашита в саму стандартную библиотеку отдельным случаем (database/sql/driver/types.go и convert.go). Проверено на go1.27.0: параметр уезжает в драйвер строкой, а Scan принимает и строку, и 16 сырых байт. Писать u.String() или свою обёртку не нужно.

Пул соединений: четыре ручки и что за ними стоит

Соединение с PostgreSQL означает отдельный процесс на сервере БД (не поток), TCP-сессию, TLS-handshake и аутентификацию. Установка стоит миллисекунды и несколько мегабайт памяти на сервере, и делать её на каждый запрос непозволительно дорого.

Поэтому соединения не открывают и не закрывают, а переиспользуют: держат наготове небольшой набор уже установленных и раздают их горутинам по мере надобности. Этот набор и называется пулом соединений. Работает он как стойка с ключами: горутине нужен запрос - она берёт свободное соединение, выполняет запрос, возвращает его в пул. Если свободных нет, а лимит открытых исчерпан, горутина ждёт, пока кто-нибудь вернёт своё. Отсюда все дальнейшие эффекты: «запросы стали медленными» часто означает не медленную базу, а очередь за соединением.

В database/sql объект *sql.DB и есть пул, а не одно соединение. Его создают один раз на весь процесс, передают всюду и не закрывают после каждого запроса; он потокобезопасен. В pgxpool ту же роль играет *pgxpool.Pool.

ПараметрЧто означаетЧто будет, если задать неверно
SetMaxOpenConns(n)максимум одновременно открытых соединений (занятых + простаивающих)0 = без лимита, дефолт: всплеск нагрузки открывает сотни соединений и упирается в max_connections сервера - «FATAL: sorry, too many clients»
SetMaxIdleConns(n)сколько держать простаивающих для переиспользованиядефолт всего 2: при MaxOpen=50 пул постоянно закрывает и открывает соединения - тихая потеря производительности, которую почти никто не замечает
SetConnMaxLifetime(d)максимальный возраст соединения, после - закрыть и открыть новое0 = вечно: соединения не переезжают после failover и не перебалансируются между репликами; рекомендуется 30 мин – 1 час
SetConnMaxIdleTime(d)сколько соединение может простаивать до закрытияпомогает отдавать ресурсы БД в тихие часы; типично 5–15 минут
db, err := sql.Open("pgx", dsn)   // не подключается, только валидирует DSN
if err != nil { return err }
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)            // держим равным MaxOpen, иначе пойдут переоткрытия
db.SetConnMaxLifetime(30 * time.Minute)
db.SetConnMaxIdleTime(5 * time.Minute)

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {   // соединение по-настоящему устанавливается здесь
    return fmt.Errorf("db unreachable: %w", err)
}

// в pgxpool то же самое, но под своими именами
cfg, _ := pgxpool.ParseConfig(dsn)
cfg.MaxConns          = 25
cfg.MinConns          = 5                 // держать прогретыми
cfg.MaxConnLifetime   = 30 * time.Minute
cfg.MaxConnIdleTime   = 5 * time.Minute
cfg.HealthCheckPeriod = 1 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, cfg)
Жизненный цикл соединения и исчерпание пула Нормальный режим: MaxOpenConns = 25 горутина db.QueryContext пул database/sql свободных: 7 · занятых: 18 есть свободное → выдать сразу нет свободного и открыто < Max → открыть PostgreSQL backend-процесс после Close/Commit соединение возвращается в пул, а не закрывается Пул исчерпан: все 25 заняты, приходит 26-я горутина горутина #26 QueryContext ОЖИДАНИЕ в очереди блокируется, НЕ ошибка выход из ожидания освободилось ИЛИ ctx done Как это выглядит на графиках: • latency растёт «ступенькой», хотя БД по CPU и диску спокойна • db.WaitCount и db.WaitDuration из DBStats уходят вверх - главный признак • ошибки не «too many connections», а context deadline exceeded в приложении Лечение: не «увеличить пул», а найти, кто держит соединения долго - медленный запрос, забытый rows.Close, долгая транзакция
Исчерпанный пул не даёт ошибки. Вызов в database/sql блокируется и ждёт свободного соединения, а выйти из ожидания может только тогда, когда соединение освободится или истечёт context. Поэтому в проде он выглядит не как «too many connections», а как рост p99 и таймауты запросов при спокойной базе.
// DBStats обязательно экспортируем в метрики
s := db.Stats()
s.MaxOpenConnections   // лимит
s.OpenConnections      // сейчас открыто
s.InUse                // сейчас занято
s.Idle                 // простаивает
s.WaitCount            // сколько раз ждали свободного соединения ← главный сигнал
s.WaitDuration         // суммарное время ожидания          ← и его цена
s.MaxIdleClosed        // закрыто из-за MaxIdleConns  ← если растёт, MaxIdle слишком мал
s.MaxLifetimeClosed    // закрыто по возрасту
Как подбирать размер пула
  • Считать от базы, а не от приложения. У PostgreSQL max_connections обычно 100–200. Если 10 подов сервиса поставят себе по 50 соединений, выйдет 500, и база откажет. Бюджет делят на все инстансы всех сервисов с запасом на миграции, мониторинг и подключение человека.
  • Больше не значит быстрее. PostgreSQL при числе активных соединений заметно выше числа ядер деградирует: растёт конкуренция за LWLock, страдают кэши. Отправная формула примерно такая: 2 × CPU + число дисков, то есть на 8-ядерном сервере речь о ~20 активных соединениях, а не о 200.
  • По закону Литтла: нужное число соединений ≈ RPS × средняя длительность запроса. 400 RPS × 10 мс = 4 соединения. Если по факту уходит 50, проблема в длительности запросов, а не в размере пула.
  • MaxIdleConns = MaxOpenConns почти всегда. Дефолтные 2 дают самую частую незамеченную проблему производительности в Go-сервисах.
  • ConnMaxLifetime обязателен при работе через балансировщик или в облаке: без него соединения намертво прилипают к одному инстансу БД и не переезжают после failover.
  • Сотни инстансов сервиса уже требуют pgbouncer, тюнинга клиентского пула тут мало.

pgbouncer: зачем и какой ценой

Пул внутри приложения решает задачу одного процесса. Но подов у сервиса двадцать, у каждого свой пул на 25 соединений, итого 500 соединений к базе, а max_connections у неё обычно 100–200, и поднимать этот лимит бессмысленно: на каждое соединение PostgreSQL заводит процесс со своей памятью. Нужен ещё один пул, общий для всех подов.

pgbouncer и есть такой внешний пул: маленький прокси, который сам держит тысячи дешёвых клиентских соединений и раскладывает их на десяток настоящих соединений к базе. Это и есть мультиплексирование.

Главная его настройка, режим пулинга, задаёт момент, когда серверное соединение освобождается и уходит следующему клиенту. Вариантов три, и различаются они только этим моментом. В transaction mode (рабочий выбор по умолчанию) серверное соединение закрепляется за клиентом только на время транзакции: сделал COMMIT - соединение тут же ушло другому. Экономия большая, но с ней приходит правило, которое ломает половину привычек: ничего нельзя оставлять «в сессии», потому что следующий запрос почти наверняка уедет на другое соединение.

pgbouncer между приложением и PostgreSQL под #1 · пул 25 под #2 · пул 25 под #3 · пул 25 … под #20 · пул 25 итого 500 клиентских соединений pgbouncer лёгкий, однопоточный, держит тысячи клиентов transaction mode 20 реальных соединений PostgreSQL процесс на соединение max_connections = 100 session mode: соединение закреплено на всю сессию клиента - работает всё, экономии почти нет transaction mode: соединение отдаётся на время транзакции - главный режим; ломает SET, temp-таблицы, LISTEN, prepared statements - до 1.21
pgbouncer мультиплексирует: раскладывает 500 клиентских соединений на 20 реальных. Ставят его не «для скорости», а потому, что каждому соединению PostgreSQL нужен процесс на сервере, а их число ограничено. Особенно в serverless и Kubernetes, где число подов меняется на ходу.
РежимКогда соединение возвращается в пулЧто ломаетсяЭкономия
sessionпри отключении клиентаничегоминимальная - только на переустановке соединений
transactionпо COMMIT/ROLLBACKprepared statements, SET, temp-таблицы, advisory locks на сессию, LISTEN/NOTIFY, курсоры вне транзакциимаксимальная и практичная - стандартный выбор
statementпосле каждого операторавсё вышеперечисленное плюс многооператорные транзакции вообщепредельная; применим только там, где транзакций нет по определению
Почему в transaction mode ломаются prepared statements

Любой db.Query(sql, args...) в драйверах Go под капотом создаёт prepared statement: база один раз разбирает запрос, строит план и запоминает его под именем, а драйвер кэширует это имя на соединении.

Transaction mode эту схему ломает: PREPARE создаёт именованный план в конкретной серверной сессии. Клиент готовит stmt_1 на соединении A, потом транзакция коммитится и соединение возвращается в пул; следующий EXECUTE stmt_1 уезжает уже на соединение B, где такого плана нет: «prepared statement stmt_1 does not exist». Хуже: план может существовать, но означать другой запрос, если другой клиент занял то же имя.

Как обходят. (1) В pgx ставят DefaultQueryExecMode: pgx.QueryExecModeSimpleProtocol или QueryExecModeExec: параметры отправляются вместе с запросом, prepared statements не создаются (защита от инъекций сохраняется, потому что параметры всё равно не склеиваются с текстом). (2) В lib/pq исторически помогал параметр DSN binary_parameters=yes, но чаще просто переходят на pgx. (3) В pgbouncer 1.21+ появилась поддержка protocol-level prepared statements (max_prepared_statements), которая отслеживает подготовленные запросы и переигрывает их на новом соединении. (4) Радикальный вариант: два пути к БД - прямой для фоновых воркеров с длинными транзакциями и через pgbouncer для API.

Связь с клиентским пулом в Go

Пулов становится два, и они не знают друг о друге. Правило: суммарный MaxOpenConns всех подов ≤ max_client_conn pgbouncer, а default_pool_size pgbouncer ≤ max_connections PostgreSQL с запасом. При этом клиентский пул можно и нужно держать небольшим: соединение до pgbouncer дешёвое, но каждое занятое соединение всё равно держит серверную сессию на время транзакции. ConnMaxLifetime стоит оставить и здесь, тогда клиенты будут перераспределяться между инстансами pgbouncer.

Параметризованные запросы и SQL-инъекция

// Дыра: склейка строк
name := r.URL.Query().Get("name")
q := "SELECT id, email FROM users WHERE name = '" + name + "'"
rows, _ := db.Query(q)
// Вход:  ' OR '1'='1
// Запрос: SELECT id, email FROM users WHERE name = '' OR '1'='1'   → вся таблица
// Вход:  '; DROP TABLE users; --
// Вход:  ' UNION SELECT id, password_hash FROM admins --           → утечка хешей

// Правильно: параметр едет отдельно от текста запроса
rows, err := db.QueryContext(ctx,
    `SELECT id, email FROM users WHERE name = $1`, name)
// Сервер получает текст запроса и значения по отдельности. Значение никогда
// не парсится как SQL: что бы в нём ни лежало, это остаётся строкой.

// Список значений
rows, err := db.QueryContext(ctx,
    `SELECT * FROM users WHERE id = ANY($1)`, pq.Array(ids))   // или pgx: ids напрямую
Где параметры НЕ работают: ORDER BY и имена объектов

Плейсхолдер подставляет значение, а не кусок синтаксиса. Нельзя подставить параметром имя таблицы, имя колонки, направление сортировки, LIMIT без приведения типа. ORDER BY $1 синтаксически допустим, но означает «сортировать по константе» и работает не так, как задумано.

// Дыра: сортировка из запроса пользователя
q := "SELECT * FROM users ORDER BY " + r.URL.Query().Get("sort")
// Вход: (CASE WHEN (SELECT substr(password,1,1) FROM admins)='a' THEN id ELSE name END)
// Так данные вытаскивают посимвольно даже без вывода ошибок: это blind SQL injection.

// Правильно: только белый список, никаких «экранирую кавычки».
var sortWhitelist = map[string]string{
    "name_asc":   "name ASC",
    "name_desc":  "name DESC",
    "date_asc":   "created_at ASC, id ASC",
    "date_desc":  "created_at DESC, id DESC",
}
order, ok := sortWhitelist[r.URL.Query().Get("sort")]
if !ok {
    order = "created_at DESC, id DESC"   // безопасный дефолт, а не ошибка
}
q := fmt.Sprintf(`SELECT id, name, created_at FROM users ORDER BY %s LIMIT $1`, order)

// Если имя объекта всё же динамическое (мультитенантные схемы), квотируем
// идентификатор, а не строку:
tbl := pgx.Identifier{schema, "orders"}.Sanitize()   // "tenant_42"."orders"

То же касается LIKE: параметр защищает от инъекции, но не от спецсимволов % и _ внутри значения. Их экранируют отдельно (ESCAPE '\'), иначе поиск по 100% найдёт лишнее.

Транзакции в Go

var (
    ErrInsufficientFunds = errors.New("insufficient funds")
    ErrAccountNotFound   = errors.New("account not found")
)

func (r *Repo) Transfer(ctx context.Context, from, to int64, amount int64) (err error) {
    tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
    if err != nil {
        return fmt.Errorf("begin: %w", err)
    }
    // Rollback после Commit возвращает sql.ErrTxDone: это не ошибка, её глотаем.
    defer func() {
        if p := recover(); p != nil {
            _ = tx.Rollback()
            panic(p)              // паника не должна ни съесть соединение, ни потеряться
        }
        if err != nil {
            _ = tx.Rollback()     // именованный err виден в defer
        }
    }()

    // UPDATE, не нашедший строку, - не ошибка SQL, а «0 строк». Без проверки
    // при нехватке денег они возьмутся из воздуха, а без счёта to - пропадут.
    if err = execOne(ctx, tx, ErrInsufficientFunds,
        `UPDATE accounts SET balance = balance - $1 WHERE id = $2 AND balance >= $1`,
        amount, from); err != nil {
        return fmt.Errorf("debit: %w", err)
    }
    if err = execOne(ctx, tx, ErrAccountNotFound,
        `UPDATE accounts SET balance = balance + $1 WHERE id = $2`, amount, to); err != nil {
        return fmt.Errorf("credit: %w", err)
    }
    if err = tx.Commit(); err != nil {
        return fmt.Errorf("commit: %w", err)
    }
    return nil
}

// execOne выполняет UPDATE, который обязан задеть ровно одну строку.
func execOne(ctx context.Context, tx *sql.Tx, errNoRow error, q string, args ...any) error {
    res, err := tx.ExecContext(ctx, q, args...)
    if err != nil {
        return err
    }
    n, err := res.RowsAffected()
    if err != nil {
        return err
    }
    if n != 1 {
        return errNoRow
    }
    return nil
}
Правила, которые проверяют почти всегда
  • defer tx.Rollback() ставится сразу после успешного BeginTx - до любого return.
  • После успешного Commit отложенный Rollback вернёт sql.ErrTxDone. Это ожидаемо, ошибку игнорируем явно (_ =), а не «забываем».
  • Внутри транзакции используем только tx. Один вызов r.db.Exec вместо tx.Exec уйдёт по другому соединению, окажется вне транзакции и заодно может намертво заблокироваться на строке, которую держит собственная же транзакция.
  • Ошибка в середине не откатывает транзакцию сама по себе, но в PostgreSQL переводит её в состояние aborted: все последующие операторы будут отвечать «current transaction is aborted». Спасает только ROLLBACK (или ROLLBACK TO SAVEPOINT).
  • Коварнее всего ошибка Commit: её результат неизвестен. Коммит мог пройти на сервере, а ответ потеряться в сети. Поэтому операции делают идемпотентными, а на бизнес-действие заводят уникальный ключ.
  • Транзакция должна быть короткой. Никаких HTTP-вызовов, ожиданий брокера и долгих вычислений внутри: транзакция держит соединение из пула, блокировки и горизонт VACUUM.

Как передавать tx между слоями

Транзакция живёт в объекте *sql.Tx, привязанном к одному соединению, а бизнес-операция обычно трогает несколько репозиториев. Значит, все они должны выполнять свои запросы через один и тот же tx. При этом сам репозиторий не должен решать, открывать транзакцию или нет, иначе «создать заказ и его позиции атомарно» просто не собрать из готовых методов.

Отсюда два стандартных приёма. В первом репозиторий принимает не конкретный тип, а узкий интерфейс (ниже он назван DBTX), который одинаково реализуют и пул, и транзакция: репозиторию всё равно, что под ним. Второй - UnitOfWork (в Go-коде его чаще зовут Transactor): объект, умеющий ровно одно - выполнить переданную функцию целиком внутри одной транзакции, закоммитив её при успехе и откатив при ошибке. Смысл в том, что границу транзакции задаёт слой сервиса: он знает бизнес-операцию целиком, а отдельный репозиторий нет.

// 1. Интерфейс, который одинаково реализуют *sql.DB и *sql.Tx (в pgx такой же, с Exec/Query/QueryRow)
type DBTX interface {
    ExecContext(ctx context.Context, q string, args ...any) (sql.Result, error)
    QueryContext(ctx context.Context, q string, args ...any) (*sql.Rows, error)
    QueryRowContext(ctx context.Context, q string, args ...any) *sql.Row
}
type UserRepo struct {
    db DBTX
}
func NewUserRepo(db DBTX) *UserRepo { return &UserRepo{db: db} }
// Репозиторий не знает, транзакция под ним или нет, и так и надо.

// 2. UnitOfWork / Transactor: транзакцией управляет слой сервиса
type Transactor interface {
    WithinTx(ctx context.Context, fn func(ctx context.Context) error) error
}

func (s *OrderService) Create(ctx context.Context, in Input) error {
    return s.tx.WithinTx(ctx, func(ctx context.Context) error {
        id, err := s.orders.Insert(ctx, in)          // репозитории получают tx из ctx
        if err != nil { return err }
        if err := s.items.InsertBulk(ctx, id, in.Items); err != nil { return err }
        return s.outbox.Enqueue(ctx, OrderCreated{ID: id})   // outbox в той же транзакции
    })
}

// 3. Реализация через context (удобно, но спорно)
type txKey struct{}

func (t *PgTransactor) WithinTx(ctx context.Context, fn func(context.Context) error) error {
    tx, err := t.db.BeginTx(ctx, nil)
    if err != nil { return err }
    defer tx.Rollback()
    if err := fn(context.WithValue(ctx, txKey{}, tx)); err != nil {
        return err
    }
    return tx.Commit()
}

func (r *UserRepo) exec(ctx context.Context) DBTX {
    if tx, ok := ctx.Value(txKey{}).(*sql.Tx); ok {
        return tx          // внутри транзакции
    }
    return r.db            // вне транзакции
}
Чем спорен tx в контексте

За: сигнатуры методов не меняются, транзакция «прозрачно» протекает через слои, не нужно тащить tx параметром через пять уровней. Против: зависимость становится скрытой, и по сигнатуре Insert(ctx, user) error не понять, участвует ли метод в транзакции; компилятор ничего не проверит; забыть WithinTx легко, и код будет «работать», просто без атомарности; тестировать тяжелее; context по соглашению предназначен для request-scoped данных и отмены, а не для внедрения зависимостей. На собеседовании лучше назвать оба варианта и критерий: явно передавать DBTX первым аргументом честнее и для нового кода лучше; tx в контексте оправдан, когда слоёв много и переписать сигнатуры невозможно.

context, rows и NULL - четыре места, где режут

// 1. context: таймаут на каждый запрос
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
rows, err := db.QueryContext(ctx, q, args...)

// 2. rows: обязательны и Close, и Err
rows, err := db.QueryContext(ctx, q)
if err != nil { return nil, err }
defer rows.Close()               // без него при раннем выходе из цикла соединение не вернётся в пул

out := make([]User, 0, 32)
for rows.Next() {
    var u User
    if err := rows.Scan(&u.ID, &u.Name); err != nil {
        return nil, err
    }
    out = append(out, u)
}
// Если rows.Next() вернул false, это может быть ошибка, а не конец данных:
// оборвалась сеть, сервер убил запрос, истёк ctx. Без этой проверки
// неполный результат молча уйдёт наверх как успешный.
if err := rows.Err(); err != nil {
    return nil, fmt.Errorf("iterate: %w", err)
}

// 3. NULL: обычный тип не примет NULL
var name string
err := row.Scan(&name)           // "converting NULL to string is unsupported"

var name sql.NullString          // вариант A: явная обёртка
_ = row.Scan(&name)
if name.Valid { use(name.String) }

var namePtr *string              // вариант B: указатель, nil == NULL
_ = row.Scan(&namePtr)

var nameStr string               // вариант C: решить в SQL
_ = row.Scan(&nameStr)           // SELECT coalesce(name, '') FROM ...

// 4. ErrNoRows: не ошибка инфраструктуры, а нормальный доменный случай
var u User
err := db.QueryRowContext(ctx, `SELECT id, name FROM users WHERE id = $1`, id).
    Scan(&u.ID, &u.Name)
switch {
case errors.Is(err, sql.ErrNoRows):        // в pgx: errors.Is(err, pgx.ErrNoRows)
    return nil, fmt.Errorf("user %d: %w", id, domain.ErrNotFound)
case err != nil:
    return nil, fmt.Errorf("get user: %w", err)
}
Что происходит с запросом в БД при отмене context

Отмена контекста не останавливает запрос мгновенно. Go отправляет серверу сообщение CancelRequest: открывает отдельное соединение к PostgreSQL и просит прервать текущий запрос указанного backend-процесса. Сервер выполнит просьбу, когда дойдёт до точки проверки прерываний (CHECK_FOR_INTERRUPTS). Пока этого не случилось, запрос продолжает жечь CPU и держать блокировки. Отсюда четыре следствия: (1) отмена остаётся асинхронной просьбой, а не гарантией; (2) уже выполненная часть UPDATE откатывается транзакцией, а не отменой; (3) серверный statement_timeout надёжнее клиентского, поэтому его выставляют и на уровне БД/роли, а не полагаются только на context; (4) само соединение после отмены Go считает грязным и может закрыть, а при массовых таймаутах это даёт шторм переподключений.

Миграции и изменение схемы без даунтайма

# goose
goose -dir ./migrations postgres "$DSN" up
goose -dir ./migrations postgres "$DSN" down      # откат одной последней
goose -dir ./migrations create add_users_phone sql # 20260826120000_add_users_phone.sql

# golang-migrate
migrate -path ./migrations -database "$DSN" up
migrate -path ./migrations -database "$DSN" force 20260826120000   # снять dirty-флаг
Expand / contract: NOT NULL колонка на таблице в 200 млн строк время 1 · EXPAND ALTER TABLE users ADD COLUMN phone text; nullable, без DEFAULT - метаданная операция, мгновенно 2 · КОД ПИШЕТ ОБА новый код заполняет phone, старый код всё ещё работает - колонка nullable, ему всё равно. Откат релиза безопасен. 3 · BACKFILL UPDATE ... WHERE id BETWEEN $1 AND $2 батчами по 10k, с паузами, следя за lag и autovacuum 4 · CONTRACT ADD CONSTRAINT ... NOT VALID; VALIDATE CONSTRAINT; валидация без ACCESS EXCLUSIVE, только SHARE UPDATE EXCLUSIVE Как НЕ надо - и что именно ляжет: ALTER TABLE users ADD COLUMN phone text NOT NULL DEFAULT ''; до PG 11 - переписывание всей таблицы под ACCESS EXCLUSIVE: прод стоит десятки минут ALTER TABLE users ALTER COLUMN phone SET NOT NULL; полный скан под ACCESS EXCLUSIVE - блокирует даже SELECT; плюс очередь блокировок за ним
Expand/contract годится для любых ломающих изменений схемы: сначала расширяем схему так, чтобы работали и старый, и новый код, потом переносим данные и только затем убираем старое. Между шагами проходит полный цикл выката, чтобы в любой момент можно было откатиться.
-- Индекс на живой таблице: только CONCURRENTLY и только вне транзакции
CREATE INDEX CONCURRENTLY users_phone_idx ON users (phone);
-- В отличие от обычного CREATE INDEX не блокирует запись, но делает два прохода и может упасть,
-- оставив невалидный индекс. Проверка:
SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid;
DROP INDEX CONCURRENTLY users_phone_idx;   -- и создать заново

-- NOT NULL без долгой блокировки (PG 12+)
ALTER TABLE users ADD CONSTRAINT users_phone_nn CHECK (phone IS NOT NULL) NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT users_phone_nn;   -- лёгкая блокировка
ALTER TABLE users ALTER COLUMN phone SET NOT NULL;      -- теперь мгновенно: констрейнт доказан

-- Любой ALTER на живой таблице запускаем с коротким lock_timeout и ретраями,
-- иначе он встанет в очередь и заблокирует все запросы за собой
SET lock_timeout = '3s';
ALTER TABLE users ...;
Колонку переименовывают минимум за два релиза
  1. Релиз 1: добавить новую колонку, начать писать в обе (в коде или триггером), читать пока из старой.
  2. Backfill: скопировать данные батчами.
  3. Релиз 2: переключить чтение на новую колонку. Пишем всё ещё в обе.
  4. Релиз 3: перестать писать в старую.
  5. Релиз 4 (через недели): DROP COLUMN.
Конфликты версий миграций в команде

Классическая ситуация: две ветки создали миграции с номерами 014 и 014, обе смёржились. Что делать: (1) перейти на timestamp вместо последовательного номера - goose и migrate это умеют, и коллизия становится маловероятной; (2) даже при timestamp остаётся риск порядка: миграция, смёрженная позже, но с более ранним timestamp, на проде выполнится, а на машине разработчика - нет, потому что версия уже «пройдена»; goose лечит это флагом allow-missing, у migrate такого нет, и тогда миграцию лучше переименовать перед мержем; (3) dirty state в golang-migrate: если миграция упала посередине, таблица версий помечается грязной и все дальнейшие запуски отказываются работать; чинится force после ручной проверки, что именно применилось; (4) миграции запускать одним процессом (отдельная джоба или шаг деплоя), а не каждым подом на старте, иначе десять подов запустят миграцию одновременно; init-контейнер тут не спасает, он тоже стартует в каждом поде; golang-migrate берёт advisory lock сам, goose - только если его явно включить, но полагаться на гонку не стоит; (5) down-миграции на проде почти не применяют: откат обычно делают «вперёд» новой миграцией, потому что down, удаляющий колонку, уничтожает данные. Писать down всё равно надо - для локальной разработки и тестов.

Вопросы

13
Суть: database/sql - абстракция стандартной библиотеки (пул + интерфейсы), сама по себе с базой не говорит. pgx - драйвер PostgreSQL, который умеет работать и под database/sql, и напрямую со своим API и пулом. sqlx - тонкая обёртка, убирающая рутину сканирования. GORM - ORM. Для нового сервиса на PostgreSQL - pgx напрямую, сканирование через pgx.CollectRows или sqlc.

Аргументация выбора

  • pgx нативно даёт бинарный протокол (меньше трафика и аллокаций), нативные типы PostgreSQL (jsonb, массивы, диапазоны, uuid без промежуточного []byte), CopyFrom, Batch, LISTEN/NOTIFY и типизированные ошибки pgconn.PgError с кодом и именем констрейнта.
  • Цена: привязка к PostgreSQL и свой API - часть экосистемы (например, sqlmock) рассчитана на database/sql. Для меня это не проблема: интеграционные тесты я всё равно пишу на testcontainers с настоящей базой, а не на моках драйвера.
  • sqlx разумно брать, если нужен стандартный интерфейс и переносимость: StructScan, Get, Select, sqlx.In закрывают 80 % боли database/sql почти без магии.
  • GORM уместен там, где действительно много однотипного CRUD и мало сложных запросов. Проблемы: сгенерированный SQL непредсказуем и его надо логировать, Preload легко забыть и получить N+1, AutoMigrate на проде опасен (он не умеет откатываться и делает неожиданные ALTER).
  • sqlc стоит упомянуть отдельно: SQL остаётся SQL-ом, но проверяется ещё при генерации и превращается в типизированные Go-функции. Это лучший из известных компромиссов между контролем и рутиной.
Чем добить

«Критерий выбора я формулирую так: смогу ли я в любой момент увидеть точный SQL, который уходит в базу, и объяснить его план. Если инструмент этому мешает, он не подходит для сервиса, где база и есть узкое место. Отсюда и порядок предпочтений: pgx/sqlc → sqlx → database/sql → ORM.»

Суть: MaxOpenConns - потолок одновременных соединений, MaxIdleConns - сколько держать наготове, ConnMaxLifetime - когда принудительно пересоздать, ConnMaxIdleTime - когда закрыть простаивающее. Дефолты вредны: MaxOpenConns = 0 (без лимита) и MaxIdleConns = 2. Подбирается от бюджета max_connections базы, поделённого на все инстансы, а не «побольше».

Почему дефолты плохие

  • С MaxOpenConns = 0 при всплеске Go откроет столько соединений, сколько горутин пришло, упрётся в max_connections сервера и получит «FATAL: sorry, too many clients already». Причём упадёт не один этот сервис, а все, кто ходит в ту же базу, включая мониторинг.
  • MaxIdleConns = 2 при MaxOpenConns = 50: как только нагрузка спадает, пул закрывает всё сверх двух, а на следующем всплеске снова устанавливает соединения - TCP + TLS + аутентификация, миллисекунды на каждое, и всё это в hot path. Симптом: MaxIdleClosed в DBStats растёт тысячами в минуту.

Как считать размер

  1. Сверху: max_connections (обычно 100–200) минус резерв (superuser_reserved_connections, мониторинг, миграции, человек с psql), делить на число инстансов всех сервисов, ходящих в эту базу.
  2. Снизу, по закону Литтла: нужное число = RPS × средняя длительность запроса. 400 RPS × 10 мс = 4 соединения на инстанс. Если получается 50, лечить надо длительность запросов.
  3. Проверка здравого смысла: PostgreSQL хорошо работает, когда активных соединений порядка 2 × CPU. Сверх этого начинается деградация из-за конкуренции за LWLock и вытеснения кэшей, и пропускная способность падает, а не растёт.
  4. MaxIdleConns = MaxOpenConns почти всегда подходит сервису под постоянной нагрузкой.
  5. ConnMaxLifetime 30–60 минут обязателен, если между приложением и БД есть балансировщик, VIP или облачный прокси: без пересоздания соединения не переедут после failover и не перебалансируются.
Что мониторить

Экспортировать db.Stats() целиком. WaitCount и WaitDuration растут, если пул мал или запросы долгие; InUse против MaxOpenConnections показывает запас до потолка; MaxIdleClosed подскажет, не мал ли MaxIdleConns. Без этих метрик исчерпание пула ищут часами.

Суть: не ошибка, а ожидание. Горутина блокируется на получении соединения и стоит в очереди, пока кто-то не освободит соединение или пока не истечёт context. Поэтому в проде это видно как рост p99 и context deadline exceeded при абсолютно спокойной базе - самый частый неверный диагноз «база тормозит».

Последовательность событий

  1. Все MaxOpenConns соединений заняты.
  2. Очередная горутина вызывает QueryContext и блокируется внутри database/sql в ожидании освобождения.
  3. Растут WaitCount и WaitDuration.
  4. Если у запроса есть context с таймаутом, по истечении вернётся context deadline exceeded. Если контекста нет, горутина будет ждать вечно.
  5. Очередь растёт, горутины копятся, растёт потребление памяти, HTTP-хендлеры не отвечают, health-check падает, под перезапускается - и трафик уходит на соседние поды, которые падают следом. Каскад.

Типичные первопричины

  • Забытый rows.Close() - соединение не возвращается в пул. Классика: return из середины цикла rows.Next() без defer rows.Close(). Симптом: InUse монотонно растёт до потолка, и всё залипает.
  • Долгие транзакции: HTTP-вызов или запись в брокер внутри BeginTx/Commit. Соединение занято всё это время.
  • Медленный запрос без индекса: 30 запросов по 2 секунды намертво выедают пул из 25.
  • N+1 при росте страницы: 500 быстрых запросов вместо двух - и пул не справляется.
  • Блокировки в БД: транзакции ждут друг друга, соединения заняты ожиданием.
Что делать
  • Всегда ставить context с таймаутом на запрос: бесконечное зависание станет быстрой видимой ошибкой.
  • Найти держателя: pg_stat_activity с сортировкой по now() - query_start, отдельно посмотреть state = 'idle in transaction' - это почти всегда баг приложения.
  • Поставить idle_in_transaction_session_timeout на стороне БД как страховку от забытого Commit.
  • Пул увеличивают в последнюю очередь: так обычно лечат симптом и перекладывают проблему на саму базу.
  • На уровне архитектуры ограничить конкурентность (семафор, worker pool) раньше, чем на уровне пула БД, чтобы отказ был контролируемым.
Суть: в PostgreSQL каждое соединение - отдельный процесс на сервере (несколько мегабайт памяти плюс накладные расходы), поэтому max_connections ограничен сотнями. pgbouncer мультиплексирует: тысячи клиентских соединений он раскладывает на десятки серверных. Три режима: session (соединение занято на всю сессию), transaction (на время транзакции - главный рабочий режим), statement (на один оператор).

Когда действительно нужен

  • Много инстансов сервиса: 30 подов × 20 соединений = 600 при max_connections = 200.
  • Kubernetes с автоскейлингом и serverless - число клиентов меняется на ходу, заранее поделить бюджет невозможно.
  • Клиенты, которые не умеют пулить (короткоживущие процессы, cron-скрипты, PHP).
  • Нужен единый шлюз для смены мастера при failover: приложения не переподключаются, pgbouncer переключает PAUSE/RESUME.

Почему ломаются prepared statements

PREPARE создаёт именованный план в конкретной серверной сессии. В transaction mode после COMMIT серверное соединение уходит другому клиенту, и следующий EXECUTE попадёт на другое соединение, где плана нет: «prepared statement "stmtcache_…" does not exist». Драйверы Go по умолчанию используют extended protocol с неявной подготовкой, поэтому проблема вылезает сразу, а не в экзотике.

Как обходят

  • pgx: config.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol (или QueryExecModeExec) - параметры отправляются вместе с запросом, именованных планов не создаётся. Защита от инъекций сохраняется, но каждый запрос планируется заново.
  • pgbouncer 1.21+ умеет отслеживать protocol-level prepared statements (max_prepared_statements > 0) и переигрывать Parse на новом серверном соединении - самое удобное современное решение.
  • Два пути к БД: API-трафик через pgbouncer, а фоновые воркеры с длинными транзакциями, LISTEN/NOTIFY и advisory-локами ходят напрямую.

Что ещё ломается в transaction mode

  • SET / SET LOCAL: сессионные настройки утекают чужому клиенту или теряются. SET LOCAL внутри транзакции безопасен.
  • Temp-таблицы: создаются в сессии, следующей транзакции их не видно.
  • LISTEN/NOTIFY живёт только в сессии.
  • Advisory locks уровня сессии (pg_advisory_lock): могут «залипнуть» на чужом соединении. Использовать транзакционные pg_advisory_xact_lock.
  • Курсоры WITH HOLD вне транзакции.
Связь с клиентским пулом

Пулов становится два. Правило: Σ MaxOpenConns всех подов ≤ max_client_conn, а default_pool_size ≤ max_connections минус резерв. Клиентский пул при этом держат небольшим: соединение до pgbouncer дёшево, но пока идёт транзакция, оно всё равно удерживает серверную сессию. И ConnMaxLifetime оставляем, чтобы клиенты перераспределялись между инстансами bouncer.

Суть: инъекция возможна там, где пользовательские данные склеиваются с текстом запроса и потому парсятся как SQL. Лечение - параметризованные запросы: текст и значения едут по протоколу отдельно, значение никогда не становится синтаксисом. Но параметром нельзя подставить имя таблицы, имя колонки или направление сортировки - там спасает только белый список.
// Уязвимо
q := "SELECT id, email FROM users WHERE name = '" + name + "'"
// name = "' OR '1'='1"                 → отдаст всю таблицу
// name = "' UNION SELECT id, password_hash FROM admins --"  → утечка
// name = "'; UPDATE users SET is_admin = true WHERE email='me@x.y'; --"

// Закрыто
rows, err := db.QueryContext(ctx, `SELECT id, email FROM users WHERE name = $1`, name)

Почему параметризация действительно защищает

В extended query protocol PostgreSQL сообщение Parse несёт текст запроса с плейсхолдерами, а Bind - значения. Сервер уже построил дерево разбора к моменту, когда увидел значение; подставить в него новый оператор невозможно физически. Защищает разделение каналов, а не экранирование или фильтрация. Поэтому «я экранирую кавычки» заведомо проигрывает: есть многобайтные кодировки, есть числовые контексты без кавычек, есть комментарии.

Динамическая сортировка и имена объектов

// ORDER BY $1 синтаксически валиден, но означает «сортировать по константе».
// Работать как задумано не будет. А склейка открывает дыру, причём blind-типа:
// злоумышленник читает данные посимвольно через CASE в ORDER BY,
// даже не видя текста ошибок.

var allowed = map[string]string{
    "date_desc": "created_at DESC, id DESC",
    "date_asc":  "created_at ASC, id ASC",
    "name":      "name ASC",
}
order, ok := allowed[req.Sort]
if !ok { order = "created_at DESC, id DESC" }
q := fmt.Sprintf(`SELECT id, name FROM users ORDER BY %s LIMIT $1 OFFSET $2`, order)

// Динамическое имя схемы (мультитенантность): квотируем идентификатор
tbl := pgx.Identifier{tenantSchema, "orders"}.Sanitize()
q := fmt.Sprintf(`SELECT id FROM %s WHERE created_at > $1`, tbl)
Эшелонированная защита
  • Параметризация обязательна как базовый уровень.
  • Белый список для всего, кроме значений.
  • Отдельная роль в БД с минимальными правами: сервису не нужен DROP TABLE, миграции катятся другой ролью.
  • Row Level Security для мультитенантности - даже при ошибке в запросе чужие строки не уйдут.
  • Не отдавать пользователю текст ошибки БД: он подсказывает структуру схемы.
  • Линтеры и статический анализ (gosec, правило про fmt.Sprintf в SQL) в CI.
  • Логировать запросы с параметрами отдельно от текста - тогда видны аномалии.
Суть: BeginTx → сразу defer с Rollback → работа только через tx → Commit. Ошибка в середине переводит транзакцию PostgreSQL в состояние aborted: все следующие операторы будут отвечать «current transaction is aborted», спасает только ROLLBACK или откат к SAVEPOINT. Ошибка Commit - неопределённость: коммит мог пройти, а ответ потеряться.
func (r *Repo) Do(ctx context.Context) (err error) {
    tx, err := r.db.BeginTx(ctx, nil)
    if err != nil { return fmt.Errorf("begin: %w", err) }
    defer func() {
        if p := recover(); p != nil { _ = tx.Rollback(); panic(p) }
        if err != nil { _ = tx.Rollback() }     // err здесь именованный результат
    }()

    if _, err = tx.ExecContext(ctx, q1, a...); err != nil { return err }
    if _, err = tx.ExecContext(ctx, q2, b...); err != nil { return err }

    if err = tx.Commit(); err != nil { return fmt.Errorf("commit: %w", err) }
    return nil
}

// SAVEPOINT: когда часть работы допустимо потерять, не теряя всю транзакцию
if _, err := tx.ExecContext(ctx, "SAVEPOINT sp1"); err != nil { return err }
if _, err := tx.ExecContext(ctx, riskyQuery); err != nil {
    _, _ = tx.ExecContext(ctx, "ROLLBACK TO SAVEPOINT sp1")   // транзакция снова рабочая
} else {
    _, _ = tx.ExecContext(ctx, "RELEASE SAVEPOINT sp1")
}

Разбор деталей

  • Именованный возврат (err error) остаётся единственным способом дать defer увидеть ошибку, с которой функция выходит. Без него откат придётся ставить на каждом return, и однажды его забудут.
  • Rollback после Commit вернёт sql.ErrTxDone. Это нормально, ошибку явно игнорируем.
  • recover в defer нужен, чтобы паника не оставила транзакцию открытой (соединение зависнет в состоянии idle in transaction до таймаута). После отката панику пробрасываем дальше - глотать её нельзя.
  • Только tx. Случайный вызов r.db.Exec внутри транзакции уйдёт по другому соединению: изменения окажутся вне транзакции, а при неудаче ещё и заблокируются на строке, которую держит собственная транзакция. Такую самоблокировку диагностируют часами.
  • Isolation. &sql.TxOptions{Isolation: sql.LevelRepeatableRead} - и не забыть, что на Repeatable Read и Serializable нужен ретрай при 40001 (serialization failure).
Ошибка Commit - самое важное место

Если Commit вернул сетевую ошибку, ты не знаешь, применилась транзакция или нет: сервер мог зафиксировать её и упасть до отправки ответа. Наивный ретрай приведёт к двойному списанию. Правильные ответы: (1) сделать операцию идемпотентной через уникальный ключ на бизнес-действие (idempotency_key) плюс ON CONFLICT DO NOTHING; (2) перед ретраем проверить состояние - «а есть ли уже такой перевод?»; (3) вернуть наверх ошибку «неизвестно» и разбирать сверкой, если операция денежная. Это, кстати, ровно та же проблема, что и в 2PC.

Суть: три подхода - (1) интерфейс DBTX, который одинаково реализуют пул и транзакция, и репозиторий не знает, что под ним; (2) UnitOfWork/Transactor: сервисный слой оборачивает работу в WithinTx(ctx, func(ctx) error); (3) прокинуть tx в context. Первые два предпочтительнее; третий удобен, но прячет зависимость.

Почему это вообще проблема

Транзакция принадлежит сценарию (создать заказ, списать со склада, положить событие в outbox), а репозитории пишутся по одной сущности. Значит, границей транзакции управляет слой выше репозитория, а репозитории должны уметь работать как внутри неё, так и вне. Если этого не решить, получается либо репозиторий, который сам открывает транзакцию (и тогда атомарности между сущностями нет), либо tx в каждой сигнатуре.

ПодходПлюсыМинусы
Интерфейс DBTX в конструктореявно, тестируемо, ноль магиинужно уметь создать «репозиторий на транзакции» - обычно метод WithTx(tx) *Repo
UnitOfWork / Transactorграница транзакции видна в одном месте; легко добавить ретраи на 40001ещё одна абстракция; при вложенных вызовах нужны savepoint-ы
tx в contextсигнатуры не меняются, транзакция «протекает» через слоискрытая зависимость, компилятор не помогает, легко забыть обёртку
Явный tx первым аргументоммаксимально честнопротекание инфраструктуры в доменные сигнатуры на всех уровнях

Аргументы против tx в context

  • По сигнатуре Insert(ctx, user) error невозможно понять, участвует ли вызов в транзакции. Код перестаёт читаться локально.
  • WithinTx легко забыть, и код будет работать, просто без атомарности. Такой баг находят в проде.
  • context по соглашению Go отвечает за отмену, дедлайны и request-scoped данные, а не за внедрение зависимостей. Это прямо написано в документации context.WithValue.
  • Тесты сложнее: для репозитория «в транзакции» нужно собрать правильный контекст.
  • Вложенность: если WithinTx вызвать внутри WithinTx, наивная реализация откроет вторую транзакцию на другом соединении и заблокируется сама на себе. Нужно уметь распознать существующую и сделать savepoint.
Что отвечать

«В новом коде беру Transactor с явным DBTX в репозиториях: граница транзакции видна, компилятор проверяет. В большом легаси, где переписать сигнатуры нереально, tx в контексте - прагматичный компромисс, но тогда я закрываю его линтером и тестом, который проверяет, что репозиторий внутри WithinTx действительно попадает в транзакцию.»

Суть: драйвер отправляет серверу CancelRequest - отдельное соединение с просьбой прервать текущий запрос конкретного backend-процесса. Это асинхронная просьба, а не гарантия: сервер прервёт работу, когда дойдёт до ближайшей точки проверки прерываний. До этого момента запрос продолжает потреблять CPU и держать блокировки.

Что важно понимать

  • Отмена ≠ откат. Прерванный UPDATE откатывается не потому, что его отменили, а потому что транзакция не была зафиксирована. Если автокоммит и оператор успел завершиться до прихода отмены, изменения останутся.
  • Задержка. Тяжёлая сортировка, длинный COPY и ожидание блокировки реагируют на отмену каждое в свой момент. Некоторые операции (например, fsync при коммите) вообще не прерываются.
  • Соединение считается грязным. Go после отмены обычно закрывает соединение, а не возвращает в пул: неизвестно, в каком оно состоянии. При массовых таймаутах это даёт шторм переподключений, который добивает базу - типичная вторая волна инцидента.
  • Отмена HTTP-запроса пользователем тоже отменяет контекст, а с ним и запрос в БД. Это хорошо для чтения и опасно для записи: сценарий «пользователь нажал Escape в середине оформления заказа» должен работать предсказуемо, поэтому для записи часто берут context.WithoutCancel (Go 1.21+) или отдельный контекст с собственным таймаутом.
Практика
  • Клиентский таймаут дублировать серверным: SET statement_timeout на роль сервиса или SET LOCAL statement_timeout в транзакции. Серверный надёжнее: он действует, даже если приложение умерло.
  • idle_in_transaction_session_timeout спасает от забытых Commit.
  • lock_timeout не даст миграции встать в очередь и заблокировать всё за собой.
  • Таймаут должен быть меньше у нижележащего слоя, чем у вышестоящего, иначе отмена не успевает сработать до того, как клиент уже ушёл.
  • Убить зависший запрос вручную: pg_cancel_backend(pid) просит вежливо, pg_terminate_backend(pid) грубо рвёт соединение.
Суть: rows.Close() возвращает соединение в пул - без него утекают соединения и пул исчерпывается. rows.Err() отличает «данные кончились» от «итерация оборвалась ошибкой» - без него вы вернёте неполный результат как успешный, и это самая тихая из возможных ошибок работы с БД.
rows, err := db.QueryContext(ctx, q, args...)
if err != nil { return nil, err }
defer rows.Close()          // (1) обязательно и сразу

var out []User
for rows.Next() {
    var u User
    if err := rows.Scan(&u.ID, &u.Name); err != nil { return nil, err }
    out = append(out, u)
}
if err := rows.Err(); err != nil {   // (2) обязательно после цикла
    return nil, fmt.Errorf("iterate rows: %w", err)
}
return out, nil

Детали, которые проверяют

  • rows.Next() возвращает false в двух разных случаях: строки закончились или произошла ошибка (обрыв сети, серверная ошибка в середине выдачи, истёкший контекст, отвалившаяся реплика). Различить их можно только через rows.Err().
  • Next(), дойдя до конца, сам вызывает Close(). Но при раннем return из цикла этого не произойдёт, поэтому defer rows.Close() нужен всегда.
  • Повторный Close() безопасен, он идемпотентен.
  • QueryRow не требует Close: Scan закрывает набор сам. Но именно поэтому QueryRow нельзя «отложить» - ошибка запроса приезжает только в Scan.
  • Nested query trap: открыть rows и внутри цикла делать другой запрос через тот же *sql.DB значит занять два соединения на горутину одновременно. При пуле в 25 и 25 параллельных обработчиках получается дедлок на пуле: все держат по одному соединению и ждут второго. Лечится так: сначала вычитать результат в память, потом делать вложенные запросы (а обычно вложенный запрос вообще не нужен - это N+1).
  • В pgx rows.Close() ничего не возвращает, а ошибку отдаёт rows.Err(); сегодня проще взять pgx.CollectRows с RowToStructByName, он сделает всё правильно за тебя.
Как выглядит утечка

DBStats.InUse монотонно растёт и не падает даже в тишине; в pg_stat_activity копятся сессии в состоянии idle; через какое-то время каждый запрос начинает ждать. В коде причина почти всегда одна: return внутри цикла rows.Next() без defer rows.Close(). Ловят это линтеры rowserrcheck и sqlclosecheck - их стоит включить в CI.

Суть: в обычный string NULL не просканируется - будет «converting NULL to string is unsupported». Три способа: обёртки sql.NullString/sql.NullInt64, указатели (*string, где nil = NULL), или убрать NULL на стороне SQL через coalesce. Разница между первыми двумя - вопрос стиля и JSON.
sql.NullString*string
Читаемость проверкиif v.Valid - явноif p != nil - привычно для Go
Риск паникинетесть - разыменование nil
JSON по умолчаниюсериализуется как объект {"String":"","Valid":false} - почти всегда не то, нужен свой MarshalJSONсериализуется как null - обычно ровно то, что нужно
Аллокациинетесть - указатель на heap
Доменная модельтащит зависимость от database/sql в доменчистый Go
pgxподдерживаетсяподдерживается, плюс есть pgtype.Text с тем же смыслом

Практический подход

  • В слое репозитория живут sql.Null* или pgtype.*, в доменной модели - указатели или отдельное значение по умолчанию. Конвертирует маппер, и домен про SQL не знает.
  • Часто чище всего убрать NULL в запросе: SELECT coalesce(middle_name, '') FROM users. Годится, когда пустая строка и NULL семантически неразличимы. Не годится, когда различимы: NULL «телефон не указан» и пустая строка «указан пустым» означают разные факты.
  • Ещё лучше не заводить NULL в схеме там, где он не несёт смысла: NOT NULL DEFAULT ''. Каждый nullable-столбец навсегда добавляет ветвление в код.
  • Помнить про агрегаты: sum() по пустому набору вернёт NULL, а не 0. Классический баг: SELECT sum(amount) FROM orders WHERE ... с приёмником int64 падает, когда заказов не нашлось. Лечится coalesce(sum(amount), 0).
Суть: «строк не найдено» - это нормальный доменный исход, а не сбой инфраструктуры. Репозиторий должен превратить sql.ErrNoRows в доменную ошибку (ErrNotFound), чтобы верхние слои не зависели от database/sql и чтобы транспортный слой смог отдать 404, а не 500.
// domain
var ErrNotFound = errors.New("not found")

// repository
func (r *Repo) GetUser(ctx context.Context, id int64) (*User, error) {
    var u User
    err := r.db.QueryRowContext(ctx,
        `SELECT id, email FROM users WHERE id = $1`, id).Scan(&u.ID, &u.Email)
    switch {
    case errors.Is(err, sql.ErrNoRows):       // pgx: errors.Is(err, pgx.ErrNoRows)
        return nil, fmt.Errorf("user %d: %w", id, domain.ErrNotFound)
    case err != nil:
        return nil, fmt.Errorf("get user %d: %w", id, err)
    }
    return &u, nil
}

// transport
if errors.Is(err, domain.ErrNotFound) {
    http.Error(w, "not found", http.StatusNotFound)
    return
}

Почему именно так

  • Слои не должны знать про драйвер. Если хендлер проверяет sql.ErrNoRows, то при замене database/sql на pgx или переезде в другое хранилище придётся править транспорт.
  • 404 против 500. Проброшенная как есть ошибка почти всегда превращается в 500 и в алерт, хотя ничего нештатного в «пользователь не найден» нет.
  • errors.Is, а не ==. Ошибка почти всегда обёрнута через %w; сравнение на равенство перестанет работать при первом же добавлении контекста.
  • У pgx своя pgx.ErrNoRows. С pgx v5.7 она оборачивает sql.ErrNoRows, и errors.Is(err, sql.ErrNoRows) срабатывает и на неё, а в pgx v4 и ранних v5 это разные ошибки - типичная ловушка при миграции с lib/pq.
  • Не для всех случаев это ошибка. Для «есть ли такой email» правильнее вернуть (bool, error) или (*User, error) с nil, nil, чем заставлять вызывающего ловить ошибку ради нормального ответа. Ошибка уместна там, где отсутствие записи действительно нарушает ожидания.
Смежная тема, которую спрашивают следом

Так же маппят и нарушение уникальности: pgconn.PgError с Code == "23505" → domain.ErrAlreadyExists → HTTP 409. И "23503" (foreign key), "40001" (serialization failure, ретраить), "40P01" (deadlock, тоже ретраить), "57014" (query canceled). Если назовёшь пару кодов SQLSTATE и покажешь, что различаешь «ретраить» и «не ретраить», это очень хорошо смотрится.

Суть: миграции - версионированные SQL-файлы, применяемые по порядку; инструмент хранит применённые версии в служебной таблице (goose_db_version, schema_migrations) и накатывает недостающие. Ключевые практики: timestamp вместо порядкового номера, один процесс-мигратор, обратно совместимые изменения, откат «вперёд», а не через down.

Что важно знать про инструменты

  • goose: файлы с секциями -- +goose Up / -- +goose Down; поддерживает Go-миграции для сложной логики; есть -- +goose NO TRANSACTION - обязательно для CREATE INDEX CONCURRENTLY, который не работает внутри транзакции.
  • golang-migrate: пары файлов *.up.sql / *.down.sql; понятие dirty state - если миграция упала посередине, база помечается грязной и дальнейшие запуски отказываются работать до force.
  • Каждая миграция по умолчанию выполняется в транзакции. В PostgreSQL DDL транзакционен, и это большое преимущество: неудачная миграция откатывается целиком. Исключения - CREATE INDEX CONCURRENTLY, ALTER TYPE ... ADD VALUE (до PG 12), VACUUM.

Конфликты версий и как их гасить

  • Одинаковые номера из двух веток лечатся переходом на timestamp-именование.
  • Порядок при мерже. Даже с timestamp бывает так: ветку с более ранним timestamp смёржили позже. На проде она применится, а у разработчика, у которого более поздняя версия уже записана, - нет. goose умеет -allow-missing, у migrate этого нет; универсальное решение - перед мержем переименовать миграцию, чтобы она стала последней.
  • Гонка при старте. Десять подов одновременно запускают миграции. Правильно - отдельная джоба или шаг деплоя, которые запускаются один раз; init-контейнер не годится, он стартует в каждом поде. golang-migrate берёт advisory lock сам, goose - только если его явно включить (WithSessionLocker в Provider API), и осознанно полагаться на это не стоит.
  • Долгая миграция блокирует деплой. Тяжёлый backfill выносится из миграции в отдельную фоновую задачу, миграция остаётся быстрой и только меняет схему.
  • Ревью миграций обязательно. Отдельным глазом смотреть на любой ALTER, на отсутствующий CONCURRENTLY и на lock_timeout.
Про down-миграции

На проде их почти не применяют. down, который делает DROP COLUMN, уничтожает данные - релиз откатить можно, а данные уже не вернуть. Поэтому откатываются обычно новой миграцией вперёд, а down пишут для локальной разработки и тестов. И проектировать миграции надо так, чтобы откат кода без отката схемы был безопасен - в этом и смысл expand/contract.

Суть: паттерн expand/contract: (1) добавить колонку nullable - это метаданная операция; (2) выкатить код, который пишет в неё, оставаясь совместимым со старым; (3) backfill батчами; (4) навесить NOT NULL через CHECK ... NOT VALID + VALIDATE. Индексы - только CREATE INDEX CONCURRENTLY. Каждый шаг - отдельный релиз, между шагами возможен откат.
-- Шаг 1 (мгновенно): nullable-колонка меняет только системный каталог
ALTER TABLE users ADD COLUMN phone text;

-- Шаг 2: релиз кода, который пишет phone. Старая версия кода тоже работает.

-- Шаг 3: backfill батчами, а не одним UPDATE
UPDATE users SET phone = derive(...)
WHERE phone IS NULL AND id BETWEEN $1 AND $2;   -- по 10k, с паузой, следя за lag

-- Шаг 4: NOT NULL без ACCESS EXCLUSIVE на весь скан
ALTER TABLE users ADD CONSTRAINT users_phone_nn CHECK (phone IS NOT NULL) NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT users_phone_nn;   -- SHARE UPDATE EXCLUSIVE
ALTER TABLE users ALTER COLUMN phone SET NOT NULL;      -- мгновенно (PG 12+)
ALTER TABLE users DROP CONSTRAINT users_phone_nn;       -- опционально

-- Индекс
CREATE INDEX CONCURRENTLY users_phone_idx ON users (phone);
SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid;  -- проверить успех

Что именно ломается при «прямом» подходе

  • ADD COLUMN ... NOT NULL DEFAULT 'x' до PostgreSQL 11 переписывал всю таблицу под ACCESS EXCLUSIVE. С PG 11 значение по умолчанию хранится в каталоге и операция мгновенна, но знать историю надо: на PG 10 это десятки минут простоя.
  • ALTER COLUMN ... SET NOT NULL напрямую запускает полный скан таблицы под ACCESS EXCLUSIVE, который блокирует даже SELECT.
  • CREATE INDEX без CONCURRENTLY блокирует запись на всё время построения.
  • Очередь блокировок. Самое коварное: ALTER, ожидающий ACCESS EXCLUSIVE, встаёт в очередь за долгим SELECT, а все запросы, пришедшие после него, встают уже за ним. Одна миграция при одном долгом отчёте кладёт весь сервис. Лечится SET lock_timeout = '3s' и ретраями.
Правила безопасных миграций
  • Схема должна быть совместима с двумя версиями кода - предыдущей и текущей. Иначе rolling deploy невозможен в принципе.
  • Никогда не переименовывать и не удалять то, что читает работающий код. Переименовывают минимум за два релиза, удаляют через недели.
  • CREATE INDEX CONCURRENTLY вне транзакции; проверять indisvalid после - при неудаче остаётся невалидный индекс, который занимает место и обновляется при записи.
  • Backfill делают фоновой задачей, а не миграцией: батчи, паузы, контроль replication lag и autovacuum.
  • lock_timeout на любом ALTER.
  • DROP COLUMN в PostgreSQL мгновенен (колонка помечается удалённой), но место освободит только VACUUM FULL или pg_repack.

Что стоит запомнить

  • database/sql.Open создаёт пул, а не гарантирует установленное соединение; подключение проверяют через PingContext.
  • Забранное rows соединение возвращается в пул после Close, а ошибки чтения проверяются через rows.Err.
  • Расширяй схему совместимыми шагами: добавь nullable-поле, раскатай код, заполни данные, затем вводи ограничение.

Частые ошибки

  • Склейка пользовательского значения с SQL открывает инъекцию; имена колонок и направление сортировки требуют allowlist, потому что плейсхолдер не подставляет идентификатор.
  • Не создавай транзакцию внутри репозитория, если её границы должны охватывать несколько операций use case.
  • Не считай context cancellation гарантией мгновенной остановки запроса на сервере БД.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Пул исчерпан или база медленная?

Метрики показывают MaxOpenConnections=25, OpenConnections=25, InUse=25 и растущие WaitCount/WaitDuration. Какую гипотезу проверить раньше, чем увеличить пул?

s := db.Stats()
fmt.Printf("max=%d open=%d inuse=%d wait=%d waitDuration=%s\n",
    s.MaxOpenConnections, s.OpenConnections, s.InUse, s.WaitCount, s.WaitDuration)
Ответ и разбор

Все соединения заняты, поэтому запросы ждут очередь пула. Сначала найди долгие запросы и транзакции, утечки rows/tx, всплеск параллелизма и соотношение числа соединений всех реплик с лимитом БД. Увеличение MaxOpen может перегрузить сервер, а не устранить причину. Метрики ожидания показывают проблему пула, но не объясняют, почему соединения удерживаются.

Код-ревью

2. Исправь чтение rows

Найди минимум две ошибки жизненного цикла соединения и предложи исправление.

rows, err := db.QueryContext(ctx, q)
if err != nil { return nil, err }
var out []User
for rows.Next() {
    var u User
    if err := rows.Scan(&u.ID, &u.Name); err != nil { return nil, err }
    out = append(out, u)
}
return out, nil
Ответ и разбор

Нет defer rows.Close(), поэтому ранний выход может удержать соединение; не проверен rows.Err(), где появится ошибка, случившаяся во время чтения. Закрой rows сразу после успешного QueryContext и после цикла верни обёрнутую rows.Err(), если она не nil. При Scan-ошибке defer закроет результат.

Применение

3. Выбери границу транзакции

Один use case сначала создаёт заказ, затем резервирует строки и записывает аудит. Репозиторий каждого шага сам начинает и коммитит транзакцию. Что может пойти не так и как изменить границу?

Ответ и разбор

Каждый шаг фиксируется отдельно, поэтому ошибка резерва или аудита оставит частичное состояние. Use case должен владеть общей транзакцией и передать её операциям через consumer-side интерфейс/Transactor либо транзакционный контекст, в рамках существующего паттерна проекта. Репозитории не должны самостоятельно решать, что многошаговая бизнес-операция завершена.

Применение

4. План миграции nullable-поля

В таблице 200 млн строк нужно сделать phone NOT NULL, а релиз будет некоторое время идти со старым и новым кодом одновременно. Составь порядок безопасных шагов.

Ответ и разбор

Добавь nullable-колонку без тяжёлого заполнения, раскатай код, совместимый с отсутствующим значением и записывающий phone, затем заполни старые строки небольшими батчами с контролем нагрузки и лага. Добавь CHECK(phone IS NOT NULL) NOT VALID, валидируй ограничение отдельно, затем установи NOT NULL поддерживаемым способом. Не удаляй старую совместимость, пока старые поды и фоновые процессы ещё работают.