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 через stdlib | pgx как драйвер для database/sql | стандартный API + актуальный драйвер | теряется часть возможностей pgx | когда нужен стандартный интерфейс, но не нужен lib/pq |
| sqlx | тонкая обёртка над database/sql | StructScan, 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» был
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)
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 - соединение
тут же ушло другому. Экономия большая, но с ней приходит правило, которое ломает половину
привычек: ничего нельзя оставлять «в сессии», потому что следующий запрос почти наверняка
уедет на другое соединение.
| Режим | Когда соединение возвращается в пул | Что ломается | Экономия |
|---|---|---|---|
| session | при отключении клиента | ничего | минимальная - только на переустановке соединений |
| transaction | по COMMIT/ROLLBACK | prepared statements, SET, temp-таблицы, advisory locks на сессию, LISTEN/NOTIFY, курсоры вне транзакции | максимальная и практичная - стандартный выбор |
| statement | после каждого оператора | всё вышеперечисленное плюс многооператорные транзакции вообще | предельная; применим только там, где транзакций нет по определению |
Любой 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.
Пулов становится два, и они не знают друг о друге. Правило: суммарный
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 напрямую
Плейсхолдер подставляет значение, а не кусок синтаксиса. Нельзя подставить параметром
имя таблицы, имя колонки, направление сортировки, 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 параметром через пять уровней.
Против: зависимость становится скрытой, и по сигнатуре
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)
}
Отмена контекста не останавливает запрос мгновенно. 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-флаг
-- Индекс на живой таблице: только 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: добавить новую колонку, начать писать в обе (в коде или триггером), читать пока из старой.
- Backfill: скопировать данные батчами.
- Релиз 2: переключить чтение на новую колонку. Пишем всё ещё в обе.
- Релиз 3: перестать писать в старую.
- Релиз 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 всё равно надо - для локальной разработки и тестов.
Вопросы
13database/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растёт тысячами в минуту.
Как считать размер
- Сверху:
max_connections(обычно 100–200) минус резерв (superuser_reserved_connections, мониторинг, миграции, человек с psql), делить на число инстансов всех сервисов, ходящих в эту базу. - Снизу, по закону Литтла: нужное число = RPS × средняя длительность запроса. 400 RPS × 10 мс = 4 соединения на инстанс. Если получается 50, лечить надо длительность запросов.
- Проверка здравого смысла: PostgreSQL хорошо работает, когда активных
соединений порядка
2 × CPU. Сверх этого начинается деградация из-за конкуренции за LWLock и вытеснения кэшей, и пропускная способность падает, а не растёт. - MaxIdleConns = MaxOpenConns почти всегда подходит сервису под постоянной нагрузкой.
- ConnMaxLifetime 30–60 минут обязателен, если между приложением и БД есть балансировщик, VIP или облачный прокси: без пересоздания соединения не переедут после failover и не перебалансируются.
Экспортировать db.Stats() целиком.
WaitCount и WaitDuration растут, если пул мал или
запросы долгие; InUse против MaxOpenConnections показывает запас
до потолка; MaxIdleClosed подскажет, не мал ли MaxIdleConns.
Без этих метрик исчерпание пула ищут часами.
context. Поэтому в проде это видно как рост p99 и
context deadline exceeded при абсолютно спокойной базе - самый частый
неверный диагноз «база тормозит».Последовательность событий
- Все
MaxOpenConnsсоединений заняты. - Очередная горутина вызывает
QueryContextи блокируется внутриdatabase/sqlв ожидании освобождения. - Растут
WaitCountиWaitDuration. - Если у запроса есть
contextс таймаутом, по истечении вернётсяcontext deadline exceeded. Если контекста нет, горутина будет ждать вечно. - Очередь растёт, горутины копятся, растёт потребление памяти, 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) раньше, чем на уровне пула БД, чтобы отказ был контролируемым.
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.
// Уязвимо
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 вернул сетевую ошибку, ты не знаешь, применилась
транзакция или нет: сервер мог зафиксировать её и упасть до отправки ответа.
Наивный ретрай приведёт к двойному списанию. Правильные ответы: (1) сделать
операцию идемпотентной через уникальный ключ на бизнес-действие
(idempotency_key) плюс ON CONFLICT DO NOTHING;
(2) перед ретраем проверить состояние - «а есть ли уже такой перевод?»;
(3) вернуть наверх ошибку «неизвестно» и разбирать сверкой, если операция денежная.
Это, кстати, ровно та же проблема, что и в 2PC.
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 действительно попадает в транзакцию.»
Что важно понимать
- Отмена ≠ откат. Прерванный
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 и
покажешь, что различаешь «ретраить» и «не ретраить», это очень хорошо смотрится.
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, который делает
DROP COLUMN, уничтожает данные - релиз откатить можно, а данные уже
не вернуть. Поэтому откатываются обычно новой миграцией вперёд,
а down пишут для локальной разработки и тестов. И проектировать
миграции надо так, чтобы откат кода без отката схемы был безопасен -
в этом и смысл expand/contract.
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 поддерживаемым способом. Не удаляй старую совместимость, пока старые поды и фоновые процессы ещё работают.