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

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

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

Урок 11 из 25 ~16 мин +50 XP

DeleteOne, DeleteMany и мягкое удаление

Удаление документов в mongo из Go выглядит самым простым CRUD-глаголом: coll.DeleteOne с фильтром — и записи нет. Пока команда не спросит, где история удалённых заказов. Разберём DeleteOne и DeleteMany с их DeletedCount, ловушку пустого фильтра, мягкое удаление через deleted_at — и спор о том, что реально выбирают на проде.

Содержание урока · 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 или по уникальному индексу, когда это возможно, — такие удаления точечны, дёшевы и не зависят от того, сколько документов накопилось в коллекции за ночь.

Два удаления: точечное и массовое

GO
func 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 через UpdateOneUpdateResultистория, восстановление, аудит

Пустой фильтр: главный риск удаления#

Фильтр bson.M{} в Find вернёт всю коллекцию, а в DeleteMany — её сотрёт. Разница в цене ошибки делает пустой фильтр delete-вызова особой строкой в код-ревью. Типовой сценарий аварии: функция чистки получает фильтр из переменной, переменная инициализируется позже и остаётся nil или пустой, DeleteMany честно выполняется по всем документам. Второй сценарий — параметризованная фильтрация с «опциональными» условиями: исключили всё, фильтр схлопнулся в пустой документ, и массовое удаление прошло по всей коллекции. Защита живёт на стороне кода: фильтры для удаления собирать отдельными функциями, которые не возвращают пустой документ в принципе; в критичных местах закладывать в фильтр обязательное окно по updated_at; и никогда не игнорировать число из лога «удалено N» — аномально большое N должно кричать в мониторинге. MongoDB, кстати, подтверждения не спросит: mongosh с его удалением по Enter — единственное место, где база переспросит, драйвер выполняет команду молча. Поэтому барьер обязан стоять в коде и в процессе ревью, а не в интерфейсе базы.

Мягкое удаление: поле 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

GO
type 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 с ручной чисткой. Плох только один вариант — «у нас удаление и так работает» без ответа на вопрос, кто и как посмотрит на удалённое завтра.

Соберите точечное удаление

Восстановите функцию: глагол удаления по _id, проверка счётчика и массовая чистка категории.

res, err := coll.(ctx, bson.M{"_id": id}) if err != nil { return err } if res. == 0 { return ErrNotFound } // стереть все черновики категории одним вызовом res2, err := coll.(ctx, bson.M{"category": "черновик", "status": "архив"})

Соберите метод жёсткого удаления

Расставьте строки метода по порядку: вызов, обработка ошибки, проверка счётчика, лог, возврат.

Строки

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

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

Карточки: удаление

Термин

DeleteOne

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

coll.DeleteOne(ctx, filter) → (*DeleteResult, error). Совпадений нет — DeletedCount 0 без ошибки; ErrNoDocuments у удаления не бывает.

1 / 6

Цена операции — последний штрих перед практикой. Удаление по _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 покажут и помеченные, и стёртые документы.

Личные заметкиПусто
Хранятся только в вашем браузере (MongoDB · DeleteOne, DeleteMany и мягкое удаление)

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

Чем DeleteOne отличается от FindOneAndDelete?

DeleteOne возвращает только счётчик DeletedCount — какие документы удалились, не узнать. FindOneAndDelete атомарно удаляет и возвращает сам документ через *SingleResult: удобно для логирования и возврата остатков. Двухшаговая пара Find плюс Delete такой атомарности не даёт: между чтением и удалением документ может изменить другой воркер, и данные окажутся рассинхронизированы. Разница и в опциях: у FindOneAndDelete есть SetSort — «удали самый старый» — и SetProjection для ответа; у DeleteOne — Hint, Comment, Collation и SetLet для переменных выражения.

Закрывает ли мягкое удаление требования закона о персональных данных?

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

Как не забывать условие deleted_at: nil в каждом фильтре?

Спрятать его в слой репозитория: метод-конструктор фильтра добавляет условие автоматически, а сырые вызовы коллекции из бизнес-кода запрещаются ревью. Полный разбор такого слоя — двадцать первый урок; до него помогает правило «одна функция собирает все фильтры коллекции» и страховка в тестах: конструктор, вернувший документ без ключа deleted_at, должен ронять проверку первым, а не прод.

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

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

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

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

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

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

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