Три часа ночи. Мониторинг пишет «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 строк.



.svg.webp)





