PGX в Go: зачем уходить с pq и где проходит граница пользы

Как собрать нормальный data-access слой на pgx: чтение в структуры без sqlx, типы PostgreSQL (jsonb/array/nullable/timestamptz), транзакции, batch и CopyFrom, настройка pgxpool, режимы за pgbouncer и типичные сложности эксплуатации

Если в 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: пул соединений между Go-приложением и PostgreSQL, бинарный поток данных

В статье

Почему вообще смотреть на 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/stdlib pgx подключается как обычный драйвер, если нужна совместимость со стандартным интерфейсом (именно этот режим используют, например, 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[] и т.д.);
  • timestamptztime.Time в бинарном формате, без ручного парсинга строк;
  • jsonbpgx сериализует/десериализует произвольный 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, а когда он только мешает.

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии