Модуль 3 · Форма данных и скорость

Справочник по MongoDB · модуль 3 · форма данных и скорость

3.2

3.2

Индексы: одиночные,
составные, уникальные

В этой главе разобрано, что такое индекс и как он меняет способ, которым сервер ищет документы. На копии журнала событий строятся индекс по одному полю и составной индекс по двум полям; по стадиям плана видно, когда сервер читает индекс, а когда — всю коллекцию. Отдельно разобраны уникальный индекс, который не пускает в коллекцию повторы, и частичный индекс для документов, в которых нужного поля нет.

Категория
Форма данных
Уровень
Начальный
Связанные темы
Фильтр и точечная нотация (глава 2.1), сортировка (глава 1.6), $exists (глава 2.5), правила $jsonSchema (глава 3.1)
Зачем это знать
Находить документы, не читая всю коллекцию, и запрещать повторы значений на уровне базы

Как запускать примеры этой главы

БазыГлава меняет песочницу. Перед началом верните базы в исходное состояние: docker compose run --rm reset или скрипт из главы 1.0, §6.
Архивmongodb-glava-3.2-python.zip, 64 КБmongodb-glava-3.2-ruby.zip, 64 КБmongodb-glava-3.2-go.zip, 71 КБmongodb-glava-3.2-cpp.zip, 68 КБ — все примеры главы готовыми программами, базы и стенд в Docker. В README.md — запуск в Docker, если Compass нет или он не подключается, и на установленном сервере с Compass, а также что должна напечатать каждая программа.
Файлlessons/ch3_2.py — пример вставляется в конец файлаlessons/ch3_2.rb — пример вставляется в конец файлаlessons/ch3_2.go — пример внутрь скобок в конце main, функции и типы — выше mainlessons/ch3_2.cpp — пример внутрь скобок в конце main, функции — выше main
Запускcd lessons, затем python ch3_2.pycd lessons, затем python3 ch3_2.py · в Docker: docker compose run --rm python lessons/ch3_2.pycd lessons, затем ruby ch3_2.rb · в Docker: docker compose run --rm ruby lessons/ch3_2.rbcd lessons, затем go run ch3_2.go · в Docker: docker compose run --rm go lessons/ch3_2.gocd lessons, затем g++ -std=c++17 ch3_2.cpp -o prog $(pkg-config --cflags --libs libmongocxx1) && ./prog · в Docker: docker compose run --rm cpp lessons/ch3_2.cpp
ПорядокКаждый пример вставляется вместо предыдущего и запускается один раз, по порядку, — так получены все выводы главы. Подробно — глава 1.0, §10.

§1Как сервер ищет без индекса

Запрос {"service": "payments"} к журналу событий находит 232 документа из 1200. Сервер заранее не знает, в каких документах записан сервис payments. Он читает все 1200 документов по порядку и у каждого проверяет поле service: 232 документа подходят, 968 прочитаны и отброшены. Такой способ называется просмотром коллекции (collection scan, в плане запроса — стадия COLLSCAN).

Так ищут слово в книге, у которой нет предметного указателя. Чтобы найти все страницы со словом «платёж», книгу листают от первой страницы до последней. В книге на 1200 страниц просматриваются все 1200, даже если слово встречается на 232. На 1200 документах просмотр занимает доли миллисекунды, на десяти миллионах — секунды. Чем больше коллекция, тем дольше выполняется каждый такой запрос.

На схеме — первые 40 событий журнала и два способа найти среди них события payments.

Первые 40 событий журналаe-00001 … e-00040
Без индекса: COLLSCAN
01020304050607080910111213141516171819202122232425262728293031323334353637383940
Прочитано 40 документов, подходят 6
С индексом по service: IXSCAN и FETCH
01020304050607080910111213141516171819202122232425262728293031323334353637383940
Прочитано 6 документов, подходят 6

Квадрат — один документ, число — конец его _id: 18 означает e-00018. Зелёные квадраты — события payments. Сплошная рамка — документ, который сервер прочитал; пунктирная — документ, который не читался.

В книге эту работу сокращает предметный указатель в конце: слова стоят в нём по алфавиту, при каждом слове записаны номера страниц. Читатель находит слово и открывает только названные страницы. В MongoDB ту же роль выполняет индекс. Индекс — отдельная структура рядом с коллекцией, в которой значения выбранного поля хранятся по порядку, и при каждом значении записаны ссылки на документы с этим значением. Ссылка — запись о том, где документ лежит на диске; по ней сервер читает документ сразу, без поиска.

В книгеВ MongoDB
СтраницаДокумент
Номер страницыСсылка на документ
Слово в указателеЗначение поля, например "payments"
Предметный указательИндекс
Листать книгу от начала до концаПросмотр коллекции, COLLSCAN
Найти слово в указателе и открыть названные страницыПоиск по индексу

Индекс по полю service для журнала событий выглядит так:

Индекс по полю service1200 записей
"api-gateway" — 239 ссылок
"notifier" — 247 ссылок
"orders" — 233 ссылки
"payments" — 232 ссылки: e-00018, e-00026, e-00030, …
"search" — 249 ссылок

В индексе 1200 записей, по одной на каждый документ, и они сгруппированы по пяти значениям поля. Значения стоят по алфавиту, поэтому сервер не читает индекс с начала: он сразу переходит к payments, как читатель открывает указатель на нужной букве. При этом значении записаны 232 ссылки, и сервер читает только эти 232 документа. Остальные 968 не читаются.

Индекс по service в масштабе1200 записей
Полоса шире экрана и прокручивается вправо.
{"service": "payments"}
api-gatewaynotifierorderspaymentssearch
Один участок: 232 записи подряд

Полоса — весь индекс слева направо, от первой записи до последней. Ширина участка соответствует числу записей. Участки идут по алфавиту значений: от api-gateway до search.

Документы при создании индекса не меняются и не переставляются. В книге с указателем страницы тоже остаются на своих местах, указатель печатается отдельно.

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

Индексы этой главы строятся на копии журнала в песочнице. База logs остаётся без новых индексов, и примеры других глав работают с ней в исходном виде. Копия — коллекция sandbox.events; в заготовке главы она открыта в переменной journal. Пример выполняет три действия: удаляет коллекцию journal (вместе с коллекцией удаляются все её индексы), копирует в неё события из logs.events и выводит список индексов.

Python · PyMongoкопия журнала и её индексы
journal.drop()
journal.insert_many(events.find())
print("событий в копии:", journal.count_documents({}))

for ix in journal.list_indexes():
    print("индекс", ix["name"], dict(ix["key"]))
C++ · mongocxxкопия журнала и её индексы
journal.drop();
std::vector<bsoncxx::document::value> all;
for (auto&& doc : events.find(make_document())) all.emplace_back(doc);
journal.insert_many(all);
std::cout << "событий в копии: " << journal.count_documents(make_document()) << std::endl;

for (auto&& ix : journal.list_indexes()) {
    std::cout << "индекс " << ix["name"].get_string().value << " "
              << bsoncxx::to_json(ix["key"].get_document().value, bsoncxx::ExtendedJsonMode::k_relaxed) << std::endl;
}
Go · mongo-driverкопия журнала и её индексы
journal.Drop(ctx)
var all []bson.D
cursor, _ := events.Find(ctx, bson.D{})
cursor.All(ctx, &all)
docs := make([]any, len(all))
for i := range all {
    docs[i] = all[i]
}
journal.InsertMany(ctx, docs)
n, _ := journal.CountDocuments(ctx, bson.D{})
fmt.Println("событий в копии:", n)

list, _ := journal.Indexes().List(ctx)
for list.Next(ctx) {
    key, _ := bson.MarshalExtJSON(list.Current.Lookup("key").Document(), false, false)
    fmt.Println("индекс", list.Current.Lookup("name").StringValue(), string(key))
}
Ruby · mongoкопия журнала и её индексы
journal.drop
journal.insert_many(events.find.to_a)
puts "событий в копии: " + journal.count_documents({}).to_s

journal.indexes.each { |ix| puts "индекс " + ix["name"] + " " + ix["key"].to_s }
Вывод в терминалрезультат запуска
событий в копии: 1200
индекс _id_ {'_id': 1}
Вывод в терминалрезультат запуска
событий в копии: 1200
индекс _id_ { "_id" : 1 }
Вывод в терминалрезультат запуска
событий в копии: 1200
индекс _id_ {"_id":1}
Вывод в терминалрезультат запуска
событий в копии: 1200
индекс _id_ {"_id"=>1}

В копии 1200 событий и один индекс. У каждого индекса есть ключ и имя. Ключ индекса — запись о том, по какому полю и в каком порядке упорядочен индекс. Ключ {"_id": 1} читается «по полю _id, по возрастанию»: 1 — по возрастанию, -1 — по убыванию. Имя индекса сервер составляет из ключа: имя поля, подчёркивание, направление. Индекс с ключом {"service": 1} получит имя service_1. У индекса по _id имя особое — _id_.

Compass, вкладка Indexes коллекции sandbox.events: один индекс _id_, тип REGULAR, свойство UNIQUE, статус READY
Compass · sandbox.events, вкладка Indexes после копирования

