10 принципов System Design при масштабировании маркетплейса до 100K пользователей

Исходная ситуация: маркетплейс на пределе

В феврале 2026 года к нам обратилась команда маркетплейса ТоргПлатформа — торговой площадки для мелкооптовых поставщиков с аудиторией ~1 000 активных пользователей. Платформа работала на одном VPS: монолитное Django-приложение, PostgreSQL 15, Nginx — всё на одном сервере с 8 vCPU и 32 GB RAM.

Бизнес рос: маркетинговая кампания обещала приток до 100 000 пользователей за полгода. Текущая архитектура не выдерживала даже 3 000 одновременных сессий — при нагрузочном тестировании через k6 сервер начинал отвечать за 5+ секунд, а на 5 000 RPS падал с OOM killer.

Мы предложили поэтапное масштабирование, основанное на 10 фундаментальных принципах System Design. Каждый принцип был внедрён с конкретными инструментами и конфигурациями.

Принцип 1: горизонтальное масштабирование вместо вертикального

Первое, что мы сделали — отказались от идеи «купить сервер побольше». Вертикальное масштабирование имеет жёсткий потолок и единую точку отказа. Вместо этого мы разделили приложение на stateless-компоненты за балансировщиком.

Развернули 4 экземпляра приложения в Docker-контейнерах за HAProxy:

# /etc/haproxy/haproxy.cfg
frontend marketplace_front
    bind *:443 ssl crt /etc/ssl/certs/torgplatforma.pem
    default_backend app_servers
    
    # Rate limiting на фронтенде
    stick-table type ip size 100k expire 30s store http_req_rate(10s)
    http-request track-sc0 src
    http-request deny deny_status 429 if { sc_http_req_rate(0) gt 100 }

backend app_servers
    balance roundrobin
    option httpchk GET /healthz
    http-check expect status 200
    
    server app1 10.0.1.10:8000 check inter 5s fall 3 rise 2
    server app2 10.0.1.11:8000 check inter 5s fall 3 rise 2
    server app3 10.0.1.12:8000 check inter 5s fall 3 rise 2
    server app4 10.0.1.13:8000 check inter 5s fall 3 rise 2

Ключевой момент — приложение стало stateless. Сессии мы перенесли в Redis, статические файлы — в S3-совместимое хранилище. Это позволило добавлять новые инстансы за минуты.

Принцип 2: шардинг базы данных

PostgreSQL с 8 миллионами строк в таблице товаров уже тормозил на сложных фильтрах. Мы применили range-based шардинг по seller_id, разбив данные на 4 шарда через Citus:

-- Установка расширения Citus
CREATE EXTENSION citus;

-- Регистрация воркеров
SELECT citus_set_coordinator_host('10.0.2.1', 5432);
SELECT * FROM citus_add_node('10.0.2.10', 5432);
SELECT * FROM citus_add_node('10.0.2.11', 5432);
SELECT * FROM citus_add_node('10.0.2.12', 5432);
SELECT * FROM citus_add_node('10.0.2.13', 5432);

-- Распределение таблицы товаров по seller_id
SELECT create_distributed_table('products', 'seller_id');
SELECT create_distributed_table('orders', 'seller_id');
SELECT create_distributed_table('order_items', 'seller_id');

-- Справочные таблицы реплицируются на все шарды
SELECT create_reference_table('categories');
SELECT create_reference_table('regions');

Выбор seller_id в качестве ключа шардинга был осознанным: 90% запросов фильтруют товары по продавцу, что обеспечивает co-location данных. Запрос к одному продавцу попадает на один шард без cross-shard joins.

Результат: среднее время запроса каталога упало с 340 мс до 45 мс.

Принцип 3: многоуровневое кеширование

Мы выстроили три уровня кеша, каждый со своей стратегией инвалидации:

# Уровень 1: CDN кеш для статики и страниц каталога
# Cloudflare Page Rules:
# *.torgplatforma.ru/catalog/* — Cache Level: Standard, TTL: 1h
# *.torgplatforma.ru/static/* — Cache Level: Aggressive, TTL: 30d

# Уровень 2: Redis кеш для данных приложения
# redis.conf (sentinel режим для HA)
port 6379
maxmemory 8gb
maxmemory-policy allkeys-lfu
save 900 1
save 300 10
replica-read-only yes
# Уровень 3: кеширование в приложении (Django)
# settings.py
CACHES = {
    'default': {
        'BACKEND': 'django_redis.cache.RedisCache',
        'LOCATION': 'redis://redis-sentinel:26379/0',
        'OPTIONS': {
            'CLIENT_CLASS': 'django_redis.client.SentinelClient',
            'SENTINELS': [
                ('10.0.3.1', 26379),
                ('10.0.3.2', 26379),
                ('10.0.3.3', 26379),
            ],
            'SENTINEL_KWARGS': {'password': 'sentinel_pass'},
        },
        'KEY_PREFIX': 'tp',
        'TIMEOUT': 300,  # 5 минут по умолчанию
    }
}

