Перейти к содержимому

database/sql и pgx: PostgreSQL в Go

Зачем знать: На уровне middle 1 вы пишете не toy-приложение, а сервис, который делает 10⁴-10⁶ запросов в секунду к Postgres. От того, как вы настроили pool, обработали ошибки, передали context — зависят latency и стабильность. На собесе спросят: разницу Query/QueryRow/Exec, что делает SetMaxOpenConns, чем pgx лучше lib/pq, как обнаружить deadlock через pgErr.Code. Это базовый уровень для backend-инженера в РФ — Postgres стек, не Mongo.

  1. Базовая концепция
  2. Под капотом / Архитектура
  3. Gotchas
  4. Производительность
  5. Вопросы на собеседовании
  6. Practice
  7. Источники

В Go два уровня работы с БД:

  1. database/sql — стандартный интерфейс stdlib. Не реализация: чтобы работать с конкретной БД, нужен драйвер.
  2. pgx (jackc/pgx) — Postgres-native драйвер, доступен и через database/sql, и как нативный API (pgxpool).

В 2026 ландшафт для Postgres:

  • jackc/pgx/v5 — современный стандарт. Умеет: native binary протокол, JSONB/UUID/arrays типы, batch, listen/notify, COPY FROM.
  • lib/pq — устарел. Maintainer официально отметил deprecated (2024). Новые проекты не выбирают.

Минимальный пример (database/sql + pgx как драйвер)

Заголовок раздела «Минимальный пример (database/sql + pgx как драйвер)»
package main
import (
"context"
"database/sql"
"fmt"
"log"
"time"
_ "github.com/jackc/pgx/v5/stdlib" // регистрация драйвера "pgx"
)
func main() {
dsn := "postgres://user:pass@localhost:5432/app?sslmode=disable"
db, err := sql.Open("pgx", dsn)
if err != nil {
log.Fatal(err)
}
defer db.Close()
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
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 {
log.Fatal(err)
}
var name string
err = db.QueryRowContext(ctx, `SELECT name FROM users WHERE id = $1`, 42).Scan(&name)
if err != nil {
log.Fatal(err)
}
fmt.Println(name)
}
import "github.com/jackc/pgx/v5/pgxpool"
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
log.Fatal(err)
}
defer pool.Close()
var name string
err = pool.QueryRow(ctx, `SELECT name FROM users WHERE id = $1`, 42).Scan(&name)

pgxpool даёт прямой доступ к pgx features (JSONB, arrays, batch) и обычно используется в новых проектах.


database/sql — это набор интерфейсов и pool/connection manager, который драйверы реализуют. Цепочка:

ваш код
database/sql (DB, Conn, Tx, Stmt, Rows)
driver.Driver (driver.Open)
драйвер (pgx, mysql, sqlite3, ...)
сеть/файл/TCP к БД

Регистрация драйвера — sql.Register("pgx", &pgxDriver{}). Обычно через _ "github.com/jackc/pgx/v5/stdlib" (init() пакета вызовет Register).

db, err := sql.Open("pgx", dsn) // НЕ подключается, только парсит dsn и создаёт DB
// err тут — только parse error
err = db.PingContext(ctx) // вот тут реально открывается connection

Правило: после Open всегда Ping (с context!) — иначе ошибка соединения вылезет на первом запросе.

*sql.DB — это не одно соединение, а пул. Под капотом:

  • Список свободных connection’ов (freeConn).
  • Список занятых (numOpen).
  • Очередь ожидающих (connRequests).
db.SetMaxOpenConns(25) // max соединений (по умолчанию unlimited!)
db.SetMaxIdleConns(5) // max idle (по умолчанию 2)
db.SetConnMaxLifetime(30*time.Minute) // max возраст
db.SetConnMaxIdleTime(5*time.Minute) // max idle time

Дефолты:

  • MaxOpenConns = 0бесконечность. ОПАСНО в production (можно исчерпать Postgres max_connections).
  • MaxIdleConns = 2. Часто мало для высокого RPS.
  • ConnMaxLifetime = 0 → коннекты живут навсегда. В k8s это плохо: Postgres перезапустился, а коннект подвис.
  • ConnMaxIdleTime = 0 → idle не закрываются.