Индексы коллекции показывает вкладка Indexes, её счётчик — 1. В таблице одна строка, _id_. Столбцы таблицы:

  • Name & Definition — имя индекса; стрелка слева раскрывает его ключ;
  • Type — вид индекса; REGULAR — обычный;
  • Size — сколько места индекс занимает на диске;
  • Usage — сколько раз индекс использовался в запросах с указанной даты;
  • Properties — свойства индекса; у _id_ свойство UNIQUE, потому что двух документов с одним _id быть не может;
  • Status — состояние; READY означает, что индекс построен.

§2Индекс по одному полю

По результату запроса нельзя узнать, каким способом сервер искал документы: с индексом и без него программа получает одни и те же 232 документа. Способ виден в плане запроса — описании шагов, которыми сервер выполняет запрос. Шаги плана называются стадиями. План возвращает метод explain; подробно он разобран в главе 3.3.

В этой главе из плана нужны только названия стадий. Их достаёт функция plan из заготовки главы. Она принимает коллекцию и фильтр и возвращает строку со стадиями, например FETCH → IXSCAN service_1.

Python · PyMongoфункция plan из заготовки главы
def plan(coll, filtr, sort=None):
    """Стадии плана, который выбрал сервер, сверху вниз: FETCH → IXSCAN service_1."""
    cursor = coll.find(filtr)
    if sort:
        cursor = cursor.sort(sort)
    stage = cursor.explain()["queryPlanner"]["winningPlan"]
    stage = stage.get("queryPlan", stage)     # MongoDB 7 кладёт план во вложенное поле
    steps = []
    while stage:
        steps.append(stage["stage"] + (" " + stage["indexName"] if "indexName" in stage else ""))
        stage = stage.get("inputStage")
    return " → ".join(steps)
C++ · mongocxxфункция plan из заготовки главы
// plan — стадии плана, который выбрал сервер, сверху вниз: FETCH → IXSCAN service_1.
std::string plan(mongocxx::database& db, const std::string& coll, bsoncxx::document::view filtr,
                 bsoncxx::document::view sort = bsoncxx::document::view{}) {
    bsoncxx::builder::basic::document cmd;
    cmd.append(kvp("find", coll), kvp("filter", filtr));
    if (!sort.empty()) cmd.append(kvp("sort", sort));
    auto res = db.run_command(make_document(kvp("explain", cmd.view())));
    auto stage = res.view()["queryPlanner"]["winningPlan"].get_document().value;
    if (stage["queryPlan"]) stage = stage["queryPlan"].get_document().value;   // MongoDB 7 кладёт план во вложенное поле
    std::string steps;
    while (true) {
        steps += std::string{stage["stage"].get_string().value};
        if (stage["indexName"]) steps += " " + std::string{stage["indexName"].get_string().value};
        if (!stage["inputStage"]) break;
        steps += " → ";
        stage = stage["inputStage"].get_document().value;
    }
    return steps;
}
Go · mongo-driverфункция plan из заготовки главы
// plan — стадии плана, который выбрал сервер, сверху вниз: FETCH → IXSCAN service_1.
func plan(coll *mongo.Collection, filtr bson.D, sort bson.D) string {
    cmd := bson.D{{Key: "find", Value: coll.Name()}, {Key: "filter", Value: filtr}}
    if sort != nil {
        cmd = append(cmd, bson.E{Key: "sort", Value: sort})
    }
    var res bson.Raw
    coll.Database().RunCommand(context.Background(), bson.D{{Key: "explain", Value: cmd}}).Decode(&res)
    stage := res.Lookup("queryPlanner", "winningPlan").Document()
    if inner, ok := stage.Lookup("queryPlan").DocumentOK(); ok { // MongoDB 7 кладёт план во вложенное поле
        stage = inner
    }
    steps := []string{}
    for {
        step := stage.Lookup("stage").StringValue()
        if name, ok := stage.Lookup("indexName").StringValueOK(); ok {
            step += " " + name
        }
        steps = append(steps, step)
        next, ok := stage.Lookup("inputStage").DocumentOK()
        if !ok {
            break
        }
        stage = next
    }
    return strings.Join(steps, " → ")
}
Ruby · mongoфункция plan из заготовки главы
# Стадии плана, который выбрал сервер, сверху вниз: FETCH → IXSCAN service_1.
def plan(coll, filtr, sort = nil)
  view = coll.find(filtr)
  view = view.sort(sort) if sort
  stage = view.explain["queryPlanner"]["winningPlan"]
  stage = stage["queryPlan"] || stage     # MongoDB 7 кладёт план во вложенное поле
  steps = []
  while stage
    steps << stage["stage"] + (stage["indexName"] ? " " + stage["indexName"] : "")
    stage = stage["inputStage"]
  end
  steps.join(" → ")
end

В главе встречаются четыре стадии. В правом столбце — то же действие с книгой из §1.

СтадияЧто делает серверТо же действие с книгой
COLLSCANЧитает все документы коллекции и проверяет условие у каждогоЛистает книгу от первой страницы до последней
IXSCAN service_1Находит нужное значение в индексе service_1 и получает ссылки на документыНаходит слово в указателе и выписывает номера страниц
FETCHЧитает документы по ссылкам, которые получила стадия IXSCANОткрывает страницы по выписанным номерам
SORTСортирует прочитанные документы в памяти сервераРаскладывает найденные страницы в нужном порядке

Строка плана читается справа налево: правая стадия выполняется первой и передаёт результат стадии слева. Строка FETCH → IXSCAN service_1 означает, что сервер сначала нашёл ссылки в индексе service_1, затем прочитал по ним документы. Стадии IXSCAN и FETCH идут парой: в индексе лежат только значения поля и ссылки, а запрос возвращает документы целиком, и их нужно прочитать.

Порядок выполнения двух планов, слева направо. Числа — из вывода примера ниже.

COLLSCANчитает все 1200 документов, у каждого проверяет serviceбез индекса · 1 стадия
программаполучает 232 документарезультат
IXSCAN service_1находит payments в индексе и получает 232 ссылкис индексом · 1-я стадия
FETCHчитает 232 документа по ссылкамс индексом · 2-я стадия
программаполучает 232 документарезультат

В строке плана стадии записаны в обратном порядке: FETCH → IXSCAN service_1. Слева стоит стадия, которая выполняется последней и отдаёт документы программе.

Пример выводит план запроса {"service": "payments"}, создаёт индекс по полю service и выводит план того же запроса ещё раз.

Индекс создаёт метод коллекции create_index. Одно поле можно передать строкой, направление тогда — по возрастанию. Метод возвращает имя созданного индекса.

Индексами коллекции управляет объект indexes; метод create_one создаёт индекс по документу ключа { "service" => 1 }. Он возвращает ответ сервера; имя индекса берётся методом indexes.get по тому же ключу.

Индексами коллекции управляет Indexes(); метод CreateOne принимает mongo.IndexModel, где в поле Keys записан ключ индекса, и возвращает имя созданного индекса.

Метод коллекции create_index принимает документ ключа и возвращает документ ответа, в котором имя индекса лежит в поле name.

Python · PyMongoиндекс по одному полю
print("до индекса:   ", plan(journal, {"service": "payments"}))

name = journal.create_index("service")
print("создан индекс:", name)

print("после индекса:", plan(journal, {"service": "payments"}))
print("событий payments:", journal.count_documents({"service": "payments"}))
C++ · mongocxxиндекс по одному полю
std::cout << "до индекса:    " << plan(sandbox, "events", make_document(kvp("service", "payments"))) << std::endl;

auto created = journal.create_index(make_document(kvp("service", 1)));
std::cout << "создан индекс: " << created.view()["name"].get_string().value << std::endl;

std::cout << "после индекса: " << plan(sandbox, "events", make_document(kvp("service", "payments"))) << std::endl;
std::cout << "событий payments: " << journal.count_documents(make_document(kvp("service", "payments"))) << std::endl;
Go · mongo-driverиндекс по одному полю
fmt.Println("до индекса:   ", plan(journal, bson.D{{Key: "service", Value: "payments"}}, nil))

name, _ := journal.Indexes().CreateOne(ctx, mongo.IndexModel{Keys: bson.D{{Key: "service", Value: 1}}})
fmt.Println("создан индекс:", name)

fmt.Println("после индекса:", plan(journal, bson.D{{Key: "service", Value: "payments"}}, nil))
n, _ := journal.CountDocuments(ctx, bson.D{{Key: "service", Value: "payments"}})
fmt.Println("событий payments:", n)
Ruby · mongoиндекс по одному полю
puts "до индекса:    " + plan(journal, { "service" => "payments" })

journal.indexes.create_one({ "service" => 1 })
puts "создан индекс: " + journal.indexes.get({ "service" => 1 })["name"]

puts "после индекса: " + plan(journal, { "service" => "payments" })
puts "событий payments: " + journal.count_documents({ "service" => "payments" }).to_s
Вывод в терминалрезультат запуска
до индекса:    COLLSCAN
создан индекс: service_1
после индекса: FETCH → IXSCAN service_1
событий payments: 232
Вывод в терминалрезультат запуска
до индекса:    COLLSCAN
создан индекс: service_1
после индекса: FETCH → IXSCAN service_1
событий payments: 232
Вывод в терминалрезультат запуска
до индекса:    COLLSCAN
создан индекс: service_1
после индекса: FETCH → IXSCAN service_1
событий payments: 232
Вывод в терминалрезультат запуска
до индекса:    COLLSCAN
создан индекс: service_1
после индекса: FETCH → IXSCAN service_1
событий payments: 232

