PostgreSQL CookbookПодключение и ориентацияЖизненный цикл соединения и пулинг
0 / 63 (0%)

Жизненный цикл соединения и пулинг

Сайт 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 — две стороны одной арифметики.

Та же мысль картинкой:

plaintext
   пул приложения (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 отфильтровать ровно свои бэкенды:

go
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 соединения, не возвращая их, спрашивает у сервера счётчик бэкендов и возвращает всё назад:

go
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 — пул ленив000
Acquire — пул открыл бэкенды440
Release — вернули, не закрыли404

Последняя строка — вся суть пула: занято=0, но всего=4. Соединения не исчезли, они простаивают и ждут следующего Acquire.

Запуск

Подними песочницу (из корня репозитория) и накати схему Brew:

sh
docker compose up -d
make lecture L=00-getting-connected/00-06-connection-lifecycle-and-pooling T=db-reset

Запусти демо:

sh
make lecture L=00-getting-connected/00-06-connection-lifecycle-and-pooling

(T=run — значение по умолчанию. Изнутри каталога юнита это просто make db-reset и make run.)

Вывод:

plaintext
Пул создан: 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 касса.

·Модуль 01

Этот урок ещё впереди

Курс изучается по порядку — чтобы открыть этот шаг, сначала завершите предыдущие. Так контекст накапливается без пропусков.

/ вы пытались открыть
Подключение и ориентация / Жизненный цикл соединения и пулинг