db.SetMaxOpenConns(25) // или 50, 100 — зависит от Postgres max_connections
db.SetMaxIdleConns(25) // = MaxOpen для прогретого пула
db.SetConnMaxLifetime(30*time.Minute) // против stale connections
db.SetConnMaxIdleTime(10*time.Minute) // балансирует idle vs reconnect overhead

Правило: MaxOpen ≤ Postgres max_connections / число инстансов сервиса. Если max_connections=200 и 10 подов, на под — 20.

МетодВозвращаетИспользовать когда
QueryContext*sql.RowsSELECT с N строками
QueryRowContext*sql.RowSELECT с одной строкой (или сразу .Scan())
ExecContextsql.ResultINSERT/UPDATE/DELETE без возврата строк
rows, err := db.QueryContext(ctx, `SELECT id, name FROM users WHERE age > $1`, 18)
if err != nil {
return err
}
defer rows.Close()
var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Name); err != nil {
return err
}
users = append(users, u)
}
if err := rows.Err(); err != nil { // ВАЖНО! Ошибки итерации
return err
}

Критично:

  1. defer rows.Close() — иначе утечка connection.
  2. rows.Err() после цикла — итерация могла прерваться по ошибке.
var name string
err := db.QueryRowContext(ctx, `SELECT name FROM users WHERE id = $1`, 42).Scan(&name)
if err == sql.ErrNoRows {
return ErrUserNotFound
}
if err != nil {
return err
}

QueryRow не возвращает err сразу — ошибка приходит в Scan. sql.ErrNoRows — если строк 0.

res, err := db.ExecContext(ctx, `UPDATE users SET name = $1 WHERE id = $2`, "alice", 42)
if err != nil {
return err
}
rows, err := res.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return ErrUserNotFound
}

Result.LastInsertId() работает только в MySQL/SQLite. В Postgres используйте RETURNING id:

var id int64
err := db.QueryRowContext(ctx,
`INSERT INTO users (name) VALUES ($1) RETURNING id`, "alice").Scan(&id)

Всегда передавайте context. Без него:

  • Запрос не отменится при timeout HTTP-запроса.
  • Запрос не отменится при Ctrl+C сервиса.
  • Невозможно реализовать deadline.
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
var v int
err := db.QueryRowContext(ctx, "SELECT pg_sleep(1)").Scan(&v)
// err: context deadline exceeded

Правило: в production коде нет методов без Context (Query, Exec, Begin без Ctx — антипаттерн в новом коде).

В pgx нативном API Context всегда первый аргумент — нет варианта без него.

stmt, err := db.PrepareContext(ctx, `SELECT name FROM users WHERE id = $1`)
if err != nil {
return err
}
defer stmt.Close()
for _, id := range ids {
var name string
if err := stmt.QueryRowContext(ctx, id).Scan(&name); err != nil {
return err
}
}

При множественных вызовах — быстрее (Postgres не парсит SQL каждый раз).

pgx по умолчанию кэширует prepared statements per connection через QueryExecModeCacheStatement (v5 default).

// Тоже самое, но без явного Prepare:
for _, id := range ids {
db.QueryRowContext(ctx, `SELECT name FROM users WHERE id = $1`, id).Scan(&name)
}
// pgx внутри: первый запрос — Prepare + Execute, остальные — Execute по cached statement.

Это часто быстрее, чем lib/pq, который не кэширует. Конфигурация в pgxpool:

config, _ := pgxpool.ParseConfig(dsn)
config.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeCacheStatement // default
// Альтернативы: QueryExecModeSimpleProtocol, QueryExecModeExec, QueryExecModeDescribeExec
var name sql.NullString
err := db.QueryRowContext(ctx, "SELECT middle_name FROM users WHERE id=$1", 42).Scan(&name)
if name.Valid {
fmt.Println(name.String)
}

sql.Null* типы:

  • NullString, NullBool, NullInt64, NullInt32, NullInt16, NullByte
  • NullFloat64, NullTime (Go 1.13+)

Альтернатива: указатели *string, *int64. Тогда nil = NULL. Многие предпочитают указатели — они лучше работают с JSON Marshal.

sql.RawBytes — указатель на внутренний буфер. Действителен только до следующего rows.Next()! Не копирует данные:

var raw sql.RawBytes
for rows.Next() {
rows.Scan(&raw)
// НЕ держите raw долго! Он перепишется на следующем Next().
}

