MongoDB Go Driver v2: миграция, которая не должна была быть такой болезненной

На днях я с горем пополам перетащил несколько своих Go-проектов с MongoDB Go драйвера v1 на v2. Линейку v1.x перестали поддерживать в начале 2026-го, т.е. больше никаких патчей безопасности, и выбора особо не было — переезжать пришлось. То, что я обнаружил на другой стороне, оказалось миграцией куда более болезненной, чем она должна быть, с кучей откровенно странных решений в дизайне и мажорным обновлением, которое умудрилось упустить главную возможность Go за последнее десятилетие — дженерики.

Это не руководство по миграции. У MongoDB есть такое руководство, и есть инструмент (marwan-at-work/mod), который справляется со сменой пути импорта с go.mongodb.org/mongo-driver на go.mongodb.org/mongo-driver/v2. Тут про решения, которые превратили миграцию в головную боль без причины, и про упущенные возможности.

немного истории: это уже вторая принудительная миграция

Но чтобы понять масштаб проблемы с v2, надо знать предысторию. Официального драйвера MongoDB для Go когда-то не было, зато было кое-что получше — драйвер, написанный энтузиастом.

mgo был создан Густаво Нимейером в конце 2010-го и публично анонсирован в марте 2011-го. Идеальным он не был, свои косяки и причуды водились, но он работал, API было вполне понятным, и все, кто работал с MongoDB из Go, на него пересели. Семь лет mgo оставался единственным вариантом для работы с MongoDB из Go. Сама MongoDB Inc. использовала mgo внутри, их mongo-tools были построены на нём, и они даже спонсировали проект начиная с 2011 года.

В начале 2018-го Густаво перестал поддерживать mgo. Он ушёл от MongoDB в своих проектах, а бесплатная поддержка чужого проекта — дело неблагодарное. Форк от сообщества (globalsign/mgo) какое-то время держался, но тоже в итоге заглох.

Реакция MongoDB? Они начали официальный Go-драйвер в начале 2018-го, выпустив первую альфу в феврале. Прошёл длинный период бета-тестирования, и наконец v1.0 GA вышла в марте 2019 — через год после того, как автор mgo отошёл от дел, и через восемь лет после создания mgo.

И вот что обидно: API официального драйвера было похоже на mgo ровно настолько, чтобы казаться знакомым, но переписывать пришлось всё равно всё. Не замена один-в-один, не форк с расширениями, а реализация с нуля со своими собственными соглашениями. Go-сообществу пришлось переписать весь MongoDB-код один раз для миграции с mgo на официальный v1. Теперь, с v2, просят сделать это снова.

Три драйвера, две принудительные миграции, ноль обратной совместимости. Вот такая вот предыстория.

налог на миграцию

Объём ломающих изменений впечатляет, и не в хорошем смысле. Целые пакеты удалены: primitive, description, gridfs, bsonrw, bsonoptions. Типы переименованы, сигнатуры методов изменены, типы ошибок убраны, билдеры опций переработаны, константы событий переименованы. Метод Client.Connect() исчез. SessionContext заменён на обычный context.Context. Collection.Clone() больше не возвращает ошибку. Distinct() возвращает другой тип. InsertMany() принимает другой параметр.

На маленьком проекте это раздражает, но терпимо. А вот на большой кодовой базе с сотнями точек взаимодействия с MongoDB, а особенно для общих библиотек, от которых зависят другие сервисы, это изменение на несколько дней по принципу «всё или ничего». Нет слоя совместимости, нет прослойки от v1 к v2, нет возможности мигрировать по частям — всё должно измениться разом. Даже мелочи вроде того, что mongo.Connect() больше не принимает контекст (теперь его передают в Ping), требуют изменений в каждом месте, где настраивается подключение.

пакет primitive: создан, чтобы быть убитым

В v1 MongoDB вынесла bson/primitive в отдельный пакет с типами ObjectID, Timestamp, DateTime и другими базовыми типами. Каждый Go-проект с MongoDB импортировал этот пакет повсюду: модели, хендлеры, сервисы, утилиты. Он использовался везде в любой нетривиальной кодовой базе.

В v2 они слили всё обратно в bson. Массовый поиск и замена по каждому файлу, который использует типы MongoDB. Вопрос очевиден: зачем это вообще было отдельным пакетом? Если ответ «это была ошибка» — ладно, все совершают ошибки проектирования. Но за эту ошибку теперь платит каждый пользователь, и никто даже не признал эту цену.

регрессия производительности, которую никто не поймал

v2.0 вышла в январе 2025-го как GA, готовая для продакшена, с примерно 20-25% более медленным BSON-анмаршалингом и значительно более высоким потреблением памяти (до 3-4x в бенчмарках). Самая базовая операция драйвера — чтение данных — и они умудрились сделать её медленнее. Причиной стала потоковая поддержка в BSON valueReader, и потребовалось время до v2.3 в августе 2025, чтобы это исправить. Семь месяцев, в течение которых собственные release notes MongoDB признавали «регрессию в v2.0».

