Опирается на правила: R-SQLC-POOL-1R-SQLC-POOL-5 и R-SQLC-POOL-X1R-SQLC-POOL-X3 из sqlc Rules → раздел 5. pgxpool и соединение.

Важно знать

  • pgxpool.Pool — singleton. Создаётся один раз при старте и инжектируется конструктором во всё, что работает с БД.
  • pgxpool.ParseConfig + pgxpool.NewWithConfig — единственный правильный путь настройки. Прямой pgxpool.New(ctx, dsn) не позволяет задать MaxConns, MinConns, HealthCheckPeriod.
  • DSN читается из переменных окружения через envconfig или os.Getenv. Хардкод строки подключения в коде — нарушение.
  • pool.Ping(ctx) при старте — fail-fast: приложение не должно подниматься без БД и падать на первом запросе пользователя.
  • pool.Close() вызывается в defer или gracefulShutdown при остановке сервиса.
  • pgx.Connect (одиночное соединение) — не для production: нет пула, нет автоматического reconnect при разрыве.
  • Глобальная переменная var pool *pgxpool.Pool — запрещена. Инжекция только через конструктор.

pgxpool.Pool — это менеджер соединений к PostgreSQL в pgx/v5. Он держит пул переиспользуемых соединений, возвращает их при ошибках, выполняет health-check и настраивается отдельно от строки DSN. Всё приложение работает с одним инстансом пула — создание соединений per-request или per-repository-method убивает производительность и исчерпывает лимит соединений базы данных.

Настройка через ParseConfig и NewWithConfig

R-SQLC-POOL-1, R-SQLC-POOL-2: пул создаётся один раз через pgxpool.ParseConfig + pgxpool.NewWithConfig, не напрямую через строку DSN.

// infrastructure/postgres.go
package infrastructure

import (
    "context"
    "fmt"
    "time"

    "github.com/jackc/pgx/v5/pgxpool"
)

type Config struct {
    DSN             string        `envconfig:"DATABASE_URL" required:"true"`
    MaxConns        int           `envconfig:"DB_MAX_CONNS"         default:"20"`
    MinConns        int           `envconfig:"DB_MIN_CONNS"         default:"2"`
    MaxConnLifetime time.Duration `envconfig:"DB_MAX_CONN_LIFETIME" default:"1h"`
}

func NewPool(cfg Config) (*pgxpool.Pool, error) {
    poolCfg, err := pgxpool.ParseConfig(cfg.DSN)
    if err != nil {
        return nil, fmt.Errorf("parse pool config: %w", err)
    }
    poolCfg.MaxConns = int32(cfg.MaxConns)
    poolCfg.MinConns = int32(cfg.MinConns)
    poolCfg.MaxConnLifetime = cfg.MaxConnLifetime
    poolCfg.HealthCheckPeriod = 30 * time.Second

    pool, err := pgxpool.NewWithConfig(context.Background(), poolCfg)
    if err != nil {
        return nil, fmt.Errorf("create pool: %w", err)
    }
    if err := pool.Ping(context.Background()); err != nil {
        return nil, fmt.Errorf("ping db: %w", err)
    }
    return pool, nil
}

Почему не pgxpool.New(ctx, dsn):

  • Нет контроля над параметрами пула. pgxpool.New принимает только строку DSN; настройки MaxConns, MinConns, MaxConnLifetime задаются через pgxpool.Config — объект, который отдаёт только ParseConfig.
  • Разные отвественности. DSN описывает куда соединяться (host, port, dbname, sslmode). Параметры пула описывают сколько и как долго. Разделение через ParseConfig + поля конфига делает эти две плоскости явными.
  • Переопределение без магических строк. В тесте poolCfg.MaxConns = 5 понятнее, чем добавление ?pool_max_conns=5 в строку DSN и надежда, что DSN правильно разберётся.

DSN из переменных окружения

R-SQLC-POOL-3: строка подключения никогда не хардкодится. Для сервисов Сбер (sber-order-service, sber-payment-service) это особенно критично — DSN содержит учётные данные.

// infrastructure/config.go
package infrastructure

import "github.com/kelseyhightower/envconfig"

func LoadConfig() (Config, error) {
    var cfg Config
    if err := envconfig.Process("", &cfg); err != nil {
        return Config{}, fmt.Errorf("load config: %w", err)
    }
    return cfg, nil
}
# .env.example (без реальных значений)
DATABASE_URL=postgres://user:password@localhost:5432/orders?sslmode=disable
DB_MAX_CONNS=20
DB_MIN_CONNS=2
DB_MAX_CONN_LIFETIME=1h

Обёртка envconfig.Process читает поля структуры через теги envconfig: и применяет дефолты через default:. Это аккуратнее ручного os.Getenv для каждого поля и даёт встроенную валидацию обязательных полей через required:"true".

Ping при старте — fail-fast

R-SQLC-POOL-4: pool.Ping(ctx) сразу после создания пула — обязательный шаг.

// cmd/api/main.go
func main() {
    cfg, err := infrastructure.LoadConfig()
    if err != nil {
        log.Fatalf("load config: %v", err)
    }

    pool, err := infrastructure.NewPool(cfg.DB)
    if err != nil {
        log.Fatalf("init db pool: %v", err)
    }
    defer pool.Close()

    // ... инициализация репозиториев, handler-ов, HTTP-сервера
}

Что даёт Ping при старте:

  • Ранний сбой виден сразу. Если переменная DATABASE_URL указывает на несуществующий хост или неверный пароль, приложение упадёт при деплое, а не при первом запросе пользователя через 10 минут.
  • Kubernetes readiness. В паре с readiness probe (/health) Ping гарантирует, что Pod помечается Ready только когда БД доступна и пул инициализирован.
  • Ошибка с контекстом. fmt.Errorf("ping db: %w", err) даёт полную цепочку: оператор видит init db pool: ping db: failed to connect to ... (FATAL: password authentication failed), а не голый connection refused.

В production-инфраструктуре Сбер pool.Ping с timeout-контекстом и логом IP-адреса целевой реплики позволяет диагностировать routing-проблемы между DC без liveness-рестартов:

func NewPool(cfg Config) (*pgxpool.Pool, error) {
    // ... ParseConfig, NewWithConfig ...

    pingCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    if err := pool.Ping(pingCtx); err != nil {
        return nil, fmt.Errorf("ping db (host=%s): %w", cfg.Host(), err)
    }
    return pool, nil
}

Инжекция через конструктор

R-SQLC-POOL-1, R-SQLC-POOL-X2: пул инжектируется в репозиторий конструктором. Глобальная переменная и init-функция — запрещены.

// adapters/out/persistence/postgres_order_repository.go
package persistence

import (
    "github.com/jackc/pgx/v5/pgxpool"
    "order-service/db"
)

type PostgresOrderRepository struct {
    q    *db.Queries
    pool *pgxpool.Pool
}

func NewPostgresOrderRepository(pool *pgxpool.Pool) *PostgresOrderRepository {
    return &PostgresOrderRepository{
        q:    db.New(pool),
        pool: pool,
    }
}
// adapters/out/persistence/postgres_customer_repository.go
type PostgresCustomerRepository struct {
    q    *db.Queries
    pool *pgxpool.Pool
}

func NewPostgresCustomerRepository(pool *pgxpool.Pool) *PostgresCustomerRepository {
    return &PostgresCustomerRepository{
        q:    db.New(pool),
        pool: pool,
    }
}

Каждый репозиторий получает тот же синглтон *pgxpool.Pool, созданный в main. db.New(pool) создаёт *db.Queries — сгенерированный sqlc-объект, который умеет работать как с pgxpool.Pool, так и с pgx.Tx (через q.WithTx(tx)). Второе поле pool нужно для BulkInsertItems через pgx.CopyFrom и для Begin при открытии транзакции на handler-е.