[]byte копирует. Безопаснее, но дороже.

pgx из коробки умеет:

  • uuid.UUID → Postgres uuid
  • time.Timetimestamp/timestamptz
  • []intint[] (массивы)
  • map[string]anyjsonb (через json.Marshal)
  • pgtype.Numericnumeric без потери точности

lib/pq всё это плохо умел — приходилось вручную писать Scanner/Valuer.

batch := &pgx.Batch{}
for _, u := range users {
batch.Queue(`INSERT INTO users (name) VALUES ($1)`, u.Name)
}
br := pool.SendBatch(ctx, batch)
defer br.Close()
for range users {
if _, err := br.Exec(); err != nil {
return err
}
}

Все запросы шлются одним пакетом, ответы читаются последовательно. На сети — огромная экономия (1 RTT вместо N).

rows := [][]any{
{"alice", 30},
{"bob", 25},
// ... тысячи строк
}
_, err := pool.CopyFrom(ctx,
pgx.Identifier{"users"},
[]string{"name", "age"},
pgx.CopyFromRows(rows))

COPY FROM — самый быстрый bulk insert (5-10x быстрее VALUES).

conn, err := pool.Acquire(ctx)
defer conn.Release()
_, err = conn.Exec(ctx, "LISTEN events")
for {
n, err := conn.Conn().WaitForNotification(ctx)
if err != nil { return err }
fmt.Println("event:", n.Payload)
}

Pub/Sub из Postgres. Полезно для invalidation кэша или sync между процессами.

Свойствоlib/pqpgx/v5
ПоддержкаDeprecated (2024)Активно (jackc/pgx)
Binary protocolТолько textBinary (быстрее)
Prepared cacheНетДа (per-conn)
UUID, JSONB, arraysНужны custom Scanner/ValuerИз коробки
BatchНетДа
Listen/NotifyБазовыйПолноценный
COPY FROMБазовыйOptimized
pgxpoolНет (только database/sql)Да (нативный)
Доступ через database/sqlДаДа (через pgx/stdlib)

Вердикт 2026: новые проекты — pgx/v5 (нативно или через stdlib).

Если Postgres перезапустился, существующий connection становится мёртвым. Что происходит:

  • database/sql ловит ошибку, помечает conn как bad, удаляет из pool.
  • Следующий запрос возьмёт новый conn (или создаст).

Но! Если есть connection_failure посередине транзакции — её не восстановить, нужно повторить.

SetConnMaxLifetime(30m) уменьшает шансы попасть на stale conn после restart’а Postgres: соединение проактивно закроется.

Sentinel error, который QueryRow.Scan возвращает, если 0 строк:

err := db.QueryRowContext(ctx, "SELECT ...").Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}

Конкретные коды ошибок Postgres:

import "github.com/jackc/pgx/v5/pgconn"
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
switch pgErr.Code {
case "23505": // unique_violation
return ErrDuplicate
case "23503": // foreign_key_violation
return ErrInvalidReference
case "40P01": // deadlock_detected
return ErrDeadlock
}
}

Коды: https://www.postgresql.org/docs/current/errcodes-appendix.html

if err != nil {
return fmt.Errorf("get user %d: %w", id, err)
}

%w сохраняет цепочку для errors.Is/As.

import "github.com/jackc/pgx/v5/tracelog"
config.ConnConfig.Tracer = &tracelog.TraceLog{
Logger: tracelog.LoggerFunc(...),
LogLevel: tracelog.LogLevelDebug,
}

Логирует каждый запрос с args и timing. На production — INFO или WARN, иначе диск утонет.

Через sqlhooks, ocsql (OpenCensus) или собственный wrapper. На уровне пула middleware redacts чувствительные args.

import (
"github.com/exaring/otelpgx" // или otelsql для database/sql
)
config, _ := pgxpool.ParseConfig(dsn)
config.ConnConfig.Tracer = otelpgx.NewTracer()

Каждый запрос — span. Видно в Jaeger/Tempo.

type Repo struct {
write *pgxpool.Pool
read *pgxpool.Pool
}
func (r *Repo) GetUser(ctx context.Context, id int64) (User, error) {
return loadFromPool(ctx, r.read, id)
}
func (r *Repo) CreateUser(ctx context.Context, u User) error {
return insertToPool(ctx, r.write, u)
}