Как регрессия производительности в основном пути чтения данных проходит через тестирование мажорного релиза? Бенчмарков что ли не было? Или они были, но на результаты не посмотрели? Такие вещи подрывают доверие к тому, как команда драйвера тестирует свои релизы.

налог InsertMany: пять лет бойлерплейта

InsertMany в v1 требовал []any, что означало, что каждый Go-разработчик писал такое:

docs := make([]any, len(myDocs))
for i, d := range myDocs { docs[i] = d }

Известный антипаттерн в Go. Все, кто писал Go-код с MongoDB, писали этот цикл сотни раз. Дженерики в Go появились в марте 2022-го с Go 1.18. Драйвер v2 вышел в январе 2025-го. Исправление (принимать any вместо []any) это лучше, но потребовался мажорный релиз, чтобы решить проблему, которая была известна пять лет.

SessionContext: кастомный контекст, которого не должно было быть

mongo.SessionContext в v1 был пользовательским типом, который встраивал и context.Context, и mongo.Session. Это нарушало базовое соглашение Go: context.Context передаётся как есть, а значения хранятся через context.WithValue. Обернуть контекст и заставить всех делать type assertions по всему коду? Такое на ревью даже джуниору бы завернули.

v2 исправила это, используя обычный context.Context с SessionFromContext(). Направление правильное, но реализация вышла так себе. Вот пример из официального руководства по миграции:

client.UseSession(context.TODO(), func(ctx context.Context) error {
    sess := mongo.SessionFromContext(ctx)
    if err := sess.StartTransaction(options.Transaction()); err != nil {
        return err
    }
    _, err = coll.InsertOne(ctx, bson.D{{"x", 1}})
    return err
})

UseSession засовывает сессию в контекст, и первое, что ты делаешь — вытаскиваешь её обратно через SessionFromContext. Запаковали, распаковали. Колбэк мог бы просто получать и контекст, и сессию отдельными аргументами, но нет — вместо этого играем в эту игру с упаковкой-распаковкой. Церемонии не стало меньше, она просто поменяла форму.

паттерн опций: не совсем идиоматичный

v1 использовал цепочку методов: options.Find().SetLimit(10).SetSort(...). Не как в большинстве Go-библиотек, но хотя бы привычно. v2 сохраняет ту же внешнюю поверхность, но внутри перешёл на паттерн с функциями-сеттерами: опции теперь неизменяемы после создания, для хранения используется options.Lister[T] вместо конкретных типов, а все функции Merge*Options (кроме MergeClientOptions) убраны. Вся остальная Go-экосистема давно пришла к двум паттернам: функциональные опции (WithLimit(10)) или простые структуры. Подход MongoDB так и остался их собственным изобретением.

Я, в общем, постоянно возвращаюсь к одному вопросу: команда Go-драйвера вообще пишет на Go каждый день, или они в основном работают на других языках и тащат оттуда паттерны? Дизайн SessionContext, структура пакета primitive… ничего из этого не похоже на решения людей, живущих в Go-экосистеме.

таймауты: упрощено, но менее гибко

Исчезли SocketTimeout, MaxTime для каждой операции и другие гранулярные настройки таймаутов. v2 заменила всё это двумя механизмами: SetTimeout() на уровне клиента (модель CSOT) и context.WithTimeout() для каждой операции. Философия здравая — таймауты через контекст это Go way, а CSOT даёт разумный дефолт для всего клиента. Но гранулярность по операциям исчезла. Если таймаут клиента 5 секунд, а один конкретный aggregation pipeline реально требует 30, нужно оборачивать этот вызов в свой контекст. Не катастрофа, но это дополнительный бойлерплейт на каждом вызове, который отклоняется от дефолта.

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

Вот это самое коварное, т.к. ошибок компиляции тут нет, только неправильное поведение в рантайме.

В v1, когда вы декодировали BSON-документ в значение типа any, вложенные документы возвращались как bson.M — мапа, к которой можно обращаться через result["field"]. В v2 они возвращаются как bson.D — упорядоченный слайс элементов bson.E. Код компилируется. Проходит ревью. А потом паникует в рантайме:

raw := bson.M{}
coll.FindOne(ctx, filter).Decode(&raw)

// v1: raw["address"] это bson.M → raw["address"].(bson.M)["city"] работает
// v2: raw["address"] это bson.D → raw["address"].(bson.M)["city"] ПАНИКА

Логика такая: bson.D сохраняет порядок полей, что важно для некоторых команд MongoDB. Ладно. Но 99% прикладного кода не интересует порядок полей, он читает пользовательские данные, а не конструирует кастомные команды MongoDB. Те, кому нужен порядок полей — ничтожное меньшинство, и они и так всегда использовали bson.D.

Молча поменяли поведение по умолчанию, без предупреждения на этапе компиляции, на пути, по которому идёт каждый пользователь — и ради кейса, который почти никому не нужен. Возможность отказаться есть, но нужно о ней знать и установить глобально. На практике каждая обёрточная библиотека, мигрировавшая на v2 и желающая обратной совместимости, заканчивает чем-то таким в настройке подключения:

