Если в Go-проекте есть PostgreSQL, то вопрос выбора клиентского слоя рано или поздно сводится к нескольким именам: database/sql, lib/pq, sqlx, pgx. Раньше pq часто был базовым выбором по инерции, а sqlx добавляли ради удобного сканирования в структуры. Сейчас в новых проектах базовым выбором всё чаще становится pgx — он закрывает и роль драйвера, и роль более современного PostgreSQL-first toolkit.
Статья — не спор «что лучше», а практический разбор: как собрать нормальный data-access слой на pgx. На одном сквозном домене пройдём чтение в структуры, типы PostgreSQL, транзакции, batch/copy, настройку пула, режимы за pgbouncer и подводные камни эксплуатации. Про реплики, failover и деградацию — сознательно нет: это уровень выше драйвера.
Это четвёртая статья серии про Go в backend.
В статье
- Почему вообще смотреть на pgx
- Сквозной домен
- Чтение в структуры без sqlx
- Типы PostgreSQL: nullable, jsonb, array, timestamptz
- Транзакции: BeginFunc
- Batch и CopyFrom
- pgxpool в production
- PgBouncer и режимы QueryExecMode
- Reconnect: что пул не спасёт
- Где pgx кусается
- Выводы
Почему вообще смотреть на pgx
lib/pq исторически был стандартным драйвером и остаётся рабочим database/sql-драйвером, но развивается вяло — новых фич в него почти не приходит. Для новых PostgreSQL-first проектов pgx обычно практичнее, и брать pq «по инерции» уже мало причин.
pgx — не просто драйвер, а PostgreSQL-first toolkit. Он работает в двух режимах:
- native (
pgx/pgxpool) — собственный API, extended-протокол, пул и helpers; database/sql— черезgithub.com/jackc/pgx/v5/stdlibpgxподключается как обычный драйвер, если нужна совместимость со стандартным интерфейсом (именно этот режим используют, например, SQL-builder’ы вроде go-jet).
Дальше — весь data-access слой в native-режиме, на одном домене.
Сквозной домен
Чтобы примеры не висели по отдельности, возьмём маленький домен и будем гонять по нему все операции. В схеме нарочно есть то, на чём видна разница драйверов: nullable-поле, jsonb, массив, timestamptz.
CREATE TABLE users (
id bigserial PRIMARY KEY,
name text NOT NULL,
email text, -- nullable
tags text[] NOT NULL DEFAULT '{}', -- массив
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE posts (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id),
title text NOT NULL,
metadata jsonb NOT NULL DEFAULT '{}', -- jsonb
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE events ( -- сюда льём bulk (CopyFrom)
id bigserial PRIMARY KEY,
post_id bigint NOT NULL,
kind text NOT NULL,
at timestamptz NOT NULL DEFAULT now()
);Чтение в структуры без sqlx
Главное, ради чего тянули sqlx, — сканирование строк в структуры — в pgx v5 встроено. Поле сопоставляется по тегу db:
type User struct {
ID int64 `db:"id"`
Name string `db:"name"`
Email *string `db:"email"` // nullable → указатель
Tags []string `db:"tags"` // text[] → []string
CreatedAt time.Time `db:"created_at"` // timestamptz → time.Time
}
rows, err := pool.Query(ctx,
`SELECT id, name, email, tags, created_at FROM users WHERE id = ANY($1)`,
[]int64{1, 2, 3})
if err != nil {
return nil, err
}
users, err := pgx.CollectRows(rows, pgx.RowToStructByName[User])Набор helpers шире, чем один RowToStructByName:
pgx.CollectOneRow(rows, pgx.RowToStructByName[User])— первая строка в структуру (илиpgx.ErrNoRows, если пусто); на нескольких строках ошибки не будет. Нужна гарантия «ровно одна» —pgx.CollectExactlyOneRow(вернётErrTooManyRows, если строк больше);pgx.RowToStructByNameLax— допускает, что для части полей структуры в результате нет колонок (остаются нулевыми);pgx.RowToMap— строка вmap[string]any, когда структура не нужна;pgx.ForEachRow— стрим по строкам без аллокации всего среза сразу;- именованные параметры через
pgx.NamedArgs(с@nameв SQL).
rows, _ := pool.Query(ctx,
`SELECT id, title FROM posts WHERE user_id = @uid`,
pgx.NamedArgs{"uid": int64(1)})
var id int64
var title string
_, err := pgx.ForEachRow(rows, []any{&id, &title}, func() error {
fmt.Printf("post %d: %s\n", id, title)
return nil
})ForEachRow сам закрывает rows и возвращает ошибку итерации — на нём удобно не забыть про rows.Err() (см. подводные камни). Отдельный sqlx в новом проекте после этого чаще всего не нужен; а raw SQL остаётся — pgx не прячет его за абстракцией.
Типы PostgreSQL: nullable, jsonb, array, timestamptz
Здесь pgx заметно удобнее pq: типы PostgreSQL мапятся на Go без ручной возни.
- nullable — указатель (
*string) илиpgtype.Text;NULLстановитсяnil, а не паникой на пустом значении; text[]/ массивы — обычный слайс[]string([]int64дляbigint[]и т.д.);timestamptz—time.Timeв бинарном формате, без ручного парсинга строк;jsonb—pgxсериализует/десериализует произвольный Go-тип; для гибких данных удобенmap[string]any, для строгих — структура сjson-тегами.
type Post struct {
ID int64 `db:"id"`
Title string `db:"title"`
Metadata map[string]any `db:"metadata"` // jsonb
}
// запись: map/структура уходит в jsonb напрямую
_, err := pool.Exec(ctx,
`INSERT INTO posts (user_id, title, metadata) VALUES ($1, $2, $3)`,
int64(1), "Hello", map[string]any{"source": "blog", "featured": true})
// чтение: jsonb -> map/структура
rows, _ := pool.Query(ctx, `SELECT id, title, metadata FROM posts`)
posts, err := pgx.CollectRows(rows, pgx.RowToStructByName[Post])Тонкость режима: автоматическое кодирование map/структуры в jsonb работает, когда pgx знает тип колонки — в режиме по умолчанию (с describe). За pgbouncer в QueryExecModeExec описания нет, и такой параметр не закодируется (cannot find encode plan); там маршалите сами и кастуйте явно — json.Marshal в строку плюс $N::jsonb.
Для нетривиальных типов (диапазоны, составные, hstore, enum) есть пакет pgtype и регистрация своих кодеков — но для большинства сервисов хватает нативного маппинга выше.
Транзакции: BeginFunc
Ручной Begin/Commit/Rollback легко испортить забытым откатом на ветке ошибки. pgx.BeginFunc берёт это на себя: коммитит при nil, откатывает при ошибке или панике.
err := pgx.BeginFunc(ctx, pool, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx,
`UPDATE users SET name = $1 WHERE id = $2`, "Alice", int64(1)); err != nil {
return err // авто-ROLLBACK
}
if _, err := tx.Exec(ctx,
`INSERT INTO posts (user_id, title) VALUES ($1, $2)`, int64(1), "New"); err != nil {
return err // авто-ROLLBACK
}
return nil // авто-COMMIT
})Внутри транзакции доступны те же Query/QueryRow/Exec и SendBatch. Для вложенности pgx.BeginFunc на tx открывает savepoint.
Batch и CopyFrom
Два разных инструмента для «много»:
Batch— несколько разных запросов одним round-trip’ом (pipelining). Ошибётесь с порядком чтения результатов — получите рассинхрон, поэтому читать надо все и в порядке очереди, аBatchResults— закрывать.CopyFrom— массовая загрузка однотипных строк через протоколCOPY, кратно быстрее пачкиINSERT.
b := &pgx.Batch{}
b.Queue(`UPDATE posts SET title = $1 WHERE id = $2`, "A", int64(1))
b.Queue(`INSERT INTO events (post_id, kind) VALUES ($1, $2)`, int64(1), "edit")
br := pool.SendBatch(ctx, b)
defer br.Close() // обязательно закрыть
if _, err := br.Exec(); err != nil { // результат 1-го запроса
return err
}
if _, err := br.Exec(); err != nil { // результат 2-го — читаем все
return err
}rows := [][]any{
{int64(1), "view"},
{int64(1), "like"},
{int64(2), "view"},
}
n, err := pool.CopyFrom(ctx,
pgx.Identifier{"events"},
[]string{"post_id", "kind"},
pgx.CopyFromRows(rows),
)Важно: CopyFrom идёт через протокол COPY, а не INSERT. Триггеры строк (BEFORE/AFTER) и check-констрейнты он всё так же выполняет, но не применяет rules, не даёт RETURNING/ON CONFLICT и грузит по принципу «всё или ничего» — одна плохая строка обрывает всю пачку. Валидацию внешних данных всё равно делайте до, на стороне приложения: быстро — не значит «можно лить что попало».
pgxpool в production
pgxpool из коробки работает, но дефолты редко подходят под нагрузку. Ключевые ручки задаются на *pgxpool.Config:
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, err
}
cfg.MaxConns = 20 // потолок соединений
cfg.MinConns = 2 // нижняя граница размера пула
cfg.MinIdleConns = 2 // держать тёплые idle-соединения (tail latency)
cfg.MaxConnLifetime = time.Hour // рецикл, чтобы не залипать на одном бэкенде
cfg.MaxConnIdleTime = 5 * time.Minute // закрывать простаивающие
cfg.HealthCheckPeriod = time.Minute // как часто пинговать простаивающие
cfg.ConnConfig.ConnectTimeout = 5 * time.Second
// AfterConnect — настроить каждое новое соединение (например, search_path)
cfg.AfterConnect = func(ctx context.Context, c *pgx.Conn) error {
_, err := c.Exec(ctx, "SET application_name = 'my-service'")
return err
}
pool, err := pgxpool.NewWithConfig(ctx, cfg)Ориентиры: MaxConns соотносите с max_connections PostgreSQL и числом реплик сервиса (сумма пулов всех подов не должна упираться в лимит базы); MaxConnLifetime полезен, чтобы соединения периодически переоткрывались (особенно за балансировщиком/pgbouncer); для tail latency лучше смотреть на MinIdleConns — он гарантирует запас именно свободных соединений, а не просто минимум в пуле (как MinConns).
PgBouncer и режимы QueryExecMode
Самая частая «загадочная» поломка — pgx за pgbouncer в transaction pooling: соединение возвращается в пул после каждой транзакции и может достаться другому клиенту. Серверные prepared statements тогда ломаются (в старом pgbouncer или без max_prepared_statements): подготовили запрос на одном бэкенд-соединении, а следующий ушёл на другое — ошибка «prepared statement does not exist».
Поскольку pgx по умолчанию (QueryExecModeCacheStatement) кэширует prepared statements, за таким pgbouncer это стреляет. Режим — осознанный выбор через cfg.ConnConfig.DefaultQueryExecMode:
| Режим | Что делает | Когда |
|---|---|---|
QueryExecModeCacheStatement (по умолчанию) |
Именованные prepared statements + кэш | Прямое соединение или pgbouncer 1.21+ с max_prepared_statements |
QueryExecModeExec |
Extended-протокол, серверный биндинг параметров, без кэша | Transaction pooling за старым pgbouncer |
QueryExecModeSimpleProtocol |
Простой протокол, параметры интерполируются на клиенте | Крайний fallback, если extended недоступен |
cfg.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeExecАльтернатива — свежий pgbouncer: с 1.21 он поддерживает prepared statements в transaction pooling (max_prepared_statements), с 1.24 этот режим включён по умолчанию. Про двойной пулинг: pgxpool + pgbouncer вместе допустимы — не раздувайте pgxpool, когда внешний pooler уже держит соединения к базе.
Reconnect: что пул не спасёт
Иллюзия «пул сам переподключится и запрос продолжится» — опасна. При сетевом сбое запрос, выполнявшийся на оборвавшемся соединении, завершится ошибкой; пул прозрачно откроет новое соединение для следующего запроса, но текущий вернёт ошибку.
Отсюда практика: восстановление соединений — забота пула, а повтор операции — забота приложения. Нужны идемпотентность (или транзакции), осознанные таймауты (context.WithTimeout на каждый запрос) и retry на уровне бизнес-логики. HealthCheckPeriod в пуле — про живость простаивающих соединений, а не про спасение прерванного запроса.
Где pgx кусается
Драйвер честный, но в эксплуатации кусается предсказуемо — эти нюансы лучше знать заранее:
- Утёкшие
rows.Queryдержит соединение, покаrowsне прочитаны до конца или не закрыты. Забылиrows.Close()/не дочитали — соединение не вернётся в пул, пул исчерпается.CollectRows/ForEachRowзакрывают сами; ручной цикл —defer rows.Close(). - Забытый
rows.Err(). Циклfor rows.Next()завершается и по концу данных, и по ошибке. Без проверкиrows.Err()после цикла ошибку итерации легко проглотить. QueryRowвозвращает ошибку только наScan. СамQueryRowне отдаётerror— он приходит из.Scan(...)(включаяpgx.ErrNoRows). Не обработалиScan— не узнали о сбое.context— реальная отмена. Отменённый/просроченныйctxпрерывает запрос на стороне сервера. Ставьте таймаут на операцию, но помните: отмена в середине — это ошибка запроса, а не «тихо ничего».Batchнадо дочитывать и закрывать. Пропустили чтение результата одного из queued-запросов — рассинхрон; не вызвалиBatchResults.Close()— утечка. Читайте ровно столько результатов, сколько поставили в очередь.CopyFrom— не валидатор. Он быстрый и всё так же гоняет триггеры строк и check-констрейнты, но ведёт себя иначе, чем построчныйINSERT(нетRETURNING/ON CONFLICT, не выполняются rules, вся пачка — одна операция «всё или ничего»). Для «доверенных» массивов — да; для внешних данных валидируйте до.
Выводы
- Новый сервис на PostgreSQL — берите
pgx(native +pgxpool): он закрывает драйвер, рольsqlxи специфику PG (jsonb/array/nullable/timestamptz) без ручной возни. - Data-access слой собирается из встроенного:
CollectRows/ForEachRowдля чтения,BeginFuncдля транзакций,Batch/CopyFromдля «много»,pgxpoolс осознанным конфигом. - За pgbouncer в transaction pooling — выбирайте
QueryExecModeосознанно (Execза старым, кэш — за 1.21+/max_prepared_statements). - Подводные камни эксплуатации — утёкшие
rows, забытыйrows.Err(), ошибка только наScan, незакрытыйBatch— важнее «скорости драйвера»: она почти никогда не узкое место, им оказываются SQL, индексы и сеть. - Есть кодовая база на
database/sql/sqlx— мигрировать не срочно;pgxдоступен и какdatabase/sql-драйвер.
Следующая статья серии — ORM в Go: GORM против go-jet: когда поверх драйвера разумен ORM, а когда он только мешает.
Комментарии