Простой подход: два пула, выбираем по типу операции. Внимание к replication lag — после INSERT’а read replica может не сразу видеть данные.


db, _ := sql.Open("pgx", "badurl")
db.Query("...") // здесь упадёт впервые

Open не подключается. Всегда Ping.

rows, _ := db.Query("...")
for rows.Next() {
if cond { return } // НЕ закрыли rows — leak!
}
rows.Close()

Решение: defer rows.Close() сразу после Query.

// ПЛОХО
db.Query("SELECT * FROM users WHERE name = '" + name + "'")
// ХОРОШО
db.Query("SELECT * FROM users WHERE name = $1", name)

Параметризованные запросы — единственная защита.

for _, userID := range ids {
db.QueryRow("SELECT * FROM orders WHERE user_id=$1", userID) // N запросов
}
// Лучше:
rows, _ := db.Query("SELECT * FROM orders WHERE user_id = ANY($1)", pq.Array(ids))

Без SetMaxOpenConns приложение может открыть тысячи conn’ов и убить Postgres.

Если есть один тяжёлый запрос (минуты), он держит conn. Если MaxOpen=10, остальные 9 на месте, и приложение зависнет под нагрузкой.

Решение: timeout’ы через context, либо отдельный pool для аналитики.

var raw sql.RawBytes
for rows.Next() {
rows.Scan(&raw)
go process(raw) // ОПАСНО: на след. Next() raw перепишется
}

Копируйте: data := append([]byte(nil), raw...).

PostgreSQL timestamp без зоны vs timestamptz — разное. pgx по умолчанию возвращает timestamptz как time.Time в UTC, timestamp как time.Time с Local.

В коде — всегда работайте с UTC, конвертируйте на границе.

numeric(10,2) — точный десятичный тип. В Go float64 теряет точность. Используйте pgtype.Numeric или shopspring/decimal:

var price decimal.Decimal
pool.QueryRow(ctx, "SELECT price FROM products WHERE id=$1", id).Scan(&price)

? placeholders не поддерживают slice. Подходы:

// Postgres: используйте ANY
db.Query("SELECT * FROM users WHERE id = ANY($1)", pq.Array([]int{1,2,3}))
// или pgx: pgxpool с pgx.QueryExecModeCacheStatement
pool.Query(ctx, "SELECT * FROM users WHERE id = ANY($1)", []int{1,2,3})

db.BeginTx берёт conn из пула. До Commit/Rollback он не возвращается. Если внутри tx делаете долгие операции — пул быстро исчерпается.

Кэш prepared per connection. При reconnect — кэш теряется. Это нормально.

В database/sql: prepared statement, созданный через db.Prepare, может потеряться, если drainнули pool. *sql.Stmt сам перевыполнит prepare на другом conn.

br := pool.SendBatch(ctx, batch)
defer br.Close() // обязательно

Иначе conn не вернётся в пул.

В lib/pq dsn sslmode=disable — но в проде это плохо. Используйте sslmode=require или verify-full.

LISTEN живёт на одном conn. Если этот conn закроется, notifications потеряются. Нужен retry loop.

SET search_path действует на conn. Если используете несколько schema, лучше fully-qualify: myschema.users.

Postgres по умолчанию UTF-8. Если БД в WIN1251, pgx может дать сюрпризы. Проверьте SHOW server_encoding.

Postgres NAMEDATALEN=64. Имена таблиц/колонок/индексов длиннее 63 символов обрезаются. Тестируйте миграции.


Формула (от Brendan Burns, Kubernetes):

connections = ((core_count * 2) + effective_spindle_count)

Для PG на SSD: cores * 2 — норма. Если 8 ядер БД — ~16 conn в пуле на сервис достаточно.

В микросервисах: instances * MaxOpen ≤ max_connections - reserved.

Внешний connection pooler. Уменьшает число физических conn’ов к Postgres:

  • transaction mode — самый эффективный, conn возвращается в пул после COMMIT. Но: prepared statements и LISTEN не работают.
  • session mode — conn держится всю сессию клиента. Работают prepared, но меньше выигрыш.

В 2026 для high-RPS сервисов почти всегда есть PgBouncer перед Postgres.