Почему не глобальная переменная:

  • Тест не может подменить соединение. Глобал зафиксирован в compile-time. Конструктор принимает интерфейс или *pgxpool.Pool — в тесте передаётся пул от Testcontainers.
  • Нет порядка инициализации. init() не гарантирует порядок между пакетами. Репозиторий из другого пакета, сконструированный до init() pool-пакета, получит nil-пул.
  • Явная зависимость лучше скрытой. Подпись NewPostgresOrderRepository(pool *pgxpool.Pool) документирует требование: «для работы нужен пул». Глобал скрывает это.

Закрытие пула при остановке сервиса

R-SQLC-POOL-5: pool.Close() вызывается при graceful shutdown.

// cmd/api/main.go
func main() {
    pool, err := infrastructure.NewPool(cfg.DB)
    if err != nil {
        log.Fatalf("init db pool: %v", err)
    }
    defer pool.Close() // закрытие при выходе из main

    srv := &http.Server{
        Addr:    cfg.Addr,
        Handler: router,
    }

    go func() {
        if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            log.Fatalf("listen: %v", err)
        }
    }()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    if err := srv.Shutdown(shutdownCtx); err != nil {
        log.Printf("server shutdown: %v", err)
    }
}

pool.Close() дожидается освобождения активных соединений и корректно закрывает все установленные TCP-сессии. Без этого при рестарте PostgreSQL получает обрывы соединений вместо корректных FIN-пакетов, что замедляет отработку idle_in_transaction_session_timeout на стороне БД.

Подробнее про порядок остановки — в graceful shutdown pgxpool.

Параметры пула — как выбирать значения

Три параметра влияют на поведение пула в production:

MaxConns — верхний предел соединений. Типичный выбор: pg_stat_activity-лимит делённый на число реплик сервиса. Для Сбер-сервисов с 10 репликами и лимитом 200 соединений: MaxConns = 15 с запасом. Дефолт pgx/v5 — 4, что заниженно для production.

MinConns — минимальное число прогретых соединений. Значение 2–3 устраняет latency-spike при первых запросах после старта. Для Product-сервиса с редким трафиком ночью MinConns = 1 снижает потребление соединений.

MaxConnLifetime — максимальное время жизни соединения. Рекомендуется ниже idle_in_transaction_session_timeout PostgreSQL (обычно 1–2 часа). Это защищает от ситуации, когда pgbouncer или балансировщик закрывают соединение со своей стороны, а pgx считает его живым.

HealthCheckPeriod — интервал проверки живости соединений в пуле. Значение 30s — разумный баланс между своевременным обнаружением мёртвых соединений и нагрузкой на БД.

poolCfg.MaxConns = int32(cfg.MaxConns)           // 20 для высоконагруженных сервисов
poolCfg.MinConns = int32(cfg.MinConns)           // 2 для прогрева
poolCfg.MaxConnLifetime = cfg.MaxConnLifetime    // 1h
poolCfg.HealthCheckPeriod = 30 * time.Second     // 30s

Что запрещено

АнтипаттернПравилоЧто взамен
pgx.Connect(ctx, dsn) в production-кодеR-SQLC-POOL-X1pgxpool.NewWithConfig — пул с reconnect
var pool *pgxpool.Pool как глобальная переменнаяR-SQLC-POOL-X2Инжекция через конструктор NewPostgresOrderRepository(pool)
pgxpool.New(ctx, dsn) без ParseConfigR-SQLC-POOL-2pgxpool.ParseConfig + поля poolCfg.MaxConns и т.д.
DSN строкой в коде ("postgres://user:pass@...")R-SQLC-POOL-3envconfig с тегом envconfig:"DATABASE_URL"
Пропуск pool.Ping(ctx) при стартеR-SQLC-POOL-X3pool.Ping с timeout-контекстом; сбой = log.Fatalf
Нет pool.Close() при остановкеR-SQLC-POOL-5defer pool.Close() в main или graceful-handler

Куда дальше

  • Repository pattern на Go — как PostgresOrderRepository получает пул и как строить интерфейс-порт в core/.
  • Транзакции на Go — как pool.Begin(ctx) используется на handler-е, WithTx(tx) и defer tx.Rollback.
  • Graceful shutdown — БД и persistence — порядок остановки: HTTP → активные транзакции → pool.Close().