opts.SetBSONOptions(&options.BSONOptions{DefaultDocumentM: true}) // bson.M для обратной совместимости с v1

Этот комментарий — «для обратной совместимости с v1» — говорит сам за себя. А если где-то в кодовой базе используется map[string]any вместо bson.M, нужна ещё одна опция (DefaultDocumentMap). Два отдельных параметра, чтобы отменить одно изменение дефолта, о котором никто не просил.

инцидент с экосистемой

Это не про API драйвера как таковое, но хорошо иллюстрирует хрупкость цепочки зависимостей. Дэвид Голден, инженер MongoDB, перенёс личные Go-библиотеки (xdg/scram, xdg/stringprep), от которых зависел драйвер, из github.com/xdg в github.com/xdg-go. Изменение личного пространства имён сломало go get -u для всех, а прокси Go-модулей закешировал сломанное состояние. Доступность продакшен-драйвера базы данных зависит от решения о переименовании личного пространства на GitHub. Так себе архитектура зависимостей, мягко говоря.

главная упущенная возможность: дженерики

v2.0 вышла в январе 2025-го, почти через три года после появления дженериков в Go. Драйвер и так ломал всё: новый путь импорта, переименованные типы, переработанные API. Если уж заставляешь всех переписывать весь MongoDB-код, тут-то и надо было сделать всё правильно. v2 использует дженерики в одном месте (options.Lister[T] для внутренней механики опций), но там, где это действительно важно, в типах документов и результатах запросов, всё по-прежнему any.

Что могли бы дать дженерики:

Типизированные коллекции. Вместо декодирования в any и надежды на лучшее:

// текущий v2 — ручной decode, никакой типобезопасности
coll := db.Collection("users")
var user User
err := coll.FindOne(ctx, filter).Decode(&user)

// что могло бы быть
coll := mongo.TypedCollection[User](db, "users")
user, err := coll.FindOne(ctx, filter)  // возвращает User напрямую

Типизированные запросы. Синтаксис bson.D{{"age", bson.D{{"$gt", 25}}}} — одна из самых частых жалоб на Go-драйвер с момента его создания. Дженерики могли бы позволить:

filter := query.Field[User]("age").Gt(25)

Опечатки в именах полей ловятся на этапе компиляции. В C#-драйвере MongoDB это есть уже давно.

Типизированные вставки. InsertMany теперь принимает any — лучше, чем []any, но всё ещё без типов. С дженериками вставка Post в коллекцию User была бы ошибкой компиляции, а не сюрпризом в рантайме.

Типизированные результаты агрегаций. Сейчас пайплайны агрегации возвращают []bson.M — полностью нетипизированные, требующие ручных type assertions для каждого поля. С дженериками определяешь структуру результата и получаешь её напрямую.

На практике каждая команда, которую я знаю и которая мигрировала на v2, в итоге писала свои дженерик-обёртки (Reader[T], BufferedWriter[T], типизированные хелперы для курсоров), потому что драйвер их не предоставляет. Код, который команда драйвера должна была написать один раз, тысячи команд пишут независимо. Потребность очевидна. Но команда драйвера решила это проигнорировать, хотя момент был идеальный: мажорная версия, которая и так ломала всё.

А v3 планируется? Потому что если да, это будет уже третий раунд «перепишите весь ваш MongoDB-код» для Go-сообщества.

что из этого следует

Ну и ирония в том, что у Go-сообщества когда-то был драйвер, который при всех его недостатках все использовали, и он со своей задачей справлялся. MongoDB позволила этому драйверу умереть, потратила восемь лет на создание официальной замены, сделала её достаточно отличающейся, чтобы потребовать полного переписывания, а затем через шесть лет выпустила v2, которая требует ещё одного полного переписывания.

Большинство ломающих изменений v2 имеют общий корень: драйвер v1 был спроектирован без соблюдения соглашений Go, а v2 расплачивается за исправление этих изначальных ошибок дизайна, попутно внося несколько новых. SessionContext не должен был существовать. primitive не должен был быть отдельным пакетом. Паттерн опций не должен был быть цепочкой методов. Всё это вещи, на которые Go-сообщество указало бы в первый же день — и, скорее всего, указало, да кто ж слушал.

Миграция на v2 была шансом принять дженерики, предоставить типизированные API, подтянуть developer experience до уровня других современных Go-библиотек. Вместо этого это большой налог за исправление прошлых ошибок, а будущее по-прежнему на паузе.

Для тех, кто планирует миграцию: закладывайте больше времени, чем кажется нужным. Инструмент для смены пути импорта справляется с лёгкой частью. Изменения API, молчаливая смена поведения декодирования и общая переработка поверхности API — вот где живёт настоящая работа. Тестируйте тщательно в рантайме, а не только компиляцию. И установите DefaultDocumentM глобально, если у вас нет конкретной причины этого не делать.

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

Этот пост переведён с английского оригинала с помощью AI и проверен человеком.