До создания индекса план — COLLSCAN: сервер прочитал все 1200 документов. Метод вернул имя нового индекса, service_1. После этого план того же запроса состоит из двух стадий: IXSCAN service_1 нашла в индексе значение payments и получила 232 ссылки, FETCH прочитала по ним 232 документа.

Запрос в программе остался прежним: в обоих вызовах функции plan один и тот же фильтр. Сервер сам проверяет, есть ли у коллекции подходящий индекс, и использует его. Событий payments найдено 232, как и без индекса: индекс меняет способ поиска, результат запроса остаётся прежним.

Compass, вкладка Indexes коллекции sandbox.events: индексы _id_ и service_1
Compass · sandbox.events после create_index

Счётчик вкладки — 2, вторая строка — service_1, тип REGULAR, свойств у него нет: индекс не уникальный и не составной.

§3Составной индекс и порядок полей

Дежурный ищет ошибки сервера в платёжном сервисе: {"service": "payments", "status": {"$gte": 500}}. В запросе два условия: сервис payments и статус от 500. Индекс service_1 помогает только с первым условием. Он отбирает 232 события payments, сервер читает все 232 документа и у каждого проверяет статус. Подходят 42 документа, остальные 190 прочитаны и отброшены.

Чтобы индекс помогал с обоими условиями, в него включают оба поля. Составной индекс — индекс по нескольким полям. Записи в нём упорядочены сначала по первому полю, а внутри одинаковых значений первого поля — по второму.

Так составлен общий список студентов колледжа, упорядоченный по группе, а внутри группы — по фамилии:

Список студентов: группа, фамилияфрагмент
ИС-21, Абрамов
ИС-21, Иванов
ИС-21, Яковлев
ИС-22, Белова
ИС-22, Иванов
ИС-22, Орлова
ИС-23, Григорьев
ИС-23, Иванов
Кого ищутКак идёт поиск по списку
Всех студентов группы ИС-22Они стоят подряд. Читается один участок списка
Иванова из группы ИС-22Сначала находится участок группы, внутри него фамилии стоят по алфавиту. Читается одна строка
Всех Ивановых колледжаИвановы есть в каждой группе и стоят в разных местах. Список читается целиком

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

Что упорядоченоПервое поле, второе полеНаходится сразуПросматривается целиком
СловарьПервая буква слова, вторая букваВсе слова на «к»; все слова на «ко»Все слова, в которых вторая буква «о»: «дом», «кот», «нос» стоят в разных частях словаря
Контакты в телефонеФамилия, имяВсе Ивановы; Иванов ПётрВсе Петры: они записаны под разными фамилиями
Папки на дискеПапка, файл в нейВсе файлы папки payments; файл 503.txt в папке paymentsФайл 503.txt, когда папка неизвестна: открывается каждая папка
Почтовые адресаГород, улицаВсе улицы Казани; улица Баумана в КазаниВсе улицы Ленина: такая улица есть во многих городах

Группа в этом списке соответствует полю service, фамилия — полю status. В индексе по service и status записи идут так:

Индекс по service и statusфрагмент
… "orders", 503 — 9 ссылок
"payments", 200 — 148 · 301 — 8 · 400 — 9 · 404 — 13 · 429 — 12
"payments", 500 — 13 · 502 — 9 · 503 — 20 ← 42 записи подряд
"search", 200 — 149 · 301 — 16 · … · 503 — 21

В этом фрагменте все события payments со статусом от 500 стоят подряд — 42 записи в одной строке. Ниже тот же индекс показан тремя способами: списком записей, полосой в масштабе и с обратным порядком полей.

Три запроса к одному списку записей

Индекс — это список записей, упорядоченный по сервису, а внутри сервиса — по статусу. Чтобы список поместился на схеме, из восьми статусов оставлены три: 200, 500 и 503.

Записи индекса по service и statusиз восьми статусов показаны три
service = "payments"
  1. "api-gateway", 200
  2. "api-gateway", 500
  3. "api-gateway", 503
  4. "notifier", 200
  5. "notifier", 500
  6. "notifier", 503
  7. "orders", 200
  8. "orders", 500
  9. "orders", 503
  10. "payments", 200
  11. "payments", 500
  12. "payments", 503
  13. "search", 200
  14. "search", 500
  15. "search", 503
Три строки подряд
service = "payments", status ≥ 500
  1. "api-gateway", 200
  2. "api-gateway", 500
  3. "api-gateway", 503
  4. "notifier", 200
  5. "notifier", 500
  6. "notifier", 503
  7. "orders", 200
  8. "orders", 500
  9. "orders", 503
  10. "payments", 200
  11. "payments", 500
  12. "payments", 503
  13. "search", 200
  14. "search", 500
  15. "search", 503
Две строки подряд
status = 503
  1. "api-gateway", 200
  2. "api-gateway", 500
  3. "api-gateway", 503
  4. "notifier", 200
  5. "notifier", 500
  6. "notifier", 503
  7. "orders", 200
  8. "orders", 500
  9. "orders", 503
  10. "payments", 200
  11. "payments", 500
  12. "payments", 503
  13. "search", 200
  14. "search", 500
  15. "search", 503
Пять строк в пяти местах

Список один и тот же во всех трёх столбцах: строки упорядочены по сервису, внутри сервиса — по статусу. Меняется только запрос. Зелёным закрашены строки, которые ему подходят.

В первых двух столбцах нужные строки стоят подряд. Сервер находит первую из них и читает вниз, пока строки подходят. В третьем столбце строки со статусом 503 разделены чужими строками, и прочитать их одним участком нельзя.

Почему поиск только по status не работает

В упорядоченном списке сервер ищет так же, как читатель ищет слово в словаре: открывает список в середине, сравнивает запись с искомым значением и по результату идёт выше или ниже. Для условия service = "payments" сравнение даёт ответ: запись "orders", 503 стоит раньше, чем нужно, значит, все записи выше неё тоже не подходят, и искать нужно ниже.

Для условия status = 503 сравнение ответа не даёт. Запись "orders", 503 подходит, запись "payments", 200 сразу под ней не подходит, а ещё ниже есть подходящая "payments", 503. По одной записи нельзя определить, в какой стороне лежат остальные: статусы идут по возрастанию только внутри одного сервиса, а на границе сервисов счёт начинается заново с 200. Поэтому запрос только по status сервер выполняет просмотром коллекции.

Тот же индекс в масштабе

На списке показаны три статуса. На полосе ниже — весь индекс: 1200 записей, пять сервисов, в каждом восемь статусов. Три полосы — один и тот же индекс и три запроса.

Индекс по service и status1200 записей
Полоса шире экрана и прокручивается вправо.
{"service": "payments"}
api-gatewaynotifierorderspaymentssearch
Один участок: 232 записи подряд. Сервер переходит к началу участка и читает до его конца
{"service": "payments", "status": {"$gte": 500}}
api-gatewaynotifierorderspaymentssearch
Один участок: 42 записи подряд в конце участка payments, статусы 500, 502 и 503
{"status": 503}
api-gatewaynotifierorderspaymentssearch
Пять участков в разных местах: 9 + 6 + 9 + 20 + 21 = 65 записей. Одного места, с которого можно начать чтение, нет

Толстые линии делят индекс на пять сервисов. Внутри каждого сервиса тонкие линии делят записи по статусам, от 200 слева до 503 справа. Зелёным закрашены записи, которые нужны запросу.

Первый запрос занимает один участок целиком, второй — конец того же участка. У третьего запроса пять участков, и между ними лежат записи с другими статусами.

Префикс индекса

Первое поле составного индекса или несколько его первых полей подряд называются префиксом. Сервер использует составной индекс, когда в условиях запроса есть его префикс. Для индекса по service и status:

  • запрос по service — индекс помогает: service — префикс, нужные записи стоят одним участком;
  • запрос по service и status — индекс помогает: в запросе есть оба поля, нужные записи стоят одним участком внутри сервиса;
  • запрос только по status — индекс не помогает: поля service в запросе нет, нужные записи стоят в пяти местах.

Обратный порядок полей

Порядок полей задаётся при создании индекса. Индекс по status и service состоит из тех же 1200 записей, но упорядочен сначала по статусу, а внутри статуса — по сервису. Его префикс — status.

Индекс по status и serviceте же 1200 записей
Полоса шире экрана и прокручивается вправо.
{"status": 503}
200301400404429500502503
Один участок: 65 записей подряд
{"service": "payments"}
200301400404429500502503
Восемь участков в разных местах: 148 + 8 + 9 + 13 + 12 + 13 + 9 + 20 = 232 записи

Толстые линии делят индекс на восемь статусов, от 200 до 503; на статус 200 приходится 777 записей из 1200. Внутри каждого статуса тонкие линии делят записи по сервисам, от api-gateway до search.

Запросы поменялись местами. События со статусом 503 теперь стоят одним участком, и запрос по status выполняется по индексу. События payments стоят в восьми местах, по одному участку в каждом статусе, и запрос только по service при таком индексе выполняется просмотром коллекции. Оба утверждения проверены на копии журнала: план первого запроса — FETCH → IXSCAN status_1_service_1, план второго — COLLSCAN.

