Урок 05

CRUD в MongoDB

Create

Один документ:

db.users.insertOne({
    email: 'alice@example.com',
    name: 'Алиса',
    age: 30,
})

Много:

db.users.insertMany([
    { email: 'a@b.com', name: 'Алиса' },
    { email: 'c@d.com', name: 'Боб' },
], { ordered: false })

С возвратом:

const result = await users.insertOne({ ... })
result.insertedId  // ObjectId

Read

Все:

db.users.find()

Один:

db.users.findOne({ email: 'a@b.com' })

Проекция:

db.users.find(
    { is_active: true },
    { email: 1, name: 1, _id: 0 }
)

Сортировка + лимит:

db.users.find().sort({ created_at: -1 }).limit(10)

Пагинация:

const cursor = db.users
    .find()
    .sort({ created_at: -1 })
    .skip(20)
    .limit(10)

⚠️ Большой skip медленный. Для больших коллекций — cursor-based:

// page 1
db.users.find().sort({ _id: 1 }).limit(10)

// page 2 — после last_id
const last = '...'
db.users.find({ _id: { $gt: ObjectId(last) } }).sort({ _id: 1 }).limit(10)

Update

Один:

db.users.updateOne(
    { _id: userId },
    { $set: { name: 'Новое' } }
)

Много:

db.users.updateMany(
    { age: { $lt: 18 } },
    { $set: { is_minor: true } }
)

Upsert:

db.users.updateOne(
    { email: 'a@b.com' },
    { $set: { name: 'Алиса' } },
    { upsert: true }
)

Replace:

db.users.replaceOne(
    { _id: userId },
    { email: 'new@b.com', name: 'New' }
)
// ⚠️ Всё, что не указано — удаляется

Правило: $set для частичного обновления, replaceOne — только если точно нужна полная замена.

Delete

Один:

db.users.deleteOne({ _id: userId })

Много:

db.users.deleteMany({ is_inactive: true })

Всё:

db.users.deleteMany({})

⚠️ deleteMany({}) — удаляет всё, но индексы сохраняются.

Операторы запросов

Сравнение:

{ age: 25 }
{ age: { $eq: 25 } }
{ age: { $ne: 25 } }
{ age: { $gt: 18 } }
{ age: { $gte: 18 } }
{ age: { $lt: 65 } }
{ age: { $lte: 65 } }
{ age: { $in: [18, 25, 30] } }
{ age: { $nin: [1, 2, 3] } }

Логические:

{ $and: [{ age: { $gte: 18 } }, { country: 'RU' }] }
{ $or: [{ status: 'paid' }, { status: 'shipped' }] }
{ $nor: [{ a: 1 }, { b: 2 }] }
{ age: { $not: { $gt: 18 } } }

Существование и тип:

{ bio: { $exists: true } }
{ bio: { $exists: false } }
{ age: { $type: 'int' } }
{ age: { $type: ['int', 'double'] } }

Regex:

{ email: { $regex: '^a', $options: 'i' } }
// $options: i — case-insensitive, m — multiline, x — extended

// Или через литерал
{ email: /^a/i }

Массивы:

{ tags: 'php' }                  // содержит php
{ tags: ['php', 'js'] }          // точно этот массив
{ tags: { $all: ['php', 'js'] } } // содержит все
{ tags: { $size: 3 } }            // длина 3
{ tags: { $elemMatch: { $gt: 5, $lt: 10 } } }  // хотя бы один элемент подходит

Вложенные:

{ 'address.city': 'Москва' }              // точечная нотация
{ address: { city: 'Москва', street: 'X' } }  // точное совпадение документа (порядок важен)

Правило: для вложенных — точечная нотация. Точное совпадение документа — редко.

Логические операторы

$and:

{ $and: [{ age: { $gte: 18 } }, { age: { $lte: 65 } }] }

$or:

{ $or: [{ status: 'paid' }, { status: 'shipped' }] }

Комбинация:

{
    $and: [
        { $or: [{ status: 'paid' }, { status: 'shipped' }] },
        { created_at: { $gte: ISODate('2026-01-01') } },
    ],
}

Что важно: $and можно опустить при нескольких полях — они и так AND:

{ age: { $gte: 18 }, country: 'RU' }
// = { $and: [{ age: { $gte: 18 } }, { country: 'RU' }] }

⚠️ Если два условия на одно поле — $and обязателен:

// ❌ Ошибка — второй перезапишет первый
{ age: { $gte: 18 }, age: { $lte: 65 } }

// ✅
{ $and: [{ age: { $gte: 18 } }, { age: { $lte: 65 } }] }
// Или короче
{ age: { $gte: 18, $lte: 65 } }

Проекция

Включение:

{ email: 1, name: 1 }
// _id тоже включится

Исключение:

{ password: 0, metadata: 0 }

⚠️ Нельзя смешивать включение и исключение (кроме _id).

Убрать _id:

{ email: 1, _id: 0 }

Из массива:

{ 'items.name': 1 }
// вернёт items как массив объектов только с name

Slice:

{ comments: { $slice: 5 } }        // первые 5
{ comments: { $slice: -5 } }       // последние 5
{ comments: { $slice: [10, 5] } } // с 10-го, 5 штук

$elemMatch в проекции:

