Содержание урока · 9 разделов
Соединение с Postgres — дорогая штука, и не только из-за времени на TCP-хендшейк, хотя он тоже стоит миллисекунд. На каждый коннект сервер создаёт отдельный процесс — backend, — который держит несколько мегабайт памяти и живёт, пока жив клиент. Десяток соединений сервер не заметит, сотня — уже серьёзная нагрузка на память и планировщик, а тысяча одновременных попыток подключения способна уложить маленький инстанс раньше, чем выполнится хоть один запрос. Go-сервис при этом многогорутинный: каждый HTTP-запрос хочет свою транзакцию, и наивное открытие соединения на каждый запрос перемножает обе проблемы. Пул решает обе сразу: соединения создаются заранее и в ограниченном количестве, переиспользуются сотнями горутин, а нехватка превращается в короткое ожидание внутри приложения вместо шторма подключений к серверу. В pgx пул — не дополнение из сторонних библиотек, а часть основного модуля: подпакет pgxpool с API, повторяющим одиночное соединение один в один. Арифметика сервера простая и суровая: дефолтный max_connections равен сотне, из них пара слотов зарезервирована суперпользователям на спасение при исчерпании, а ещё несколько уйдёт мониторингу и миграциям. Два сервиса по десять подов — уже двадцать инстансов, и каждому MaxConns не может быть выше четвёрки. Отсюда правило, которое я повторяю на каждом код-ревью сервиса с базой: размер пула — глобальный бюджет, распределённый по инстансам, а не локальная настройка одного приложения. Пул, кстати, не координируется с соседними инстансами: каждый живёт своим бюджетом, и сумма сходится только в вашей голове или в конфиге деплоя.
pgxpool.New: замена одной строки#
Переезд с одиночного соединения на пул минимален: pgx.Connect превращается в pgxpool.New, тип pgx.Conn — в pgxpool.Pool, а conn.Close(ctx) — в pool.Close(). Всё остальное API совпадает: pool.Exec, pool.Query, pool.QueryRow и pool.Begin работают так же, как у одиночного соединения, поэтому первые уроки курса проходят без переписывания кода. Создание пула ленивое: New только разбирает конфиг, а соединения открываются по мере необходимости — до MinConns в простое и до MaxConns под нагрузкой. Ошибка вернётся сразу, если строка подключения битая, и позже — если база недоступна: поэтому пул пингуют при старте, чтобы поймать проблему там, где она есть, а не в первом запросе пользователя. Ленивость при этом не значит «никогда»: фоновая горутина пула сама дотягивает число соединений до MinConns, поэтому сразу после запуска в pg_stat_activity видно ровно столько сессий приложения, сколько обещала настройка — тёплый старт без ручной подготовки. Ещё одна деталь, которую удобно знать заранее: DSN пула — та же строка из третьего урока, плюс параметры с префиксом pool_, так что конфигурация не раздваивается.
Пул с лимитом из строки подключения
GOpackage main
import (
"context"
"fmt"
"log"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
ctx := context.Background()
pool, err := pgxpool.New(ctx,
"postgres://app:secret@localhost:5432/app?pool_max_conns=10")
if err != nil {
log.Fatal(err)
}
defer pool.Close()
if err = pool.Ping(ctx); err != nil {
log.Fatal(err)
}
tag, err := pool.Exec(ctx,
"CREATE TABLE IF NOT EXISTS notes (" +
"id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, title text)")
if err != nil {
log.Fatal(err)
}
fmt.Println("готово:", tag.String())
}Пул создаётся из DSN с параметром pool_max_conns=10 — потолок одновременных соединений задаётся прямо в строке. Ping при старте ловит недоступную базу до первого запроса, а не посреди вечера. Exec выполняет DDL — таблица notes станет полигоном для INSERT и SELECT в пятом уроке. pool.Close() освобождает все соединения при выходе из программы.
MaxConns, MinConns и жизненный цикл коннектов#
Настройки пула живут в структуре pgxpool.Config, и их немного. MaxConns — потолок одновременных соединений: по умолчанию большее из чисел четыре и количества ядер процессора. MinConns — сколько коннектов пул держит даже в простое: ноль по умолчанию, то есть проснувшийся после ночи простоя сервис заново греет соединения на первых запросах. MaxConnLifetime — максимальный возраст соединения, дефолт час: по истечении коннект закрывается и открывается свежий, что спасает после перезапусков базы и переподключений за балансировщиками. MaxConnIdleTime — сколько простаивающий коннект живёт без дела, дефолт тридцать минут: незанятые соединения закрываются, чтобы не занимать серверную память впустую. Практическое следствие: скрипт, который раз в час делает один запрос, каждый раз получает холодное соединение и платит хендшейком — такому удобнее одиночный Conn, чем пул с горячими коннектами, которые всё равно умрут от простоя. HealthCheckPeriod — как часто пул проверяет здоровье коннектов, по умолчанию минута. К lifetime настраивается и добавка-джиттер, MaxConnLifetimeJitter: добавьте её, чтобы соединения не умирали синхронной волной после одного и того же момента — без джиттера рестарт базы уронил бы и переоткрыл все коннекты разом. Все значения задаются и из DSN: pool_max_conns, pool_min_conns, pool_max_conn_lifetime, pool_max_conn_idle_time, pool_health_check_period — код конфигурации нужен, только когда числа считаются динамически из окружения. Ещё одна деталь механики: соединение считается занятым не на время запроса, а на всю транзакцию — Begin держит коннект до Commit или Rollback. Пул, где половина горутин сидит в длинных транзакциях, испытывает дефицит даже при скромном темпе запросов: для пула одна долгая транзакция хуже сотни коротких. Если AcquiredConns всё время прилип к потолку, ищите не настройку MaxConns, а транзакции без конца.
Параметры pgxpool: DSN, поле Config и дефолт#
| Параметр в DSN | Поле Config | Дефолт | За что отвечает |
|---|---|---|---|
| pool_max_conns | MaxConns | max(4, NumCPU) | потолок одновременных соединений |
| pool_min_conns | MinConns | 0 | минимум соединений в простое |
| pool_max_conn_lifetime | MaxConnLifetime | 1 час | максимальный возраст соединения |
| pool_max_conn_idle_time | MaxConnIdleTime | 30 минут | максимальный простой незанятого коннекта |
| pool_health_check_period | HealthCheckPeriod | 1 минута | период фоновых проверок здоровья |
Классика дежурств: десять подов сервиса по двадцать соединений — и на базе кончается лимит max_connections, который по умолчанию равен сотне. Новые подключения получают FATAL: sorry, too many clients already, readiness-пробы краснеют, деплой останавливается, а ручной psql в контейнер тоже не проходит — все слоты заняты вашим же сервисом. Считайте всегда: сумма MaxConns всех инстансов плюс запас для миграций, админ-сессий и мониторинга должна быть меньше max_connections. Десять подов — значит пять-семь соединений на каждый, а не двадцать.
Настройка пула из кода
GOcfg, err := pgxpool.ParseConfig(os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
cfg.MaxConns = 8
cfg.MinConns = 2
cfg.MaxConnLifetime = 30 * time.Minute
cfg.MaxConnIdleTime = 5 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, cfg)ParseConfig разбирает строку подключения вместе с параметрами пула, после чего поля можно переопределить из кода — удобно, когда значения считаются из размеров окружения: числа подов, переменных деплоя. NewWithConfig собирает пул из готовой конфигурации и возвращает ту же структуру, что и New. Заметьте типы: MaxConns — int32, время — time.Duration.
Шесть правил настройки пула#
- Посчитайте: сумма MaxConns всех инстансов плюс запас из 10-20 соединений меньше max_connections базы
- MinConns 2-5 для сервиса с ровным трафиком: не греть соединения после каждого простоя
- MaxConnLifetime меньше таймаутов балансировщиков и файрволов: соединение умрёт раньше, чем их отрежут
- MaxConns выше числа ядер базы почти не даёт прироста: Postgres масштабирует CPU, а не процессы
- Не ставьте -1 (без лимита): шторм соединений на пике положит базу раньше, чем вы это заметите
- Меряйте pool.Stat() до и после изменения настроек: числа вместо ощущений
Ping перед работой: старт сервиса и readiness#
Две ситуации, где Ping обязателен. Первая — старт: пул ленивый, и без проверки сервис поднимется зелёным даже с недоступной базой, а ошибка приедет с первым пользовательским запросом. Пинг после создания пула превращает эту ошибку в быстрый и понятный крах деплоя — fail fast в чистом виде. Вторая — readiness-проба Kubernetes или health-эндпоинт: периодический pool.Ping(ctx) показывает балансировщику, жива ли база, а не сам процесс сервиса. Таймаут задаётся контекстом, а не параметром: оберните вызов в context.WithTimeout на пару секунд, чтобы проба не зависла на недоступной сети — подробности в пятнадцатом уроке про контексты и таймауты. Полезная деталь: Ping берёт соединение из пула как обычный запрос, так что постоянные пинги каждую секунду сами по себе нагрузки не создают, но и пользы сверх health-проб от них нет. Кстати, Ping при пустом пуле сам открывает соединение: первый пинг после старта — заодно и прогрев. В readiness-эндпоинте это выглядит как три строки обработчика: контекст с таймаутом на пару секунд, pool.Ping и выбор кода ответа по ошибке. Главное — не путать эту пробу с liveness: живой без базы сервис продолжает отвечать ошибками приложения, и убивать его рестартом бессмысленно.
pool.Stat(): сколько коннектов занято прямо сейчас#
Пул знает о себе всё, и pgxpool.Stat() отдаёт это наружу методами: TotalConns — сколько соединений живёт, AcquiredConns — сколько занято горутинами, IdleConns — сколько простаивает, MaxConns — потолок. Отдельно полезны AcquireCount — сколько раз горутины брали коннект, и EmptyAcquireCount — сколько раз запрос на соединение застал пул пустым и ждал: растущий EmptyAcquireCount — первый симптом нехватки MaxConns, задолго до жалоб пользователей на медленные ответы. Я вывожу эти числа в метрики на старте сервиса и смотрю график перед любым тюнингом: пул, настроенный по ощущениям, исправляется за один взгляд на TotalConns против MaxConns. Собирайте Stat периодически, раз в десять-пятнадцать секунд, а не на каждый запрос: график гладкий, накладные расходы незаметны. Здоровая картина, к которой я стремлюсь: IdleConns около MinConns в спокойные часы, AcquiredConns на треть ниже потолка даже в пик, EmptyAcquireCount около нуля. Отклонения читаются однозначно: растущий Idle при нулевом Acquired — пул больше, чем нужно; прилипший Acquired — либо мало соединений, либо длинные транзакции. Вынос в Prometheus и алерты — тема двадцать четвёртого урока, а здесь — привычка: перед каждым изменением настроек запишите текущие значения Stat, иначе сравнить будет не с чем.
Кейс, показывающий цену одного числа. Ночной джобой миграций команда запускала пересчёт агрегатов, который открывал собственные соединения в обход пула сервиса — по двадцать на инстанс. В ночь большого релиза сервис упёрся в пик трафика, джоб стартовал на всех подах одновременно, и пул запросов исчерпался: горутины стояли в очереди за коннектом, p95 уехал за секунды, но всё продолжало отвечать — пустой пул ждёт, а не роняет запросы. Диагноз поставился не по коду, а по Stat: EmptyAcquireCount рос тысячами в минуту, TotalConns прилип к MaxConns. Починили двумя строками: отдельный лимит для джоба из общего расчёта и метрика на EmptyAcquireCount с алертом. С тех пор правило команды звучит так: любой новый компонент, которому нужна база, объявляет свой лимит явно и входит в общий расчёт до деплоя, а не после первого инцидента. Вывод кейса: пул не падает, он тихо замедляет — увидеть это можно только числами. И вторая половина морали: компонент, открывающий соединения мимо общего пула, обязан считаться в том же бюджете max_connections, иначе бюджет лживый.
Снимок состояния пула
GOstat := pool.Stat()
fmt.Printf("соединений: %d из %d (занято %d, свободно %d)
",
stat.TotalConns(), stat.MaxConns(), stat.AcquiredConns(), stat.IdleConns())
fmt.Printf("забор коннекта: %d раз, из них с ожиданием: %d
",
stat.AcquireCount(), stat.EmptyAcquireCount())Stat вызывается в любой момент: это мгновенный снимок состояния пула, без блокировок и без обращения к серверу. Первая строка показывает текущую нагрузку, вторая — дефицит: EmptyAcquireCount растёт, когда горутины ждут свободный коннект. Такой вывод удобно повесить за флагом отладки или отправлять в логи по таймеру — раз в минуту хватает.
pool.Query и pool.Exec сами берут соединение из пула и возвращают его. Вручную Acquire нужен редко: серия запросов на одном коннекте, временные таблицы, advisory-блокировки. Забытый Release после Acquire — тихая дыра: соединение числится занятым, пул открывает новое, MaxConns кончается. Если берёте вручную — Release через defer сразу после Acquire.
Проверка: дефолт MaxConns
Какой MaxConns будет у пула без явной настройки?
Проверка: лимиты сервера
Новый клиент пытается подключиться, а max_connections сервера исчерпаны. Что произойдёт?
Что напечатает программа?
Прочитайте код и выберите, что он выведет:
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
log.Fatal(err)
}
pool.Close()
err = pool.Ping(ctx)
fmt.Println(err)Настройте пул из строки подключения
Восстановите создание пула с лимитом соединений и проверкой связи.
Соберите настройку пула из кода
Расставьте строки в порядке исполнения: разбор конфига, ошибки, лимиты, создание пула, закрытие.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: пул соединений
Пул настроен — теперь запросы: в пятом уроке отправим первые INSERT и SELECT и познакомимся с CommandTag. Про мониторинг пула и медленных запросов — двадцать четвёртый урок; про таймауты контекста — пятнадцатый. Формат курса, как и всюду: код с сетью в браузерной песочнице не исполняется — практикуйтесь локально с Go и контейнером Postgres из второго урока, а песочница откроет тематическую программу на чистом Go. Полный план из двадцати пяти уроков живёт на странице курса pgx — оттуда видно, куда мы дойдём к финальному проекту.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Соединение Postgres — процесс на стороне сервера: пул переиспользует коннекты и ограничивает их число.
- pgxpool.New заменяет pgx.Connect одной строкой; API Exec, Query, QueryRow и Begin совпадает.
- Дефолты: MaxConns max(4, NumCPU), MinConns 0, lifetime час, idle тридцать минут, health check минута.
- Считайте сумму MaxConns всех инстансов против max_connections базы — иначе FATAL: too many clients already.
- Ping при старте и в readiness-пробах; таймаут задаётся контекстом.
- pool.Stat() показывает TotalConns, IdleConns и EmptyAcquireCount — меряйте до и после тюнинга.
- Acquire вручную — только для серии запросов на одном коннекте; Release через defer.
Домашнее задание
Переведите демо из второго урока на pgxpool с pool_max_conns=5 и добавьте пинг при старте. Запустите десять параллельных запросов из десяти горутин и понаблюдайте pool.Stat() до и после: сколько соединений создал пул и сколько раз горутины ждали? Поднимите лимит до двадцати — изменится ли картина? Запишите числа и сравните EmptyAcquireCount при лимитах пять и двадцать: эта разница и есть цена очереди, она пригодится в уроке про наблюдаемость.
Личные заметкиПусто
Вопросы и ответы по уроку
Зачем пул, если можно открывать соединение на каждый запрос?
Каждое соединение — процесс на сервере: сотни параллельных подключений съедят память и лимит max_connections, а хендшейки добавят миллисекунды к каждому запросу. Пул держит несколько готовых коннектов и раздаёт их горутинам: под нагрузкой это предсказуемо и дёшево, а самая дорогая часть — хендшейк и серверный процесс — платится один раз на коннект, а не на запрос.
Что лучше: MaxConns побольше?
Нет: после пары десятков активных соединений на ядро Postgres тратит время на переключения, а не на запросы. Потолок диктует max_connections базы минус запас, делённые на число инстансов. Больше пользы даст очередь внутри пула, чем толпа коннектов. Если пику действительно нужно больше — масштабируйте базу или ставьте внешний пулер, а не откручивайте лимит до упора.
Нужен ли pgbouncer рядом с pgxpool?
Когда инстансов сервиса много или соединения открывают serverless-функции — да: pgbouncer мультиплексирует физические соединения. Для одного-трёх сервисов с разумным MaxConns хватает встроенного пула. Классический transaction-режим pgbouncer не дружит с prepared statements — нужен простой протокол запросов или свежая версия pgbouncer с поддержкой protocol-level prepared statements.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
MongoDB в Go: официальный драйвер mongo-driver v2
Курс MongoDB для Go: подключение, bson-теги, CRUD, фильтры, агрегации, индексы, транзакции и Change Streams. 25 уроков по mongo-driver v2.
- Подключаться к MongoDB и настраивать клиента
- Моделировать документы структурами и bson-тегами
- Делать CRUD, сложные фильтры и проекции
- Строить агрегации $match/$group/$lookup