# Паттерн cache-aside для карточки товара
def get_product(product_id):
    cache_key = f'product:{product_id}'
    product = cache.get(cache_key)
    if product is None:
        product = Product.objects.select_related(
            'seller', 'category'
        ).get(id=product_id)
        cache.set(cache_key, product, timeout=600)
    return product

Стратегия инвалидации: при обновлении товара продавцом вызывается cache.delete(f'product:{product_id}') и отправляется purge-запрос в CDN через API. Cache hit rate достиг 87% за первую неделю.

Принцип 4: очереди сообщений для асинхронной обработки

Синхронная обработка заказа занимала 3-4 секунды: проверка наличия, резервирование, уведомление продавца, формирование документов. Мы вынесли всё, кроме резервирования, в очередь на Kafka:

# docker-compose.kafka.yml
services:
  kafka:
    image: confluentinc/cp-kafka:7.6.0
    environment:
      KAFKA_BROKER_ID: 1
      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
      KAFKA_NUM_PARTITIONS: 12
      KAFKA_DEFAULT_REPLICATION_FACTOR: 3
      KAFKA_MIN_INSYNC_REPLICAS: 2
      KAFKA_LOG_RETENTION_HOURS: 168
      KAFKA_LOG_SEGMENT_BYTES: 1073741824
# producer.py — публикация события заказа
from confluent_kafka import Producer
import json

producer = Producer({
    'bootstrap.servers': 'kafka:9092',
    'acks': 'all',
    'retries': 5,
    'enable.idempotence': True,  # Принцип 7: идемпотентность
})

def publish_order_event(order):
    event = {
        'event_type': 'order.created',
        'order_id': str(order.id),
        'seller_id': str(order.seller_id),
        'items': [{
            'product_id': str(item.product_id),
            'quantity': item.quantity,
            'price': str(item.price),
        } for item in order.items.all()],
        'timestamp': order.created_at.isoformat(),
    }
    producer.produce(
        topic='marketplace.orders',
        key=str(order.seller_id),  # партиционирование по seller
        value=json.dumps(event),
    )
    producer.flush()

Консьюмеры обрабатывали уведомления, генерацию PDF-документов и аналитику параллельно. Время ответа на создание заказа упало с 3.4 секунды до 280 мс.

Принцип 5: Circuit Breaker и Принцип 6: Rate Limiting

Маркетплейс интегрировался с 12 платёжными шлюзами и службами доставки. Падение одного внешнего сервиса не должно тянуть за собой всю платформу. Мы внедрили Circuit Breaker через библиотеку pybreaker:

# circuit_breaker.py
import pybreaker
import requests

# Конфигурация Circuit Breaker для платёжного шлюза
payment_breaker = pybreaker.CircuitBreaker(
    fail_max=5,           # 5 ошибок подряд → размыкание
    reset_timeout=30,     # через 30 секунд пробуем снова
    exclude=[requests.exceptions.Timeout],  # таймауты не считаем
)

@payment_breaker
def process_payment(order_id, amount, gateway='primary'):
    response = requests.post(
        f'https://api.{gateway}-pay.ru/v1/charge',
        json={'order_id': order_id, 'amount': amount},
        timeout=5,
    )
    response.raise_for_status()
    return response.json()

def process_payment_with_fallback(order_id, amount):
    try:
        return process_payment(order_id, amount, gateway='primary')
    except pybreaker.CircuitBreakerError:
        # Основной шлюз недоступен — переключаемся на резервный
        return process_payment(order_id, amount, gateway='backup')

Для Rate Limiting мы использовали алгоритм Token Bucket через Redis, ограничивая API на уровне пользователя:

# rate_limiter.py
import redis
import time

r = redis.Redis(host='redis-sentinel', port=6379)

def check_rate_limit(user_id, max_requests=100, window=60):
    key = f'ratelimit:{user_id}:{int(time.time()) // window}'
    pipe = r.pipeline()
    pipe.incr(key)
    pipe.expire(key, window + 1)
    result = pipe.execute()
    current = result[0]
    if current > max_requests:
        return False, max_requests - current  # отклонить
    return True, max_requests - current  # пропустить

Каждый пользователь ограничен 100 запросами в минуту. Для API-партнёров лимит увеличен до 1 000 req/min с отдельным ключом.

Принцип 7: идемпотентность и Принцип 8: eventual consistency

Идемпотентность критически важна в маркетплейсе: покупатель может нажать «Оплатить» дважды, сеть может дублировать запрос. Мы реализовали идемпотентные ключи на уровне API:

# middleware/idempotency.py
import hashlib
from django.core.cache import cache

class IdempotencyMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        if request.method in ('POST', 'PUT', 'PATCH'):
            idempotency_key = request.headers.get('X-Idempotency-Key')
            if idempotency_key:
                cache_key = f'idempotent:{idempotency_key}'
                cached_response = cache.get(cache_key)
                if cached_response:
                    return cached_response  # возвращаем кешированный ответ
                
                response = self.get_response(request)
                
                if response.status_code < 500:
                    cache.set(cache_key, response, timeout=86400)
                return response
        
        return self.get_response(request)

