Когда начинаешь новый проект, рано или поздно встаёт вопрос: какую базу данных взять? Обычно выбирают либо по привычке («у нас всегда PG»), либо по моде («MongoDB — это NoSQL, а NoSQL современно»). Оба подхода приводят к проблемам.
Один и тот же запрос идёт двумя путями, и видно, где какой дешевле. Карточку заказа PostgreSQL собирает из трёх таблиц на чтении, MongoDB достаёт одним куском — это локальность. Зато новое поле в PostgreSQL появляется у всех строк сразу и с проверенным типом, а в MongoDB часть документов остаётся без него, и проверку берёт на себя читающий код.
Разные базы — для разных задач
Заказ с позициями в PostgreSQL это две таблицы и JOIN, в MongoDB один документ, и оба варианта работают ровно до запроса, под который модель не проектировали. Выбирают между ними по задаче, и задача упирается в схему. PostgreSQL — реляционная база данных. Данные хранятся в таблицах со строгой схемой: у каждой колонки есть тип, ограничения (NOT NULL, FOREIGN KEY), и строки из разных таблиц можно соединять через JOIN.
MongoDB — документная база. Данные хранятся в коллекциях документов, каждый документ — это JSON-объект. Схема гибкая: два документа в одной коллекции могут иметь разный набор полей.
Ни одна не лучше другой в целом. Они решают разные задачи.
Когда данные связаны между собой
Представьте интернет-магазин: заказ принадлежит покупателю, содержит товары, у товаров — категории и остатки на складе. Эти сущности постоянно ссылаются друг на друга.
Для таких данных лучше подходит PostgreSQL. JOIN-ы между таблицами — это его родная операция. Внешние ключи гарантируют, что не появится заказ с несуществующим покупателем. NOT NULL и CHECK ловят ошибки ещё до записи в базу.
MongoDB лучше подходит, когда типичный запрос — «дай мне этот объект целиком»: профиль пользователя со всеми настройками, заказ со всеми позициями, событие с произвольными атрибутами. Всё это лежит рядом и читается одним обращением.
Соединять коллекции в MongoDB тоже можно — для этого в конвейере агрегации есть стадия $lookup, она появилась ещё в версии 3.2. Так что честная формулировка не «в MongoDB нельзя соединять», а «в MongoDB это дороже и с оговорками»: $lookup не работает так же гибко, как JOIN, и на больших коллекциях быстро становится узким местом. Если соединения нужны в каждом втором запросе — это не задача для документной базы.
То, что MongoDB держит одним документом, PostgreSQL собирает на чтении из трёх таблиц.
живой пример
SELECT jsonb_pretty(jsonb_build_object(
'order_id', o.id,
'status', o.status,
'customer', c.email,
'items', (SELECT jsonb_agg(jsonb_build_object('product', p.title, 'qty', i.quantity))
FROM order_items i
JOIN products p ON p.id = i.product_id
WHERE i.order_id = o.id)
)) AS document
FROM orders o
JOIN customer c ON c.id = o.customer_id
ORDER BY o.id
LIMIT 1;
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
На выходе — тот самый документ, который в MongoDB лежал бы на диске готовым. Разница не в результате, а в цене: здесь его собрали соединение и подзапрос.
Ссылочной целостности в MongoDB нет
Отдельный аргумент, который часто решает дело. В PostgreSQL внешний ключ — это обещание базы: заказ с несуществующим покупателем записать нельзя, и удалить покупателя с заказами тоже нельзя (или можно с явным ON DELETE). Проверяет это база, для всех, кто к ней подключается.
В MongoDB такого механизма нет. Ссылка между документами — обычное поле со значением, и ничто не мешает записать ссылку на удалённый документ или удалить документ, на который ссылаются. Значит, целостность становится задачей приложения: проверять перед записью, каскадно удалять в коде, периодически искать висячие ссылки заданием сверки. И поддерживать это приходится во всех приложениях, которые ходят в базу, включая скрипты поддержки и миграции данных.
Отсюда практическое следствие: чем больше связей между сущностями, тем дороже отсутствие внешних ключей. Модель, где связанные данные лежат внутри одного документа, от этого не страдает вовсе — связи там просто нет. Как только приходится ссылаться между коллекциями, целостность переезжает в код, и это надо считать частью стоимости.
Когда структура данных нестабильна
В реляционной базе схему проектируют заранее: колонки и типы объявлены до первой записи. Появилось новое поле — пишется миграция, и таблица меняется целиком.
Иногда это неудобно. Каталог товаров — хороший пример: у куртки есть размер и материал, у телевизора — диагональ и разрешение, у книги — автор и ISBN. Если таблица одна, придётся либо делать десятки колонок (большинство будут пустыми), либо выносить атрибуты в отдельную таблицу «ключ–значение».
MongoDB справляется с этим проще: каждый документ хранит только свои поля.
// Куртка
{ "name": "Куртка зимняя", "size": "L", "material": "полиэстер" }
// Телевизор
{ "name": "Телевизор 55", "diagonal": 55, "resolution": "4K" }
У этого различия есть устоявшиеся названия: PostgreSQL работает по принципу schema-on-write (схема при записи: база проверяет каждую строку на соответствие явной схеме), MongoDB — schema-on-read (схема при чтении: структура неявная, и её интерпретирует читающий код). «Бессхемность» документной базы — иллюзия: схема никуда не делась, она просто переехала из базы в код приложения.
Переезд, впрочем, не обязательный. С версии 3.6 у коллекции MongoDB можно задать validator с описанием $jsonSchema — перечислить обязательные поля и их типы — и база начнёт отбраковывать документы прямо на записи, как реляционная. По умолчанию проверки нет, и пользуются ею нечасто, но возможность штатная. То есть «бессхемность» — это выбор команды, а не свойство базы.
Дальше — про случай, когда проверку не включили. Переезд схемы в код видно на маленькой программе. Представим, что в реляционной таблице колонка status объявлена как TEXT NOT NULL: тогда одни и те же три записи проходят проверку на входе — и ту же самую проверку при разборе.
живой пример
import java.util.List;
import java.util.Map;
public class SchemaCheck {
public static void main(String[] args) {
List<Map<String, Object>> incoming = List.of(
Map.of("id", 1, "email", "ann@shop.io", "status", "active"),
Map.of("id", 2, "email", "bob@shop.io"),
Map.of("id", 3, "email", "cat@shop.io", "status", 7));
for (Map<String, Object> row : incoming) {
Object status = row.get("status");
String verdict = status == null ? "отказ: status объявлен NOT NULL, значения нет"
: status instanceof String ? "принята"
: "отказ: status не текст";
System.out.println("схема при записи, строка " + row.get("id") + ": " + verdict);
}
long active = incoming.stream().filter(d -> "active".equals(d.get("status"))).count();
long broken = incoming.stream().filter(d -> !(d.get("status") instanceof String)).count();
System.out.println("схема при чтении: приняты все " + incoming.size() + " документа");
System.out.println("при разборе: активных " + active + ", без пригодного status " + broken);
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
package main
import "fmt"
func main() {
incoming := []map[string]any{
{"id": 1, "email": "ann@shop.io", "status": "active"},
{"id": 2, "email": "bob@shop.io"},
{"id": 3, "email": "cat@shop.io", "status": 7},
}
for _, row := range incoming {
var verdict string
switch row["status"].(type) {
case nil:
verdict = "отказ: status объявлен NOT NULL, значения нет"
case string:
verdict = "принята"
default:
verdict = "отказ: status не текст"
}
fmt.Printf("схема при записи, строка %d: %s\n", row["id"], verdict)
}
active, broken := 0, 0
for _, row := range incoming {
status, isText := row["status"].(string)
if isText && status == "active" {
active++
}
if !isText {
broken++
}
}
fmt.Printf("схема при чтении: приняты все %d документа\n", len(incoming))
fmt.Printf("при разборе: активных %d, без пригодного status %d\n", active, broken)
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
const incoming = [
{ id: 1, email: 'ann@shop.io', status: 'active' },
{ id: 2, email: 'bob@shop.io' },
{ id: 3, email: 'cat@shop.io', status: 7 },
];
for (const row of incoming) {
const status = row.status;
const verdict = status == null ? 'отказ: status объявлен NOT NULL, значения нет'
: typeof status === 'string' ? 'принята' : 'отказ: status не текст';
console.log(`схема при записи, строка ${row.id}: ${verdict}`);
}
const active = incoming.filter((d) => d.status === 'active').length;
const broken = incoming.filter((d) => typeof d.status !== 'string').length;
console.log(`схема при чтении: приняты все ${incoming.length} документа`);
console.log(`при разборе: активных ${active}, без пригодного status ${broken}`);
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
incoming = [
{"id": 1, "email": "ann@shop.io", "status": "active"},
{"id": 2, "email": "bob@shop.io"},
{"id": 3, "email": "cat@shop.io", "status": 7},
]
for row in incoming:
status = row.get("status")
if status is None:
verdict = "отказ: status объявлен NOT NULL, значения нет"
elif isinstance(status, str):
verdict = "принята"
else:
verdict = "отказ: status не текст"
print(f"схема при записи, строка {row['id']}: {verdict}")
active = sum(1 for d in incoming if d.get("status") == "active")
broken = sum(1 for d in incoming if not isinstance(d.get("status"), str))
print(f"схема при чтении: приняты все {len(incoming)} документа")
print(f"при разборе: активных {active}, без пригодного status {broken}")
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Две записи из трёх база не пустила внутрь. Документная база примет все три, и разбираться с двумя неполными придётся читающему коду — при каждом чтении.
Оговорка про NOT NULL здесь не для красоты. Само по себе отсутствие значения при вставке реляционную базу не смущает: колонка получит NULL или значение по умолчанию, и строка запишется. Отказ появляется ровно там, где схема этого потребовала — NOT NULL, CHECK, тип колонки. Строгость реляционной базы не бесплатная данность, а то, что объявили при проектировании.
Второе слово из той же обоймы — локальность: документ лежит одним непрерывным куском, и чтение профиля или карточки товара достаёт всё сразу — там, где реляционная схема потребует соединения. Обратная сторона: база загружает и переписывает документ целиком даже ради маленького фрагмента, поэтому большие и часто дописываемые документы съедают преимущество. А у роста документа есть жёсткий потолок: 16 МБ. В эту стену и упирается заманчивая модель «всё в одном документе» — заказ с тысячей позиций или пост со всеми комментариями рано или поздно перестают в неё помещаться, и вложенное приходится выносить в отдельную коллекцию.
Оговорка: схема, меняющаяся хаотично, — не повод брать MongoDB, а признак плохо спроектированного домена.
Транзакции: когда операция должна выполниться целиком или не выполниться вовсе
Перевод денег между счетами — классический пример: списать с одного счёта и зачислить на другой нужно как единое действие, а при сбое посередине — откатить обе операции.
PostgreSQL поддерживает полноценные ACID-транзакции с самого начала. Транзакция на нескольких таблицах — обычная операция.
MongoDB добавил многодокументные транзакции в версии 4.0 (для replica set), а для шардированных кластеров — в 4.2. Они дороже по накладным расходам, чем в PG. Если большая часть операций требует транзакций на нескольких документах — это сигнал, что схема изначально лучше легла бы в реляционную базу.
Объём данных и масштабирование
Пока данных немного (до нескольких сотен гигабайт на одном сервере), обе базы работают одинаково хорошо. Разница появляется при росте.
PostgreSQL отлично справляется на одном мощном сервере. Партиционирование таблиц (разбивка по диапазону, списку значений или хешу) решает большинство проблем с производительностью. Горизонтальное масштабирование на несколько серверов возможно, но требует отдельного инструмента (Citus).
MongoDB изначально проектировался под горизонтальное масштабирование. Sharded cluster — стандартный режим работы больших кластеров. Если заранее ясно, что данных будет десятки терабайт и более одного сервера — MongoDB упрощает инфраструктуру.
Про масштабирование PostgreSQL стоит перечислить шаги в порядке, в котором их применяют, — «сразу шардирование» бывает нужно намного позже, чем кажется.
Первый шаг — реплики на чтение: аналитика, отчёты и тяжёлые списки уезжают с основного сервера, и он остаётся только под запись. Дёшево, не меняет ни схему, ни код (кроме маршрутизации), закрывает большинство случаев «база не справляется с чтением». Второй — партиционирование больших таблиц: данные остаются в одной базе, но запросы читают только нужный отрезок, а старые части удаляются мгновенно. Третий — логическая репликация: часть таблиц уезжает в отдельную базу для отдельной подсистемы (или в аналитическое хранилище), при этом источник правды остаётся один. И только четвёртый — шардирование (Citus и аналоги), то есть настоящее размазывание одной таблицы по нескольким серверам с выбором ключа шардирования и всеми граблями распределённых соединений.
Шаги масштабирования PostgreSQL в порядке цены: смотрите, сколько ступеней стоит между сегодняшней нагрузкой и шардированием.
Порядок здесь не случайный: каждый следующий шаг дороже предыдущего по эксплуатации и по сложности кода. MongoDB даёт шардирование «из коробки» раньше — но и в ней ключ шардирования выбирается один раз и навсегда, с теми же последствиями, что и ключ раздела в Cassandra.
PostgreSQL + jsonb: третий вариант, о котором часто забывают
Между «чистым реляционным» и «чистым документным» есть промежуточный вариант: в PostgreSQL есть тип jsonb, который позволяет хранить произвольный JSON прямо в колонке, строить по нему индексы и делать поиск внутри JSON.
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
seller_id BIGINT NOT NULL,
title TEXT NOT NULL,
attributes JSONB NOT NULL DEFAULT '{}'::jsonb
);
-- GIN-индекс для быстрого поиска по содержимому JSON
CREATE INDEX products_attributes_gin ON products USING GIN (attributes);
Ровно такая таблица лежит в учебной песочнице — вместе с этим индексом. Так что поиск по содержимому JSON можно запустить прямо отсюда и поменять цвет на blue или white:
живой пример
SELECT title, attributes
FROM products
WHERE attributes @> '{"color": "black"}';
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Это даёт гибкость атрибутов при сохранении связей, внешних ключей и транзакций. Задача звучит как «нужна гибкость атрибутов, но данные связаны» — пробуйте jsonb прежде чем переходить на MongoDB.
Цена у этого варианта есть, и знать её надо до того, как половина модели переедет в документ.
Обновление переписывает всё значение. Изменение одного ключа в документе означает новую версию всей строки — сам механизм версионирования строк в PostgreSQL по-другому не умеет. Документ на сто килобайт, который правят по одному полю тысячу раз в день, создаёт сто мегабайт мусора в день, и уборка это чувствует. MongoDB здесь честно выигрывает: она умеет менять поле внутри документа на месте.
Индекс по документу весит. GIN-индекс с обычным набором операций (jsonb_ops) индексирует и ключи, и значения, и на широких документах бывает больше самой таблицы. Вариант jsonb_path_ops вдвое компактнее и быстрее, но умеет отвечать только на «содержит вот такой фрагмент» (@>), а не на «есть ли такой ключ».
Статистики по содержимому нет. Планировщик не знает, сколько строк подойдёт под условие по ключу документа, и подставляет грубую оценку — отсюда неудачные планы на больших таблицах. Лечится выносом горячего поля в обычную колонку (и это же главный совет из статьи про jsonb): документ остаётся документом, а то, по чему фильтруют, живёт колонкой с индексом.
Практический вывод: jsonb силён, когда документ читают целиком и меняют редко — снимок ответа чужой службы, настройки, редкие атрибуты. Как основное изменяемое хранилище он проигрывает и обычным колонкам, и настоящей документной базе.
Что умеет каждая база
| Задача | Что лучше |
|---|---|
| CRUD-сервис с понятной схемой и связями | PostgreSQL |
| Биллинг, бухгалтерия, финансы | PostgreSQL |
| Каталог товаров с разными атрибутами | MongoDB или PG + jsonb |
| Профиль пользователя с вложенными данными | MongoDB или PG + jsonb |
| Лента событий / логи | MongoDB, ClickHouse |
| Полнотекстовый поиск с фильтрами | PostgreSQL FTS, на больших объёмах — OpenSearch |
| Аналитика и агрегаты | ClickHouse / DuckDB |
Типичные ошибки при выборе
«MongoDB — потому что схема может меняться». Гибкость схемы не отменяет проектирование данных: через год, если миграций никто не писал, непонятно, какие документы в коллекции валидны.
«Возьмём обе для гибкости». Два набора миграций, два мониторинга, два бэкапа, риск рассинхрона. Две базы берут, когда природа задач реально разная: транзакционные данные в PG и растущий лог событий в MongoDB.
«PG + jsonb всегда заменит MongoDB». При глубокой вложенности (несколько уровней массивов внутри JSON) запросы на jsonb читаются хуже и работают медленнее, чем эквивалент в MongoDB.
«MongoDB не нужны миграции». Нужны. Новое обязательное поле, переименование, смена типа — те же миграции, только писать их приходится руками, и пропуск всплывает позже.
Глубже: операционная сторона и лицензиярасширенное
PostgreSQL — зрелый инструмент с десятилетиями практики: миграции через Flyway или Liquibase, мониторинг через pg_stat_*, резервное копирование и репликация отработаны. Своих хлопот хватает и тут: autovacuum и раздувание таблиц, лимит соединений, обновление мажорной версии.
MongoDB требует другого набора знаний: индексы строятся по другим правилам, шардинг — отдельная инженерная дисциплина, бэкап sharded cluster нетривиален. Нет человека с реальным опытом MongoDB в эксплуатации — это риск при первой серьёзной проблеме.
Про индексы в MongoDB стоит сказать конкретнее, потому что правила действительно другие.
Порядок полей в составном индексе подчиняется правилу «сначала равенство, потом сортировка, потом диапазон» (его так и называют по первым буквам английских слов — ESR). То есть для запроса «заказы этого покупателя, свежие первыми, за последний месяц» индекс строят как {customerId: 1, createdAt: -1} — равенство первым, сортировка второй, а условие диапазона по той же createdAt работает по ней же. Перепутанный порядок даёт сортировку в памяти, а она ограничена и при переполнении запрос падает, если не разрешена запись на диск.
Второе правило жёстче реляционного: рабочая часть индексов должна помещаться в память. MongoDB держит индексы в кэше, и когда они перестают влезать, каждое обращение превращается в чтение с диска — деградация резкая, а не плавная. Отсюда практическая проверка при выборе: посчитать размер индексов на ожидаемом объёме (db.collection.stats() показывает totalIndexSize) и сравнить с памятью машины.
И третье: в MongoDB нет частичных индексов в смысле PostgreSQL, зато есть свои — частичный по условию (partialFilterExpression), разреженный (только для документов, где поле есть) и с истечением срока (expireAfterSeconds, удаляет документы сам). Последний — прямой ответ на «журналы и сессии», который в PostgreSQL требует отдельного задания.
Лицензия и где это можно поставить
Фактор, который не видно в сравнении возможностей, но он влияет на решение. PostgreSQL распространяется под собственной свободной лицензией, близкой к BSD: можно всё, включая продажу сервиса на её основе. MongoDB с 2018 года — под лицензией SSPL, и ключевое ограничение в ней не про использование внутри продукта, а про предоставление её как сервиса: тот, кто продаёт MongoDB как услугу, обязан открыть исходники всей обвязки. Для обычного приложения это ничего не меняет, но два практических следствия есть: MongoDB нет в стандартных репозиториях некоторых дистрибутивов, а у части облачных провайдеров вместо неё предлагают совместимые по протоколу реализации. Если база выбирается на годы, лицензию стоит прочитать до, а не после.
Коротко
- PostgreSQL лучше, когда данные связаны между собой и нужны JOIN-ы, транзакции, внешние ключи.
- MongoDB лучше, когда данные читаются целиком как документы, схема разнородная, а масштаб предполагает несколько серверов.
- Разница не только в скорости: при схеме на записи структуру проверяет база, при схеме на чтении — код, который документ разбирает.
- PostgreSQL +
jsonb— промежуточный вариант: гибкость JSON при сохранении реляционных гарантий. - MongoDB поддерживает транзакции, но они дороже — если транзакции нужны постоянно, скорее всего нужен PG.
- Две базы в одном сервисе оправданы только когда природа задач реально разная, а команда умеет обслуживать обе.
jsonbплатит за гибкость: правка одного ключа переписывает всё значение, GIN весит много (jsonb_path_opsвдвое компактнее), статистики по содержимому нет — горячее поле выносят в колонку.- В MongoDB нет внешних ключей: целостность переезжает в приложение и во все скрипты, которые ходят в базу.
- Масштабируют PostgreSQL по шагам: реплики чтения, партиционирование, логическая репликация и только потом шардирование; в MongoDB составной индекс строят по правилу «равенство, сортировка, диапазон», а индексы должны влезать в память.
Что почитать дальше
- JSONB в PostgreSQL — что это, как работает и когда применять — промежуточный вариант подробно: операторы, GIN-индексы и где он вреден.
- Моделирование документов в MongoDB: embed vs reference, индексы — как проектировать документ, если выбор пал на MongoDB.
- ACID, read и write concerns, транзакции в MongoDB — во что обходятся многодокументные транзакции.
- Cassandra, PostgreSQL или MongoDB: когда брать колоночную NoSQL — третий вариант в том же выборе.