Отсюда правило выбора порядка: первым в составном индексе ставят поле, условие на которое есть в каждом запросе. Дежурный всегда указывает сервис и не всегда указывает статус, поэтому в примере ниже индекс строится по service, затем по status.

Составной индекс задаётся списком пар «поле, направление». Порядок пар в списке и есть порядок полей в индексе.

Составной индекс задаётся хешем из нескольких полей. Хеш Ruby сохраняет порядок ключей, и порядок полей в нём — порядок полей в индексе.

Составной индекс задаётся в Keys документом bson.D из нескольких полей. bson.D сохраняет порядок, и он становится порядком полей в индексе.

Составной индекс задаётся документом из нескольких полей; порядок полей в документе — порядок полей в индексе.

Пример создаёт индекс по service и status и выводит планы трёх запросов: по обоим полям, только по status и по service с сортировкой по status.

Python · PyMongoсоставной индекс
name = journal.create_index([("service", 1), ("status", 1)])
print("создан индекс:", name)

print("service и status:          ", plan(journal, {"service": "payments", "status": {"$gte": 500}}))
print("только status:             ", plan(journal, {"status": 503}))
print("service, порядок по status:", plan(journal, {"service": "payments"}, [("status", 1)]))
C++ · mongocxxсоставной индекс
auto created = journal.create_index(make_document(kvp("service", 1), kvp("status", 1)));
std::cout << "создан индекс: " << created.view()["name"].get_string().value << std::endl;

std::cout << "service и status:           "
          << plan(sandbox, "events", make_document(kvp("service", "payments"), kvp("status", make_document(kvp("$gte", 500)))))
          << std::endl;
std::cout << "только status:              " << plan(sandbox, "events", make_document(kvp("status", 503))) << std::endl;
std::cout << "service, порядок по status: "
          << plan(sandbox, "events", make_document(kvp("service", "payments")), make_document(kvp("status", 1)))
          << std::endl;
Go · mongo-driverсоставной индекс
name, _ := journal.Indexes().CreateOne(ctx, mongo.IndexModel{
    Keys: bson.D{{Key: "service", Value: 1}, {Key: "status", Value: 1}}})
fmt.Println("создан индекс:", name)

fmt.Println("service и status:          ",
    plan(journal, bson.D{{Key: "service", Value: "payments"}, {Key: "status", Value: bson.D{{Key: "$gte", Value: 500}}}}, nil))
fmt.Println("только status:             ", plan(journal, bson.D{{Key: "status", Value: 503}}, nil))
fmt.Println("service, порядок по status:",
    plan(journal, bson.D{{Key: "service", Value: "payments"}}, bson.D{{Key: "status", Value: 1}}))
Ruby · mongoсоставной индекс
journal.indexes.create_one({ "service" => 1, "status" => 1 })
puts "создан индекс: " + journal.indexes.get({ "service" => 1, "status" => 1 })["name"]

puts "service и status:           " + plan(journal, { "service" => "payments", "status" => { "$gte" => 500 } })
puts "только status:              " + plan(journal, { "status" => 503 })
puts "service, порядок по status: " + plan(journal, { "service" => "payments" }, { "status" => 1 })
Вывод в терминалрезультат запуска
создан индекс: service_1_status_1
service и status:           FETCH → IXSCAN service_1_status_1
только status:              COLLSCAN
service, порядок по status: FETCH → IXSCAN service_1_status_1
Вывод в терминалрезультат запуска
создан индекс: service_1_status_1
service и status:           FETCH → IXSCAN service_1_status_1
только status:              COLLSCAN
service, порядок по status: FETCH → IXSCAN service_1_status_1
Вывод в терминалрезультат запуска
создан индекс: service_1_status_1
service и status:           FETCH → IXSCAN service_1_status_1
только status:              COLLSCAN
service, порядок по status: FETCH → IXSCAN service_1_status_1
Вывод в терминалрезультат запуска
создан индекс: service_1_status_1
service и status:           FETCH → IXSCAN service_1_status_1
только status:              COLLSCAN
service, порядок по status: FETCH → IXSCAN service_1_status_1

Индекс получил имя service_1_status_1: в нём оба поля в порядке создания. Три строки плана:

  • по обоим полям — FETCH → IXSCAN service_1_status_1. Сервер выполнил запрос по новому индексу;
  • только по status — COLLSCAN. Поле status не входит в префикс, сервер читает всю коллекцию;
  • по service с сортировкой по status — FETCH → IXSCAN service_1_status_1, стадии SORT нет. Внутри значения payments записи индекса уже упорядочены по статусу, и сервер отдаёт документы в порядке индекса. В списке колледжа студенты одной группы тоже уже стоят по фамилиям, отдельно сортировать их не нужно.

Для первого запроса подходили два индекса: service_1 и составной. Когда подходят несколько индексов, сервер пробует их на запросе и оставляет тот, по которому результат получается за меньшее число шагов. По составному индексу читаются 42 документа, по service_1 — 232, и сервер выбрал составной.

Compass, окно Explain Plan для запроса service payments и status от 500: стадии FETCH и IXSCAN по индексу service_1_status_1, 42 документа, 42 ключа индекса
Compass · Explain для { service: "payments", status: { $gte: 500 } }

Кнопка Explain на вкладке Documents показывает план запроса из поля фильтра. В дереве те же две стадии, что вывела функция plan: снизу IXSCAN по индексу service_1_status_1, над ней FETCH, обе вернули 42 документа. Справа сводка: прочитано 42 ключа индекса и 42 документа — столько же, сколько найдено. Остальные числа сводки разобраны в главе 3.3.

§4Удаление индекса

После создания составного индекса у коллекции два индекса, которые начинаются с поля service: service_1 и service_1_status_1. Запрос по одному service может выполнить любой из них, потому что service — префикс составного индекса. Список колледжа, упорядоченный по группе и фамилии, уже позволяет найти всех студентов группы; второй список, упорядоченный только по группе, ничего к нему не добавляет.

Индекс service_1 стал лишним. Он занимает место на диске, и при каждой записи в коллекцию сервер обновляет и его (§8). Лишний индекс удаляют. Индекс удаляется по имени.

Метод коллекции drop_index принимает имя индекса.

Метод indexes.drop_one принимает имя индекса.

Метод Indexes().DropOne принимает имя индекса.

Метод indexes().drop_one принимает имя индекса.

Пример удаляет service_1, выводит оставшиеся индексы и планы двух запросов: только по service и по service с сортировкой по времени события ts.

Python · PyMongoудаление лишнего индекса
journal.drop_index("service_1")
for ix in journal.list_indexes():
    print("индекс", ix["name"], dict(ix["key"]))

print("только service:        ", plan(journal, {"service": "payments"}))
print("service, порядок по ts:", plan(journal, {"service": "payments"}, [("ts", -1)]))
C++ · mongocxxудаление лишнего индекса
journal.indexes().drop_one("service_1");
for (auto&& ix : journal.list_indexes()) {
    std::cout << "индекс " << ix["name"].get_string().value << " "
              << bsoncxx::to_json(ix["key"].get_document().value, bsoncxx::ExtendedJsonMode::k_relaxed) << std::endl;
}

std::cout << "только service:         " << plan(sandbox, "events", make_document(kvp("service", "payments"))) << std::endl;
std::cout << "service, порядок по ts: "
          << plan(sandbox, "events", make_document(kvp("service", "payments")), make_document(kvp("ts", -1)))
          << std::endl;
Go · mongo-driverудаление лишнего индекса
journal.Indexes().DropOne(ctx, "service_1")
list, _ := journal.Indexes().List(ctx)
for list.Next(ctx) {
    key, _ := bson.MarshalExtJSON(list.Current.Lookup("key").Document(), false, false)
    fmt.Println("индекс", list.Current.Lookup("name").StringValue(), string(key))
}

fmt.Println("только service:        ", plan(journal, bson.D{{Key: "service", Value: "payments"}}, nil))
fmt.Println("service, порядок по ts:",
    plan(journal, bson.D{{Key: "service", Value: "payments"}}, bson.D{{Key: "ts", Value: -1}}))
Ruby · mongoудаление лишнего индекса
journal.indexes.drop_one("service_1")
journal.indexes.each { |ix| puts "индекс " + ix["name"] + " " + ix["key"].to_s }

puts "только service:         " + plan(journal, { "service" => "payments" })
puts "service, порядок по ts: " + plan(journal, { "service" => "payments" }, { "ts" => -1 })
Вывод в терминалрезультат запуска
индекс _id_ {'_id': 1}
индекс service_1_status_1 {'service': 1, 'status': 1}
только service:         FETCH → IXSCAN service_1_status_1
service, порядок по ts: SORT → FETCH → IXSCAN service_1_status_1
Вывод в терминалрезультат запуска
индекс _id_ { "_id" : 1 }
индекс service_1_status_1 { "service" : 1, "status" : 1 }
только service:         FETCH → IXSCAN service_1_status_1
service, порядок по ts: SORT → FETCH → IXSCAN service_1_status_1
Вывод в терминалрезультат запуска
индекс _id_ {"_id":1}
индекс service_1_status_1 {"service":1,"status":1}
только service:         FETCH → IXSCAN service_1_status_1
service, порядок по ts: SORT → FETCH → IXSCAN service_1_status_1
Вывод в терминалрезультат запуска
индекс _id_ {"_id"=>1}
индекс service_1_status_1 {"service"=>1, "status"=>1}
только service:         FETCH → IXSCAN service_1_status_1
service, порядок по ts: SORT → FETCH → IXSCAN service_1_status_1