Для Eventual Consistency мы приняли осознанное решение: каталог товаров может отставать от реальных остатков на 5-10 секунд. Покупатель видит «В наличии», но точная проверка происходит при оформлении заказа. Синхронизация остатков работает через Kafka-консьюмер с debounce:

# consumer_stock_sync.py
from collections import defaultdict
import threading

stock_buffer = defaultdict(int)
buffer_lock = threading.Lock()

def flush_stock_updates():
    """Записываем накопленные изменения в кеш каждые 5 секунд."""
    with buffer_lock:
        for product_id, delta in stock_buffer.items():
            cache_key = f'stock:{product_id}'
            current = cache.get(cache_key, 0)
            cache.set(cache_key, current + delta, timeout=3600)
        stock_buffer.clear()
    
    threading.Timer(5.0, flush_stock_updates).start()

flush_stock_updates()

Такой подход позволил снять нагрузку с БД: 95% запросов каталога обслуживаются из кеша без обращения к PostgreSQL.

Принцип 9: Observability и Принцип 10: Graceful Degradation

Без наблюдаемости масштабирование превращается в полёт вслепую. Мы развернули полный стек мониторинга:

# docker-compose.monitoring.yml
services:
  prometheus:
    image: prom/prometheus:v2.50.0
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
    command:
      - '--storage.tsdb.retention.time=90d'
      - '--storage.tsdb.retention.size=50GB'

  grafana:
    image: grafana/grafana:10.3.0
    environment:
      GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASS}

  loki:
    image: grafana/loki:2.9.4
    command: -config.file=/etc/loki/loki.yml
# prometheus.yml — ключевые метрики маркетплейса
scrape_configs:
  - job_name: 'django-app'
    metrics_path: '/metrics'
    static_configs:
      - targets:
        - 'app1:8000'
        - 'app2:8000'
        - 'app3:8000'
        - 'app4:8000'

  - job_name: 'haproxy'
    static_configs:
      - targets: ['haproxy:8405']

  - job_name: 'postgresql'
    static_configs:
      - targets: ['postgres-exporter:9187']

  - job_name: 'redis'
    static_configs:
      - targets: ['redis-exporter:9121']

Мы настроили 4 уровня алертов: P1 (платежи не проходят → Telegram + звонок), P2 (latency > 2с → Telegram), P3 (ошибки > 1% → Telegram), P4 (диск > 80% → email).

Для Graceful Degradation определили приоритеты функций:

  • Критические (никогда не отключаем): оформление заказа, оплата, авторизация
  • Важные (деградируем при нагрузке): поиск по каталогу, рекомендации
  • Необязательные (отключаем первыми): отзывы, рейтинги, аналитика продавца

При превышении 80% CPU на нодах автоматически отключаются необязательные сервисы через feature flags в Redis. Маркетплейс продолжает принимать заказы даже при частичной деградации.

Часто задаваемые вопросы

Начните с горизонтального масштабирования и кеширования — они дают максимальный эффект при минимальных переделках. Сделайте приложение stateless, перенесите сессии в Redis, поставьте балансировщик. Шардинг и очереди внедряйте, когда упрётесь в потолок одной БД или синхронной обработки.
Ключ шардинга должен обеспечивать co-location связанных данных. Для маркетплейса seller_id идеален, если большинство запросов привязаны к конкретному продавцу. Для соцсетей — user_id, для SaaS — tenant_id. Главный критерий: запрос не должен ходить на все шарды одновременно.
Нет. Для баланса, платежей и транзакций нужна строгая консистентность (strong consistency). Eventual consistency подходит для каталога, остатков на витрине, рейтингов — данных, где задержка в 5-10 секунд не приводит к финансовым потерям. Точная проверка остатков должна происходить в момент оформления заказа.
Circuit Breaker не подходит для операций, которые обязательно должны завершиться (платёж, отправка критичного уведомления). В таких случаях используйте retry с exponential backoff и dead letter queue. Circuit Breaker идеален для необязательных зависимостей — рекомендации, аналитика, обогащение данных.
В нашем кейсе инфраструктура выросла с 1 сервера (15 000 руб/мес) до 12 серверов + Kafka + Redis Sentinel + мониторинг (около 180 000 руб/мес). Но стоимость простоя при падении монолита в пик продаж составляла 2-3 млн рублей за час. Масштабирование окупилось за первый месяц работы.

Нужна помощь с проектом?

Специалисты АйТи Фреш помогут с архитектурой, DevOps, безопасностью и разработкой — 15+ лет опыта

📞 Связаться с нами
#system design#масштабирование маркетплейса#горизонтальное масштабирование#шардинг базы данных#кеширование redis#message queue kafka#circuit breaker#rate limiting
Комментарии 0

Оставить комментарий

загрузка...