API работает, но ломается под нагрузкой: полный разбор Go + PostgreSQL

Рецензент: Илья Новиков

Три часа ночи. Мониторинг пишет «500 Internal Server Error», дашборд Grafana красный. Локально всё идеально, а под реальным трафиком — таймаут за таймаутом. Знакомо? В статье — системный разбор того, почему Go-сервис с PostgreSQL ломается под нагрузкой и как это починить без гадания.

Почему API на Go ломается под нагрузкой

Когда трафик растёт, первым отказывает не код, а соединения с базой. PostgreSQL держит каждое соединение как отдельный процесс, и при нехватке процессов новые запросы встают в очередь или получают ошибку.

Анатомия отказа: как растёт очередь

Представьте: 50 горутин одновременно запрашивают соединение из пула, но пул настроен на MaxConns: 10. Сорок горутин ждут. Каждая держит HTTP-соединение открытым. Пользователи видят таймаут.

Это connection exhaustion — самая частая причина падений в production. Второй сценарий — connection leak: горутина взяла соединение, получила ошибку, забыла закрыть rows, соединение не вернулось в пул. Через час пул пуст.

⚠️ pgx.Conn не является потокобезопасным — использовать его напрямую в нескольких горутинах нельзя. Только pgxpool.Pool.

Три главных сигнала: что смотреть первым

Прежде чем лезть в код, смотрим три метрики:

  1. pg_stat_activity — сколько соединений в статусе active, idle, idle in transaction. Много idle in transaction — транзакции не закрываются.

  2. pool.Stats() в Go — AcquiredConns, IdleConns, TotalConns. Если AcquiredConns ≈ MaxConns, пул насыщен.

  3. Лог PostgreSQL с log_min_duration_statement = 100 — запросы медленнее 100 мс попадают в лог. Находим топ пожирателей времени.

Настройка pgxpool для production

pgxpool — стандартный путь к PostgreSQL из Go-сервиса в 2026 году. Библиотека pgx v5 включает pgxpool.Pool — потокобезопасный пул с автоматическим переоткрытием соединений.

Параметры пула: что означает каждый

Параметр Что делает Рекомендация
MaxConns Максимум одновременных соединений из приложения ≤ max_connections PostgreSQL минус 5–10 резерв
MinConns Соединения, которые держим тёплыми 2–5 для сервисов с редким трафиком
MaxConnLifetime Время жизни соединения 30–60 минут (ротация во избежание TCP-зависания)
MaxConnIdleTime Закрываем простаивающее соединение 5–15 минут
HealthCheckPeriod Как часто проверяем живость соединений 1 минута

Значение MaxConns рассчитывается от max_connections PostgreSQL. По умолчанию max_connections = 100. Оставьте ≥10 для утилит (psql, мигратор, мониторинг).

Готовый конфиг с объяснениями

package db

import (
	"context"
	"fmt"
	"time"

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

func NewPool(dsn string) (*pgxpool.Pool, error) {
	cfg, err := pgxpool.ParseConfig(dsn)
	if err != nil {
		return nil, fmt.Errorf("parse dsn: %w", err)
	}

	cfg.MaxConns = 20
	cfg.MinConns = 3
	cfg.MaxConnLifetime = 45 * time.Minute
	cfg.MaxConnIdleTime = 10 * time.Minute
	cfg.HealthCheckPeriod = time.Minute

	// statement_timeout на уровне соединения
	cfg.ConnConfig.RuntimeParams["statement_timeout"] = "5000" // 5 секунд

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

	return pool, nil
}

Это рабочий шаблон. Значения MaxConns подбирайте под реальный max_connections вашего PostgreSQL-кластера.

pgxpool vs PgBouncer: когда нужен внешний пул

Вопрос «pgxpool или PgBouncer?» часто задают неправильно. Это не «или/или», а два уровня пулинга.

pgxpool управляет соединениями внутри одного процесса Go-приложения.

PgBouncer агрегирует соединения от нескольких экземпляров сервиса — незаменим при горизонтальном масштабировании в Kubernetes.

Ключевое ограничение PgBouncer: в режиме transaction pooling (E26) prepared statements (E28) не работают — это подтверждает и опыт Авито на Хабре. Если используете pgx c PreferSimpleProtocol = true или отключаете prepared statements явно — transaction mode безопасен.

Критерий pgxpool PgBouncer
Масштаб Один процесс Несколько экземпляров
Prepared statements Поддерживаются Несовместимы с transaction mode
Накладные расходы Минимальны Дополнительный сетевой хоп
Нужен отдельный сервис Нет Да

Диагностика: найти узкое место за 10 минут

Хороший отладчик начинает с данных, а не с догадок. Вот последовательность:

pg_stat_activity: читаем правильно

SELECT pid, state, wait_event_type, wait_event,
       now() - query_start AS duration, query
FROM pg_stat_activity
WHERE state <> 'idle'
ORDER BY duration DESC
LIMIT 20;

Смотрите на колонку wait_event:

  • Lock — кто-то ждёт блокировку (→ проверяем deadlock / long transaction)
  • Client — соединение открыто, но Go-код не отдал его в пул (→ утечка)
  • IO — дисковые операции (→ seq scan, нет индекса)

EXPLAIN ANALYZE: расшифровка плана

Для каждого медленного запроса из pg_stat_statements запускаем:

EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM orders WHERE user_id = 42;

Видите Seq Scan on orders? Создаём индекс:

CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id);

