Жизненный цикл соединения и пулинг
Сайт Brew выкатили в прод, пошёл трафик — и логи приложения запестрели FATAL: sorry, too many clients already. Расследование показало банальное: код открывал новое соединение pgx.Connect на каждый HTTP-запрос и не закрывал его аккуратно. Под нагрузкой число соединений упёрлось в серверный лимит max_connections, и Postgres начал отказывать всем — включая здоровые запросы.
Во всех прошлых юнитах мы звали pg.NewPool и не задумывались, что там внутри. Этот юнит открывает чёрный ящик: что такое соединение с точки зрения сервера, зачем нужен пул, сколько соединений он держит и как увидеть свои бэкенды глазами самого Postgres через pg_stat_activity. Это raw-pgx юнит — урок про API пула, а не про запросы, поэтому sqlc здесь ни при чём.
Соединение — это процесс на сервере, и он не бесплатный
Когда клиент подключается к Postgres, сервер форкает под него отдельный процесс — backend. Этот процесс живёт всё время соединения и держит свою память (work_mem, кеши, состояние сессии). Открыть его — это TCP-рукопожатие, аутентификация, инициализация сессии: миллисекунды, которые под нагрузкой складываются в заметную задержку. А держать их открытыми — это память и планировщик ОС на каждый backend. Поэтому у сервера есть жёсткий потолок — max_connections (по умолчанию ~100): не «сколько вытянет», а сколько backends он вообще разрешит.
Практический вывод: соединение — дорогой и ограниченный ресурс. Открывать его на каждый запрос — антипаттерн (та самая ошибка Brew). Правильно — открыть пул один раз на старте приложения и переиспользовать соединения.
Пул: открыть однажды, переиспользовать многократно
Пул (pgxpool) держит набор уже открытых соединений. Логика работы простая:
- Acquire — взять соединение из пула. Если свободное есть — отдаётся мгновенно. Если нет, но лимит
MaxConnsне достигнут — пул открывает новое. Если лимит достигнут — Acquire ждёт, пока кто-то вернёт своё. - Release — вернуть соединение в пул. Важно: Release не закрывает backend, он оставляет его открытым и помечает свободным для следующего Acquire. В этом весь смысл: рукопожатие платится один раз, дальше соединение живёт и переиспользуется.
Пул ленивый: после NewPool соединений ещё нет (MinConns=0 по умолчанию), первое откроется при первом Acquire. И у пула есть встроенная статистика — pool.Stat(): сколько соединений открыто сейчас (TotalConns), сколько выдано в работу (AcquiredConns), сколько простаивает готовыми (IdleConns).
Ключевое соответствие: одно соединение в пуле = один backend на сервере. Размер пула приложения — это и есть число процессов, которое оно занимает у Postgres. Поэтому MaxConns пула и серверный max_connections — две стороны одной арифметики.
Та же мысль картинкой:
пул приложения (MaxConns=4) сервер (postgres)
┌────────────────────┐
│ слот 1 ▣ acquired │──▶ backend 1 ─┐
│ слот 2 ▣ acquired │──▶ backend 2 │ один слот пула =
│ слот 3 ▢ idle │──▶ backend 3 │ один backend на сервере
│ слот 4 ▢ idle │──▶ backend 4 ─┘
└────────────────────┘
Acquire: ▢ idle → ▣ acquired (нет свободного, не лимит → открыть; лимит → ждать)
Release: ▣ acquired → ▢ idle (backend НЕ закрывается — простаивает, готов к Acquire)Что показывает наш код
Демо создаёт маленький пул (MaxConns=4) и прослеживает жизненный цикл соединений, сверяясь с тем, что видит сам сервер. Соединения помечены application_name — чтобы в pg_stat_activity отфильтровать ровно свои бэкенды:
pool, err := pg.NewPool(ctx,
pg.WithMaxConns(4),
func(c *pgxpool.Config) { // кастомный Option (escape-hatch)
c.ConnConfig.RuntimeParams["application_name"] = "brew-pool-demo"
},
)pg.WithMaxConns — штатный опцион из internal/pg. Рядом — литерал func(*pgxpool.Config): это та же pg.Option (escape-hatch для тонкой донастройки пула под урок), он проставляет application_name в стартовый пакет каждого соединения. Дальше демо захватывает все 4 соединения, не возвращая их, спрашивает у сервера счётчик бэкендов и возвращает всё назад:
conns := make([]*pgxpool.Conn, 0, 4)
for i := 0; i < 4; i++ {
c, _ := pool.Acquire(ctx) // пул вынужден открыть реальный backend
conns = append(conns, c)
}
// пул исчерпан — count делаем по уже захваченному соединению, иначе pool.Query заблокируется
conns[0].QueryRow(ctx, "SELECT count(*) FROM pg_stat_activity WHERE application_name = $1", appName).Scan(&backends)
for _, c := range conns {
c.Release() // не закрывает backend — оставляет простаивать
}pool.Stat() до захвата, после захвата и после возврата — три среза, по которым видно весь цикл:
| Момент | TotalConns (всего) | AcquiredConns (занято) | IdleConns (простаивают) |
|---|---|---|---|
после NewPool — пул ленив | 0 | 0 | 0 |
4×Acquire — пул открыл бэкенды | 4 | 4 | 0 |
4×Release — вернули, не закрыли | 4 | 0 | 4 |
Последняя строка — вся суть пула: занято=0, но всего=4. Соединения не исчезли, они простаивают и ждут следующего Acquire.
Запуск
Подними песочницу (из корня репозитория) и накати схему Brew:
docker compose up -d
make lecture L=00-getting-connected/00-06-connection-lifecycle-and-pooling T=db-resetЗапусти демо:
make lecture L=00-getting-connected/00-06-connection-lifecycle-and-pooling(T=run — значение по умолчанию. Изнутри каталога юнита это просто make db-reset и make run.)
Вывод:
Пул создан: MaxConns=4, application_name="brew-pool-demo".
1) Сразу после NewPool пул ленив — соединений ещё нет:
всего=0 занято=0 простаивают=0 (макс=4)
2) Захватили 4 соединения (pool.Acquire) — пул открыл столько реальных бэкендов:
всего=4 занято=4 простаивают=0 (макс=4)
3) Сколько бэкендов с application_name="brew-pool-demo" видит Postgres (pg_stat_activity): 4
4) Вернули все 4 в пул (conn.Release) — соединения не закрылись, а простаивают:
всего=4 занято=0 простаивают=4 (макс=4)Шаг 1 — пул пуст (ленивый). Шаг 2 — захват открыл ровно 4 backend'а, и сервер их подтверждает на шаге 3: счётчик pg_stat_activity совпал с числом захваченных соединений. Шаг 4 — Release вернул их в пул, не закрывая: занято=0, но всего=4 — четыре backend'а так и остались жить, готовые к переиспользованию.
Заборчик
- «Больше соединений = быстрее» — миф. Каждый backend стоит серверу памяти и места в планировщике ОС, и за пределами числа ядер лишние соединения только добавляют конкуренции, а не пропускной способности. Размер пула — это компромисс, а не «побольше»; в проде его подбирают под нагрузку и под
max_connections, а не оставляют дефолт. - Когда инстансов приложения много и суммарно их пулы упираются в серверный лимит — между ними и Postgres ставят внешний пулер (PgBouncer); у него свои подводные камни (transaction-mode ломает session-level вещи вроде advisory-локов и
LISTEN/NOTIFY) — это тема капстона 10-04. - Каждый
Acquireдолжен иметь парныйRelease(обычноdefer), иначе соединение «утекает» из пула навсегда — это медленный аналог той самой ошибки Brew. - Не держи захваченное соединение во время долгого внешнего I/O: ты блокируешь дефицитный ресурс, пока ждёшь чужой сервис.
Что забрать с собой
- Соединение с Postgres — это backend-процесс на сервере: дорогой при открытии, занимающий память, ограниченный серверным
max_connections. Открывать его на каждый запрос — антипаттерн. - Пул открывает соединения один раз и переиспользует:
Acquireберёт (или открывает, или ждёт у лимита),Releaseвозвращает, не закрывая. Пул ленивый — соединения появляются при первом Acquire. - Одно соединение в пуле = один backend на сервере;
pool.Stat()иpg_stat_activityпоказывают это с двух сторон и сходятся. - Размер пула подбирают под нагрузку и
max_connections; каждыйAcquireобязан иметьRelease.
На этом модуль 00 «Подключение и ориентация» закрыт: есть песочница, psql под рукой, рабочий конвейер «SQL руками → sqlc → типизированный pgx-код» и понимание, что пул делает с соединениями. Дальше — модуль 01 «Типы данных»: какой тип выбрать и почему, начиная с денег, где numeric против float решает, сойдётся ли у Brew касса.