С PgBouncer + pgx нужна осторожность с prepared statements: либо QueryExecModeSimpleProtocol, либо включить prepared statements в PgBouncer (если transaction mode — отключите кэш в pgx).

  • Один запрос: SELECT id FROM users WHERE id=1 ~1ms (network + parse + execute + return).
  • 1000 запросов sequential: ~1s.
  • С Batch: 1000 запросов ~10-50ms (один пакет).
  • С CopyFrom: 100к строк ~100ms.

Если важна latency — минимизируйте RTT (batch, prepared, local conn).

Без prepared: parse + plan + execute. Каждый раз. С prepared: parse + plan один раз, потом только execute.

Выигрыш — 10-30% для простых запросов, до 5x для сложных с большим plan’ом.

pgx использует binary вместо text — int/timestamps передаются как 4/8 байт, не как строки. Экономия CPU и сети.

МетодСкорость (10k строк)
INSERT row-by-row~10 сек
INSERT с multi-VALUES (10k штук)~1 сек
Batch (pgx.Batch)~500 ms
COPY FROM~50-100 ms

COPY — победитель для bulk. VALUES имеет лимит на количество параметров (~65535 в Postgres).

Запросы без индексов — full scan. EXPLAIN ANALYZE:

EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'a@b.com';

Если Seq Scan на большой таблице — добавляйте индекс.

Pod restarts → нужно правильно настроить:

  • SetConnMaxLifetime(30m) — соединение не живёт вечно.
  • SetConnMaxIdleTime(10m) — idle закрывается.
  • Graceful shutdown: db.Close() дожидается активных запросов.
stats := db.Stats()
log.Printf("open=%d in_use=%d idle=%d wait=%d wait_time=%v",
stats.OpenConnections, stats.InUse, stats.Idle, stats.WaitCount, stats.WaitDuration)

В Prometheus: экспортируйте Stats() как gauge’и. Высокий WaitCount = пул исчерпан, увеличьте MaxOpen.

В pgxpool: pool.Stat().

Exec().RowsAffected() дешевле, чем Query + Scan для случаев, когда возвращаемые данные не нужны.

Cursor-based (WHERE id > $1 ORDER BY id LIMIT 100) быстрее OFFSET 10000 LIMIT 100. Offset проходит N строк, cursor — ничего.

В Postgres: log_min_duration_statement = 500ms — будут логироваться запросы дольше 500ms. Анализируйте регулярно.


1. В чём разница sql.Open и db.Ping? Open парсит DSN, не подключается. Ping устанавливает реальное соединение.

2. Что такое *sql.DB? Не одно соединение, а пул. Управляет открытием/закрытием conn’ов автоматически.

3. Зачем SetMaxOpenConns? По умолчанию 0 (unlimited) — приложение может убить Postgres. Production — обязательно ставить (например, 25-100).

4. Что делает SetConnMaxLifetime? Закрывает соединение после N минут. Защищает от stale conn’ов (DNS rebalance, перезапуск БД).

5. Что произойдёт, если не вызвать rows.Close()? Connection не вернётся в пул → leak → пул исчерпан → новые запросы блокируются.

6. Чем Query отличается от QueryRow? Query возвращает *sql.Rows (итератор по строкам). QueryRow — одну строку (или sql.ErrNoRows).

7. Что возвращает Exec? sql.Result с методами LastInsertId() (только MySQL/SQLite) и RowsAffected().

8. Зачем context в запросах? Cancel при timeout/shutdown. Без context запрос продолжит выполняться, даже если HTTP handler уже вернул response.

9. Что такое prepared statement? Парсенный и распланированный запрос на стороне БД. Повторное выполнение пропускает parse/plan.

10. Кэширует ли pgx prepared statements? Да, по умолчанию через QueryExecModeCacheStatement (per connection).

11. Что такое sql.ErrNoRows? Sentinel error, возвращается QueryRow.Scan при отсутствии строк. Проверяется через errors.Is.

12. Как обработать unique_violation?

var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" { ... }

13. Чем pgx лучше lib/pq? Binary protocol, native UUID/JSONB/arrays, prepared cache, batch, CopyFrom, активная поддержка. lib/pq deprecated в 2024.

14. Что такое pgxpool? Native pool в pgx (без database/sql прослойки). Дает прямой доступ к pgx фичам.

15. Как делать bulk insert? CopyFrom (быстрее всего), Batch (1 RTT), multi-VALUES (стандартный SQL).

