RE:NODE

Базы данных14 мин чтения

PostgreSQL или MongoDB: выбираем по форме ваших данных

Как выбрать по форме данных, а не по моде: модель данных, транзакции, индексы, JSONB, агрегация и во что каждая обходится на небольшом сервере.

Обновлено

0 прочтений

Обе отличные, обе зрелые, и обе потянут всё, что вы строите. Тот, кто говорит, что одна из них просто лучше, не слышал уточняющего вопроса. Выбор имеет значение на краях, а края наступают позже, чем вы думаете: обычно в первый раз, когда нужно задать вопрос, под который схема не проектировалась, или когда недописанная запись впервые стоит вам денег.

Коротко: если ваши данные - это сущности, ссылающиеся друг на друга, и вам понадобится задавать незапланированные вопросы, берите PostgreSQL. Если ваши данные - самодостаточные документы, различающиеся по форме, читаемые целиком и записываемые целиком, MongoDB будет приятна. Если вы действительно не можете решить, берите PostgreSQL, потому что он умеет хранить документы, а обратное верно куда меньше. Остальная часть статьи - рассуждение и конкретика, позволяющая проверить его на вашем приложении.

Единственное различие, из которого следует всё остальное#

PostgreSQL хранит строки в таблицах с фиксированным набором типизированных столбцов, а связи между таблицами выражаются ключами и разрешаются при запросе через join. MongoDB хранит документы BSON в коллекциях, каждый документ самодостаточен, внутри него вложенные объекты и массивы, а связи либо встроены, либо разрешаются вторым запросом.

Из этого вытекает всё остальное: транзакции, схема, индексирование, сколько памяти нужно каждой, как вы обновляетесь. Конкретный пример делает это очевидным. Заказ с тремя позициями и покупателем:

sql
-- 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;
javascript
// 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 покупателя один раз, поэтому его изменение меняет его везде. В этом вся сделка на одном экране: документы оптимизированы под чтение объекта, таблицы - под то, чтобы знать факт один раз.

SQL с joinfindOne по _idПриложениенужен один заказPostgreSQLorders, items, usersMongoDBколлекция ordersJoin при чтениистрока на позициюВстроенныйодин документ
Один и тот же заказ, смоделированный дважды

Схема: её обеспечивает база данных или вы сами#

PostgreSQL не позволит вставить строку, не соответствующую таблице. У столбцов есть типы, NOT NULL значит не null, внешний ключ отказывается указывать на несуществующую строку, а ограничение CHECK отвергает бессмыслицу. Цена в том, что изменение формы - операция, которую нужно планировать; как это делается, описано в статье миграции схемы без простоя.

MongoDB сохранит всё, что вы ей пришлёте. Это по-настоящему полезно, пока форма ещё меняется каждую неделю, и это медленная утечка, когда она уже не меняется: через два года в одной коллекции лежат четыре поколения документов, и каждый путь чтения должен справляться со всеми четырьмя. Лекарство - валидация схемы, которая есть в MongoDB с версии 3.6 и которую почти никто не включает:

javascript
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 - конвейер агрегации: массив стадий, каждая из которых преобразует поток документов.

javascript
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 из одного узла:

mongod.conf
replication:  replSetName: rs0
javascript
// then, once, from mongoshrs.initiate()

Это даёт oplog, транзакции и change streams на одной машине. Это также означает, что строке подключения нужен ?replicaSet=rs0, а имя хоста в конфигурации replica set должно быть таким, которое ваше приложение действительно может разрешить, - обычная первая ошибка. Подробно URI разобран в статье строки подключения MongoDB.

Задайте себе один вопрос: если процесс умрёт между двумя записями, потеряет ли кто-нибудь деньги или доверие? Платежи, кредиты, остатки на складе, всё, где две записи должны согласоваться, - это транзакционный случай, и PostgreSQL даёт это вообще без настройки.

Индексы и запросы, которые без них ломаются#

Обе базы быстры с индексом и позорны без него, и отказ выглядит одинаково: запрос, мгновенный на десяти тысячах строк, занимает девять секунд на двух миллионах.

ПотребностьPostgreSQLMongoDB
Тип индекса по умолчаниюB-treeB-tree
Несколько столбцовСоставной индекс, правило левого префиксаCompound-индекс, то же правило префикса
Только часть таблицыPartial-индекс с WHEREPartial-индекс с 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: бинарный документ, индексируемый и допускающий запросы, с операторами вхождения и выражениями пути. Документную модель можно построить внутри реляционной.

sql
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 - признак того, что вам стоило взять документную базу. Документная база, у которой просят отчёт по трём коллекциям, - признак обратного.

Во что каждая обходится на небольшом сервере#

Обе вполне комфортны на скромном железе, если их настроить. Обе мучительны на скромном железе с настройками по умолчанию.

PostgreSQLMongoDB
Порт по умолчанию543227017
Модель соединенийОдин процесс ОС на соединениеОдин поток на соединение
Главная настройка памяти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 - это кэш, и проверять нужно, что он увидел лимит контейнера, а не память хоста. Спросите у него напрямую:

javascript
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. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.

0/2000