Остались два индекса — _id_ и составной. Запрос по service выполнен по составному индексу: service — его префикс.

Во втором плане первой стоит стадия SORT. Индекс отдаёт события payments в порядке статусов, а запрос требует порядок по времени ts. Поля ts в индексе нет, поэтому сервер читает 232 документа и сортирует их в памяти. В списке колледжа так выглядит запрос «студенты группы ИС-22 по дате рождения»: участок группы находится сразу, но внутри него фамилии стоят по алфавиту, и по дате рождения их раскладывают отдельно. Если запрос с такой сортировкой выполняется часто, для него строят индекс, в котором после service идёт ts.

Шесть первых событий paymentsstatus и время ts за 01.09.2026
Порядок в индексе: по status
  1. 200 · 00:51
  2. 200 · 00:59
  3. 301 · 01:17
  4. 404 · 00:32
  5. 404 · 01:06
  6. 500 · 01:14
Статусы идут по возрастанию, время — вперемешку
Порядок в запросе: по ts
  1. 404 · 00:32
  2. 200 · 00:51
  3. 200 · 00:59
  4. 404 · 01:06
  5. 500 · 01:14
  6. 301 · 01:17
Время идёт по возрастанию. Такой порядок сервер получает стадией SORT

Порядок по убыванию индекс тоже даёт. Для сортировки по status от больших значений к меньшим сервер читает тот же индекс service_1_status_1 с конца, и стадии SORT в плане нет.

Удалить можно любой индекс, кроме _id_. На попытку удалить его сервер отвечает ошибкой cannot drop _id index.

Compass, вкладка Indexes коллекции sandbox.events: _id_ размером 4,1 kB и service_1_status_1 размером 24,6 kB со свойством COMPOUND
Compass · sandbox.events после удаления service_1

Осталось два индекса. У составного свойство COMPOUND — «составной». Размеры в столбце Size: 4,1 kB у _id_ и 24,6 kB у составного на 1200 событий.

§5Уникальный индекс

При регистрации на сайте форма отвечает «эта почта уже занята», если на такой адрес уже заведена учётная запись. В базе данных эту проверку выполняет уникальный индекс. Индекс с параметром unique — уникальный — не допускает в коллекции двух документов с одинаковым значением индексированного поля.

Сервер проверяет это при каждой вставке и каждом обновлении. Он ищет новое значение в индексе, и если оно там уже есть, отклоняет запись: документ не записывается, программа получает ошибку. Так же приходит отказ валидатора из главы 3.1. Значение в индексе находится быстро, поэтому для проверки сервер не читает коллекцию.

Порядок проверки для записи из примера ниже:

insert_oneновый покупатель Ольга Кравец, почта user01@example.com1 · запись
индекс email_1значение user01@example.com в индексе уже есть: это почта Анны Беловой2 · проверка
ответкод 11000, документ не записан3 · отказ

Уникальным индексом защищают значения, которые должны встречаться один раз: почту покупателя, номер заказа, артикул товара.

Пример копирует покупателей из shop.customers в clients (sandbox.customers), создаёт уникальный индекс по полю email и пробует записать нового покупателя с почтой, которая уже есть у Анны Беловой.

Параметр передаётся в create_index именованным аргументом unique=True. Отказ из-за повтора PyMongo выбрасывает исключением DuplicateKeyError; повторившееся значение лежит в e.details["keyValue"].

Параметр передаётся в create_one вторым аргументом unique: true. Отказ приходит исключением Mongo::Error::OperationFailure; повторившееся значение лежит в ответе сервера, e.document["writeErrors"][0]["keyValue"].

Параметры индекса собираются в options.Index(), уникальность задаёт SetUnique(true). Отказ приходит ошибкой mongo.WriteException; повторившееся значение достаёт Raw.Lookup("keyValue") из первой ошибки записи.

Параметры индекса — объект mongocxx::options::index, уникальность задаёт unique(true). Отказ приходит исключением mongocxx::operation_exception; повторившееся значение лежит в ответе сервера, в первом элементе writeErrors.

Python · PyMongoуникальный индекс
from pymongo.errors import DuplicateKeyError

clients.drop()
clients.insert_many(db["customers"].find())
print("создан индекс:", clients.create_index("email", unique=True))

try:
    clients.insert_one({"_id": "c-101", "name": "Ольга Кравец", "email": "user01@example.com", "city": "Ярославль"})
    print("Ольга Кравец записана")
except DuplicateKeyError as e:
    print("Ольга Кравец отклонена, код", e.code, "— уже есть", e.details["keyValue"])
print("покупателей:", clients.count_documents({}))
C++ · mongocxxуникальный индекс
clients.drop();
std::vector<bsoncxx::document::value> all;
for (auto&& doc : db["customers"].find(make_document())) all.emplace_back(doc);
clients.insert_many(all);
mongocxx::options::index unique;
unique.unique(true);
auto created = clients.create_index(make_document(kvp("email", 1)), unique);
std::cout << "создан индекс: " << created.view()["name"].get_string().value << std::endl;

try {
    clients.insert_one(make_document(kvp("_id", "c-101"), kvp("name", "Ольга Кравец"),
                                     kvp("email", "user01@example.com"), kvp("city", "Ярославль")));
    std::cout << "Ольга Кравец записана" << std::endl;
} catch (const mongocxx::operation_exception& e) {
    std::cout << "Ольга Кравец отклонена, код " << e.code().value() << " — уже есть "
              << bsoncxx::to_json(e.raw_server_error()->view()["writeErrors"][0]["keyValue"].get_document().value,
                                  bsoncxx::ExtendedJsonMode::k_relaxed) << std::endl;
}
std::cout << "покупателей: " << clients.count_documents(make_document()) << std::endl;
Go · mongo-driverуникальный индекс
clients.Drop(ctx)
var all []bson.D
cursor, _ := db.Collection("customers").Find(ctx, bson.D{})
cursor.All(ctx, &all)
docs := make([]any, len(all))
for i := range all {
    docs[i] = all[i]
}
clients.InsertMany(ctx, docs)
name, _ := clients.Indexes().CreateOne(ctx, mongo.IndexModel{
    Keys:    bson.D{{Key: "email", Value: 1}},
    Options: options.Index().SetUnique(true)})
fmt.Println("создан индекс:", name)

_, err := clients.InsertOne(ctx, bson.D{
    {Key: "_id", Value: "c-101"},
    {Key: "name", Value: "Ольга Кравец"},
    {Key: "email", Value: "user01@example.com"},
    {Key: "city", Value: "Ярославль"}})
var we mongo.WriteException
if errors.As(err, &we) {
    fmt.Println("Ольга Кравец отклонена, код", we.WriteErrors[0].Code, "— уже есть", we.WriteErrors[0].Raw.Lookup("keyValue"))
} else {
    fmt.Println("Ольга Кравец записана")
}
n, _ := clients.CountDocuments(ctx, bson.D{})
fmt.Println("покупателей:", n)
Ruby · mongoуникальный индекс
clients.drop
clients.insert_many(db[:customers].find.to_a)
clients.indexes.create_one({ "email" => 1 }, unique: true)
puts "создан индекс: " + clients.indexes.get({ "email" => 1 })["name"]

begin
  clients.insert_one({ "_id" => "c-101", "name" => "Ольга Кравец", "email" => "user01@example.com", "city" => "Ярославль" })
  puts "Ольга Кравец записана"
rescue Mongo::Error::OperationFailure => e
  puts "Ольга Кравец отклонена, код #{e.code} — уже есть #{e.document["writeErrors"][0]["keyValue"]}"
end
puts "покупателей: " + clients.count_documents({}).to_s
Вывод в терминалрезультат запуска
создан индекс: email_1
Ольга Кравец отклонена, код 11000 — уже есть {'email': 'user01@example.com'}
покупателей: 20
Вывод в терминалрезультат запуска
создан индекс: email_1
Ольга Кравец отклонена, код 11000 — уже есть { "email" : "user01@example.com" }
покупателей: 20
Вывод в терминалрезультат запуска
создан индекс: email_1
Ольга Кравец отклонена, код 11000 — уже есть {"email": "user01@example.com"}
покупателей: 20
Вывод в терминалрезультат запуска
создан индекс: email_1
Ольга Кравец отклонена, код 11000 — уже есть {"email"=>"user01@example.com"}
покупателей: 20

Индекс получил имя email_1. Вставка отклонена с кодом 11000. Название этой ошибки — DuplicateKey, «повтор ключа»; ключом здесь называется значение индексированного поля. Поле keyValue в ответе сервера называет значение, которое уже есть в индексе: почту user01@example.com. Покупателей по-прежнему 20: отклонённый документ не записан.

Compass, вкладка Indexes коллекции sandbox.customers: индекс email_1 со свойством UNIQUE
Compass · sandbox.customers, уникальный индекс по почте

