.NET Tutorial
Middle6 мин чтенияВопрос 8 из 10

Что такое connection pooling для БД и HTTP?

Зачем нужен пул

PostgreSQLПулПриложениеБез пула — каждый запросfork серверногопроцессаС пуломTCP SYN / SYN-ACK / ACKTLS handshakeаутентификация (SCRAM)SELECT ... (сам запрос — 1 мс)закрытьOpen() — взять готовоесоединение (микросекунды)SELECT ...Dispose() — вернуть в пул
PostgreSQLПулПриложениеБез пула — каждый запросfork серверногопроцессаС пуломTCP SYN / SYN-ACK / ACKTLS handshakeаутентификация (SCRAM)SELECT ... (сам запрос — 1 мс)закрытьOpen() — взять готовоесоединение (микросекунды)SELECT ...Dispose() — вернуть в пул
Новое соединение против соединения из пула

Пул соединений ADO.NET

да

нет, размер < Max

нет, пул полон

не дождались

using var conn = new NpgsqlConnection(cs)
await conn.OpenAsync()

Есть свободное
в пуле?

Выдать

Открыть новое физическое

Ждать Timeout (15–30 с)

InvalidOperationException:
timeout obtaining a connection

Запросы

Dispose → вернуть в пул
(сброс состояния сессии)

да

нет, размер < Max

нет, пул полон

не дождались

using var conn = new NpgsqlConnection(cs)
await conn.OpenAsync()

Есть свободное
в пуле?

Выдать

Открыть новое физическое

Ждать Timeout (15–30 с)

InvalidOperationException:
timeout obtaining a connection

Запросы

Dispose → вернуть в пул
(сброс состояния сессии)

Жизненный цикл соединения в пуле
Параметры строки подключения (Npgsql / SqlClient)
Host=db;Database=shop;Username=app;Password=***;
Pooling=true;                 # по умолчанию true
Minimum Pool Size=0;          # держать открытыми минимум N
Maximum Pool Size=100;        # по умолчанию 100
Connection Idle Lifetime=300; # закрывать простаивающие (Npgsql)
Timeout=15;                   # ожидание соединения из пула / установки
Command Timeout=30;           # таймаут команды

Важно:

  • Пул — на строку подключения (и на процесс). Разные строки (даже порядок параметров, другой пароль, другая БД) → разные пулы.
  • Dispose обязателен — using/await using. Без него соединение вернётся в пул только после финализации — практически утечка.
  • EF Core сам открывает и закрывает соединение на время запроса/SaveChanges (или на время транзакции) — пулом пользуется автоматически.

Диагностика исчерпания

Типичная утечка
public async Task<int> CountAsync()
{
    var conn = new NpgsqlConnection(_cs);          // нет using!
    await conn.OpenAsync();
    return (int)(long)(await new NpgsqlCommand("SELECT count(*) FROM orders", conn).ExecuteScalarAsync())!;
}   // соединение не возвращено в пул
  • Счётчики: Npgsql/SqlClient публикуют метрики (db.client.connections.usage, idle/used, pending requests) через OpenTelemetry.
  • На стороне БД: SELECT state, count(*) FROM pg_stat_activity GROUP BY state; — много idle in transaction? Долгие транзакции держат соединения.
  • Долгие запросы под нагрузкой: каждый держит соединение дольше → при 100 одновременных медленных запросах пул исчерпан.

Сколько соединений нужно

до 1000 соединений

через пулер

10 подов × Max Pool Size 100

Pod 1: до 100

Pod 2: до 100

... Pod 10: до 100

PostgreSQL
max_connections = 200

PgBouncer
(transaction pooling)

PostgreSQL:
20–50 реальных соединений

до 1000 соединений

через пулер

10 подов × Max Pool Size 100

Pod 1: до 100

Pod 2: до 100

... Pod 10: до 100

PostgreSQL
max_connections = 200

PgBouncer
(transaction pooling)

PostgreSQL:
20–50 реальных соединений

Суммарные соединения к БД растут с числом экземпляров
  • Больше соединений ≠ быстрее: БД эффективна при числе активных соединений порядка «ядра × 2–4». Сотни активных соединений конкурируют за CPU, память и блокировки.
  • PgBouncer / Odyssey / RDS Proxy / Azure PgBouncer: тысячи клиентских соединений → десятки серверных. Режим transaction pooling несовместим с session-состоянием (prepared statements на уровне сессии, SET, advisory locks уровня сессии) — Npgsql: No Reset On Close=true, осторожно с Max Auto Prepare.
  • Multiplexing Npgsql: несколько команд по одному соединению — меньше соединений при высокой нагрузке.

