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.
Три главных сигнала: что смотреть первым
Прежде чем лезть в код, смотрим три метрики:
pg_stat_activity— сколько соединений в статусеactive,idle,idle in transaction. Многоidle in transaction— транзакции не закрываются.pool.Stats()в Go —AcquiredConns,IdleConns,TotalConns. ЕслиAcquiredConns ≈ MaxConns, пул насыщен.Лог 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_connectionsPostgreSQL. По умолчанию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 - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.