{ items: { $elemMatch: { price: { $gt: 100 } } } }
// вернёт только первый подходящий элемент

Сортировка

.sort({ created_at: -1 })
.sort({ age: 1, name: -1 })   // два поля

По вложенному:

.sort({ 'address.city': 1 })

По массиву:

.sort({ tags: 1 })   // по минимальному элементу

Правило: без sort порядок не определён.

Лимиты и курсоры

.find().limit(10)
.find().skip(20).limit(10)
.find().batchSize(100)   // размер батча по сети

Cursor — итератор:

const cursor = db.users.find({ is_active: true })
while (await cursor.hasNext()) {
    const doc = await cursor.next()
    // ...
}

Что даёт: не грузит всё в память сразу.

Count

db.users.countDocuments({ is_active: true })
db.users.estimatedDocumentCount()   // быстрее, но приблизительно

Разница:

  • countDocuments — точно, медленнее.
  • estimatedDocumentCount — из метаданных, быстро.

Distinct

db.users.distinct('country')
// [ 'RU', 'US', 'DE' ]

db.users.distinct('tags')
// все теги без дублей

Примеры запросов

Пользователи из России старше 18:

db.users.find({ country: 'RU', age: { $gte: 18 } })

Заказы за последние 7 дней, только оплаченные:

db.orders.find({
    status: 'paid',
    created_at: { $gte: new Date(Date.now() - 7 * 86400 * 1000) },
}).sort({ created_at: -1 })

Товары с тегом sale и ценой < 1000:

db.products.find({
    tags: 'sale',
    price: { $lt: NumberDecimal('1000') },
})

Пользователи без bio:

db.users.find({ bio: { $exists: false } })
// или
db.users.find({ bio: null })

⚠️ { bio: null } находит и null, и отсутствующие. $exists: false — только отсутствующие.

Производительность

explain():

db.users.find({ email: 'a@b.com' }).explain('executionStats')

Что смотреть:

  • executionStats.executionTimeMillis — время.
  • executionStats.totalDocsExamined — сколько прочитано.
  • executionStats.nReturned — сколько вернулось.
  • winningPlan.stage — COLLSCAN (плохо) или IXSCAN (хорошо).

Правило: totalDocsExamined / nReturned должно быть близко к 1.

Индекс:

db.users.createIndex({ email: 1 })
db.users.createIndex({ country: 1, age: -1 })
db.users.createIndex({ tags: 1 })
db.users.createIndex({ 'address.city': 1 })
db.users.createIndex({ email: 1 }, { unique: true })

Просмотр:

db.users.getIndexes()

Транзакции

С 4.0 (replica set):

const session = db.getMongo().startSession()
session.startTransaction()

try {
    const users = session.getDatabase('myapp').users
    const accounts = session.getDatabase('myapp').accounts

    await users.updateOne(
        { _id: fromId },
        { $inc: { balance: -100 } },
        { session }
    )
    await accounts.updateOne(
        { _id: toId },
        { $inc: { balance: 100 } },
        { session }
    )

    await session.commitTransaction()
} catch (e) {
    await session.abortTransaction()
    throw e
} finally {
    await session.endSession()
}

⚠️ Транзакции в MongoDB дорогие. Не использовать в hot path.

Правило: в 99% случаев структура документа решает проблему атомарности без транзакций.

Bulk write

db.users.bulkWrite([
    { insertOne: { document: { email: 'a@b.com' } } },
    { updateOne: { filter: { email: 'c@d.com' }, update: { $set: { name: 'X' } } } },
    { deleteOne: { filter: { email: 'x@y.com' } } },
])

Что даёт: одна операция, меньше round-trips.

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

1. find() без индекса:

COLLSCAN по миллионам

Решение: explain() + индекс.

2. $and не нужен:

{ $and: [{ a: 1 }, { b: 2 }] }   // = { a: 1, b: 2 }

Решение: упростить.

3. Два условия на одно поле без $and:

{ age: { $gte: 18 }, age: { $lte: 65 } }   // ошибка

Решение: { age: { $gte: 18, $lte: 65 } }.

4. skip больших значений:

skip(1000000) — медленно

Решение: cursor-based pagination.

5. replaceOne вместо $set:

Стирает поля

Решение: $set.

6. deleteMany({}) вместо drop():

Медленнее, индексы остаются

Решение: drop().

7. Транзакции для простых операций:

Дорого

Решение: документная структура решает.

8. countDocuments на миллионах:

Медленно

Решение: estimatedDocumentCount или кэш.

Практика

1. Создайте коллекцию, вставьте 5 документов.

2. Найдите по вложенному полю.

3. Обновите через $set, $inc, $push.

4. Удалите по условию.

5. Сделайте пагинацию через skip/limit.

6. Сделайте cursor-based пагинацию.

7. explain() до и после индекса.

8. Upsert: обновите существующего и создайте нового.

Итог

  • CRUD: insertOne, find, updateOne, deleteOne.
  • Операторы: $set, $inc, $push, $pull.
  • Запросы: $gt, $in, $exists, $regex, $elemMatch.
  • Проекция: включение или исключение.
  • Пагинация: cursor-based для больших.
  • explain() — анализ.
  • Индекс — обязателен для частых запросов.
  • Транзакции — дорого, только для критичного.
  • Bulk write — для массовых.

Дальше — запросы и агрегация.