Содержание урока · 10 разделов
FindOne из прошлого урока закрывает сценарий «страница товара». Но в каталоге нужна страница из двадцати товаров, в админке — список всех неоплаченных заказов, в отчёте — выгрузка за месяц. Под все три задачи в mongo уходит один глагол — Find: он возвращает не срез и не список, а курсор. Курсор — это поток документов: сервер не отправляет все результаты разом, а отдаёт их партиями по мере того, как вы читаете. Механика экономит память обеих сторон: коллекция из миллиона документов не обязана целиком помещаться ни в RAM сервера, ни в ваш процесс. Но вместе с удобством приходит ответственность, которой не было у FindOne: курсор — ресурс с жизненным циклом, его нужно закрывать, ошибки итерации нужно проверять отдельно, а выбор между «собрать всё через cursor.All» и «итерировать через cursor.Next» — это осознанное решение о памяти. В этом уроке разберём оба пути, правило defer Close, коварный cursor.Err() после цикла и то, что происходит на сервере, когда курсор забыли. Примеры требуют живой MongoDB — песочница сайта показывает тематическую программу на чистом Go, а контейнер из второго урока держите под рукой.
Find и курсор в Go: как работает выборка#
Вызов выглядит так: cursor, err := coll.Find(ctx, filter). Контекст задаёт дедлайн, фильтр — условия поиска (сейчас достаточно bson.M{"status": "paid"}), опции через options.Find() добавят сортировку, лимит и проекцию — но об этом дальше по курсу. Возвращаемая пара уже знакома по вставкам: курсор и ошибка. Ошибка из Find — это ошибки самой команды: сеть, таймаут, опечатка в операторе фильтра; проверяйте её сразу и без вопросов. Дальше начинается работа с *mongo.Cursor. Первая партия документов приезжает вместе с ответом на find — по умолчанию до 101 документа; если выборка больше, драйвер запрашивает следующие партии командой getMore, и именно во время этих доборок могут всплыть ошибки сети. Поэтому после цикла обязана стоять проверка cursor.Err() — она вернёт последнюю ошибку, виденную курсором, или nil. Сам цикл — обычный for с условием cursor.Next(ctx): метод блокируется до следующего документа и возвращает true, пока есть что читать, false — когда данные кончились или случилась ошибка. Текущий документ курсор отдаёт через cursor.Decode(&doc) — та же распаковка BSON в структуру по bson-тегам, что у FindOne. Пустая выборка — не ошибка: ни одного Next true не случится, цикл не выполнится ни разу, err останется nil; где ErrNoDocuments, о котором писал шестой урок, там не нужен.
Собрать всё разом: cursor.All
GOpackage main
import (
"context"
"fmt"
"log"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
type Product struct {
SKU string `bson:"sku"`
Name string `bson:"name"`
Price int64 `bson:"price"`
}
func main() {
client, err := mongo.Connect(options.Client().ApplyURI("mongodb://localhost:27017"))
if err != nil {
log.Fatal(err)
}
defer client.Disconnect(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
coll := client.Database("shop").Collection("products")
var products []Product
cursor, err := coll.Find(ctx, bson.M{"in_stock": true})
if err != nil {
log.Fatal(err)
}
defer cursor.Close(ctx)
if err := cursor.All(ctx, &products); err != nil {
log.Fatal(err)
}
fmt.Printf("найдено %d товаров
", len(products))
}Find выполняет команду и возвращает курсор; ошибку команды проверяем сразу. cursor.All итерирует курсор до конца и декодирует каждый документ в срез продуктов — цель обязана быть указателем на срез. Важный нюанс из документации драйвера: All сам закрывает курсор после чтения, поэтому defer Close рядом с ним — страховка от ошибок на середине, а не двойное закрытие: Close идемпотентен.
cursor.All: когда список уместен#
All — короткий путь, и в большинстве сервисных сценариев он правильный: страница каталога, список ролей пользователя, справочник городов. Данные гарантированно помещаются в память — скажем, до десятков тысяч строк, — и вам нужен срез, чтобы отдать его в JSON, отсортировать или посчитать len. Всё, что делает All, легко написать вручную циклом, но шаблон повторяется на каждом углу, и драйвер оформил его методом. Три детали, о которых стоит знать. Первая: All полностью перезаписывает целевой срез — передавать его можно смело, старые значения исчезнут. Вторая: если курсор уже частично итерировался вручную, All включит в результат только непрочитанные документы — смешивать ручной цикл и All в одном курсоре не стоит, это путь к «половинным» спискам. Третья: All закрывает курсор сам — даже если контекст отменён посреди чтения, закрытие уедет через context.Background(), чтобы серверный курсор не остался висеть. Отсюда же ответ на частый вопрос: ставить ли defer cursor.Close рядом с All — можно и полезно, Close идемпотентен, второй вызов ничего не изменит; правило проекта «каждый Find получает defer Close» выполняется без исключений и не конфликтует с All. Если же выборка измеряется миллионами документов, All перестаёт быть коротким путём и становится проблемой: дальше — про ручную итерацию.
cursor.Next и Decode: ручная итерация#
Ручной цикл нужен там, где документы не должны копиться: потоковая обработка, запись в другой канал, агрегация на лету с сбросом результата после каждого элемента. Схема одна и та же, и её стоит выучить до автоматизма. for cursor.Next(ctx) { ... } — тело выполняется для каждого документа; внутри cursor.Decode(&order) распаковывает текущий документ, после чего вы делаете своё дело: пишете в канал, обновляете индекс, отправляете письмо. Выход из цикла — не всегда конец данных: Next возвращает false и на исчерпании, и на ошибке. Различить их — работа cursor.Err(): после цикла nil означает «данные кончились штатно», не-nil — «поток оборвался». Проверка не опциональна: без неё оборванная сеть выглядит как аккуратный ранний конец списка, и отчёт покажет половину заказов без единого сообщения об ошибке. Внутри тела тоже можно ошибиться — Decode вернёт ошибку несовместимого типа; обрабатывайте её так же честно, как и сетевую. Отдельный сценарий — tailable-курсоры, которые не закрываются после исчерпания и ждут новых документов; для них существует неблокирующий TryNext, а основная работа с потоками событий живёт в Change Streams — девятнадцатом уроке, до которого мы дойдём после агрегаций. Для обычных списков правило простое: Next блокирующий, false — сигнал выйти, cursor.Err() — сигнал разобраться.
Потоковая обработка: цикл с Next
GOcursor, err := coll.Find(ctx, bson.M{"status": "paid"})
if err != nil {
return fmt.Errorf("find paid orders: %w", err)
}
defer cursor.Close(ctx)
for cursor.Next(ctx) {
var o Order
if err := cursor.Decode(&o); err != nil {
return fmt.Errorf("decode order: %w", err)
}
total += o.Total
count++
}
if err := cursor.Err(); err != nil {
return fmt.Errorf("iterate orders: %w", err)
}
fmt.Printf("заказов %d, сумма %d
", count, total)Каркас потоковой обработки: Find проверяется сразу, defer Close страховает любой выход из функции — ранний return, ошибку, панику. Decode внутри цикла распаковывает по одному документу, память не растёт с длиной выборки. Финальная проверка cursor.Err() отличает честный конец данных от оборванной сети: без неё отчёт молча показал бы половину заказов.
Правила курсора одним списком#
- Каждый Find получает defer cursor.Close(ctx) — сразу после проверки err.
- Ошибку из Find проверяем сразу: это ошибки команды, а не пустая выборка.
- Пустая выборка — не ошибка: err nil, ни одного Next, цикл молчит.
- После цикла с Next обязательно cursor.Err() — отличить конец данных от обрыва.
- cursor.All закрывает курсор сам; defer Close рядом — безвредная страховка.
- Decode в цикле — с проверкой ошибки: несовместимый тип ловится по месту.
- Миллионы документов — только ручной цикл; All соберёт всё в память.
All против ручного цикла#
| Критерий | cursor.All | Цикл с cursor.Next |
|---|---|---|
| Память | весь результат в срезе | один документ за раз |
| Объём выборки | десятки тысяч максимум | миллионы и потоки |
| Проверка ошибок | одна err после вызова | cursor.Err() после цикла |
| Закрытие курсора | сам закрывает после чтения | только defer Close |
| Типовой сценарий | страница каталога, JSON-ответ | выгрузка, конвейер, агрегация на лету |
Размер партии и память: настройка потока#
Поток документов настраивается, и главное колесо — размер партии. По умолчанию первый ответ find несёт до 101 документа, а каждая следующая порция ограничена шестнадцатью мегабайтами BSON — сервер упакует в неё столько документов, сколько влезет. Если документы тяжёлые, порция может состоять из двух-трёх штук; если лёгкие — из тысяч. Опцией options.Find().SetBatchSize(n) вы задаёте свою величину, и вот когда это осмысленно. Меньше партии — для узких клиентов и тяжёлых документов: каждая итерация тянет меньше памяти, но чаще ходит по сети, и общее число обращений к серверу растёт. Больше — для выгрузок лёгких документов: меньше округов, выше пропускная способность; но и буфер между чтениями толще. Ручной цикл с Next выбирает документы из текущей партии без сети и отправляется за новой только при её исчерпании, поэтому BatchSize — настройка не памяти вашего процесса, а округов. С cursor.All связь прямая: он всё равно вычитает всё до конца, и партия влияет только на число сетевых вызовов по пути. Не путайте с лимитом: Limit ограничивает количество документов результата, BatchSize — только способ доставки; страница каталога на двадцать записей обычно укладывается в дефолт и в настройках не нуждается.
Механика экономит округи: команда find выполняется вместе с доставкой первой партии, поэтому короткая выборка — страница каталога, список ролей — получает все документы одним обращением к серверу. getMore отправляется только когда первая партия исчерпана, а в выборке есть продолжение.
Ловушка незакрытого курсора mongodb#
Что происходит, если Close забыли? Курсор — не объект в памяти драйвера, а ресурс на сервере: MongoDB держит состояние выборки — позицию, буферы, блокировки — и ждёт, когда клиент запросит следующую партию или закроет курсор. Дефолтный таймаут бездействия — десять минут: только спустя их сервер сам приберёт забытый курсор, и всё это время он занимает память и слот в списке открытых курсоров. Один забытый курсор в тесте — мелочь; утечка в горячем хендлере, который выполняется по сто раз в минуту и падает на середине итерации без defer Close, — уже десятки тысяч мёртвых курсоров до конца смены. Хронический симптом — ошибки вида cursor not found: клиент почему-то обращается к курсору, который сервер уже зачистил, — значит, где-то курсор жив дольше положенного или его таскают между горутинами. Отсюда три привычки. Первая: defer cursor.Close(ctx) пишется в ту же секунду, что и сам Find, — механически, как defer resp.Body.Close() в HTTP. Вторая: курсор не переезжает между горутинами — итерирует тот, кто создал. Третья: долгие фоновые выгрузки, которые реально живут дольше десяти минут, получают опцию NoCursorTimeout — осознанно, с комментарием, потому что она отключает страховку сервера. А главный аварийный рубильник — killCursors через mongosh, когда нужно прибрать чужой мусор прямо сейчас.
Типичная ошибка: после цикла нет cursor.Err(), сеть моргнула на середине выгрузки, Next вернул false — и код принял это за конец данных. Бухгалтерия получает отчёт о половине заказов, без ошибок в логах и без алертов: формально всё завершилось успешно. Условие, при котором это стреляет раз в месяц, — любая длинная выборка и нестабильная сеть между подами и кластером. Лекарство: проверка cursor.Err() после цикла и ретрай всей выгрузки при не-nil.
Проверка: конец данных
Что означает false от cursor.Next(ctx) после нескольких успешных итераций?
Проверка: кто закрывает курсор
Вы вызвали cursor.All(ctx, &docs). Что с закрытием курсора?
Сколько строк напечатает цикл?
Прочитайте код и выберите, что он выведет:
// в коллекции три товара: a, b, c
cursor, err := coll.Find(ctx, bson.M{})
if err != nil { log.Fatal(err) }
defer cursor.Close(ctx)
for cursor.Next(ctx) {
var p Product
cursor.Decode(&p)
fmt.Println(p.SKU)
}
fmt.Println("err:", cursor.Err())Соберите работу с курсором
Восстановите три ключевых вызова: получение курсора, его закрытие и сбор всех документов в срез.
Соберите потоковую выгрузку
Расставьте строки ручной итерации по порядку: запрос, страховка закрытия, цикл, декодирование, финальные проверки.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: курсоры
Списки читаются уверенно: Find возвращает курсор, All собирает страницу целиком, ручной цикл с Next ведёт потоки любого размера, а тройка «defer Close — cursor.Err() — не таскать курсор между горутинами» держит серверные ресурсы в порядке. Дальше условие поиска повзрослеет: в уроке про фильтры появится $gt, $in и диапазоны вместо примитивного bson.M{"status": "paid"}. Страница каталога без сортировки — лотерея: в уроке про проекции, сортировку и лимиты настроим порядок, лимиты и отбор полей через options.Find(). Когда списки станут медленными, загляните в урок про производительность — там курсор встретится с индексами и размером партии. И повторю практический совет: разница между All и Next проживается только на объёме — соберите в mongosh коллекцию на сто тысяч документов и попробуйте оба пути локально; песочница сайта драйвер не исполняет, зато такая самопроверка нагляднее любых слов о памяти.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- coll.Find(ctx, filter) возвращает (*mongo.Cursor, error): ошибка из Find — про команду, пустая выборка — не ошибка.
- Курсор отдаёт документы партиями; первая — до 101 документа, дальше getMore по мере чтения.
- cursor.All собирает всё в срез и закрывает курсор сам; Close идемпотентен — defer Close безвреден.
- Ручной цикл for cursor.Next(ctx) + cursor.Decode(&doc) — для потоков и миллионов документов.
- После цикла обязателен cursor.Err(): false от Next не различает конец данных и обрыв сети.
- Незакрытый курсор — серверный ресурс до таймаута в 10 минут; defer Close пишется сразу после Find.
- Миллионы документов — только ручная итерация; All уложит всё в память процесса.
Домашнее задание
Соберите коллекцию минимум из сотни документов и прочитайте её двумя путями: cursor.All в срез и ручной цикл с Next, считая документы и сумму по числовому полю. Затем уберите проверку cursor.Err() и приостановите сервер на середине чтения — отчёт покажет успех на половине данных. Верните проверку, повторите запуск и убедитесь, что обрыв стал виден.
Личные заметкиПусто
Вопросы и ответы по уроку
Нужен ли defer cursor.Close, если использую cursor.All?
Можно без него — All закрывает курсор сам, даже при отмене контекста. Но единое правило «каждому Find — defer Close» проще держать в голове, а Close идемпотентен: страховка ничего не ломает и спасает, если код потом перепишут на ручной цикл.
Почему Find не возвращает ErrNoDocuments при пустой выборке?
Пустой список — легитимный результат, а не отсутствие ответа: каталог без товаров в новой категории — норма. ErrNoDocuments живёт только у операций, обещающих один документ: FindOne, FindOneAndUpdate и родня. Для Find пустота — ноль итераций цикла.
Можно ли читать один курсор из нескольких горутин?
Нет: курсор держит позицию и текущий документ, параллельная итерация разорвёт порядок и состояние. Разносите документы по горутинам после чтения — через канал: один читатель, много обработчиков. Ошибка cursor not found часто начинается именно с курсора, доехавшего до чужой горутины.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Zerolog в Go: быстрое JSON-логирование
Курс zerolog для Go: JSON-события, уровни, поля, ConsoleWriter, контекстные поля, ротация, sampling, HTTP-логи и тесты. 25 уроков с примерами.
- Писать структурированные JSON-логи с нуля аллокаций
- Выбирать уровни и поля события осознанно
- Настраивать ConsoleWriter для dev и JSON для прода
- Строить сублоггеры и контекстные поля на запрос