
Каждое новое соединение к базе — это не просто «дырка в сокете». Для PostgreSQL это отдельный серверный процесс, память, планировщик, переключения контекста, рукопожатия, а иногда ещё и TLS. Когда приложение создаёт сотни и тысячи коротких соединений, база тратит больше времени на обслуживающий шум, чем на сами запросы. Итоги предсказуемы: скачки задержек, ошибки «не могу подключиться», рост потребления памяти и CPU.
Пул соединений решает эту проблему. Приложение отдаёт запросы в пул, а пул — дозирует их к базе через небольшое число постоянных серверных соединений. В результате:
PostgreSQL создаёт отдельный процесс на каждое клиентское соединение. Даже «пустой» процесс требует памяти (как правило, несколько мегабайт) и времени CPU. Когда таких соединений сотни и тысячи, увеличиваются:
База начинает «пылесосить» ресурсы на обслуживание соединений вместо выполнения запросов. Пулинг решает именно эту часть.
Есть два основных подхода:
На практике лучше комбинировать: небольшой пул в каждом приложении плюс pgbouncer перед базой.
[databases]
# Имена логические -> куда pgbouncer коннектится
app = host=127.0.0.1 port=5432 dbname=appdb auth_user=pgbouncer_user
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
unix_socket_dir =
# Режим пуллинга: для веб-нагрузки чаще всего transaction
pool_mode = transaction
# Сколько клиентских соединений pgbouncer готов принять
max_client_conn = 5000
# Сколько серверных соединений pgbouncer держит к реальной БД
# Это главное «окно» в базу: подберите под ядра/нагрузку
default_pool_size = 100
reserve_pool_size = 20
# Таймауты и «здоровье»
server_idle_timeout = 600
server_lifetime = 3600
query_timeout = 15s
query_wait_timeout = 2s
# Аутентификация
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
# Приводим сессию к чистому состоянию
server_reset_query = DISCARD ALL
server_reset_query_always = 1
# Игнорируем параметры, мешающие мультиплексировать соединения
ignore_startup_parameters = extra_float_digits,options
# TLS при необходимости
# client_tls_sslmode = require
# client_tls_key_file = /etc/pgbouncer/client.key
# client_tls_cert_file = /etc/pgbouncer/client.crt
# client_tls_ca_file = /etc/pgbouncer/ca.crt
# Логи
log_connections = 1
log_disconnections = 1
stats_period = 60
Файл /etc/pgbouncer/userlist.txt:
"pgbouncer_user" "md5<хеш_пароля>"
"app_user" "md5<хеш_пароля>"
Важно: default_pool_size — это суммарные серверные соединения на БД-юзер/БД-пару. Если у вас несколько сервисов/пользователей, распределите лимит осознанно.
На стороне БД:
statement_timeout = '5s' (или ваш безопасный предел);idle_in_transaction_session_timeout = '30s'.superuser_reserved_connections = 3.tcp_keepalives_idle, tcp_keepalives_interval, tcp_keepalives_count.log_min_duration_statement для поиска долгих запросов.Чтобы прикинуть нужный размер пула, используйте простую модель очереди:
Пример: у вас 100 RPS, средняя SQL‑нагрузка на запрос 20 мс (0,02 секунды). Тогда N = 100 * 0,02 = 2. Берём запас ×2–×3 на пики и разброс: 4–6 соединений. Для микросервиса это обычно 4–16. Если сервисов 20 штук, сумма перебора на базе недопустима: пусть каждый держит небольшой пул, а pgbouncer дозирует поверх.
Главное правило: рост числа инстансов сервиса не должен линейно увеличивать соединения к базе. В идеале суммарный лимит известен и контролируется через pgbouncer и max_connections.
// package.json должен содержать "pg"
// npm install pg
const { Pool } = require('pg');
// Важно: ограничить пул и ввести таймауты
const pool = new Pool({
host: process.env.DB_HOST || '127.0.0.1',
port: Number(process.env.DB_PORT || 6432), // идём через pgbouncer
database: process.env.DB_NAME || 'appdb',
user: process.env.DB_USER || 'app_user',
password: process.env.DB_PASSWORD || 'secret',
// Пул приложения
max: Number(process.env.DB_POOL_MAX || 8),
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
// Удобно для диагностики
application_name: 'web-api',
// Сразу просим сервер убивать слишком долгие запросы
statement_timeout: 5000,
});
async function queryWithTimeout(sql, params, timeoutMs = 3000) {
const client = await pool.connect();
try {
// Доп. страховка на уровне клиента
const timeout = setTimeout(() => {
try { client.release(); } catch (_) {}
}, timeoutMs + 100);
// Локально можно ужесточить timeout для запроса
await client.query('SET LOCAL statement_timeout = $1', [String(timeoutMs)]);
const res = await client.query(sql, params);
clearTimeout(timeout);
return res.rows;
} finally {
client.release();
}
}
// Пример HTTP‑хэндлера с load shedding
const http = require('http');
const server = http.createServer(async (req, res) => {
if (req.url === '/health') {
res.writeHead(200); res.end('ok'); return;
}
const start = Date.now();
try {
const rows = await Promise.race([
queryWithTimeout('SELECT now() AS ts', [], 1000),
new Promise((_, reject) => setTimeout(() => reject(new Error('queue-timeout')), 800)),
]);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ts: rows[0].ts, took_ms: Date.now() - start }));
} catch (e) {
// Если очередь пула забита — сразу 503, чтобы не завалить базу
const status = e.message === 'queue-timeout' ? 503 : 500;
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: e.message }));
}
});
server.listen(8080, () => console.log('Listening on :8080'));
Советы:
// go get github.com/jackc/pgx/v5/pgxpool
package main
import (
context "context"
"fmt"
"log"
"net/http"
"os"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
dsn := fmt.Sprintf(
"host=%s port=%s dbname=%s user=%s password=%s pool_max_conns=%s application_name=web-api",
getEnv("DB_HOST", "127.0.0.1"),
getEnv("DB_PORT", "6432"),
getEnv("DB_NAME", "appdb"),
getEnv("DB_USER", "app_user"),
getEnv("DB_PASSWORD", "secret"),
getEnv("DB_POOL_MAX", "8"),
)
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
log.Fatalf("parse config: %v", err)
}
// Страхуемся от подвисших коннектов и «стариков»
cfg.MaxConnLifetime = time.Hour
cfg.MaxConnIdleTime = 30 * time.Second
cfg.HealthCheckPeriod = 30 * time.Second
cfg.MaxConnLifetimeJitter = 30 * time.Second
// Просим сервер обрывать долгие запросы
cfg.ConnConfig.RuntimeParams["statement_timeout"] = "5000" // мс
// Ограничим ожидание соединения из пула
cfg.MaxConnAcquireTimeout = 800 * time.Millisecond
pool, err := pgxpool.NewWithConfig(context.Background(), cfg)
if err != nil {
log.Fatalf("connect pool: %v", err)
}
defer pool.Close()
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
http.HandleFunc("/time", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 900*time.Millisecond)
defer cancel()
// Если пул забит, NewWithConfig учтёт MaxConnAcquireTimeout: вернётся ошибка
var ts time.Time
if err := pool.QueryRow(ctx, "SELECT now()::timestamptz").Scan(&ts); err != nil {
status := http.StatusInternalServerError
if err == context.DeadlineExceeded {
status = http.StatusServiceUnavailable // быстрый отказ, чтобы не душить базу
}
w.WriteHeader(status)
_, _ = w.Write([]byte(err.Error()))
return
}
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte(ts.Format(time.RFC3339Nano)))
})
log.Println("listen :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
func getEnv(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
Ключевые моменты:
prepareThreshold=0, в некоторых ORM — своя настройка);SET LOCAL ... внутри транзакции.idle_in_transaction_session_timeout на стороне БД и контролируйте это метриками.Что мониторить:
Полезные команды:
-- pgbouncer (через psql на порт pgbouncer)
SHOW STATS;
SHOW POOLS;
SHOW SERVERS;
SHOW CLIENTS;
Экспортеры метрик для Prometheus есть как для PostgreSQL, так и для pgbouncer — подключите их первыми.
Грубая прикидка: если без пула ваша база держала 1500 соединений, а с pgbouncer — 200–300, экономия памяти может составлять десятки гигабайт. Это либо меньше класс машины, либо больше запас под кэш и планы.
Пулинг соединений — простая по идее, но очень эффективная практика. Комбинация pgbouncer и небольших пулов в приложениях даёт контролируемую очередь запросов, держит базу в стабильном состоянии, снижает расходы и делает систему предсказуемой на пиках. Внедрение занимает день‑два, а отдача заметна сразу: исчезают «шторма соединений», улучшается latency, падают расходы на классы машин. Это тот случай, когда инженерная дисциплина напрямую экономит деньги и улучшает продуктовые метрики.