CONCURRENTLY позволяет создать индекс без блокировки таблицы — обязательно в production.

Авторская ремарка: Я наблюдал кейс, когда добавление одного индекса снизило время ответа с 800 мс до 4 мс на таблице в 10 млн строк. Seq scan по горячей таблице под нагрузкой — первое, что проверяй.

pprof: где горутины уходят в ожидание

Включите pprof-эндпоинт в dev/staging:

import _ "net/http/pprof"

go func() {
    _ = http.ListenAndServe(":6060", nil)
}()

Затем:

# Снять goroutine dump под нагрузкой
curl http://localhost:6060/debug/pprof/goroutine?debug=2 > goroutine.txt

# Найти goroutines в ожидании pgxpool.Acquire
grep -A 5 "pgxpool" goroutine.txt

Если сотни горутин застряли в pgxpool.(*Pool).Acquire — пул насыщен, увеличиваем MaxConns или снижаем нагрузку через кэш/circuit breaker.

Пять антипаттернов, которые убивают production

pgx.Conn в горутинах — тихая смерть

pgx.Conn — это одно соединение. Вызывать его из нескольких горутин одновременно — гонка данных. Типичная ошибка при миграции с database/sql:

// ❌ Плохо — pgx.Conn не потокобезопасен
conn, _ := pgx.Connect(ctx, dsn)
go func() { conn.Exec(ctx, "...") }()
go func() { conn.Exec(ctx, "...") }()

// ✓ Хорошо — pgxpool.Pool безопасен для любого числа горутин
pool, _ := pgxpool.New(ctx, dsn)
go func() { pool.Exec(ctx, "...") }()
go func() { pool.Exec(ctx, "...") }()

N+1 в Go: как выявить и исправить

N+1 — загрузка родительских сущностей одним запросом и дочерних — по одному запросу на каждого родителя. На 100 пользователей = 101 запрос. На 10 000 — 10 001. База умирает.

// ❌ N+1: цикл с отдельным запросом
for _, user := range users {
    row := pool.QueryRow(ctx, "SELECT * FROM orders WHERE user_id=$1", user.ID)
    // ...
}

// ✓ Один запрос с IN
rows, err := pool.Query(ctx,
    "SELECT * FROM orders WHERE user_id = ANY($1)",
    pq.Array(userIDs))

Или используйте pgx.Batch (E29) для нескольких независимых запросов:

batch := &pgx.Batch{}
for _, id := range ids {
    batch.Queue("SELECT name FROM products WHERE id=$1", id)
}
br := pool.SendBatch(ctx, batch)
defer br.Close()

Вставка в цикле vs COPY FROM

INSERT в цикле на 10 000 строк = 10 000 round-trip'ов к базе.

COPY FROM передаёт всё за один вызов:

_, err := pool.CopyFrom(
    ctx,
    pgx.Identifier{"events"},
    []string{"user_id", "action", "created_at"},
    pgx.CopyFromRows(rows),
)

rows.Close() — обязательно, даже при ошибке

rows, err := pool.Query(ctx, "SELECT id FROM users")
if err != nil {
    return err
}
defer rows.Close() // ← без этого соединение не вернётся в пул

for rows.Next() {
    // ...
}
if err := rows.Err(); err != nil {
    return err
}

Пропустить defer rows.Close() = утечка соединения. Один пропуск под нагрузкой → пул засоряется за минуты.

Запросы без таймаута

// ❌ Запрос может висеть вечно
rows, err := pool.Query(context.Background(), "SELECT ...")

// ✓ Таймаут 3 секунды на операцию
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
rows, err := pool.Query(ctx, "SELECT ...")

Или выставьте statement_timeout глобально в ConnConfig.RuntimeParams, как показано в конфиге выше.

Авторская ремарка: Самый частый вопрос на code review — «зачем defer cancel()?». Без него горутина с контекстом живёт до завершения родительского хэндлера. При 1000 RPS это тысячи незакрытых контекстов.

Мониторинг и circuit breaker

Prometheus-метрики pgxpool

pgxpool.Pool.Stat() возвращает структуру с метриками. Экспортируем в Prometheus:

type PoolCollector struct{ pool *pgxpool.Pool }

func (c *PoolCollector) Describe(ch chan<- *prometheus.Desc) { /* ... */ }

