MongoDB vs PostgreSQL: на примерах
Задача
Спроектировать схему интернет-магазина на MongoDB и PostgreSQL. Сравнить:
- Структуру.
- Запросы.
- Производительность.
- Гибкость.
Сценарий
Требования:
- Пользователи.
- Товары с гибкими характеристиками.
- Заказы с позициями.
- Отзывы.
- Категории (дерево).
PostgreSQL — реляционная схема
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE categories (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
parent_id BIGINT REFERENCES categories(id),
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE
);
CREATE TABLE products (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
category_id BIGINT NOT NULL REFERENCES categories(id),
sku VARCHAR(64) NOT NULL UNIQUE,
name TEXT NOT NULL,
price NUMERIC(10, 2) NOT NULL,
attributes JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
status TEXT NOT NULL DEFAULT 'pending',
total NUMERIC(12, 2) NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE order_items (
order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id BIGINT NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL,
price_at_purchase NUMERIC(10, 2) NOT NULL,
PRIMARY KEY (order_id, product_id)
);
CREATE TABLE reviews (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
product_id BIGINT NOT NULL REFERENCES products(id),
user_id BIGINT NOT NULL REFERENCES users(id),
rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5),
body TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
7 таблиц. Нормализация до 3NF. FK гарантируют целостность.
MongoDB — документная схема
Коллекция users:
{
_id: ObjectId('...'),
email: 'a@b.com',
name: 'Алиса',
created_at: ISODate('2026-10-15'),
}
Коллекция categories:
{
_id: ObjectId('...'),
parent_id: ObjectId('...'),
name: 'Смартфоны',
slug: 'smartphones',
ancestors: [ObjectId('...'), ObjectId('...')], // для быстрых запросов
}
Коллекция products:
{
_id: ObjectId('...'),
category: {
id: ObjectId('...'),
name: 'Смартфоны', // денормализация
slug: 'smartphones',
},
sku: 'IPHONE-15',
name: 'iPhone 15',
price: NumberDecimal('99990.00'),
attributes: {
brand: 'Apple',
color: 'black',
storage: 256,
weight: 171,
},
rating_avg: 4.7, // денормализация
rating_count: 42,
created_at: ISODate('2026-10-15'),
}
Коллекция orders:
{
_id: ObjectId('...'),
user_id: ObjectId('...'),
user_email: 'a@b.com', // денормализация
status: 'paid',
items: [
{
product_id: ObjectId('...'),
name: 'iPhone 15', // snapshot
price: NumberDecimal('99990.00'),
quantity: 1,
},
],
total: NumberDecimal('99990.00'),
created_at: ISODate('2026-10-15'),
}
Коллекция reviews:
{
_id: ObjectId('...'),
product_id: ObjectId('...'),
user_id: ObjectId('...'),
user_name: 'Алиса', // денормализация
rating: 5,
body: 'Отличный',
created_at: ISODate('2026-10-15'),
}
5 коллекций. Данные, которые читаются вместе — в одном документе.
Запрос 1. Список товаров в категории
PostgreSQL:
SELECT id, name, price, attributes
FROM products
WHERE category_id = 5 AND is_active
ORDER BY created_at DESC
LIMIT 20;
MongoDB:
db.products.find(
{ 'category.id': categoryId, is_active: true },
{ name: 1, price: 1, attributes: 1 }
).sort({ created_at: -1 }).limit(20)
Что быстрее: примерно одинаково. Оба с индексом.
Запрос 2. Товары с фильтром по характеристикам
PostgreSQL JSONB:
SELECT id, name, price
FROM products
WHERE attributes @> '{"brand": "Apple"}'
AND (attributes->>'storage')::int >= 128
AND price < 100000;
Нужен GIN-индекс:
CREATE INDEX idx_products_attrs ON products USING GIN (attributes);
MongoDB:
db.products.find({
'attributes.brand': 'Apple',
'attributes.storage': { $gte: 128 },
price: { $lt: NumberDecimal('100000') },
})
Индекс:
db.products.createIndex({ 'attributes.brand': 1, 'attributes.storage': 1, price: 1 })
Что быстрее: сопоставимо. MongoDB проще в синтаксисе, Postgres мощнее для сложных условий.
Запрос 3. Заказ с позициями
PostgreSQL:
SELECT
o.id, o.status, o.total, o.created_at,
jsonb_agg(jsonb_build_object(
'name', oi.product_name,
'price', oi.price_at_purchase,
'qty', oi.quantity
)) AS items
FROM orders o
LEFT JOIN order_items oi ON oi.order_id = o.id
WHERE o.id = 12345
GROUP BY o.id;
Что важно: один запрос + JOIN + агрегация JSON.
MongoDB:
db.orders.findOne({ _id: orderId })
// items уже внутри
Что быстрее: MongoDB быстрее — один документ.
Правило: документная модель выигрывает, когда данные читаются вместе.
Запрос 4. Топ-10 клиентов по покупкам
PostgreSQL:
SELECT
u.email,
SUM(o.total) AS spent,
COUNT(o.id) AS orders
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE o.status = 'paid'
AND o.created_at >= NOW() - INTERVAL '30 days'
GROUP BY u.id, u.email
ORDER BY spent DESC
LIMIT 10;
Что даёт: сложный аналитический запрос, оптимизируется хорошо.
MongoDB:
db.orders.aggregate([
{ $match: {
status: 'paid',
created_at: { $gte: new Date(Date.now() - 30 * 86400 * 1000) },
} },
{ $group: {
_id: '$user_id',
email: { $first: '$user_email' },
spent: { $sum: '$total' },
orders: { $sum: 1 },
} },
{ $sort: { spent: -1 } },
{ $limit: 10 },
])
Что быстрее: PostgreSQL обычно быстрее на больших объёмах.
Причина: оптимизатор, параллелизм.
Запрос 5. Средний рейтинг товара
PostgreSQL:
SELECT
p.id,
p.name,
AVG(r.rating) AS avg_rating,
COUNT(r.id) AS count
FROM products p
LEFT JOIN reviews r ON r.product_id = p.id
WHERE p.id = 5
GROUP BY p.id;
MongoDB (если рейтинг денормализован):
db.products.findOne({ _id: productId }, { rating_avg: 1, rating_count: 1 })
Что быстрее: MongoDB мгновенно (денормализация).
Обновление в Mongo:
db.reviews.insertOne({ ... })
db.products.updateOne(
{ _id: productId },
{
$inc: { rating_count: 1 },
$set: { rating_avg: ... } // пересчёт
}
)
Или триггер через change streams.
Запрос 6. Дерево категорий
PostgreSQL — recursive CTE:
WITH RECURSIVE tree AS (
SELECT id, name, parent_id, 0 AS depth, ARRAY[id] AS path
FROM categories WHERE parent_id IS NULL
UNION ALL
SELECT c.id, c.name, c.parent_id, t.depth + 1, t.path || c.id
FROM categories c
JOIN tree t ON c.parent_id = t.id
)
SELECT * FROM tree ORDER BY path;
MongoDB — $graphLookup:
db.categories.aggregate([
{ $match: { _id: rootId } },
{ $graphLookup: {
from: 'categories',
startWith: '$_id',
connectFromField: '_id',
connectToField: 'parent_id',
as: 'descendants',
} },
])
Что быстрее: сопоставимо.
MongoDB проще, если использовать ancestors (материализованный путь):
db.categories.find({ ancestors: rootId })
Правило: дерево в Mongo — денормализация через ancestors.
Запрос 7. Полнотекстовый поиск
PostgreSQL:
SELECT id, name, ts_rank(search, query) AS rank
FROM (
SELECT id, name, to_tsvector('russian', name || ' ' || description) AS search,
to_tsquery('russian', 'iphone') AS query
FROM products
) t
WHERE search @@ query
ORDER BY rank DESC
LIMIT 20;
С GIN-индексом:
CREATE INDEX idx_products_search ON products
USING GIN (to_tsvector('russian', name || ' ' || COALESCE(description, '')));
MongoDB:
db.products.find(
{ $text: { $search: 'iphone' } },
{ score: { $meta: 'textScore' } }
).sort({ score: { $meta: 'textScore' } })
С индексом:
db.products.createIndex(
{ name: 'text', description: 'text' },
{ weights: { name: 10, description: 1 }, default_language: 'russian' }
)
Что быстрее: Postgres обычно, особенно для русского языка.
Для серьёзного поиска: Elasticsearch.
Сравнение операций
Вставка заказа:
PostgreSQL:
BEGIN;
INSERT INTO orders (...) VALUES (...) RETURNING id;
INSERT INTO order_items (...) VALUES (...), (...), (...);
UPDATE inventory SET quantity = quantity - 1 WHERE ...;
COMMIT;
MongoDB:
db.orders.insertOne({ ..., items: [...], total })
db.inventory.updateMany(...)
MongoDB проще, но:
- Нет транзакций без явного
startSession. - Inventory и order — в разных коллекциях → атомарность только через транзакцию.
Правило: если все данные помещаются в один документ — Mongo атомарен. Если нет — транзакция.
Обновление заказа:
PostgreSQL:
UPDATE orders SET status = 'shipped' WHERE id = 12345;
MongoDB:
db.orders.updateOne({ _id: orderId }, { $set: { status: 'shipped' } })
Одинаково.
Удаление товара:
PostgreSQL:
DELETE FROM products WHERE id = 5;
-- Упадёт, если есть FK из order_items
-- Или CASCADE, если настроено
MongoDB:
db.products.deleteOne({ _id: productId })
// Заказы всё ещё содержат snapshot этого товара
MongoDB не блокирует удаление, но заказы сохраняют снимок.
Гибкость схемы
Новое поле color:
PostgreSQL:
ALTER TABLE products ADD COLUMN color VARCHAR(32);
-- Или добавить в attributes JSONB
MongoDB:
// Просто добавляем в документ — никаких миграций
db.products.updateMany({}, { $set: { 'attributes.color': 'unknown' } })
MongoDB проще, особенно когда поля различаются между документами.
Целостность
FK в PostgreSQL:
- Нельзя вставить
order_itemsс несуществующимproduct_id. - Нельзя удалить
product, пока на него ссылаются.
MongoDB:
- FK нет. Осиротевшие ссылки возможны.
- Приложение само проверяет (или через транзакцию).
Правило: для критичной целостности — PostgreSQL.
Транзакции
PostgreSQL:
- Дешёвые, MVCC.
- Разные уровни изоляции.
MongoDB:
- С 4.0 — есть.
- Дорогие.
- Ограничение 60 сек.
Правило: для сложных транзакций — PostgreSQL.
Производительность по операциям
| Операция | PostgreSQL | MongoDB |
|---|---|---|
| Точечный по PK | Отлично | Отлично |
| Заказ с позициями | JOIN + агрегация | Документ целиком (быстрее) |
| Список с фильтром | Отлично | Отлично |
| Топ клиентов | Отлично | Средне |
| Денормализованный рейтинг | MEDIUM (нужен триггер) | Мгновенно |
| Дерево категорий | CTE | $graphLookup или ancestors |
| Полнотекст | Хорошо | Хорошо (слабее) |
| Сложные JOIN | Отлично | $lookup — медленно |
Что выбрать
PostgreSQL, если:
- Целостность критична (FK).
- Много аналитики.
- Сложные JOIN.
- Транзакции важны.
- Схема стабильна.
MongoDB, если:
- Данные читаются вместе (заказ + позиции).
- Схема часто меняется.
- Денормализация естественна.
- Простые запросы.
- Прототип.
Гибрид:
- Postgres для транзакций.
- Mongo для каталога, гибких данных.
- Redis для кэша.
- Elasticsearch для поиска.
Практика: решите для своего проекта
1. Возьмите 5 самых частых запросов.
2. Напишите для каждого:
- SQL.
- MQL.
3. Сравните: какой проще, какой быстрее?
4. Оцените: что важнее для проекта?
5. Выберите по боли.
Частые ошибки
1. «Mongo проще»:
Проще только поначалу
Решение: оценивать долгосрочно.
2. «Postgres быстрее»:
Зависит от запроса
Решение: свой workload.
3. Игнорировать денормализацию:
Mongo без денормализации = медленно
Решение: денормализовать.
4. Игнорировать FK в Postgres:
Осиротевшие записи
Решение: FK + ON DELETE.
5. Дерево категорий в Mongo без ancestors:
$graphLookup медленный
Решение: материализованный путь.
6. Полнотекст в Mongo без настройки:
Русский плохо работает
Решение: default_language: 'russian'.
7. Транзакции в Mongo для простого:
Дорого
Решение: документная атомарность.
8. Всё в один документ:
16 MB лимит
Решение: разбить.
Практика: реализуйте оба
1. Разверните Postgres + Mongo в Docker.
2. Создайте схемы для интернет-магазина.
3. Залейте одинаковые данные (10k товаров, 1k заказов).
4. Прогоните 6-7 типичных запросов.
5. Замерьте время.
6. Сделайте выводы.
Итог
- Postgres: FK, транзакции, JOIN, аналитика.
- Mongo: документы, гибкость, денормализация.
- Заказ с позициями — Mongo быстрее.
- Топ клиентов — Postgres быстрее.
- Дерево категорий — оба работают.
- Полнотекст — Postgres обычно лучше.
- Схема — Postgres строгая, Mongo гибкая.
- Выбор — по паттернам доступа.
Дальше — Redis: когда база данных — это кэш.