Жизненный цикл соединения и пулинг
Вечер запуска сайта Brew. Трафик пошёл — и логи приложения краснеют одной строкой: FATAL: sorry, too many clients already. Команда стягивается к одному монитору.
Разобраться — и есть этот юнит. Он открывает чёрный ящик 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: ты блокируешь дефицитный ресурс, пока ждёшь чужой сервис.
Типичные ошибки модуля 00
Наутро после war-room команда садится за короткий постмортем, а раз это последний юнит модуля — грабли первого контакта собираются в одну таблицу. Общее у них то, что на демо их не видно: одни молча отдают не то (инъекция, перепутанный Scan), другие копятся и роняют приложение уже в проде под нагрузкой (соединение на запрос, утечка пула). Повесь эту таблицу у себя над код-ревью.
| ловушка | юнит | как правильно |
|---|---|---|
склейка SQL строками с вводом: ' OR 1=1 -- закрывает кавычку и обходит фильтр — на демо «работает», на ревью утекает вся таблица | 00-04 | значения только параметром ($1, $2, …): текст и данные едут на сервер раздельно, значение мимо парсера, кодом стать не может |
ручной rows.Scan с перепутанным порядком колонок одного типа: компилятор молчит, name ложится в поле category | 00-04 → 00-05 | пиши query.sql и генерируй разбор через sqlc — рассинхрон типов ловит компилятор после make gen, а не пользователь в рантайме |
новое соединение pgx.Connect на каждый запрос: под нагрузкой упор в max_connections и FATAL: too many clients для всех, включая здоровые запросы | 00-06 | открой пул один раз на старте приложения и переиспользуй соединения |
Acquire без парного Release: соединение «утекает» из пула навсегда — медленный аналог той же ошибки | 00-06 | каждый Acquire закрывай Release (обычно defer) |
Что забрать с собой
- Соединение с Postgres — это backend-процесс на сервере: дорогой при открытии, занимающий память, ограниченный серверным
max_connections. Открывать его на каждый запрос — антипаттерн. - Пул открывает соединения один раз и переиспользует:
Acquireберёт (или открывает, или ждёт у лимита),Releaseвозвращает, не закрывая. Пул ленивый — соединения появляются при первом Acquire. - Одно соединение в пуле = один backend на сервере;
pool.Stat()иpg_stat_activityпоказывают это с двух сторон и сходятся. - Размер пула подбирают под нагрузку и
max_connections; каждыйAcquireобязан иметьRelease.
Поздно вечером, когда сайт снова принимает заказы, в командном чате всплывает сообщение от Эмиля — основателя, знакомого тебе пока только по чеку заказа #1 в рамке.
На этом модуль 00 «Подключение и ориентация» закрыт: есть песочница, psql под рукой, рабочий конвейер «SQL руками → sqlc → типизированный pgx-код» и понимание, что пул делает с соединениями. Дальше — модуль 01 «Типы данных»: какой тип выбрать и почему, начиная с отчёта для Эмиля — с денег, где numeric против float решает, сойдётся ли у Brew касса до копейки.