Урок 04

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.

Производительность по операциям

ОперацияPostgreSQLMongoDB
Точечный по 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: когда база данных — это кэш.