У индекса email_1 свойство UNIQUE, как у _id_: оба не допускают повторов. Документов на вкладке Documents — 20.

§6Уникальный индекс на данных с повторами

Уникальный индекс можно создать только на коллекции, где повторов уже нет. Создавая индекс, сервер проходит по всем документам и заносит в индекс значение из каждого. Если очередное значение в индекс уже занесено, требование «без повторов» нарушено самими данными, и сервер прекращает создание индекса.

Так же нельзя объявить номера зачётных книжек уникальными, пока в списке группы у двух студентов записан один номер. Сначала исправляют список, затем вводят правило.

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

Python · PyMongoуникальный индекс на данных с повторами
from pymongo.errors import OperationFailure

print("городов:", len(clients.distinct("city")), "на", clients.count_documents({}), "покупателей")
try:
    clients.create_index("city", unique=True)
    print("индекс по city создан")
except OperationFailure as e:
    print("индекс по city не создан, код", e.code)
print("индексы:", [ix["name"] for ix in clients.list_indexes()])
C++ · mongocxxуникальный индекс на данных с повторами
auto cities = clients.distinct("city", make_document());
auto values = (*cities.begin())["values"].get_array().value;
std::cout << "городов: " << std::distance(values.begin(), values.end())
          << " на " << clients.count_documents(make_document()) << " покупателей" << std::endl;

mongocxx::options::index unique;
unique.unique(true);
try {
    clients.create_index(make_document(kvp("city", 1)), unique);
    std::cout << "индекс по city создан" << std::endl;
} catch (const mongocxx::operation_exception& e) {
    std::cout << "индекс по city не создан, код " << e.code().value() << std::endl;
}

std::cout << "индексы:";
for (auto&& ix : clients.list_indexes()) std::cout << " " << ix["name"].get_string().value;
std::cout << std::endl;
Go · mongo-driverуникальный индекс на данных с повторами
cities, _ := clients.Distinct(ctx, "city", bson.D{}).Raw()
values, _ := cities.Values()
n, _ := clients.CountDocuments(ctx, bson.D{})
fmt.Println("городов:", len(values), "на", n, "покупателей")

_, err := clients.Indexes().CreateOne(ctx, mongo.IndexModel{
    Keys:    bson.D{{Key: "city", Value: 1}},
    Options: options.Index().SetUnique(true)})
var ce mongo.CommandError
if errors.As(err, &ce) {
    fmt.Println("индекс по city не создан, код", ce.Code)
} else {
    fmt.Println("индекс по city создан")
}

names := []string{}
list, _ := clients.Indexes().List(ctx)
for list.Next(ctx) {
    names = append(names, list.Current.Lookup("name").StringValue())
}
fmt.Println("индексы:", names)
Ruby · mongoуникальный индекс на данных с повторами
puts "городов: #{clients.distinct("city").size} на #{clients.count_documents({})} покупателей"
begin
  clients.indexes.create_one({ "city" => 1 }, unique: true)
  puts "индекс по city создан"
rescue Mongo::Error::OperationFailure => e
  puts "индекс по city не создан, код #{e.code}"
end
puts "индексы: " + clients.indexes.map { |ix| ix["name"] }.to_s
Вывод в терминалрезультат запуска
городов: 5 на 20 покупателей
индекс по city не создан, код 11000
индексы: ['_id_', 'email_1']
Вывод в терминалрезультат запуска
городов: 5 на 20 покупателей
индекс по city не создан, код 11000
индексы: _id_ email_1
Вывод в терминалрезультат запуска
городов: 5 на 20 покупателей
индекс по city не создан, код 11000
индексы: [_id_ email_1]
Вывод в терминалрезультат запуска
городов: 5 на 20 покупателей
индекс по city не создан, код 11000
индексы: ["_id_", "email_1"]

Городов пять на 20 покупателей, значит, города повторяются. Индекс не создан, код ошибки тот же, что при вставке повтора, — 11000. В списке индексов коллекции его нет: там по-прежнему _id_ и email_1. Перед созданием уникального индекса на коллекции с данными повторы находят и устраняют.

В PyMongo отказ при постройке приходит тем же исключением DuplicateKeyError; пример ловит его через общий класс OperationFailure, от которого наследуются все ошибки сервера.

Отказ при постройке приходит ошибкой команды mongo.CommandError; код лежит в её поле Code.

§7Документ без поля и частичный индекс

В уникальный индекс заносятся все документы коллекции, в том числе те, в которых индексированного поля нет. Для такого документа в индекс записывается значение null — «значения нет».

Сравнение — картотека анкет, в которой у двух анкет не может совпадать графа «почта». Анкета с незаполненной графой тоже стоит в картотеке, и в этой графе у неё прочерк. Первый прочерк допустим. Второй прочерк совпадает с первым, и по правилу картотеки это повтор. С индексом так же: при уникальном индексе по email без почты может быть только один покупатель, второй даст повтор значения null. Пример добавляет двух покупателей без почты.

Если документы без поля допустимы, индекс делают частичным. Частичный индекс содержит не все документы коллекции: параметр partialFilterExpression задаёт условие, и в индекс попадают только документы, которые ему соответствуют. Условие {"email": {"$exists": true}} читается «поле email в документе есть». Документы без почты в такой индекс не заносятся, значений null в нём нет, и уникальность проверяется только среди документов с почтой.

Что записано в индексе email_1два покупателя без почты
Уникальный индекс
  1. null — Андрей Носов
  2. null — Вера Лосева
  3. user01@example.com — Анна Белова
  4. user02@example.com — Игорь Титов
  5. … ещё 18 адресов
Второе значение null — повтор, Вера Лосева отклонена
Частичный уникальный индекс
  1. user01@example.com — Анна Белова
  2. user02@example.com — Игорь Титов
  3. … ещё 18 адресов
Андрей Носов и Вера Лосева записаны в коллекцию, в индексе их нет

Параметры существующего индекса изменить нельзя. Поэтому пример удаляет email_1 и создаёт индекс заново, уже с условием.

Параметр передаётся в create_index именованным аргументом partialFilterExpression, значение — документ условия.

Параметр передаётся в create_one как partial_filter_expression:, значение — документ условия.

Условие задаёт SetPartialFilterExpression в той же цепочке options.Index(), что и SetUnique.

Условие задаёт partial_filter_expression объекта параметров. Объект хранит ссылку на документ условия, поэтому документ лежит в отдельной переменной has_email и живёт до создания индекса.

Python · PyMongoдокумент без поля и частичный индекс
from pymongo.errors import DuplicateKeyError

clients.insert_one({"_id": "c-102", "name": "Андрей Носов", "city": "Казань"})
try:
    clients.insert_one({"_id": "c-103", "name": "Вера Лосева", "city": "Казань"})
    print("второй покупатель без почты записан")
except DuplicateKeyError as e:
    print("второй покупатель без почты отклонён — уже есть", e.details["keyValue"])

clients.drop_index("email_1")
clients.create_index("email", unique=True, partialFilterExpression={"email": {"$exists": True}})

clients.insert_one({"_id": "c-103", "name": "Вера Лосева", "city": "Казань"})
print("покупателей без почты:", clients.count_documents({"email": {"$exists": False}}))
try:
    clients.insert_one({"_id": "c-104", "name": "Ольга Кравец", "email": "user01@example.com"})
except DuplicateKeyError as e:
    print("повтор почты отклонён — уже есть", e.details["keyValue"])
C++ · mongocxxдокумент без поля и частичный индекс
auto key_value = [](const mongocxx::operation_exception& e) {
    return bsoncxx::to_json(e.raw_server_error()->view()["writeErrors"][0]["keyValue"].get_document().value,
                            bsoncxx::ExtendedJsonMode::k_relaxed);
};

clients.insert_one(make_document(kvp("_id", "c-102"), kvp("name", "Андрей Носов"), kvp("city", "Казань")));
try {
    clients.insert_one(make_document(kvp("_id", "c-103"), kvp("name", "Вера Лосева"), kvp("city", "Казань")));
    std::cout << "второй покупатель без почты записан" << std::endl;
} catch (const mongocxx::operation_exception& e) {
    std::cout << "второй покупатель без почты отклонён — уже есть " << key_value(e) << std::endl;
}

clients.indexes().drop_one("email_1");
auto has_email = make_document(kvp("email", make_document(kvp("$exists", true))));
mongocxx::options::index partial;
partial.unique(true);
partial.partial_filter_expression(has_email.view());
clients.create_index(make_document(kvp("email", 1)), partial);

clients.insert_one(make_document(kvp("_id", "c-103"), kvp("name", "Вера Лосева"), kvp("city", "Казань")));
std::cout << "покупателей без почты: "
          << clients.count_documents(make_document(kvp("email", make_document(kvp("$exists", false))))) << std::endl;
