Mid📖Теория4 min

MongoDB

Документная модель данных, sharding, replica set, aggregation pipeline -- архитектура и паттерны использования

MongoDB

Документная модель

MongoDB хранит данные в формате BSON (Binary JSON). Каждый документ -- самодостаточная единица данных, которая может содержать вложенные объекты и массивы.

Документ vs Строка

<?php

declare(strict_types=1);

/**
 * Document model: embed related data in one document
 */

// Relational approach: 3 tables, 2 JOINs
// orders -> order_items -> products

// Document approach: one document
$orderDocument = [
    '_id' => 'ord_abc123',
    'user' => [
        'id' => 'user_456',
        'name' => 'John Doe',
        'email' => '[email protected]',
    ],
    'items' => [
        [
            'product_id' => 'prod_1',
            'name' => 'Laptop',
            'price' => 999.99,
            'quantity' => 1,
        ],
        [
            'product_id' => 'prod_2',
            'name' => 'Mouse',
            'price' => 29.99,
            'quantity' => 2,
        ],
    ],
    'total_amount' => 1059.97,
    'status' => 'completed',
    'shipping_address' => [
        'street' => '123 Main St',
        'city' => 'Moscow',
        'zip' => '101000',
    ],
    'created_at' => new \DateTimeImmutable(),
];

Когда embedding, когда referencing

Стратегия Когда использовать Пример
Embedding Данные читаются вместе, 1:1, 1:few Адрес в заказе
Referencing Данные читаются отдельно, many:many Пользователь в заказе
Hybrid Embedding часто используемых полей Имя пользователя + reference на полный профиль

Архитектура MongoDB

Replica Set

Группа серверов MongoDB с автоматическим failover:

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   Primary   │────>│  Secondary  │────>│  Secondary  │
│             │     │             │     │             │
│  Reads +    │     │  Reads      │     │  Reads      │
│  Writes     │     │  (optional) │     │  (optional) │
│             │     │             │     │             │
│  oplog ─────┼─────┤  oplog      │     │  oplog      │
└─────────────┘     └─────────────┘     └─────────────┘
                         │
                    Election при
                    падении Primary
  • Primary -- принимает все записи
  • Secondary -- реплицирует данные, может обслуживать чтения
  • Arbiter -- участвует только в голосовании (не хранит данные)

Sharding

Горизонтальное распределение данных по нескольким серверам:

┌──────────┐
│  mongos  │  (Router)
│ (router) │
└────┬─────┘
     │
┌────┴──────────────────────────────────┐
│          Config Servers                │
│    (metadata, chunk mapping)          │
└────┬──────────────┬───────────────┬───┘
     │              │               │
┌────┴────┐   ┌─────┴────┐   ┌─────┴────┐
│ Shard 1 │   │ Shard 2  │   │ Shard 3  │
│ RS      │   │ RS       │   │ RS       │
│         │   │          │   │          │
│user_id  │   │user_id   │   │user_id   │
│ A-F     │   │ G-O      │   │ P-Z      │
└─────────┘   └──────────┘   └──────────┘

Shard Key Selection

Тип shard key Плюсы Минусы
Hashed Равномерное распределение Нет range queries
Ranged Range queries эффективны Hotspots возможны
Compound Баланс между распределением и запросами Сложнее выбрать

Правило: shard key выбирается один раз и не может быть изменён без пересоздания коллекции. Выбирайте ключ с высокой кардинальностью и частым использованием в запросах.

CRUD операции из PHP

<?php

declare(strict_types=1);

/**
 * MongoDB CRUD operations with PHP driver
 */
final class MongoOrderRepository
{
    private \MongoDB\Collection $collection;

    public function __construct(
        \MongoDB\Client $client,
        string $database = 'shop',
    ) {
        $this->collection = $client->selectCollection($database, 'orders');
    }

    /**
     * Create indexes
     */
    public function ensureIndexes(): void
    {
        $this->collection->createIndexes([
            ['key' => ['user.id' => 1, 'created_at' => -1]],
            ['key' => ['status' => 1]],
            ['key' => ['created_at' => 1], 'expireAfterSeconds' => 86400 * 365], // TTL: 1 year
        ]);
    }

    /**
     * Insert document
     */
    public function create(array $orderData): string
    {
        $result = $this->collection->insertOne([
            'user' => $orderData['user'],
            'items' => $orderData['items'],
            'total_amount' => $orderData['total_amount'],
            'status' => 'pending',
            'created_at' => new \MongoDB\BSON\UTCDateTime(),
        ]);

        return (string) $result->getInsertedId();
    }

    /**
     * Find with filters
     */
    public function findByUser(string $userId, int $limit = 20): array
    {
        $cursor = $this->collection->find(
            ['user.id' => $userId],
            [
                'sort' => ['created_at' => -1],
                'limit' => $limit,
                'projection' => [
                    'user.email' => 0, // Exclude sensitive data
                ],
            ]
        );

        return $cursor->toArray();
    }

