---
title: "Пул соединений и производительность"
description: "Производительность mongo в Go: maxPoolSize и maxConnecting в пуле драйвера, наблюдение за коннектами, explain против сканов и удешевление агрегаций."
course: "MongoDB в Go: официальный драйвер mongo-driver v2"
url: "https://golangmaster.ru/courses/mongo-driver/lessons/24-proizvoditelnost/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Пул соединений и производительность

*Курс «MongoDB в Go: официальный драйвер mongo-driver v2», урок 24 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/mongo-driver/lessons/24-proizvoditelnost/*

mongo производительность складывается из трёх слоёв, и слой драйвера — лишь первый: пул соединений решает, сколько операций поедет одновременно, план запроса — прочитает ли сервер десять документов или всю коллекцию, а конвейер агрегаций — где произойдут вычисления. В этом уроке разберём maxpoolsize go-клиента с соседними настройками, исчерпание пула и его диагностику, explain как рентген медленных запросов и приёмы удешевления агрегаций. Все цифры — из исходников драйвера v2: дефолты пула, поведение при блокировке и события мониторинга, без фольклора из форумов.

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

### Пул соединений: как операция берёт коннект

Клиент драйвера держит пул соединений к каждому серверу отдельным хозяйством: операция берёт коннект из пула, выполняет команду и возвращает его обратно. Дефолтный потолок — сто соединений на сервер: параметр maxPoolSize, а ноль означает отсутствие лимита. Когда потолок достигнут, новые операции не разворачиваются и не падают — они блокируются в очереди до освобождения коннекта или до дедлайна своего контекста. Отсюда простая арифметика пропускной способности: сто коннектов и операция в пятьдесят миллисекунд дают около двух тысяч операций в секунду на сервер, а тот же пул с операциями по секунде — сотню. Рядом живут три настройки, которые дополняют потолок. minPoolSize поддерживает минимальный запас готовых коннектов: по умолчанию ноль, и фоновая горутина клиента подогревает запас, если его опустошили. maxConnecting ограничивает, сколько рукопожатий устанавливается одновременно, — по умолчанию два, чтобы всплеск нагрузки не обрушил на сервер сотню параллельных подключений. Сердцебиение в десять секунд держит карту живости серверов свежей, а connectTimeout ограничивает установку одного соединения тридцатью секундами. Все пять параметров задаются через options.Client() при создании клиента, и менять их стоит осознанно: дефолты подобраны так, чтобы обычный сервис не трогал их вовсе.

### Клиент с настроенным пулом

```go
client, err := mongo.Connect(options.Client().
        ApplyURI("mongodb://localhost:27017").
        SetMaxPoolSize(50).              // потолок коннектов к каждому серверу
        SetMinPoolSize(5).               // фоновая поддержка запаса
        SetMaxConnecting(4).             // параллельных рукопожатий
        SetMaxConnIdleTime(5*time.Minute).
        SetConnectTimeout(10*time.Second).
        SetHeartbeatInterval(10*time.Second))
if err != nil {
        return fmt.Errorf("connect: %w", err)
}
defer client.Disconnect(context.Background())
```

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

### Параметры пула: дефолты и смысл

| Параметр | Дефолт | За что отвечает |
| --- | --- | --- |
| maxPoolSize | 100 на сервер | потолок коннектов; при исчерпании операции ждут; 0 — без лимита |
| minPoolSize | 0 | минимальный запас готовых коннектов, поддерживается фоном |
| maxConnecting | 2 | сколько соединений устанавливается одновременно |
| maxIdleTimeMS | 0 — вечно | через сколько простоя коннект закрывается (SetMaxConnIdleTime) |
| connectTimeoutMS | 30000 | лимит на установку одного соединения (SetConnectTimeout) |
| heartbeatFrequencyMS | 10000 | частота проверки живости серверов (SetHeartbeatInterval) |
| serverSelectionTimeoutMS | 30000 | ожидание подходящего живого сервера перед операцией |

### Исчерпание пула: что видит операция

Очередь за коннектом имеет предел — дедлайн контекста операции. Если все сто коннектов заняты, а контекст истёк раньше, чем кто-то вернул свой, операция получает WaitQueueTimeoutError с текстом «timed out while checking out a connection from connection pool»: внутри сидит причина из контекста, а предикат IsTimeout из урока про ошибки возвращает на него true. Кто держит коннекты долго? Незакрытый курсор: Find возвращает соединение в пул только после исчерпания всех батчей или явного Close — горутина, прочитавшая первую страницу и вышедшая по return, утащила коннект навсегда. Долгая агрегация и транзакция держат коннект на всё время работы. Симптом исчерпания характерен: сервис здоров, но с некоторого момента каждая операция начинается с миллисекунд ожидания, потом сыплются таймауты с текстом про connection pool, хотя сама база отвечает мгновенно. Похожую картину даёт и сетевой сбой: пул после ошибки соединения чистится и наращивается заново, и кратковременный провал пропускной способности после каждого обрыва — нормальное поведение, а не отдельная поломка. Диагноз ставится событиями пула, о которых дальше, а лечение всегда одно: найти того, кто держит коннекты, а не растить потолок.

> **Живое место: курсор без Close.** Хендлер листал заказы курсором, находил нужную страницу и выходил из цикла по break, не вызвав Close. Соединение оставалось у курсора до конца жизни горутины: пул на сто коннектов исчерпался за неделю, и весь сервис встал в очередь с таймаутами проверки соединений, хотя mongod дышал ровно. Если прерываете чтение курсора раньше конца, то закрывайте Close явно или берите cursor.All, который закрывает сам: правило одно — коннект вернулся, только когда курсор дочитан или закрыт.

### Наблюдение за пулом: события вместо Stat

У пула нет метода вроде Stat с готовыми счётчиками — сравнить с пулом pgx не получится, и это честно задокументировано архитектурой драйвера. Наблюдение строится на событиях: options.Client().SetPoolMonitor принимает PoolMonitor, чей колбэк вызывается на каждое событие жизненного цикла соединений. Полезные типы: ConnectionCheckedOut и ConnectionCheckedIn — взятие и возврат коннекта, ConnectionCheckOutFailed — операция не дождалась соединения, с полем Reason и ошибкой контекста, ConnectionCreated и ConnectionClosed — рождение и смерть коннекта. Каждое событие несёт адрес сервера и длительность, поэтому пары CheckedOut и CheckedIn дают живую картину: сколько коннектов занято прямо сейчас, сколько операций ждало и на чём именно. На графах обычно достаточно трёх серий: занятые коннекты, время ожидания коннекта и число CheckOutFailed — последняя серия обязана быть нулевой, и каждая единица там — будущий таймаут в хендлере. Рядом стоит SetMonitor для команд: CommandSucceededEvent и CommandFailedEvent приходят на каждую команду с полем Duration — готовый материал для замера p95 без внешних агентов. Оба монитора ставятся при создании клиента и пишут в вашу метрику одной строкой кода.

### Индексы против сканов: explain как рентген

Пул — транспорт, а тормоза живут в плане запроса. Скан коллекции читает все документы подряд, ход по индексу — только нужные записи дерева: именно эту разницу делает CreateOne из урока про индексы. Проверяется она explain: команда explain оборачивает ваш запрос и с параметром verbosity executionStats возвращает, что сервер делал на самом деле. Ключевые поля лежат в executionStats: nReturned — сколько документов вернулось, totalDocsExamined — сколько прочитано, totalKeysExamined — сколько записей индекса перебрано, executionTimeMillis — время в миллисекундах. Здоровый запрос читает близко к тому, что возвращает: examined примерно равен returned. Особый случай — покрытие: когда все нужные поля лежат в индексе, totalDocsExamined падает до нуля, и сервер вообще не заглядывает в документы. Запрос, который на десяток строк перечитал миллион документов, — кандидат на индекс или на перестройку фильтра. Опцию SetHint можно принудить запрос к конкретному индексу, когда планировщик промахивается, а compound-индексы проектируются по правилу равенство, сортировка, диапазон — все эти буквы живут в уроке про индексы. С таймингами в v2 проще, чем в v1: отдельных опций maxTime у операций нет, дедлайн берётся из контекста, а клиентский SetTimeout уезжает на сервер как maxTimeMS сам.

### Explain проблемного запроса через RunCommand

```go
cmd := bson.D{
        {Key: "explain", Value: bson.M{
                "find":   "users",
                "filter": bson.M{"email": "ada@example.com"},
        }},
        {Key: "verbosity", Value: "executionStats"},
}

var res bson.M
if err := client.Database("shop").RunCommand(ctx, cmd).Decode(&res); err != nil {
        return fmt.Errorf("explain: %w", err)
}
if stats, ok := res["executionStats"].(bson.M); ok {
        log.Printf("nReturned=%v totalDocsExamined=%v",
                stats["nReturned"], stats["totalDocsExamined"])
}
```

Команда explain заворачивает обычный find в документ с verbosity executionStats и уезжает через RunCommand как любой другой документ. Ответ декодируется в bson.M, из executionStats берутся nReturned и totalDocsExamined: равенство говорит об индексном пути, разрыв на порядки — о скане, который лечится индексом из урока про CreateOne.

### Агрегации по дешевле: сузить, потом свернуть

Конвейер агрегаций стоит ровно столько, сколько данных через него проехало. Первое правило из базового урока агрегаций — $match первым: до группировки сервер опирается на индексы, после — фильтрует уже свёрнутые итоги. Второе — отсекать объём как можно раньше: $limit и $project перед тяжёлыми этапами уменьшают и число документов, и ширину каждого, а сортировка с лимитом после сужения — дешевле той же сортировки по всей выборке. Третье — помнить потолки: память этапа ограничена сотней мегабайт, а документ — шестнадцатью, и длинный $push добирается до обоих; SetAllowDiskUse разрешает выгрузку на диск, но это не ускорение, а возможность долететь — обмен с файлами медленнее памяти. Четвёртое — не считать в Go то, что считает сервер: выкачивать сырые документы циклом и суммировать в приложении — значит перекачивать сеть и держать коннекты дольше необходимого. Размер батчей курсора регулируется SetBatchSize: крупные батчи экономят поездки, мелкие — память; для панельных отчётов обычно достаточно дефолта. Все эти приёмы не требуют ничего, кроме перестановки этапов, — самая дешёвая оптимизация из возможных.

> **Живое место: maxPoolSize как кнопка ускорить.** Команда подняла maxPoolSize с сотни до тысячи, надеясь ускорить дашборд. Тормоза остались: узким местом была агрегация без $match, перечитывавшая миллион документов на каждый запрос, — а сервер получил тысячу одновременных коннектов, память на каждый и обстрел тяжёлыми запросами. Если explain показывает миллионы examined документов при десятке возвращённых, то растить пул бессмысленно и вредно: сначала план запроса, потом транспорт, а не наоборот.

> **Живое место: медленный запрос, которого нет в логах.** Драйвер не пишет лог на медленные операции: успешный find за восемь секунд и find за миллисекунду возвращают одинаковый CommandSucceededEvent. Без своего мониторинга медлятина невидима, пока не станет инцидентом. Если нет APM, то поставьте SetMonitor с порогом: колбэк Succeeded сверяет Duration с лимитом и пишет предупреждение в лог. Похожую работу делает и серверный профайлер базы — включается одной командой и показывает операции выше порога.

### Правила производительности одним списком

- Дефолты пула: сто коннектов на сервер, ожидание в очереди при исчерпании, два рукопожатия одновременно.
- У каждого курсора — дочитывание или Close: коннект возвращается только освобождённым.
- Статистики-метода у пула нет: ставьте SetPoolMonitor и считайте события CheckedOut и CheckedIn сами.
- Медленный запрос доказывает explain: examined близко к returned — хорошо, разрыв в тысячи раз — скан.
- $match первым, $limit и $project раньше тяжёлых этапов, allowDiskUse — костыль, а не ускорение.
- Считает сервер, а не цикл в Go: перекачка сырых документов съедает сеть и держит коннекты.

### Проверка: исчерпание пула

**Вопрос:** Все коннекты пула заняты, дедлайн контекста операции истёк. Что вернёт операция?

- Пул автоматически расширит лимит и выделит новый коннект
- Операция вернёт ошибку немедленно, не начавшись
- ✅ WaitQueueTimeoutError с текстом про соединение; IsTimeout вернёт true
- Пустой результат без ошибки

При исчерпании пула операция стоит в очереди до дедлайна контекста, затем получает WaitQueueTimeoutError — её причина в Wrapped, а IsTimeout узнаёт её по цепочке. Лимит драйвер сам не расширяет: лечится поиском держателей коннектов, а не потолком.

### Проверка: чтение explain

**Вопрос:** В executionStats значение totalDocsExamined равно nReturned. О чём это говорит?

- Сервер вернул результат из кэша, документы не читались
- ✅ Запрос читает примерно столько документов, сколько возвращает, — план работает на индексе
- Индекс не используется, идёт скан коллекции
- Фильтр не совпал ни с одним документом

Равенство прочитанных и возвращённых документов — признак точного плана: индекс ведёт прямо к нужным документам. Скан даёт обратную картину: examined на порядки больше returned, и лечится это индексом из урока про индексы.

### Что напечатают два предиката?

```go
// клиент: SetMaxPoolSize(1); mongod отвечает за 300 мс
// горутины А и Б уже держат свои операции,
// у горутины В дедлайн контекста — 100 мс
err := findOne(ctxC)

fmt.Println(mongo.IsTimeout(err), mongo.IsNetworkError(err))
```

**Что выведет программа?**

- true true
- ✅ true false
- false true
- false false

Третья операция ждёт единственный коннект, дедлайн истекает раньше возврата, и наружу прилетает WaitQueueTimeoutError с контекстной причиной внутри — IsTimeout узнаёт её. Метки NetworkError у этой ошибки нет: до сетевого обмена дело не дошло, поэтому IsNetworkError отвечает false.

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

Восстановите три вызова: потолок коннектов, параллельные рукопожатия и лимит простоя.

```go
client, err := mongo.Connect(options.Client().
        ApplyURI("mongodb://localhost:27017").
        ___(50).
        SetMinPoolSize(5).
        ___(4).
        ___(5*time.Minute))
```

Подсказка: Потолок задаёт SetMaxPoolSize, параллельные рукопожатия — SetMaxConnecting, а время простоя коннекта — SetMaxConnIdleTime.

Правильные ответы в пропусках: `SetMaxPoolSize`, `SetMaxConnecting`, `SetMaxConnIdleTime`

### Соберите диагностику тормозов

Расставьте строки функции по порядку: explain, запуск, чтение статистики, вывод вердикта.

Правильный порядок строк:

1. `func diagnose(ctx context.Context, db *mongo.Database, filter bson.M) {`
2. `  // снять explain проблемного запроса`
3. `  cmd := bson.D{`
4. `    {Key: "explain", Value: bson.M{"find": "users", "filter": filter}},`
5. `    {Key: "verbosity", Value: "executionStats"},`
6. `  }`
7. `  var res bson.M`
8. `  if err := db.RunCommand(ctx, cmd).Decode(&res); err != nil { log.Fatalf("explain: %v", err) }`
9. `  stats, _ := res["executionStats"].(bson.M)`
10. `  log.Printf("nReturned=%v totalDocsExamined=%v", stats["nReturned"], stats["totalDocsExamined"])`
11. `  // examined близко к returned — план работает на индексе`
12. `}`

### Карточки: пул и скорость

- **maxPoolSize** — Потолок коннектов к каждому серверу: сто по умолчанию, ноль — без лимита. При исчерпании операции ждут в очереди до дедлайна контекста. Настраивается SetMaxPoolSize.
- **minPoolSize и maxConnecting** — minPoolSize — минимальный запас готовых коннектов, дефолт ноль, поддерживается фоновой горутиной. maxConnecting — сколько рукопожатий устанавливается одновременно, дефолт два.
- **WaitQueueTimeoutError** — Операция не дождалась коннекта: текст про timed out while checking out a connection from connection pool, причина контекста в Wrapped, IsTimeout возвращает true.
- **SetPoolMonitor** — Колбэк на события пула: ConnectionCheckedOut, ConnectionCheckedIn, ConnectionCheckOutFailed с Reason, ConnectionCreated и Closed. Готового метода Stat у пула в v2 нет.
- **explain и executionStats** — RunCommand с обёрткой explain и verbosity executionStats: nReturned против totalDocsExamined. Близко — индексный путь; разрыв на порядки — скан, кандидат на индекс.
- **Таймауты операций в v2** — Отдельных опций maxTime у операций нет: дедлайн берётся из контекста, клиентский SetTimeout уезжает на сервер как maxTimeMS. Медленные команды видны через SetMonitor по Duration.

Три уровня собраны в одну цепочку диагностики: метрики пула показывают, хватает ли транспорта, explain — куда уходит время сервера, форма конвейера — кто держит коннекты дольше необходимого. Индексы, которые превращают скан в ход по дереву, ставились в [уроке про индексы](/courses/mongo-driver/lessons/17-indeksy/) — начните оптимизацию там, с ключей и правила равенство-сортировка-диапазон. Стоимость конвейера и потолки памяти разобраны в [базовом уроке агрегаций](/courses/mongo-driver/lessons/15-agregacii-bazovye/), а WaitQueueTimeoutError и его братья классифицированы в [уроке про ошибки драйвера](/courses/mongo-driver/lessons/20-oshibki-drivera/) тем же IsTimeout. Следующий логичный шаг — свести всё в итоговый проект курса: там каталог товаров заставит работать и индексы, и пул, и агрегации одновременно. Песочница сайта не исполняет драйвер, поэтому цифры из урока проверьте на своём стенде: снимите explain с двух-трёх своих запросов и сравните examined с returned — разрыв, если он есть, найдётся в первые минуты.

## Коротко: что запомнить

- Пул соединений — по одному на сервер: maxPoolSize 100 по умолчанию, операции ждут коннект при исчерпании.
- minPoolSize держит запас коннектов, maxConnecting ограничивает рукопожатия двумя, heartbeat живёт десять секунд.
- Коннект возвращается пулу только после исчерпания или Close курсора — незакрытый курсор утащит соединение.
- Метода Stat у пула нет: наблюдение через SetPoolMonitor и события CheckedOut, CheckedIn, CheckOutFailed.
- IsTimeout распознаёт WaitQueueTimeoutError — таймаут проверки коннекта выглядит как обычный таймаут.
- Explain с executionStats доказывает диагноз: examined близко к returned — индекс, разрыв на порядки — скан.
- Отдельных maxTime-опций в v2 нет: дедлайн контекста и клиентский SetTimeout, уезжающий на сервер как maxTimeMS.
- Агрегации дешевле, когда $match первым, лишние поля отсечены, а счёт выполняет сервер, а не цикл в Go.

## Домашнее задание

Проведите полную диагностику одного своего запроса. Снимите explain через RunCommand и запишите nReturned и totalDocsExamined; если разрыв больше сотни, добавьте индекс и снимите explain снова. Затем повесьте SetPoolMonitor на клиент и посчитайте за минуту события CheckedOut и CheckedIn — сравните с потолком пула. Напоследок запустите два запроса с дедлайном контекста в миллисекунду и убедитесь по IsTimeout, что таймаут на исчерпании пула классифицируется так же, как таймаут сервера.

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

### Какое значение maxPoolSize выставить в реальном сервисе?

Начните со стопроцентного дефолта и меняйте только при доказанной нужде. Каждый коннект — файловый дескриптор и память на обоих концах, а лимит дескрипторов на сервере обычно жёстче, чем ваш потолок пула: тысяча коннектов из десяти инстансов сервиса легко упирается в него ещё до базы данных. Считайте потолок как произведение инстансов на maxPoolSize и держите запас, а причину исчерпания ищите в держателях коннектов, а не в цифре. Если операции короткие, а коннекты простаивают, — уменьшать тоже можно: меньше коннектов, меньше памяти на обоих концах.

### Действительно ли нужно закрывать курсор, если читается всё через All?

cursor.All закрывает курсор сам — это его контракт, и там Close не нужен. Ручной цикл по Next требует Close, когда выход происходит раньше исчерпания батчей: по break, по ошибке декодирования, по отмене контекста. Дешевле всего правило кодстайла: ручной цикл — только там, где нужна потоковая обработка, а во всех остальных случаях All.

### SetTimeout на клиенте вместо дедлайнов на операциях — хорошая идея?

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