Содержание урока · 10 разделов
- Пул соединений: как операция берёт коннект
- Параметры пула: дефолты и смысл
- Исчерпание пула: что видит операция
- Наблюдение за пулом: события вместо Stat
- Индексы против сканов: explain как рентген
- Агрегации по дешевле: сузить, потом свернуть
- Правила производительности одним списком
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Пятничный вечер, дашборд менеджеров грузится восемь секунд, все смотрят в сторону базы. Первая реакция — поднять лимиты: больше соединений, больше памяти, быстрее машина. Опыт показывает, что в девяти случаях из десяти узкое место сидит не там: сто коннектов простаивают, пока один медленный запрос держит сервер за горло, а объяснение запроса показывает скан всей коллекции ради десятка строк. Производительность mongo-сервиса — это дисциплина из трёх уровней, и важно понимать, что настраивается в клиенте, что — планом запроса, а что — формой конвейера. В этом уроке пройдём все три: сначала пул соединений с его потолками и очередями, затем индексы против сканов с инструментом проверки, потом агрегации с правилом «сузить, потом свернуть». Каждый раздел заканчивается диагностируемым симптомом: что вы увидите в логах, метриках и explain, когда уровень плохо настроен.
Пул соединений: как операция берёт коннект#
Клиент драйвера держит пул соединений к каждому серверу отдельным хозяйством: операция берёт коннект из пула, выполняет команду и возвращает его обратно. Дефолтный потолок — сто соединений на сервер: параметр maxPoolSize, а ноль означает отсутствие лимита. Когда потолок достигнут, новые операции не разворачиваются и не падают — они блокируются в очереди до освобождения коннекта или до дедлайна своего контекста. Отсюда простая арифметика пропускной способности: сто коннектов и операция в пятьдесят миллисекунд дают около двух тысяч операций в секунду на сервер, а тот же пул с операциями по секунде — сотню. Рядом живут три настройки, которые дополняют потолок. minPoolSize поддерживает минимальный запас готовых коннектов: по умолчанию ноль, и фоновая горутина клиента подогревает запас, если его опустошили. maxConnecting ограничивает, сколько рукопожатий устанавливается одновременно, — по умолчанию два, чтобы всплеск нагрузки не обрушил на сервер сотню параллельных подключений. Сердцебиение в десять секунд держит карту живости серверов свежей, а connectTimeout ограничивает установку одного соединения тридцатью секундами. Все пять параметров задаются через options.Client() при создании клиента, и менять их стоит осознанно: дефолты подобраны так, чтобы обычный сервис не трогал их вовсе.
Клиент с настроенным пулом
GOclient, 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, хотя сама база отвечает мгновенно. Похожую картину даёт и сетевой сбой: пул после ошибки соединения чистится и наращивается заново, и кратковременный провал пропускной способности после каждого обрыва — нормальное поведение, а не отдельная поломка. Диагноз ставится событиями пула, о которых дальше, а лечение всегда одно: найти того, кто держит коннекты, а не растить потолок.
Хендлер листал заказы курсором, находил нужную страницу и выходил из цикла по 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
GOcmd := 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 с сотни до тысячи, надеясь ускорить дашборд. Тормоза остались: узким местом была агрегация без $match, перечитывавшая миллион документов на каждый запрос, — а сервер получил тысячу одновременных коннектов, память на каждый и обстрел тяжёлыми запросами. Если explain показывает миллионы examined документов при десятке возвращённых, то растить пул бессмысленно и вредно: сначала план запроса, потом транспорт, а не наоборот.
Драйвер не пишет лог на медленные операции: успешный find за восемь секунд и find за миллисекунду возвращают одинаковый CommandSucceededEvent. Без своего мониторинга медлятина невидима, пока не станет инцидентом. Если нет APM, то поставьте SetMonitor с порогом: колбэк Succeeded сверяет Duration с лимитом и пишет предупреждение в лог. Похожую работу делает и серверный профайлер базы — включается одной командой и показывает операции выше порога.
Правила производительности одним списком#
- Дефолты пула: сто коннектов на сервер, ожидание в очереди при исчерпании, два рукопожатия одновременно.
- У каждого курсора — дочитывание или Close: коннект возвращается только освобождённым.
- Статистики-метода у пула нет: ставьте SetPoolMonitor и считайте события CheckedOut и CheckedIn сами.
- Медленный запрос доказывает explain: examined близко к returned — хорошо, разрыв в тысячи раз — скан.
- $match первым, $limit и $project раньше тяжёлых этапов, allowDiskUse — костыль, а не ускорение.
- Считает сервер, а не цикл в Go: перекачка сырых документов съедает сеть и держит коннекты.
Проверка: исчерпание пула
Все коннекты пула заняты, дедлайн контекста операции истёк. Что вернёт операция?
Проверка: чтение explain
В executionStats значение totalDocsExamined равно nReturned. О чём это говорит?
Что напечатают два предиката?
Прочитайте код и выберите, что он выведет:
// клиент: SetMaxPoolSize(1); mongod отвечает за 300 мс
// горутины А и Б уже держат свои операции,
// у горутины В дедлайн контекста — 100 мс
err := findOne(ctxC)
fmt.Println(mongo.IsTimeout(err), mongo.IsNetworkError(err))Соберите настройку пула
Восстановите три вызова: потолок коннектов, параллельные рукопожатия и лимит простоя.
Соберите диагностику тормозов
Расставьте строки функции по порядку: explain, запуск, чтение статистики, вывод вердикта.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: пул и скорость
Три уровня собраны в одну цепочку диагностики: метрики пула показывают, хватает ли транспорта, explain — куда уходит время сервера, форма конвейера — кто держит коннекты дольше необходимого. Индексы, которые превращают скан в ход по дереву, ставились в уроке про индексы — начните оптимизацию там, с ключей и правила равенство-сортировка-диапазон. Стоимость конвейера и потолки памяти разобраны в базовом уроке агрегаций, а WaitQueueTimeoutError и его братья классифицированы в уроке про ошибки драйвера тем же IsTimeout. Следующий логичный шаг — свести всё в итоговый проект курса: там каталог товаров заставит работать и индексы, и пул, и агрегации одновременно. Песочница сайта не исполняет драйвер, поэтому цифры из урока проверьте на своём стенде: снимите explain с двух-трёх своих запросов и сравните examined с returned — разрыв, если он есть, найдётся в первые минуты.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Пул соединений — по одному на сервер: 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 как общий предохранитель.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Zerolog в Go: быстрое JSON-логирование
Курс zerolog для Go: JSON-события, уровни, поля, ConsoleWriter, контекстные поля, ротация, sampling, HTTP-логи и тесты. 25 уроков с примерами.
- Писать структурированные JSON-логи с нуля аллокаций
- Выбирать уровни и поля события осознанно
- Настраивать ConsoleWriter для dev и JSON для прода
- Строить сублоггеры и контекстные поля на запрос