    /**
     * Atomic update with operators
     */
    public function updateStatus(string $orderId, string $newStatus): bool
    {
        $result = $this->collection->updateOne(
            ['_id' => new \MongoDB\BSON\ObjectId($orderId)],
            [
                '$set' => [
                    'status' => $newStatus,
                    'updated_at' => new \MongoDB\BSON\UTCDateTime(),
                ],
                '$push' => [
                    'status_history' => [
                        'status' => $newStatus,
                        'changed_at' => new \MongoDB\BSON\UTCDateTime(),
                    ],
                ],
            ]
        );

        return $result->getModifiedCount() > 0;
    }

    /**
     * Bulk write operations
     */
    public function bulkUpdateStatuses(array $orderStatuses): int
    {
        $operations = array_map(
            fn(array $item) => [
                'updateOne' => [
                    ['_id' => new \MongoDB\BSON\ObjectId($item['id'])],
                    ['$set' => ['status' => $item['status']]],
                ],
            ],
            $orderStatuses
        );

        $result = $this->collection->bulkWrite($operations);
        return $result->getModifiedCount();
    }
}

Aggregation Pipeline

Aggregation pipeline -- мощный инструмент для обработки и анализа данных в MongoDB:

<?php

declare(strict_types=1);

/**
 * MongoDB Aggregation Pipeline examples
 */
final class OrderAnalytics
{
    public function __construct(
        private readonly \MongoDB\Collection $orders,
    ) {}

    /**
     * Revenue by category per month
     */
    public function revenueByCategory(int $year): array
    {
        $pipeline = [
            // Stage 1: Filter by year
            ['$match' => [
                'created_at' => [
                    '$gte' => new \MongoDB\BSON\UTCDateTime(mktime(0, 0, 0, 1, 1, $year) * 1000),
                    '$lt' => new \MongoDB\BSON\UTCDateTime(mktime(0, 0, 0, 1, 1, $year + 1) * 1000),
                ],
                'status' => 'completed',
            ]],

            // Stage 2: Unwind items array
            ['$unwind' => '$items'],

            // Stage 3: Group by month and category
            ['$group' => [
                '_id' => [
                    'month' => ['$month' => '$created_at'],
                    'category' => '$items.category',
                ],
                'revenue' => ['$sum' => ['$multiply' => ['$items.price', '$items.quantity']]],
                'order_count' => ['$sum' => 1],
            ]],

            // Stage 4: Sort
            ['$sort' => ['_id.month' => 1, 'revenue' => -1]],

            // Stage 5: Reshape output
            ['$project' => [
                '_id' => 0,
                'month' => '$_id.month',
                'category' => '$_id.category',
                'revenue' => ['$round' => ['$revenue', 2]],
                'order_count' => 1,
            ]],
        ];

        return $this->orders->aggregate($pipeline)->toArray();
    }

    /**
     * Top customers by spending
     */
    public function topCustomers(int $limit = 10): array
    {
        $pipeline = [
            ['$match' => ['status' => 'completed']],
            ['$group' => [
                '_id' => '$user.id',
                'name' => ['$first' => '$user.name'],
                'total_spent' => ['$sum' => '$total_amount'],
                'order_count' => ['$sum' => 1],
                'avg_order' => ['$avg' => '$total_amount'],
                'last_order' => ['$max' => '$created_at'],
            ]],
            ['$sort' => ['total_spent' => -1]],
            ['$limit' => $limit],
        ];

        return $this->orders->aggregate($pipeline)->toArray();
    }
}

Consistency Levels

MongoDB предлагает настраиваемый уровень консистентности:

Write Concern Описание Производительность
w: 0 Fire and forget Максимальная
w: 1 Подтверждение от primary Высокая
w: "majority" Подтверждение от большинства Средняя
Read Concern Описание
local Данные из primary (могут быть откачены)
majority Данные, подтверждённые большинством
linearizable Самые актуальные, подтверждённые данные

Для финансовых операций: writeConcern: majority + readConcern: majority. Для логов и метрик: w: 1 достаточно.

Когда использовать MongoDB

Подходит

  • Каталоги продуктов с переменными атрибутами
  • CMS с гибкой структурой контента
  • IoT данные с разной схемой от разных устройств
  • Прототипирование с быстро меняющейся схемой

Не подходит

  • Финансовые транзакции с многотабличными JOIN
  • Сложная аналитика (лучше ClickHouse)
  • Данные с жёсткой нормализованной схемой
  • Когда нужны foreign key constraints

Итоги

  • Документная модель подходит для данных с переменной структурой
  • Embedding vs Referencing -- ключевое решение при моделировании
  • Replica Set обеспечивает высокую доступность и автоматический failover
  • Sharding масштабирует запись горизонтально, но выбор shard key критичен
  • Aggregation Pipeline -- мощный инструмент аналитики внутри MongoDB
  • Используйте MongoDB когда гибкость схемы важнее строгих транзакций