func (c *PoolCollector) Collect(ch chan<- prometheus.Metric) {
	s := c.pool.Stat()
	ch <- prometheus.MustNewConstMetric(
		prometheus.NewDesc("pgxpool_acquired_conns", "Acquired conns", nil, nil),
		prometheus.GaugeValue, float64(s.AcquiredConns()),
	)
	ch <- prometheus.MustNewConstMetric(
		prometheus.NewDesc("pgxpool_idle_conns", "Idle conns", nil, nil),
		prometheus.GaugeValue, float64(s.IdleConns()),
	)
	ch <- prometheus.MustNewConstMetric(
		prometheus.NewDesc("pgxpool_total_conns", "Total conns", nil, nil),
		prometheus.GaugeValue, float64(s.TotalConns()),
	)
}

Алерт в Grafana: если pgxpool_acquired_conns / pgxpool_total_conns > 0.85 дольше 30 секунд — пул перегружен, нужен масштаб или оптимизация запросов.

Circuit breaker + retry в Go

Под нагрузкой одна медленная БД может положить весь сервис: каждый запрос ждёт, горутины накапливаются, память растёт. Circuit breaker (E44) разрывает цепочку:

import "github.com/sony/gobreaker"

var cb *gobreaker.CircuitBreaker

func init() {
	cb = gobreaker.NewCircuitBreaker(gobreaker.Settings{
		Name:        "postgres",
		MaxRequests: 5,
		Interval:    10 * time.Second,
		Timeout:     30 * time.Second,
		ReadyToTrip: func(c gobreaker.Counts) bool {
			return c.ConsecutiveFailures > 3
		},
	})
}
func GetUser(ctx context.Context, id int) (*User, error) {
	result, err := cb.Execute(func() (interface{}, error) {
		return queryUser(ctx, id)
	})
	if err != nil {
		return nil, err
	}
	return result.(*User), nil
}

При трёх последовательных ошибках цепочка «открывается» — запросы возвращают ошибку мгновенно, не нагружая БД. Через 30 секунд автоматически пробует снова (half-open). Это стандартный паттерн для Go-сервисов с PostgreSQL в production-среде.

Альтернативный взгляд

Некоторые команды избегают сложного пулинга и circuit breaker'ов, выбирая read replicas + Redis как основной инструмент масштабирования. Логика: снизить нагрузку на primary — эффективнее, чем тюнить пул. Это справедливо для read-heavy сервисов, но не решает проблему write-нагрузки и не защищает от медленных запросов. Оба подхода работают лучше вместе.

Нетривиальный факт

PostgreSQL создаёт отдельный OS-процесс на каждое соединение (не поток). При max_connections = 500 и среднем потреблении памяти 5–10 МБ на процесс только на соединения уходит 2,5–5 ГБ RAM. Именно поэтому PgBouncer и pgxpool критичны: они держат соединений столько, сколько реально нужно, а не столько, сколько запросили клиенты.

FAQ

Какой MaxConns выставить в pgxpool?

MaxConns должен быть меньше max_connections PostgreSQL минус резерв 5–10 для служебных соединений. При max_connections=100 — не более 90, распределяя между экземплярами сервиса.

В чём разница pgx.Conn и pgxpool.Pool?

pgx.Conn — одно соединение, не потокобезопасно. pgxpool.Pool — пул соединений, безопасен для любого числа горутин. В production используйте только pgxpool.

Нужен ли PgBouncer, если в Go уже есть pgxpool?

При одном экземпляре сервиса pgxpool достаточно. При горизонтальном масштабировании (несколько подов в Kubernetes) PgBouncer агрегирует соединения от всех подов.

Как найти утечку соединений в Go?

Проверьте pg_stat_activity на соединения в статусе idle in transaction. В коде найдите места, где pool.Query() вызывается без defer rows.Close(). Мониторьте метрику pgxpool_acquired_conns.

Что делать, если PostgreSQL тормозит из-за seq scan?

Запустите EXPLAIN (ANALYZE, BUFFERS) для медленного запроса. Если видите Seq Scan — создайте индекс через CREATE INDEX CONCURRENTLY.

Как защитить Go API от каскадного отказа при деградации PostgreSQL?

Используйте circuit breaker (например, gobreaker). При нескольких последовательных ошибках соединения с БД цепочка открывается — запросы возвращают ошибку немедленно, не нагружая PostgreSQL.

Почему INSERT в цикле медленнее COPY FROM?

Каждый INSERT — отдельный round-trip к PostgreSQL. COPY FROM передаёт все строки за один вызов, что на порядок быстрее для массовой вставки от 100 строк.

Автор

Данил Мануйлов

Тимлид команды разработки

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.

Следите за нами в соцсетях:

Читайте также

Клиент-сервер

Symfony Messenger на практике: от настройки маршрутизации и retry до спасения сообщений из failed transport

Клиент-сервер

Celery в Python: настройка фоновых задач, повторов, расписания и разделения очередей

Клиент-сервер

Enum в PHP на практике: статусы заказов, backed enums и безопасная работа с БД

Клиент-сервер

Связи в Laravel Eloquent: моделирование отношений, Eager Loading и контроль SQL-запросов