Дайджесты новостей
Минималистичный студийный натюрморт с механическим таймером и прозрачными капсулами пула соединений Go database/sql.

Диагностика очереди пула соединений в Go database/sql

Типичная детективная история в бэкенд-разработке: дежурный инженер видит в Grafana резкий всплеск времени отклика HTTP-ручек сервиса на Go. Ответы отдаются за секунду вместо привычных двадцати миллисекунд. Инженер бросается в логи базы данных, включает журнал медленных запросов и с удивлением обнаруживает: в PostgreSQL или MySQL все чисто. Каждый SQL-запрос отрабатывает за стремительные две миллисекунды.

Где же тогда теряются сотни миллисекунд? Ответ кроется в невидимой для СУБД очереди — внутри стандартного пакета database/sql. Задержка возникает еще до того, как первый байт запроса уходит в сеть: горутины блокируются на внутреннем семафоре приложения в ожидании освобождения TCP-сокета из пула. Мы сталкивались с похожим эффектом в материале PostgreSQL тормозит, а процессор свободен: ищем тяжелые запросы через pg_stat_statements, когда истинная причина задержек маскировалась за благополучными метриками процессора.

Как устроен пул и почему возникает голодание

Объект *sql.DB в Go — это не одно физическое сетевое подключение, а умный потокобезопасный пул коннектов. Когда твой код вызывает db.Query() или db.Exec(), рантайм берет свободный сокет из пула либо открывает новый, если текущее количество меньше лимита SetMaxOpenConns.

Но если лимит исчерпан, а все соединения заняты выполняющимися операциями, горутина засыпает на мьютексе. Если кто-то забыл вызвать rows.Close() или транзакция зависла в ожидании внешнего HTTP-ответа, сокет не возвращается в пул. Новые запросы встают в очередь, формируя эффект лавинообразного голодания (pool starvation).

Правильная настройка пула требует баланса четырех параметров:

package main

import (
    "database/sql"
    "time"

    _ "github.com/jackc/pgx/v5/stdlib"
)

// InitPool настраивает предсказуемые границы пула соединений
func InitPool(db *sql.DB) {
    // Жесткий потолок физических сокетов (InUse + Idle)
    db.SetMaxOpenConns(40)

    // Количество горячих простаивающих коннектов
    db.SetMaxIdleConns(20)

    // Ротация сокетов для защиты от утечек памяти и смены DNS
    db.SetConnMaxLifetime(30 * time.Minute)

    // Закрытие лишних простаивающих сокетов при спаде нагрузки
    db.SetConnMaxIdleTime(5 * time.Minute)
}

Интроспекция через db.Stats() и расчет дельты

Чтобы обнаружить проблему в продакшене, стандартная библиотека предоставляет метод db.Stats(). Он возвращает структуру sql.DBStats со счетчиками состояния пула:

  • InUse: сколько сокетов заняты выполнением запросов прямо сейчас;
  • Idle: сколько готовых соединений ждут работы;
  • WaitCount: сколько раз горутинам пришлось заблокироваться из-за нехватки слота;
  • WaitDuration: суммарное время ожидания соединений с момента старта процесса.

Главная ловушка новичков — смотреть на мгновенные значения WaitCount. Поскольку этот счетчик монотонно растет с момента запуска пода, само по себе большое число ни о чем не говорит. Для диагностики критически важен замер скользящей дельты:

package main

import (
    "database/sql"
    "log"
    "time"
)

// MonitorPoolDeltas периодически замеряет скорость накопления очереди в пуле
func MonitorPoolDeltas(db *sql.DB, interval time.Duration) {
    ticker := time.NewTicker(interval)
    var lastWaitCount int64
    var lastWaitDuration time.Duration

    for range ticker.C {
        stats := db.Stats()

        deltaWaitCount := stats.WaitCount - lastWaitCount
        deltaWaitDuration := stats.WaitDuration - lastWaitDuration

        lastWaitCount = stats.WaitCount
        lastWaitDuration = stats.WaitDuration

        if deltaWaitCount > 0 {
            avgWait := deltaWaitDuration / time.Duration(deltaWaitCount)
            log.Printf("[ВНИМАНИЕ] Голодание пула: %d горутин ждали слот, среднее ожидание: %v (InUse: %d, Open: %d)",
                deltaWaitCount, avgWait, stats.InUse, stats.OpenConnections)
        }
    }
}
Опасность слепого наращивания MaxOpenConns

Когда инженеры видят рост WaitCount, первая реакция — задрать SetMaxOpenConns с 40 до 500. Но если у тебя развернуто 20 подов в Kubernetes, база получит до 10 000 параллельных сессий. PostgreSQL моментально упадет с ошибкой «sorry, too many clients already» из-за нехватки RAM на backend worker-процессы. Решать проблему нужно сокращением длительности транзакций и выносом долгих вычислений за пределы блокировок.