Содержание урока · 10 разделов
Сценарий, знакомый каждому бэкенду: пользователь нажимает «удалить аккаунт», а через неделю пишет в поддержку «верните всё как было». Или бухгалтер удаляет накладную, а отчёт за квартал уже сверен с её цифрами, и аудиторы спрашивают, откуда она исчезла. В MongoDB за кнопкой «удалить» стоят два разных подхода. Первый — физическое удаление: DeleteOne стирает документ из коллекции безвозвратно, и от него не остаётся ничего, кроме записи в журнале сервера. Второй — мягкое удаление: документ остаётся, но получает метку deleted_at, и все читающие запросы перестают его видеть. Выбор между ними — не техническая мелочь, а продуктовое решение: он определяет, можно ли откатить операцию, что покажут отчёты за прошлый месяц, сколько диска съест коллекция и чем команда ответит на запрос о персональных данных. В этом уроке пройдём оба пути: синтаксис и опции delete-глаголов драйвера, счётчик DeletedCount, самую дорогую ловушку курса — пустой фильтр в DeleteMany, — и схему мягкого удаления, которую на проде чаще всего комбинируют с жёстким.
DeleteOne и DeleteMany: как удалять из Go#
Глагол удаления в драйвере самый скупой на церемонии: coll.DeleteOne(ctx, filter) возвращает (*DeleteResult, error) с одним полем DeletedCount. Совпадение не нашлось — счётчик равен нулю и ошибки нет: удаление, в отличие от FindOne из шестого урока, не отвечает ErrNoDocuments на пустоту. Семантика «удалил или нет» читается по числу, а ветка ошибки зарезервирована для настоящих проблем: сети, таймаутов, недоступности сервера — их разбор в двадцатом уроке. DeleteMany устроен так же, только стирает все совпавшие документы и возвращает их общее количество в том же DeleteResult. Оба принимают опции после фильтра: options.DeleteOne() и options.DeleteMany() дают SetHint для подсказки индекса, SetComment для метки в профайлере сервера и SetCollation для правил сравнения строк. Отдельное поле Acknowledged отвечает за подтверждённость записи: при обычной настройке write concern оно всегда true, а в непризнанных конфигурациях счётчик теряет смысл — это редкий режим, но знать о нём стоит до продакшена. Практическое правило любого mongo delete go-кода: удаляйте по _id или по уникальному индексу, когда это возможно, — такие удаления точечны, дёшевы и не зависят от того, сколько документов накопилось в коллекции за ночь.
Два удаления: точечное и массовое
GOfunc deleteProduct(ctx context.Context, coll *mongo.Collection, id bson.ObjectID) error {
res, err := coll.DeleteOne(ctx, bson.M{"_id": id})
if err != nil {
return fmt.Errorf("delete product: %w", err)
}
log.Printf("продуктов удалено: %d", res.DeletedCount)
// чистка архивных черновиков старше года — одним вызовом
purge, err := coll.DeleteMany(
ctx,
bson.M{
"status": "архив",
"updated_at": bson.M{"$lt": time.Now().AddDate(-1, 0, 0)},
},
options.DeleteMany().SetComment("чистка архивных черновиков"),
)
if err != nil {
return fmt.Errorf("purge archives: %w", err)
}
log.Printf("архивов удалено: %d", purge.DeletedCount)
return nil
}DeleteOne по _id — точечное удаление по индексу: даже на огромной коллекции это одна операция записи. DeleteMany стирает пакет и отчитывается общим числом в том же DeleteResult. SetComment помечает операцию — её видно в профайлере сервера среди медленных запросов. Ошибка здесь означает сеть или сервер: пустое совпадение ошибкой не будет никогда.
DeleteResult: счётчик вместо документа#
DeleteResult — тип-минималист: DeletedCount int64 и больше ничего. Из него принципиально нельзя узнать, какие именно документы были удалены: драйвер получает от сервера только число. Если перед удалением нужны данные документа — не «сначала Find, потом Delete», а FindOneAndDelete: он атомарно удаляет и возвращает документ через знакомый *SingleResult из шестого урока, с Decode и ErrNoDocuments на пустоте. У FindOneAndDelete и опции полезные: SetSort выбирает, какой из совпавших документов стереть, — «самый старый по _id» уходит одним вызовом без предварительного Find, а SetProjection ограничивает набор полей в ответе; сортировка там пишется тем же bson.D, что и везде. Двухшаговая схема Find плюс Delete теряет атомарность между шагами: другой воркер успеет удалить документ первым, и ваш Delete скажет DeletedCount 0 уже после того, как вы прочитали данные, — для логирования это мелочь, для возврата товара на склад — рассинхрон. Второе следствие скупости DeleteResult — безопасность повторов: повторное удаление того же _id вернёт ноль и не упадёт, поэтому ретраи безвредны, а бизнес-логика «документ точно был» должна опираться на счётчик, а не на отсутствие ошибки. Третье следствие — тесты: проверять удаление следует через DeletedCount, а не через отсутствие ошибки, иначе тест пропустит ситуацию «фильтр не совпал», которая на проде означает невыполненное требование.
Три инструмента убрать документ#
| Инструмент | Что делает | Возвращает | Когда брать |
|---|---|---|---|
| DeleteOne | удаляет один документ по фильтру | DeleteResult.DeletedCount | точечное удаление по _id или уникальному ключу |
| DeleteMany | удаляет все совпавшие документы | DeleteResult.DeletedCount | массовая чистка по условию |
| FindOneAndDelete | удаляет и возвращает документ | *SingleResult | нужны данные документа: лог, возврат остатков |
| Мягкое удаление | $set deleted_at через UpdateOne | UpdateResult | история, восстановление, аудит |
Пустой фильтр: главный риск удаления#
Фильтр bson.M{} в Find вернёт всю коллекцию, а в DeleteMany — её сотрёт. Разница в цене ошибки делает пустой фильтр delete-вызова особой строкой в код-ревью. Типовой сценарий аварии: функция чистки получает фильтр из переменной, переменная инициализируется позже и остаётся nil или пустой, DeleteMany честно выполняется по всем документам. Второй сценарий — параметризованная фильтрация с «опциональными» условиями: исключили всё, фильтр схлопнулся в пустой документ, и массовое удаление прошло по всей коллекции. Защита живёт на стороне кода: фильтры для удаления собирать отдельными функциями, которые не возвращают пустой документ в принципе; в критичных местах закладывать в фильтр обязательное окно по updated_at; и никогда не игнорировать число из лога «удалено N» — аномально большое N должно кричать в мониторинге. MongoDB, кстати, подтверждения не спросит: mongosh с его удалением по Enter — единственное место, где база переспросит, драйвер выполняет команду молча. Поэтому барьер обязан стоять в коде и в процессе ревью, а не в интерфейсе базы.
Скрипт чистки тестовых заказов, дописанный под вечер, уезжает на прод с bson.M{} вместо bson.M{"status": "test"} — и утро начинается с пустой коллекции. Обязательность фильтра никто не проверит за вас: держите правило ревью «delete-вызов с литеральным пустым фильтром — блокер», собирайте фильтры через именованные функции, а не литералами на месте вызова, и требуйте вторую подпись на скрипты с DeleteMany.
Мягкое удаление: поле deleted_at#
Мягкое удаление — не отдельный глагол драйвера, а соглашение: документ помечается меткой времени, и читающие запросы фильтруют метку. Модель получает DeletedAt *time.Time с тегом deleted_at,omitempty: nil значит «жив», указатель — потому что нулевое время и «метки нет» разные вещи, эта история уже была в пятом уроке. Само удаление — UpdateOne с $set: bson.M{"deleted_at": time.Now()}. А вот читающие запросы меняются все без исключения: каждый Find обязан добавлять условие "deleted_at": nil, иначе мёртвые документы просачиваются в выдачи. Полдюжины мест, где надо помнить об условии, — полдюжины шансов забыть, поэтому условие прячут в слой репозитория: метод-конструктор фильтра добавляет его автоматически, а сырые вызовы коллекции из бизнес-кода запрещают линтом и ревью. Приём называется soft delete, и mongodb-сообщество сходится в одном: пометка — половина решения, вторая половина — дисциплина чтения. Без неё мягкое удаление превращается в медленную утечку данных в отчёты: помеченные записи всплывают в списках, счётчиках и экспортах, а исправлять это приходится точечно, по мере жалоб.
Soft delete через UpdateOne
GOtype Product struct {
ID bson.ObjectID `bson:"_id,omitempty"`
Name string `bson:"name"`
DeletedAt *time.Time `bson:"deleted_at,omitempty"`
}
func (r *ProductRepo) SoftDelete(ctx context.Context, id bson.ObjectID) error {
now := time.Now()
res, err := r.coll.UpdateOne(
ctx,
bson.M{"_id": id, "deleted_at": nil},
bson.M{"$set": bson.M{"deleted_at": now}},
)
if err != nil {
return fmt.Errorf("soft delete: %w", err)
}
if res.MatchedCount == 0 {
return ErrNotFound // уже удалён или не существует
}
return nil
}Мягкое удаление — это UpdateOne из девятого урока: $set переводит deleted_at из nil в метку времени. Условие deleted_at: nil в фильтре делает вызов идемпотентным: повторный вызов ничего не найдёт и вернёт MatchedCount 0, что репозиторий переводит в ErrNotFound. Указатель *time.Time различает «метки нет» и нулевое время — тот же приём, что в пятом уроке.
Когда мягкое удаление оправдано#
- Документы связаны ссылками: заказы, платежи, отзывы — разрыв ссылок дороже диска.
- Нужны отчёты по истории: «что удаляли в марте» отвечает только пометка.
- Пользователь может восстановить запись сам: корзина, черновики, отписка от рассылки.
- Удаление — часть процесса с оспариванием: модерация, возвраты, жалобы.
- Ссылочная целостность держится на приложении, а не на базе.
- Не оправдано: персональные данные по требованию закона — здесь нужно физическое стирание.
Проверка: DeleteOne по несуществующему _id
coll.DeleteOne(ctx, bson.M{"_id": id}): документа с таким _id нет. Что вернёт вызов?
Проверка: пустой фильтр
Что сделает coll.DeleteMany(ctx, bson.M{})?
Что напечатают три удаления?
Прочитайте код и выберите, что он выведет:
// в коллекции 3 документа со статусом «архив»
r1, _ := coll.DeleteOne(ctx, bson.M{"status": "архив"})
r2, _ := coll.DeleteOne(ctx, bson.M{"status": "архив"})
r3, _ := coll.DeleteMany(ctx, bson.M{"status": "архив"})
fmt.Println(r1.DeletedCount, r2.DeletedCount, r3.DeletedCount)Что выбирают на проде: спор без конца#
Физическое удаление дешевле в поддержке: никакой лишней логики в каждом запросе, никакой растущей коллекции, и закон о персональных данных получает то, что просит, — данные действительно исчезают. Мягкое удаление покупает восстановление и историю: «случайно удалили номенклатуру» лечится одним апдейтом вместо подъёма бэкапа за ночь, аудиторам можно показать, что и когда помечалось, а основная коллекция остаётся единственным источником правды — точечное восстановление не требует поднимать копию базы целиком. На проде чаще всего живёт комбинация: пометка deleted_at сразу и физическое стирание позже — фоновая задача по расписанию или по запросу субъекта данных. Стирание старьёвки удобно делегировать серверу: TTL-индекс удаляет документы по истечении срока, и семнадцатый урок покажет его через CreateOne. Запрос «mongo delete go» приводит в этот раздел чаще всего не за синтаксисом, а за ответом «а как у вас»: у платежных записей долгая жизнь и редкое удаление, корзины и сессии умирают по TTL с рождения, каталоги живут на soft delete с ручной чисткой. Плох только один вариант — «у нас удаление и так работает» без ответа на вопрос, кто и как посмотрит на удалённое завтра.
Уникальный индекс на email и мягкое удаление ссорятся: пользователь удалился, поле осталось — повторная регистрация упрётся в duplicate key. Решение — частичный уникальный индекс с условием deleted_at: nil: уникальность действует только среди живых. Создание индексов и partialFilterExpression — семнадцатый урок, схема коллекции с уникальностью — двадцать второй.
Соберите точечное удаление
Восстановите функцию: глагол удаления по _id, проверка счётчика и массовая чистка категории.
Соберите метод жёсткого удаления
Расставьте строки метода по порядку: вызов, обработка ошибки, проверка счётчика, лог, возврат.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: удаление
Цена операции — последний штрих перед практикой. Удаление по _id всегда дёшево: значение лежит в обязательном индексе по _id, сервер находит документ за один шаг и не зависит от размеров коллекции. DeleteMany по неиндексированному полю — совсем другая история: серверу придётся просканировать коллекцию, чтобы найти совпадения, а на больших объёмах это минуты работы диска и ощутимая нагрузка на кэш. Фильтры массовой чистки проектируйте под индексы, как и читающие запросы, — и проверяйте их глазами в mongosh до запуска, а не после. Отдельная тема — удаления в горячих путях: DeleteOne на каждый запрос пользователя спокойно живёт рядом с FindOne, а вот DeleteMany в хендлере — признак того, что чистку пора выносить в фоновую задачу. Перед запуском чистки проверяйте фильтр тем же условием в Find: совпавший набор — ровно то, что стёрлось бы, и разница между «пятью сотнями» и «пятью сотнями тысяч» видна до катастрофы, а не после. На этом CRUD-глаголы собраны в полный набор: читать, писать, менять, удалять и «обновить или вставить» из десятого урока. Дальше — два урока про то, что крутится вокруг списка: в уроке про ObjectID вскроем 12 байт _id, который вы передавали в DeleteOne, а в уроке про проекции, сортировку и лимиты — как отдавать списки без лишних полей и страницами. Мягкое удаление потребует индексного хозяйства: урок про CreateOne и текстовый поиск покажет TTL и частичные индексы, а слой репозитория спрячет условие deleted_at в одном месте. Проверять сценарии удаления лучше на живой базе: песочница сайта — тематическая программа на чистом Go, а драйвер с MongoDB в Docker ставятся двумя командами из второго урока.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- DeleteOne и DeleteMany возвращают DeleteResult с единственным полем DeletedCount; отсутствие совпадений — не ошибка.
- DeleteMany с пустым фильтром стирает всю коллекцию — пустой фильтр в delete-коде под запретом ревью.
- Нужен сам документ перед удалением — FindOneAndDelete вернёт его одной атомарной операцией.
- Мягкое удаление — UpdateOne с $set deleted_at; указатель *time.Time различает «нет метки» и нулевое время.
- Каждый читающий запрос обязан добавлять условие deleted_at: nil — надёжнее прятать его в слое репозитория.
- Уникальный индекс с мягким удалением конфликтует: дубликат вернёт повторная регистрация email — лечится частичным индексом.
- На проде обычно комбинируют: пометка сразу, физическое стирание позже — по расписанию или по TTL.
Домашнее задание
Переведите коллекцию товаров на мягкое удаление: добавьте DeletedAt *time.Time с тегом deleted_at,omitempty, напишите SoftDelete через UpdateOne и список-метод, который автоматически добавляет условие deleted_at: nil. Затем добавьте жёсткую чистку: DeleteMany по документам, помеченным больше месяца назад. Проверьте идемпотентность: повторный SoftDelete того же идентификатора должен вернуть ErrNotFound, а не ошибку. Всё прогоните локально: контейнер MongoDB из второго урока и mongosh покажут и помеченные, и стёртые документы.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем DeleteOne отличается от FindOneAndDelete?
DeleteOne возвращает только счётчик DeletedCount — какие документы удалились, не узнать. FindOneAndDelete атомарно удаляет и возвращает сам документ через *SingleResult: удобно для логирования и возврата остатков. Двухшаговая пара Find плюс Delete такой атомарности не даёт: между чтением и удалением документ может изменить другой воркер, и данные окажутся рассинхронизированы. Разница и в опциях: у FindOneAndDelete есть SetSort — «удали самый старый» — и SetProjection для ответа; у DeleteOne — Hint, Comment, Collation и SetLet для переменных выражения.
Закрывает ли мягкое удаление требования закона о персональных данных?
Нет: право на забвение отвечает именно физическое стирание — помеченные документы остаются в файлах и бэкапах. Практичный компромисс — пометка deleted_at сразу и фоновая процедура окончательного стирания по расписанию, с логом того, что и когда было стёрто. Срок между пометкой и стиранием команда фиксирует в политике хранения данных, чтобы аудитору было с чем сверяться.
Как не забывать условие deleted_at: nil в каждом фильтре?
Спрятать его в слой репозитория: метод-конструктор фильтра добавляет условие автоматически, а сырые вызовы коллекции из бизнес-кода запрещаются ревью. Полный разбор такого слоя — двадцать первый урок; до него помогает правило «одна функция собирает все фильтры коллекции» и страховка в тестах: конструктор, вернувший документ без ключа deleted_at, должен ронять проверку первым, а не прод.
Термины курса
Понятия, на которых строится этот курс, — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылок на практику.
Изучайте дальше
Следующая ступень учебного пути
Zerolog в Go: быстрое JSON-логирование
Курс zerolog для Go: JSON-события, уровни, поля, ConsoleWriter, контекстные поля, ротация, sampling, HTTP-логи и тесты. 25 уроков с примерами.
- Писать структурированные JSON-логи с нуля аллокаций
- Выбирать уровни и поля события осознанно
- Настраивать ConsoleWriter для dev и JSON для прода
- Строить сублоггеры и контекстные поля на запрос