Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
🌐
Клиент-сервер
Опубликовано:
18.06.2026
Обновлено:
09.07.2026

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 - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

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