HTTP connection pooling

new HttpClient() на запрос

Новый пул и новые соединения
на каждый вызов

Тысячи сокетов в TIME_WAIT →
SocketException: address already in use

static HttpClient навсегда

Соединения живут вечно

DNS изменился (blue-green, failover) —
клиент ходит на старый IP

new HttpClient() на запрос

Новый пул и новые соединения
на каждый вызов

Тысячи сокетов в TIME_WAIT →
SocketException: address already in use

static HttpClient навсегда

Соединения живут вечно

DNS изменился (blue-green, failover) —
клиент ходит на старый IP

Проблемы new HttpClient и вечного статического клиента
Правильно: IHttpClientFactory
builder.Services.AddHttpClient<ICatalogClient, CatalogClient>(c =>
{
    c.BaseAddress = new Uri("https://catalog.internal");
    c.Timeout = TimeSpan.FromSeconds(10);
})
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
    PooledConnectionLifetime = TimeSpan.FromMinutes(2),       // пересоздавать соединения → подхватывать изменения DNS
    PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1),
    MaxConnectionsPerServer = 100                              // лимит одновременных соединений к хосту (HTTP/1.1)
})
.AddStandardResilienceHandler();

public sealed class CatalogClient(HttpClient http) : ICatalogClient    // typed client: HttpClient создаётся фабрикой
{
    public Task<ProductDto?> GetAsync(int id, CancellationToken ct) => http.GetFromJsonAsync<ProductDto>($"/products/{id}", ct);
}
Правильно без DI: один клиент с ограниченным временем жизни соединений
private static readonly HttpClient Http = new(new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(2) });

IHttpClientFactory кэширует и переиспользует HttpMessageHandler-ы (с пулами соединений), периодически ротируя их (HandlerLifetime, по умолчанию 2 минуты), а сами HttpClient — дешёвые обёртки, которые можно создавать часто.

Другие пулы

Ресурс Пул / переиспользование
Redis (StackExchange.Redis) Один ConnectionMultiplexer на приложение (мультиплексирование)
RabbitMQ Одно IConnection на приложение, каналы — на поток/потребителя
gRPC Один GrpcChannel на адрес (HTTP/2 мультиплексирование)
DbContext AddDbContextPool — пул объектов контекста (не соединений)
Массивы, объекты ArrayPool<T>, ObjectPool<T>

Вопросы на засыпку

Закрывает ли Dispose соединения физическое TCP-соединение?

Нет, при включённом пуле — возвращает его в пул (сбрасывая состояние сессии). Физически соединение закрывается по истечении idle-времени, ClearPool, при ошибке соединения или выключенном пуле.

Почему после failover БД приложение долго сыплет ошибками?

В пуле остаются «мёртвые» соединения к старому primary. Драйверы обнаруживают их при использовании (ошибка) и удаляют; можно вызвать NpgsqlConnection.ClearAllPools()/SqlConnection.ClearAllPools(), настроить keepalive, ограничить время жизни соединений (Connection Lifetime), использовать retry-стратегию.

Сколько ставить Max Pool Size?

Исходя из пропускной способности БД и числа экземпляров: сумма по всем экземплярам не должна превышать разумного числа активных соединений БД (или идти через пулер). Увеличение пула — не лекарство от медленных запросов: оно лишь переносит очередь из приложения в БД.

Что такое TIME_WAIT и почему new HttpClient его вызывает?

После закрытия TCP-соединения сторона-инициатор держит сокет в состоянии TIME_WAIT (до нескольких минут), чтобы корректно обработать запоздавшие пакеты. Новый HttpClient с новым handler-ом на каждый запрос открывает и закрывает соединения массово — эфемерные порты заканчиваются.

Итог

Пул соединений убирает дорогую установку соединения из каждого запроса. Для БД — всегда Dispose, короткие транзакции, разумный Max Pool Size с учётом числа экземпляров и пулер (PgBouncer) при масштабировании. Для HTTP — IHttpClientFactory или один клиент с PooledConnectionLifetime, но никогда не new HttpClient() на запрос.