Обе отличные, обе зрелые, и обе потянут всё, что вы строите. Тот, кто говорит, что одна из них просто лучше, не слышал уточняющего вопроса. Выбор имеет значение на краях, а края наступают позже, чем вы думаете: обычно в первый раз, когда нужно задать вопрос, под который схема не проектировалась, или когда недописанная запись впервые стоит вам денег.
Коротко: если ваши данные - это сущности, ссылающиеся друг на друга, и вам понадобится задавать незапланированные вопросы, берите PostgreSQL. Если ваши данные - самодостаточные документы, различающиеся по форме, читаемые целиком и записываемые целиком, MongoDB будет приятна. Если вы действительно не можете решить, берите PostgreSQL, потому что он умеет хранить документы, а обратное верно куда меньше. Остальная часть статьи - рассуждение и конкретика, позволяющая проверить его на вашем приложении.
Единственное различие, из которого следует всё остальное#
PostgreSQL хранит строки в таблицах с фиксированным набором типизированных столбцов, а связи между таблицами выражаются ключами и разрешаются при запросе через join. MongoDB хранит документы BSON в коллекциях, каждый документ самодостаточен, внутри него вложенные объекты и массивы, а связи либо встроены, либо разрешаются вторым запросом.
Из этого вытекает всё остальное: транзакции, схема, индексирование, сколько памяти нужно каждой, как вы обновляетесь. Конкретный пример делает это очевидным. Заказ с тремя позициями и покупателем:
-- PostgreSQL: three tables, one querySELECT o.id, o.placed_at, u.email, i.sku, i.quantity, i.price_centsFROM orders oJOIN users u ON u.id = o.user_idJOIN order_items i ON i.order_id = o.idWHERE o.id = 4821;// MongoDB: one document, one readdb.orders.findOne({ _id: 4821 })// {// _id: 4821, placedAt: ISODate("2026-04-02T10:11:00Z"),// user: { id: 77, email: "a@example.com" },// items: [// { sku: "AB-1", quantity: 2, priceCents: 1200 },// { sku: "CD-9", quantity: 1, priceCents: 4500 }// ]// }Документная версия - это одно чтение с диска, и результат приходит в форме, которой ваш код уже пользуется. Реляционная стоит join, но хранит email покупателя один раз, поэтому его изменение меняет его везде. В этом вся сделка на одном экране: документы оптимизированы под чтение объекта, таблицы - под то, чтобы знать факт один раз.
Схема: её обеспечивает база данных или вы сами#
PostgreSQL не позволит вставить строку, не соответствующую таблице. У столбцов есть типы, NOT NULL значит не null, внешний ключ отказывается указывать на несуществующую строку, а ограничение CHECK отвергает бессмыслицу. Цена в том, что изменение формы - операция, которую нужно планировать; как это делается, описано в статье миграции схемы без простоя.
MongoDB сохранит всё, что вы ей пришлёте. Это по-настоящему полезно, пока форма ещё меняется каждую неделю, и это медленная утечка, когда она уже не меняется: через два года в одной коллекции лежат четыре поколения документов, и каждый путь чтения должен справляться со всеми четырьмя. Лекарство - валидация схемы, которая есть в MongoDB с версии 3.6 и которую почти никто не включает:
db.createCollection("orders", { validator: { $jsonSchema: { bsonType: "object", required: ["userId", "placedAt", "items"], properties: { userId: { bsonType: "int" }, placedAt: { bsonType: "date" }, items: { bsonType: "array", minItems: 1 } } }}, validationLevel: "moderate", // only documents you update must pass validationAction: "error" // reject rather than warn})validationLevel: "moderate" применяет правило к вставкам и к обновлениям документов, которые его уже проходят, а именно это и нужно, когда вы ужесточаете правила на живой коллекции. Включайте валидацию в начале проекта, а не после того, как всё загрязнилось.
Честный итог таков: схема есть у обеих баз. В одной она записана в самой базе данных и проверяется при каждой записи. В другой она записана в коде вашего приложения, размазана по каждой функции, читающей коллекцию, и проверяется надеждой.
Запросы: SQL, join и конвейер агрегации#
Сила SQL не в том, что его приятно писать. Она в том, что он декларативный и старый: планировщик может переписать ваш запрос, а опыт сорока лет людей, задающих неудобные вопросы, впитан в язык. Оконные функции, общие табличные выражения, GROUP BY ROLLUP, боковые join, рекурсивные запросы для дерева - всё это стандартно и доступно, и обо всём этом не нужно думать до того дня, когда оно понадобится.
Аналог в MongoDB - конвейер агрегации: массив стадий, каждая из которых преобразует поток документов.
db.orders.aggregate([ { $match: { placedAt: { $gte: ISODate("2026-04-01") } } }, { $unwind: "$items" }, { $group: { _id: "$items.sku", units: { $sum: "$items.quantity" }, revenue: { $sum: { $multiply: ["$items.quantity", "$items.priceCents"] } } } }, { $sort: { revenue: -1 } }, { $limit: 20 }])Это вполне хороший запрос, и читать его несложно. На масштабе проявляются два практических различия. Во-первых, стадия $match должна идти первой, если вы хотите, чтобы использовался индекс, а конвейеры, собранные кодом приложения, склонны ставить её куда-то ещё. Во-вторых, join существуют - $lookup выполняет левое внешнее соединение и может использовать индекс по присоединяемому полю, - но нет планировщика, который выбирал бы между hash join и merge join на основе статистики. Соединение трёх больших коллекций - то место, где документные базы перестают быть весёлыми.
Родственный момент, на котором попадаются: countDocuments() в MongoDB действительно считает, и на большой коллекции это медленно. estimatedDocumentCount() мгновенна, но читает метаданные коллекции, поэтому игнорирует ваш фильтр. У PostgreSQL та же проблема с другой стороны: COUNT(*) с условием WHERE - это скан, если только индекс его не покрывает. Бесплатного подсчёта нет ни в одной из баз, и любая страница, показывающая общее число строк, рано или поздно станет самой медленной страницей, какая у вас есть. Как выяснить, какой из ваших запросов таков, описано в статье как читать EXPLAIN ANALYZE.
Транзакции и во что обходится недописанная запись#
В PostgreSQL полноценные многооператорные, многотабличные ACID-транзакции были всегда. BEGIN, пять действий, COMMIT - и либо произошли все пять, либо ни одного. Уровень изоляции по умолчанию - read committed; REPEATABLE READ и SERIALIZABLE доступны, когда нужны.
MongoDB гарантирует, что обновление одного документа атомарно, включая изменения вложенных полей и массивов. Для хорошо встроенного документа это покрывает больше случаев, чем ожидают: добавление позиции в заказ и обновление итога заказа - это одно обновление одного документа, и оно либо происходит, либо нет.
Многодокументные транзакции тоже есть, начиная с 4.0, с одним условием, которое чрезвычайно важно на небольшом сервере: им нужен replica set. Автономный mongod их выполнять не может. Если вы запускаете один экземпляр и хотите транзакции, запустите этот единственный экземпляр как replica set из одного узла:
replication: replSetName: rs0// then, once, from mongoshrs.initiate()Это даёт oplog, транзакции и change streams на одной машине. Это также означает, что строке подключения нужен ?replicaSet=rs0, а имя хоста в конфигурации replica set должно быть таким, которое ваше приложение действительно может разрешить, - обычная первая ошибка. Подробно URI разобран в статье строки подключения MongoDB.
Задайте себе один вопрос: если процесс умрёт между двумя записями, потеряет ли кто-нибудь деньги или доверие? Платежи, кредиты, остатки на складе, всё, где две записи должны согласоваться, - это транзакционный случай, и PostgreSQL даёт это вообще без настройки.
Индексы и запросы, которые без них ломаются#
Обе базы быстры с индексом и позорны без него, и отказ выглядит одинаково: запрос, мгновенный на десяти тысячах строк, занимает девять секунд на двух миллионах.
| Потребность | PostgreSQL | MongoDB |
|---|---|---|
| Тип индекса по умолчанию | B-tree | B-tree |
| Несколько столбцов | Составной индекс, правило левого префикса | Compound-индекс, то же правило префикса |
| Только часть таблицы | Partial-индекс с WHERE | Partial-индекс с partialFilterExpression |
| Вычисляемое значение | Индекс по выражению или generated-столбец | Индекс по полю, которое вы поддерживаете сами |
| Внутри JSON-документа | GIN-индекс по jsonb | Индекс по пути через точку |
| Полнотекстовый поиск | tsvector плюс GIN | Текстовый индекс, один на коллекцию |
| Уникальность | Ограничение UNIQUE | Уникальный индекс |
| Чтение плана | EXPLAIN (ANALYZE, BUFFERS) | .explain("executionStats") |
Основную нагрузку несут два правила. Правило левого префикса применимо к обеим: индекс по (status, created_at) помогает запросу с фильтром по status или по обоим полям, но не запросу с фильтром только по created_at. А правило ESR в MongoDB - сначала поля равенства, затем поля сортировки, затем поля диапазона - самый полезный совет об индексах в этой экосистеме, и подробно он разобран в статье индексы и проектирование схемы в MongoDB.
Где они действительно различаются: PostgreSQL охотно поддерживает десяток типов индексов и комбинирует несколько индексов для одного запроса через bitmap scan, а платит за это скоростью записи и работой VACUUM; см. vacuum и раздувание в Postgres. MongoDB ограничивает вас 64 индексами на коллекцию и допускает только один текстовый индекс, а каждый индекс должен помещаться в память вместе с рабочим набором, иначе скорость чтения обрушивается. На экземпляре в 1 ГБ смотреть нужно на размер индексов, а не на число документов. Ту же работу на реляционной стороне делает статья индексы Postgres простыми словами.
Документы в Postgres: JSON, JSONB и когда этого достаточно#
Этот факт решает большинство споров. В PostgreSQL есть тип jsonb: бинарный документ, индексируемый и допускающий запросы, с операторами вхождения и выражениями пути. Документную модель можно построить внутри реляционной.
CREATE TABLE events ( id bigserial PRIMARY KEY, received_at timestamptz NOT NULL DEFAULT now(), kind text NOT NULL, payload jsonb NOT NULL);-- index the whole document for containment queriesCREATE INDEX events_payload_gin ON events USING gin (payload jsonb_path_ops);-- "every event whose payload mentions this account"SELECT id, received_at FROM eventsWHERE payload @> '{"account": {"id": 77}}';-- pull one field out as textSELECT payload ->> 'source' AS source FROM events WHERE kind = 'webhook';-> возвращает JSON, ->> возвращает текст; путаница между ними объясняет изрядную долю недоумённых условий WHERE. jsonb_path_ops делает GIN-индекс меньше и быстрее, но поддерживает только вхождение, а обычно только оно и нужно. А когда одно поле внутри документа оказывается важным, generated-столбец превращает его в настоящий типизированный индексируемый столбец, не переписывая ничего из того, что читает JSON.
Обратный ход гораздо хуже. Документное хранилище, которое заставляют вести себя реляционно, превращается в join, написанные вручную в коде приложения: получить заказы, собрать ID пользователей, получить пользователей, сшить их в цикле. Это работает, это медленнее, и каждый такой шов - место, где данные могут разойтись сами с собой.
Итак: используйте jsonb для действительно изменчивых частей - тел webhook, событий, пользовательских полей отдельных арендаторов, кэшируемых ответов API - и столбцы для остального. Таблица из одного столбца id и одного блоба jsonb - признак того, что вам стоило взять документную базу. Документная база, у которой просят отчёт по трём коллекциям, - признак обратного.
Во что каждая обходится на небольшом сервере#
Обе вполне комфортны на скромном железе, если их настроить. Обе мучительны на скромном железе с настройками по умолчанию.
| PostgreSQL | MongoDB | |
|---|---|---|
| Порт по умолчанию | 5432 | 27017 |
| Модель соединений | Один процесс ОС на соединение | Один поток на соединение |
| Главная настройка памяти | shared_buffers, по умолчанию 128 МБ | Кэш WiredTiger, по умолчанию 50% ОЗУ минус 1 ГБ, минимум 256 МБ |
| Память на запрос | work_mem, по умолчанию 4 МБ, на каждый узел сортировки или hash | Стадия сортировки ограничена, уходит на диск |
| Сжатие на диске | TOAST для больших значений | Snappy по умолчанию, на коллекцию |
| Предел соединений | max_connections, по умолчанию 100 | Пул драйвера, по умолчанию 100 на пул |
В Postgres первым нужно менять shared_buffers - обычная отправная точка около четверти памяти, доступной серверу, - а осторожным нужно быть с work_mem, потому что он выделяется на каждый узел сортировки или hash в каждом запросе, а не один раз. Пятьдесят соединений, выполняющих запрос с тремя сортировками по 64 МБ, - это не 64 МБ. Полный набор настроек - в статье настройка Postgres для небольших серверов.
Главное число MongoDB - это кэш, и проверять нужно, что он увидел лимит контейнера, а не память хоста. Спросите у него напрямую:
db.serverStatus().wiredTiger.cache["maximum bytes configured"]На экземпляре в 1 ГБ это должен быть минимум в 256 МБ, а не несколько гигабайт. Если он сообщает невозможное значение, задайте storage.wiredTiger.engineConfig.cacheSizeGB явно и перезапустите.
Соединения - вторая общая статья расходов, и причина у каждой своя. Соединение PostgreSQL - это процесс со своей памятью, поэтому сотня простаивающих соединений - это реально потерянная память; у MongoDB соединения - потоки и дешевле, но пул из 100 на каждый процесс приложения всё равно складывается. В обоих случаях лечение одно и то же, и это не более крупный тариф; см. пулы соединений и ошибка, которая приходит в худший момент.
На RE:NODE обе базы продаются как ваш собственный сервер, а не общий кластер: PostgreSQL от $6 в месяц и MongoDB от $7, тарифы идут от 1 ГБ памяти и 20 ГБ NVMe до 14 ГБ и 100 ГБ, а пароль суперпользователя генерируется для каждого сервера, а не остаётся опубликованным паролем по умолчанию, с которым поставляются стандартные образы. У вас есть файлы и консоль, так что postgresql.conf и mongod.conf вы правите сами, в этом и смысл таблицы выше.
Таблица решений и честный выбор по умолчанию#
| Если это верно для вашего проекта | Склоняйтесь к |
|---|---|
| Деньги, остатки, кредиты, всё, где две записи должны согласоваться | PostgreSQL |
| Отчёты, агрегация и join по нескольким сущностям | PostgreSQL |
| Вопросы, о которых вы ещё не подумали | PostgreSQL |
| Сильные навыки SQL в команде | PostgreSQL |
| Записи, которые действительно различаются по форме от одной к другой | MongoDB |
| Читаются целиком и записываются целиком, в собственной форме приложения | MongoDB |
| Схема всё ещё меняется еженедельно, продукт не устоялся | MongoDB |
| Большой объём событий или логов, где join не нужны | MongoDB |
| Горизонтальное шардирование - ближайшее требование | MongoDB |
| Один небольшой сервер, одно приложение, нет команды эксплуатации | PostgreSQL |
Если не можете решить, берите PostgreSQL. Он прекрасно справляется с документами через jsonb, когда они нужны, и ничего вам не стоит в тот день, когда понадобятся join, транзакция или отчёт. Цена выбора PostgreSQL, если его строгость так и не понадобится, - несколько лишних операторов CREATE TABLE. Цена ошибки в другую сторону - переписывание.
Две вещи, которые не должны входить в решение. Скорость: на масштабе одного небольшого сервера обе ограничены вашими индексами и запросами, а не движком, и один отсутствующий индекс весит больше любого выбора движка. И «какая масштабируется»: шардирование MongoDB действительно встроено, но шардированный кластер - это серверы конфигурации плюс маршрутизаторы плюс replica set, а такое не запускают рядом с хобби-проектом. Реплики для чтения и машина покрупнее уводят почти всех дальше, чем любая из двух.
Что бы вы ни выбрали, операционная работа одна и та же: настоящая резервная копия с проверенным восстановлением (резервные копии и восстановление баз данных), закрытое положение в сети (чек-лист безопасности баз данных) и дамп, который вы умеете снять вручную с помощью pg_dump и pg_restore или mongodump и mongorestore.
FAQ#
MongoDB быстрее PostgreSQL?
Для получения одного самодостаточного документа по ключу - обычно да, потому что это одно чтение вместо join. Почти для всего остального разницу определяет наличие правильного индекса. Тесты, показывающие большой разрыв, обычно сравнивают настроенный экземпляр одной базы с установкой другой по умолчанию.
Может ли PostgreSQL заменить MongoDB с помощью JSONB?
Для большинства небольших и средних приложений - да. jsonb с индексом GIN даёт хранение без схемы, запросы вхождения и выражения пути внутри базы, которая к тому же умеет транзакции и join. Чего он не даёт - встроенного шардирования MongoDB и синтаксиса её конвейера агрегации.
Нужен ли replica set, чтобы пользоваться MongoDB?
Чтобы хранить данные - нет, а для многодокументных транзакций и change streams - да: автономный mongod не поддерживает ни то, ни другое. Запуск одного узла как replica set из одного члена с replSetName и rs.initiate() занимает минуту и открывает и то, и другое.
Какая дешевле в эксплуатации на небольшом сервере?
Они близки. PostgreSQL экономнее с простаивающими соединениями в абсолютных числах только при правильном пулинге, ведь каждое из них - процесс; MongoDB нужен кэш WiredTiger плюс место под индексы. На тарифе в 1 ГБ обе прекрасно работают для настоящего приложения, и в обоих случаях ограничивающий фактор - помещаются ли ваш рабочий набор и индексы в память.
Можно ли использовать обе в одном приложении?
Да, многие так и делают: Postgres для записей, которые должны быть верны, MongoDB для событий или документов. Будьте честны насчёт цены: два движка, которые нужно резервировать, обновлять, мониторить и к которым нужно подключаться, и ни одной транзакции, охватывающей пару. Делайте это, потому что так требует нагрузка, а не чтобы не выбирать.
Насколько трудно перейти позже?
Труднее, чем сменить хостинг, легче, чем переписывание. Данные конвертируются, код запросов - нет. Каждое место, где ваше приложение обращается к базе данных, придётся менять, поэтому выбор стоит двадцати минут сейчас, а не двух недель через год.




Комментарии
Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.