16. Что такое sql.Null* типы? Обёртки {Value, Valid} для nullable колонок. Альтернатива — указатели.

17. Как защититься от SQL injection? Параметризованные запросы ($1, $2). Никогда конкатенация строк.

18. Что такое sql.RawBytes? Указатель на внутренний буфер драйвера. Не копирует, но действителен только до следующего Next().

19. Что такое read replica и как с ним работать? Реплика для read-only запросов. Два пула: write (master), read (replica). Внимание к replication lag.

20. Что такое PgBouncer? Внешний pooler перед Postgres. Уменьшает число conn’ов. Transaction mode — самый эффективный, но ограничивает prepared statements.

21. Что произойдёт, если Postgres restart’нётся? Существующие conn’ы становятся bad. database/sql их выкинет из пула, следующий запрос откроет новый. Активные tx — потеряются.

22. Что такое N+1 и как избежать? Цикл с запросом внутри: 1 главный + N для каждой строки. Лечится JOIN’ом или WHERE id = ANY($1).

23. Что делать с long-running query? Timeout через context, отдельный pool для аналитики (чтобы не блокировать OLTP), или вынести в отдельный воркер.

24. Когда использовать LISTEN/NOTIFY? Light pub/sub из Postgres: cache invalidation, sync между процессами. Не для high-throughput — Kafka/Redis лучше.

25. Чем COPY FROM лучше INSERT? В разы быстрее: bypass’ит SQL parsing, использует binary format. Для bulk-загрузки — выбор #1.

26. Что такое idle vs in_use в pool? OpenConnections = InUse + Idle. InUse — занятые сейчас, Idle — свободные, готовые к выдаче.

27. Что такое WaitCount в db.Stats? Сколько раз приложение ждало свободный conn (когда пул исчерпан). Высокий — увеличить MaxOpen.

28. Как трассировать SQL запросы? OpenTelemetry: otelsql для database/sql, otelpgx для pgxpool. Каждый запрос — span с SQL текстом (sanitized).

29. Как логировать SQL? pgx tracelog, или обернуть driver. В проде — только slow queries или DEBUG.

30. Что такое RETURNING в Postgres? INSERT ... RETURNING id возвращает значения. Используется вместо LastInsertId(), которого в Postgres нет.


Создайте UserRepo с методами Create/Get/Update/Delete, используя pgxpool. Покройте unit-тестами с testcontainers.

Запустите два процесса, делающих 1000 запросов параллельно. Замерьте latency при MaxOpen=5/25/100. Постройте график.

Реализуйте CreateUser, обрабатывающий дубликат email через pgErr.Code == "23505". Возвращайте свою ошибку ErrEmailTaken.

Загрузите 100k строк в таблицу. Сравните: row-by-row, multi-VALUES, Batch, CopyFrom. Замерьте время.

Запустите SELECT pg_sleep(10) с context.WithTimeout(1s). Убедитесь, что запрос отменился, проверьте сообщение об ошибке.

Реализуйте Repo с двумя pgxpool (write/read). Замоксайте через testcontainers два postgres контейнера. Проверьте, что Get идёт на read, Create на write.

Реализуйте уведомление о новых users: при INSERT триггер шлёт NOTIFY, отдельная goroutine LISTEN’ит и обновляет кеш.

Экспортируйте db.Stats() или pool.Stat() как Prometheus gauges. Что увидите при нагрузке: WaitCount?


  1. database/sql docs: https://pkg.go.dev/database/sql.
  2. jackc/pgx v5: https://github.com/jackc/pgx, https://pkg.go.dev/github.com/jackc/pgx/v5.
  3. Why pgx: https://github.com/jackc/pgx/wiki — рассказ автора о Migration с lib/pq.
  4. Don’t Defer Your Errors (Mat Ryer): лекция о rows.Err().
  5. Postgres error codes: https://www.postgresql.org/docs/current/errcodes-appendix.html.
  6. PgBouncer docs: https://www.pgbouncer.org/usage.html.
  7. otelpgx: https://github.com/exaring/otelpgx.
  8. Brendan Burns “Sizing connections” — для формулы пула.
  9. Книга “Postgres High Performance” Gregory Smith.
  10. Книга “Learning Go” Bodner (2nd ed., 2024) — глава по работе с БД.