Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 4 из 25 ~20 мин +20 XP

Пул соединений pgxpool

pgxpool — пул соединений, без которого не обходится ни один Go-сервис с Postgres: он держит несколько готовых коннектов, выдаёт их горутинам по запросу и следит за жизненным циклом каждого. Разберём, зачем пул нужен, как настроить MaxConns и MinConns под лимиты базы, почему Ping входит в стартовую проверку сервиса и как pool.Stat показывает, что происходит внутри.

Содержание урока · 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_, так что конфигурация не раздваивается.

Пул с лимитом из строки подключения

GO
package 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_connsMaxConnsmax(4, NumCPU)потолок одновременных соединений
pool_min_connsMinConns0минимум соединений в простое
pool_max_conn_lifetimeMaxConnLifetime1 часмаксимальный возраст соединения
pool_max_conn_idle_timeMaxConnIdleTime30 минутмаксимальный простой незанятого коннекта
pool_health_check_periodHealthCheckPeriod1 минутапериод фоновых проверок здоровья

Настройка пула из кода

GO
cfg, 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, иначе бюджет лживый.

Снимок состояния пула

GO
stat := 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 растёт, когда горутины ждут свободный коннект. Такой вывод удобно повесить за флагом отладки или отправлять в логи по таймеру — раз в минуту хватает.

Проверка: дефолт 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)

Настройте пул из строки подключения

Восстановите создание пула с лимитом соединений и проверкой связи.

pool, err := pgxpool.(ctx, "postgres://app:secret@localhost:5432/app?=10") if err != nil { log.Fatal(err) } defer pool.() if err = pool.(ctx); err != nil { log.Fatal(err) }

Соберите настройку пула из кода

Расставьте строки в порядке исполнения: разбор конфига, ошибки, лимиты, создание пула, закрытие.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: пул соединений

Термин

pgxpool.Pool

нажмите, чтобы перевернуть
Определение

Пул соединений pgx: выдаёт коннекты горутинам, следит за лимитами и жизненным циклом. Создаётся pgxpool.New или NewWithConfig.

1 / 6

Пул настроен — теперь запросы: в пятом уроке отправим первые 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 при лимитах пять и двадцать: эта разница и есть цена очереди, она пригодится в уроке про наблюдаемость.

Личные заметкиПусто
Хранятся только в вашем браузере (pgx · Пул соединений pgxpool)

Вопросы и ответы по уроку

Зачем пул, если можно открывать соединение на каждый запрос?

Каждое соединение — процесс на сервере: сотни параллельных подключений съедят память и лимит max_connections, а хендшейки добавят миллисекунды к каждому запросу. Пул держит несколько готовых коннектов и раздаёт их горутинам: под нагрузкой это предсказуемо и дёшево, а самая дорогая часть — хендшейк и серверный процесс — платится один раз на коннект, а не на запрос.

Что лучше: MaxConns побольше?

Нет: после пары десятков активных соединений на ядро Postgres тратит время на переключения, а не на запросы. Потолок диктует max_connections базы минус запас, делённые на число инстансов. Больше пользы даст очередь внутри пула, чем толпа коннектов. Если пику действительно нужно больше — масштабируйте базу или ставьте внешний пулер, а не откручивайте лимит до упора.

Нужен ли pgbouncer рядом с pgxpool?

Когда инстансов сервиса много или соединения открывают serverless-функции — да: pgbouncer мультиплексирует физические соединения. Для одного-трёх сервисов с разумным MaxConns хватает встроенного пула. Классический transaction-режим pgbouncer не дружит с prepared statements — нужен простой протокол запросов или свежая версия pgbouncer с поддержкой protocol-level prepared statements.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

MongoDB25 уроков~12 чпрактика25 уроков уже доступны

MongoDB в Go: официальный драйвер mongo-driver v2

Курс MongoDB для Go: подключение, bson-теги, CRUD, фильтры, агрегации, индексы, транзакции и Change Streams. 25 уроков по mongo-driver v2.

  • Подключаться к MongoDB и настраивать клиента
  • Моделировать документы структурами и bson-тегами
  • Делать CRUD, сложные фильтры и проекции
  • Строить агрегации $match/$group/$lookup