Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 7 из 25 ~18 мин +50 XP

Find и курсор: чтение списков

Как только одному документу тесно — список товаров, лента заказов, выгрузка отчёта — в игру вступают Find и курсор, и запрос «mongo find cursor go» становится ежедневной практикой golang-разработчика. Разберём cursor.All, ручной цикл с cursor.Next, defer cursor.Close и проверку cursor.Err — плюс ловушку незакрытого курсора, из-за которой сервер копит мёртвые выборки.

Содержание урока · 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

GO
package 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

GO
cursor, 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 — только способ доставки; страница каталога на двадцать записей обычно укладывается в дефолт и в настройках не нуждается.

Ловушка незакрытого курсора mongodb#

Что происходит, если Close забыли? Курсор — не объект в памяти драйвера, а ресурс на сервере: MongoDB держит состояние выборки — позицию, буферы, блокировки — и ждёт, когда клиент запросит следующую партию или закроет курсор. Дефолтный таймаут бездействия — десять минут: только спустя их сервер сам приберёт забытый курсор, и всё это время он занимает память и слот в списке открытых курсоров. Один забытый курсор в тесте — мелочь; утечка в горячем хендлере, который выполняется по сто раз в минуту и падает на середине итерации без defer Close, — уже десятки тысяч мёртвых курсоров до конца смены. Хронический симптом — ошибки вида cursor not found: клиент почему-то обращается к курсору, который сервер уже зачистил, — значит, где-то курсор жив дольше положенного или его таскают между горутинами. Отсюда три привычки. Первая: defer cursor.Close(ctx) пишется в ту же секунду, что и сам Find, — механически, как defer resp.Body.Close() в HTTP. Вторая: курсор не переезжает между горутинами — итерирует тот, кто создал. Третья: долгие фоновые выгрузки, которые реально живут дольше десяти минут, получают опцию NoCursorTimeout — осознанно, с комментарием, потому что она отключает страховку сервера. А главный аварийный рубильник — killCursors через mongosh, когда нужно прибрать чужой мусор прямо сейчас.

Проверка: конец данных

Что означает 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())

Соберите работу с курсором

Восстановите три ключевых вызова: получение курсора, его закрытие и сбор всех документов в срез.

cursor, err := coll.(ctx, bson.M{"in_stock": true}) if err != nil { log.Fatal(err) } defer cursor.(ctx) var products []Product if err := cursor.(ctx, &products); err != nil { log.Fatal(err) }

Соберите потоковую выгрузку

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

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: курсоры

Термин

Курсор

нажмите, чтобы перевернуть
Определение

Поток документов от сервера: данные приезжают партиями (первая — до 101 документа), остальное — по getMore. Ресурс с жизненным циклом.

1 / 6

Списки читаются уверенно: 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() и приостановите сервер на середине чтения — отчёт покажет успех на половине данных. Верните проверку, повторите запуск и убедитесь, что обрыв стал виден.

Личные заметкиПусто
Хранятся только в вашем браузере (MongoDB · Find и курсор: чтение списков)

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

Нужен ли defer cursor.Close, если использую cursor.All?

Можно без него — All закрывает курсор сам, даже при отмене контекста. Но единое правило «каждому Find — defer Close» проще держать в голове, а Close идемпотентен: страховка ничего не ломает и спасает, если код потом перепишут на ручной цикл.

Почему Find не возвращает ErrNoDocuments при пустой выборке?

Пустой список — легитимный результат, а не отсутствие ответа: каталог без товаров в новой категории — норма. ErrNoDocuments живёт только у операций, обещающих один документ: FindOne, FindOneAndUpdate и родня. Для Find пустота — ноль итераций цикла.

Можно ли читать один курсор из нескольких горутин?

Нет: курсор держит позицию и текущий документ, параллельная итерация разорвёт порядок и состояние. Разносите документы по горутинам после чтения — через канал: один читатель, много обработчиков. Ошибка cursor not found часто начинается именно с курсора, доехавшего до чужой горутины.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

zerolog25 уроков~12 чпрактика25 уроков уже доступны

Zerolog в Go: быстрое JSON-логирование

Курс zerolog для Go: JSON-события, уровни, поля, ConsoleWriter, контекстные поля, ротация, sampling, HTTP-логи и тесты. 25 уроков с примерами.

  • Писать структурированные JSON-логи с нуля аллокаций
  • Выбирать уровни и поля события осознанно
  • Настраивать ConsoleWriter для dev и JSON для прода
  • Строить сублоггеры и контекстные поля на запрос