try {
    clients.insert_one(make_document(kvp("_id", "c-104"), kvp("name", "Ольга Кравец"), kvp("email", "user01@example.com")));
} catch (const mongocxx::operation_exception& e) {
    std::cout << "повтор почты отклонён — уже есть " << key_value(e) << std::endl;
}
Go · mongo-driverдокумент без поля и частичный индекс
clients.InsertOne(ctx, bson.D{{Key: "_id", Value: "c-102"}, {Key: "name", Value: "Андрей Носов"}, {Key: "city", Value: "Казань"}})
_, err := clients.InsertOne(ctx, bson.D{{Key: "_id", Value: "c-103"}, {Key: "name", Value: "Вера Лосева"}, {Key: "city", Value: "Казань"}})
var we mongo.WriteException
if errors.As(err, &we) {
    fmt.Println("второй покупатель без почты отклонён — уже есть", we.WriteErrors[0].Raw.Lookup("keyValue"))
} else {
    fmt.Println("второй покупатель без почты записан")
}

clients.Indexes().DropOne(ctx, "email_1")
clients.Indexes().CreateOne(ctx, mongo.IndexModel{
    Keys: bson.D{{Key: "email", Value: 1}},
    Options: options.Index().SetUnique(true).
        SetPartialFilterExpression(bson.D{{Key: "email", Value: bson.D{{Key: "$exists", Value: true}}}})})

clients.InsertOne(ctx, bson.D{{Key: "_id", Value: "c-103"}, {Key: "name", Value: "Вера Лосева"}, {Key: "city", Value: "Казань"}})
n, _ := clients.CountDocuments(ctx, bson.D{{Key: "email", Value: bson.D{{Key: "$exists", Value: false}}}})
fmt.Println("покупателей без почты:", n)
_, err = clients.InsertOne(ctx, bson.D{{Key: "_id", Value: "c-104"}, {Key: "name", Value: "Ольга Кравец"}, {Key: "email", Value: "user01@example.com"}})
if errors.As(err, &we) {
    fmt.Println("повтор почты отклонён — уже есть", we.WriteErrors[0].Raw.Lookup("keyValue"))
}
Ruby · mongoдокумент без поля и частичный индекс
clients.insert_one({ "_id" => "c-102", "name" => "Андрей Носов", "city" => "Казань" })
begin
  clients.insert_one({ "_id" => "c-103", "name" => "Вера Лосева", "city" => "Казань" })
  puts "второй покупатель без почты записан"
rescue Mongo::Error::OperationFailure => e
  puts "второй покупатель без почты отклонён — уже есть #{e.document["writeErrors"][0]["keyValue"]}"
end

clients.indexes.drop_one("email_1")
clients.indexes.create_one({ "email" => 1 }, unique: true,
                           partial_filter_expression: { "email" => { "$exists" => true } })

clients.insert_one({ "_id" => "c-103", "name" => "Вера Лосева", "city" => "Казань" })
puts "покупателей без почты: " + clients.count_documents({ "email" => { "$exists" => false } }).to_s
begin
  clients.insert_one({ "_id" => "c-104", "name" => "Ольга Кравец", "email" => "user01@example.com" })
rescue Mongo::Error::OperationFailure => e
  puts "повтор почты отклонён — уже есть #{e.document["writeErrors"][0]["keyValue"]}"
end
Вывод в терминалрезультат запуска
второй покупатель без почты отклонён — уже есть {'email': None}
покупателей без почты: 2
повтор почты отклонён — уже есть {'email': 'user01@example.com'}
Вывод в терминалрезультат запуска
второй покупатель без почты отклонён — уже есть { "email" : null }
покупателей без почты: 2
повтор почты отклонён — уже есть { "email" : "user01@example.com" }
Вывод в терминалрезультат запуска
второй покупатель без почты отклонён — уже есть {"email": null}
покупателей без почты: 2
повтор почты отклонён — уже есть {"email": "user01@example.com"}
Вывод в терминалрезультат запуска
второй покупатель без почты отклонён — уже есть {"email"=>nil}
покупателей без почты: 2
повтор почты отклонён — уже есть {"email"=>"user01@example.com"}

Первый покупатель без почты записан, второй отклонён: в keyValue стоит почта со значением null (Python печатает его как None). После замены индекса частичным второй покупатель записан, покупателей без почты теперь двое. Повтор почты user01@example.com частичный индекс по-прежнему отклоняет.

Compass, вкладка Indexes коллекции sandbox.customers: email_1 со свойствами UNIQUE и PARTIAL, документов 22
Compass · sandbox.customers после замены индекса частичным

У нового email_1 два свойства: UNIQUE и PARTIAL — «частичный». Значок i рядом со свойством показывает условие индекса. Документов 22: к двадцати покупателям добавились двое без почты.

§8Какие поля индексировать

Индекс ускоряет чтение и замедляет запись. В книгу с тремя указателями новая страница добавляется в четыре действия: сама страница и запись в каждом указателе. Сервер работает так же: при каждой вставке, обновлении и удалении он меняет документ и каждый индекс коллекции.

У копии журнала после §4 два индекса. Вставка одного события состоит из трёх действий:

insert_oneв журнал добавляется одно событиезапись
коллекциядокумент записандействие 1
индекс _id_добавлена запись с новым _idдействие 2
индекс service_1_status_1запись вставлена на своё место по сервису и статусудействие 3

Индекс занимает место на диске. На копии журнала из 1200 событий составной индекс занимает 24,6 kB (кадр в §4), и размер растёт вместе с коллекцией. У одной коллекции может быть не больше 64 индексов.

Поэтому индексы строят под запросы, которые выполняются часто: поиск дежурного по сервису и статусу, вход покупателя по почте. Поле, по которому почти не ищут, индексировать не нужно.

Польза от индекса зависит от того, сколько разных значений у поля. У поля level три значения на 1200 событий, и значение info стоит у 777 из них. По индексу сервер получит 777 ссылок и прочитает 777 документов — больше половины коллекции. Указатель, в котором при слове записано больше половины страниц книги, чтение почти не сокращает. У почты покупателя каждое значение встречается один раз, и по индексу читается один документ.

Индекс создают для поля:

  • по которому часто ищут или сортируют;
  • у которого много разных значений;
  • значения которого не должны повторяться, — тогда индекс уникальный.

Индексировать можно и вложенные поля, и поля массивов. Ключ записывается точечной нотацией, как в фильтре (глава 2.1): {"delivery.city": 1} — индекс по городу доставки заказа. Если поле — массив или лежит внутри массива, как items.sku, сервер заносит в индекс каждый элемент отдельно. Такой индекс называется многоключевым (multikey) и находит заказ по любому из его артикулов. В предметном указателе одна страница тоже записана при нескольких словах — при каждом, которое на ней встречается.

§9Индексы в Compass

Кадры в разделах выше сняты на тех же коллекциях, что и примеры. Индексы коллекции в Compass находятся на вкладке Indexes; таблица индексов разобрана в §1.

Кнопка Create Index открывает окно создания индекса.

Compass, окно Create Index для sandbox.events: строка Index fields с полем и типом, кнопка плюс, раздел Options с параметрами Create unique index, Index name, Create TTL, Partial Filter Expression
Compass · окно Create Index, раздел Options раскрыт

В строке Index fields выбирается поле и тип: 1 или -1 — направление, как в коде. Кнопка + справа добавляет строку для следующего поля составного индекса. В разделе Options — параметры: Create unique index делает индекс уникальным, Index name задаёт своё имя вместо составленного сервером, Partial Filter Expression — условие частичного индекса. Остальные параметры окна в этой главе не используются. Кнопка Create Index внизу станет активной, когда выбрано хотя бы одно поле с типом.

Удаляется индекс из строки таблицы: при наведении курсора в конце строки появляются кнопки Hide Index (скрыть индекс от планировщика запросов, не удаляя) и Drop Index (удалить).

Использует ли запрос индекс, Compass показывает кнопкой Explain на вкладке Documents (кадр в §3): для запроса из поля фильтра строится тот же план, что возвращает метод explain, в виде дерева стадий.

§10Частые ошибки

Что написаноЧто происходитКак правильно
Индекс по status и service для запроса только по serviceПлан — COLLSCAN: service не первое поле индексаПервым в индексе ставить поле, которое есть во всех запросах: service, затем status
Индексы по service и по service + status одновременноПервый лишний: запросы по service обслуживает префикс составного, а запись обновляет обаУдалить service_1
Сортировка по полю, которого нет в индексе запросаВ плане стадия SORT: документы сортируются в памяти после чтенияДобавить поле сортировки в индекс после полей условия
Уникальный индекс на коллекции, где значения уже повторяютсяИндекс не создаётся: ошибка 11000 при постройкеНайти и устранить повторы, затем создать индекс
Уникальный индекс по полю, которого нет в части документовВторой документ без поля отклонён: в индексе уже есть nullЧастичный индекс с partialFilterExpression: {"email": {"$exists": true}}
Тот же ключ индекса с другими параметрами, например с uniqueОшибка IndexKeySpecsConflict (код 86): параметры существующего индекса не меняютсяУдалить индекс и создать заново с новыми параметрами
Удаление индекса _id_Ошибка cannot drop _id indexИндекс по _id есть у каждой коллекции, удалить его нельзя
Что написаноЧто происходитКак правильно
Индекс по status и service для запроса только по serviceПлан — COLLSCAN: service не первое поле индексаПервым в индексе ставить поле, которое есть во всех запросах: service, затем status
Индексы по service и по service + status одновременноПервый лишний: запросы по service обслуживает префикс составного, а запись обновляет обаУдалить service_1
Сортировка по полю, которого нет в индексе запросаВ плане стадия SORT: документы сортируются в памяти после чтенияДобавить поле сортировки в индекс после полей условия
Уникальный индекс на коллекции, где значения уже повторяютсяИндекс не создаётся: ошибка 11000 при постройкеНайти и устранить повторы, затем создать индекс
Уникальный индекс по полю, которого нет в части документовВторой документ без поля отклонён: в индексе уже есть nullЧастичный индекс с partialFilterExpression: {"email": {"$exists": true}}
Тот же ключ индекса с другими параметрами, например с uniqueОшибка IndexKeySpecsConflict (код 86): параметры существующего индекса не меняютсяУдалить индекс и создать заново с новыми параметрами
Удаление индекса _id_Ошибка cannot drop _id indexИндекс по _id есть у каждой коллекции, удалить его нельзя
Что написаноЧто происходитКак правильно
Индекс по status и service для запроса только по serviceПлан — COLLSCAN: service не первое поле индексаПервым в индексе ставить поле, которое есть во всех запросах: service, затем status
Индексы по service и по service + status одновременноПервый лишний: запросы по service обслуживает префикс составного, а запись обновляет обаУдалить service_1
Сортировка по полю, которого нет в индексе запросаВ плане стадия SORT: документы сортируются в памяти после чтенияДобавить поле сортировки в индекс после полей условия
Уникальный индекс на коллекции, где значения уже повторяютсяИндекс не создаётся: ошибка 11000 при постройкеНайти и устранить повторы, затем создать индекс
Уникальный индекс по полю, которого нет в части документовВторой документ без поля отклонён: в индексе уже есть nullЧастичный индекс с partialFilterExpression: {"email": {"$exists": true}}
Тот же ключ индекса с другими параметрами, например с uniqueОшибка IndexKeySpecsConflict (код 86): параметры существующего индекса не меняютсяУдалить индекс и создать заново с новыми параметрами
Удаление индекса _id_Ошибка cannot drop _id indexИндекс по _id есть у каждой коллекции, удалить его нельзя
Что написаноЧто происходитКак правильно
Индекс по status и service для запроса только по serviceПлан — COLLSCAN: service не первое поле индексаПервым в индексе ставить поле, которое есть во всех запросах: service, затем status
Индексы по service и по service + status одновременноПервый лишний: запросы по service обслуживает префикс составного, а запись обновляет обаУдалить service_1
Сортировка по полю, которого нет в индексе запросаВ плане стадия SORT: документы сортируются в памяти после чтенияДобавить поле сортировки в индекс после полей условия
Уникальный индекс на коллекции, где значения уже повторяютсяИндекс не создаётся: ошибка 11000 при постройкеНайти и устранить повторы, затем создать индекс
Уникальный индекс по полю, которого нет в части документовВторой документ без поля отклонён: в индексе уже есть nullЧастичный индекс с partialFilterExpression: {"email": {"$exists": true}}
Тот же ключ индекса с другими параметрами, например с uniqueОшибка IndexKeySpecsConflict (код 86): параметры существующего индекса не меняютсяУдалить индекс и создать заново с новыми параметрами
Удаление индекса _id_Ошибка cannot drop _id indexИндекс по _id есть у каждой коллекции, удалить его нельзя

§11Проверьте себя

Чем план COLLSCAN отличается от FETCH → IXSCAN?

При COLLSCAN сервер читает все документы коллекции и проверяет условие у каждого. При IXSCAN он находит нужные значения в упорядоченном индексе и получает ссылки на подходящие документы, а FETCH читает только их.

Есть индекс по service и status. Каким из запросов он поможет: по service, по status, по обоим полям?

Запросу по service и запросу по обоим полям. service — префикс индекса; записи с одним значением status разбросаны по разным сервисам, и запрос только по status выполняется просмотром коллекции.

Когда в плане появляется стадия SORT?

Когда порядок, заданный сортировкой, не совпадает с порядком записей в индексе, по которому выполнен запрос, — например, при сортировке по ts с индексом по service и status. Тогда сервер сортирует прочитанные документы в памяти.

Почему не создаётся уникальный индекс по city?

В коллекции уже есть покупатели с одинаковым городом. При постройке сервер проходит по всем документам и останавливается на первом повторе с ошибкой 11000.

Зачем уникальному индексу по email условие {"email": {"$exists": true}}?

Без условия документы без почты попадают в индекс со значением null, и второй такой документ даёт повтор. С условием они в индекс не попадают, и уникальность проверяется только среди документов с почтой.

§12Самостоятельная работа

Работа выполняется на языке, выбранном в шапке главы, в той же заготовке lessons/ch3_2. Нужна копия заказов магазина в песочнице: коллекция sandbox.orders_idx, в которую скопированы документы shop.orders (коллекция orders заготовки), — так же, как копия журнала в §1.

  1. Выполнить запрос без индексаПосчитать заказы, доставленные в Москву: {"delivery.city": "Москва", "status": "доставлен"}, и вывести план этого запроса функцией plan.
  2. Создать составной индексПо городу доставки и статусу, в этом порядке. Вывести имя индекса и план того же запроса.
  3. Проверить префиксВывести планы двух запросов: только по статусу "доставлен" и только по городу "Москва". Объяснить, почему планы разные.
  4. Создать уникальный индексПо номеру заказа number. Затем попробовать записать заказ с номером "2026-0001" и вывести код ошибки и keyValue.
Ответ для самопроверки

Заказов, доставленных в Москву, 12; план без индекса — COLLSCAN. Составной индекс получает имя delivery.city_1_status_1, после него план — FETCH → IXSCAN delivery.city_1_status_1. Запрос только по статусу выполняется просмотром коллекции (COLLSCAN), только по городу — по составному индексу: город — первое поле индекса, статус — второе. Уникальный индекс по номеру получает имя number_1 и создаётся без ошибок: номера заказов не повторяются. Заказ с номером 2026-0001 отклонён с кодом 11000, keyValue — номер "2026-0001".

§13Шпаргалка

Индекс по полю coll.create_index("service")
Составной индекс coll.create_index([("service", 1), ("status", 1)])
Уникальный индекс coll.create_index("email", unique=True)
Частичный индекс coll.create_index("email", unique=True, partialFilterExpression={"email": {"$exists": True}})
Список и удаление coll.list_indexes() coll.drop_index("service_1")
Повтор в уникальном индексе except DuplicateKeyError as e: e.code, e.details["keyValue"]
Индекс по полю coll.create_index(make_document(kvp("service", 1)));
Составной индекс coll.create_index(make_document(kvp("service", 1), kvp("status", 1)));
Уникальный индекс mongocxx::options::index o; o.unique(true);
Частичный индекс o.partial_filter_expression(has_email.view());
Список и удаление coll.list_indexes() coll.indexes().drop_one("service_1");
Повтор в уникальном индексе catch (const mongocxx::operation_exception& e) e.raw_server_error()->view()["writeErrors"][0]["keyValue"]
Индекс по полю coll.Indexes().CreateOne(ctx, mongo.IndexModel{ Keys: bson.D{{Key: "service", Value: 1}}})
Составной индекс Keys: bson.D{{Key: "service", Value: 1}, {Key: "status", Value: 1}}
Уникальный индекс Options: options.Index().SetUnique(true)
Частичный индекс options.Index().SetUnique(true). SetPartialFilterExpression(bson.D{...})
Список и удаление coll.Indexes().List(ctx) coll.Indexes().DropOne(ctx, "service_1")
Повтор в уникальном индексе errors.As(err, &we) we.WriteErrors[0].Raw.Lookup("keyValue")
Индекс по полю coll.indexes.create_one({ "service" => 1 })
Составной индекс coll.indexes.create_one({ "service" => 1, "status" => 1 })
Уникальный индекс coll.indexes.create_one({ "email" => 1 }, unique: true)
Частичный индекс coll.indexes.create_one({ "email" => 1 }, unique: true, partial_filter_expression: { "email" => { "$exists" => true } })
Список и удаление coll.indexes.each { |ix| ... } coll.indexes.drop_one("service_1")
Повтор в уникальном индексе rescue Mongo::Error::OperationFailure => e e.document["writeErrors"][0]["keyValue"]

Итог главы

Индекс хранит значения полей коллекции упорядоченно, со ссылками на документы, и позволяет серверу найти документы без просмотра всей коллекции: в плане запроса вместо COLLSCAN появляется IXSCAN с именем индекса и FETCH. Индекс по _id есть у каждой коллекции, остальные создаются явно и удаляются по имени. Составной индекс упорядочен по первому полю, затем по второму и помогает запросам, условия которых начинаются с его префикса; если сортировка совпадает с порядком индекса, стадии SORT в плане нет. Уникальный индекс отклоняет повтор значения с ошибкой 11000 и не создаётся на данных, где повторы уже есть; документ без поля попадает в индекс со значением null, и частичный индекс с условием partialFilterExpression исключает такие документы. Каждый индекс замедляет запись и занимает место, поэтому индексы строят под частые запросы.

Закрепить главу можно в практикуме «Инженер платформы: индексы журнала»: в нём выбирается тип индекса по описанию задачи, индекс под запрос проверяется по плану сервера, а размеры индексов измерены на журнале из 1 000 000 событий.

Глава 3.2 · Справочник по MongoDB Автор: Макаров Максим Николаевич