Как мы выбирали NoSQL-базу для обработки 500 000 событий в секунду в рекламной платформе

Исходная ситуация

Рекламная платформа «РекламаПро» обрабатывала показы, клики и конверсии для 200 рекламодателей. Вся аналитика хранилась в MySQL 8.0 на одном сервере: 48 ядер, 256 GB RAM, NVMe-массив из четырёх дисков. Система работала, но приближалась к потолку — 80 000 событий в секунду при пиковой нагрузке, а бизнес планировал рост до 500 000 событий/сек.

Проблемы, с которыми команда пришла к нам в itfresh.ru:

  • Запись — INSERT-ы в таблицу событий блокировали SELECT-ы аналитических дашбордов. Отчёты за день строились 12 минут.
  • Масштабирование — MySQL шардинг требовал переписывания приложения, а репликация не решала проблему записи.
  • Гибкость схемы — каждый рекламодатель хотел трекать свои кастомные параметры, и ALTER TABLE на таблице с 2 миллиардами строк занимал 6 часов.

CAP-теорема: что выбрать для ad-tech

Прежде чем сравнивать конкретные базы, мы объяснили команде «РекламаПро» теорему CAP. В распределённой системе можно гарантировать только два свойства из трёх:

  • Consistency (согласованность) — все узлы видят одинаковые данные.
  • Availability (доступность) — система отвечает на каждый запрос.
  • Partition tolerance (устойчивость к разделению) — система работает при потере связи между узлами.

Для рекламной платформы приоритеты были ясны: доступность и устойчивость к разделению (AP). Потерять пару событий при сетевом сбое допустимо — это повлияет на точность статистики на 0.001%. А вот недоступность системы на 5 минут — это потеря десятков тысяч долларов рекламного бюджета.

Классификация популярных NoSQL-баз по CAP:

БазаCAP-модельСценарий
RedisCP (с Sentinel/Cluster)Кэширование, счётчики, сессии
MongoDBCP (по умолчанию)Документы, каталоги, CMS
CassandraAP (tunable consistency)Логи, события, IoT, time-series
Neo4jCA (не для распределённых)Графы, рекомендации, fraud detection

Key-Value: Redis для реального времени

Redis мы рассматривали для горячих данных: счётчики показов/кликов в реальном времени, рейт-лимитинг, кэш профилей рекламодателей.

# redis.conf — конфигурация для ad-tech нагрузки
bind 10.0.0.5
protected-mode yes
port 6379

# Выделяем 32 GB под данные
maxmemory 32gb
maxmemory-policy allkeys-lru

# Persistence: AOF для надёжности
appendonly yes
appendfsync everysec
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 256mb

# Производительность
io-threads 4
io-threads-do-reads yes
tcp-backlog 511
timeout 300

Пример атомарного инкремента счётчика показов:

# Python — подсчёт показов с TTL
import redis

r = redis.Redis(host='10.0.0.5', port=6379, decode_responses=True)

def record_impression(campaign_id: str, timestamp: int):
    pipe = r.pipeline()
    # Счётчик за текущую минуту
    minute_key = f"impressions:{campaign_id}:{timestamp // 60}"
    pipe.incr(minute_key)
    pipe.expire(minute_key, 86400)  # TTL 24 часа
    
    # Общий счётчик кампании
    pipe.hincrby(f"campaign:{campaign_id}", "total_impressions", 1)
    
    # Rate limiting: не больше 1000 показов/сек на кампанию
    rate_key = f"rate:{campaign_id}:{timestamp}"
    pipe.incr(rate_key)
    pipe.expire(rate_key, 2)
    pipe.execute()

Redis отлично справился с задачей реального времени, но для долгосрочного хранения и аналитики он не подходит — данные должны где-то жить постоянно.

Document Store: MongoDB для профилей и кампаний

MongoDB мы выбрали для хранения профилей рекламодателей, настроек кампаний и таргетинга. Главное преимущество — гибкая схема: каждый рекламодатель может иметь свой набор полей без ALTER TABLE.

# mongod.conf — конфигурация replica set
storage:
  dbPath: /var/lib/mongodb
  wiredTiger:
    engineConfig:
      cacheSizeGB: 48
      journalCompressor: snappy
    collectionConfig:
      blockCompressor: snappy

replication:
  replSetName: rs-adtech
  oplogSizeMB: 10240

net:
  port: 27017
  bindIp: 10.0.0.10
  maxIncomingConnections: 5000

setParameter:
  wiredTigerConcurrentReadTransactions: 256
  wiredTigerConcurrentWriteTransactions: 128

Пример документа кампании с кастомными полями:

// Документ кампании — каждый рекламодатель добавляет свои поля
db.campaigns.insertOne({
  _id: ObjectId(),
  advertiser_id: "adv_12345",
  name: "Летняя распродажа 2026",
  status: "active",
  budget: { daily: 50000, total: 1500000, currency: "RUB" },
  targeting: {
    geo: ["RU-MOW", "RU-SPE", "RU-KDA"],
    age: { min: 25, max: 45 },
    interests: ["electronics", "gadgets"],
    devices: ["mobile", "tablet"]
  },
  // Кастомные поля рекламодателя
  custom_params: {
    attribution_window: 7,
    viewability_threshold: 0.5,
    brand_safety_categories: ["politics", "adult"]
  },
  schedule: {
    start: ISODate("2026-06-01"),
    end: ISODate("2026-08-31"),
    hours: { from: 8, to: 23 }
  },
  created_at: ISODate("2026-05-15")
});

// Индексы для быстрого поиска
db.campaigns.createIndex({ advertiser_id: 1, status: 1 });
db.campaigns.createIndex({ "targeting.geo": 1 });
db.campaigns.createIndex({ "schedule.start": 1, "schedule.end": 1 });

Column-Family: Cassandra для событий и аналитики

Для основной задачи — приём и хранение 500 000 событий в секунду — мы выбрали Apache Cassandra. Именно она проектировалась для write-heavy нагрузок с линейным горизонтальным масштабированием.

# cassandra.yaml — ключевые параметры кластера из 6 нод
cluster_name: 'adtech-events'
num_tokens: 256

seed_provider:
  - class_name: org.apache.cassandra.locator.SimpleSeedProvider
    parameters:
      - seeds: "10.0.1.1,10.0.1.2"

listen_address: 10.0.1.1
rpc_address: 10.0.1.1

endpoint_snitch: GossipingPropertyFileSnitch

commitlog_sync: periodic
commitlog_sync_period_in_ms: 10000
commitlog_segment_size_in_mb: 64

memtable_heap_space_in_mb: 4096
memtable_offheap_space_in_mb: 4096

compaction_throughput_mb_per_sec: 256
concurrent_reads: 64
concurrent_writes: 128
concurrent_counter_writes: 64

Схема таблицы событий с правильным партиционированием:

-- Таблица событий: партиция по campaign_id + дате
CREATE TABLE events.impressions (
    campaign_id text,
    event_date date,
    event_time timestamp,
    event_id timeuuid,
    user_id text,
    creative_id text,
    placement_id text,
    geo_country text,
    geo_city text,
    device_type text,
    cost decimal,
    custom_data map<text, text>,
    PRIMARY KEY ((campaign_id, event_date), event_time, event_id)
) WITH CLUSTERING ORDER BY (event_time DESC)
  AND compaction = {'class': 'TimeWindowCompactionStrategy',
                    'compaction_window_size': 1,
                    'compaction_window_unit': 'DAYS'}
  AND default_time_to_live = 7776000  -- 90 дней
  AND gc_grace_seconds = 864000;

-- Материализованное представление для аналитики по гео
CREATE MATERIALIZED VIEW events.impressions_by_geo AS
    SELECT * FROM events.impressions
    WHERE geo_country IS NOT NULL
      AND campaign_id IS NOT NULL
      AND event_date IS NOT NULL
      AND event_time IS NOT NULL
      AND event_id IS NOT NULL
    PRIMARY KEY ((geo_country, event_date), event_time, event_id, campaign_id);

Результат: кластер из 6 нод (32 ядра, 128 GB RAM, NVMe каждая) стабильно обрабатывает 520 000 записей в секунду при задержке записи P99 = 4 мс.

Graph DB: Neo4j для рекомендаций

Неожиданно пригодилась и графовая база. «РекламаПро» хотела строить look-alike аудитории — находить пользователей, похожих на тех, кто уже конвертировался. Реляционные JOIN-ы на таких запросах умирали, а графовая модель справлялась за секунды.

# neo4j.conf — настройка для аналитических запросов
dbms.memory.heap.initial_size=8g
dbms.memory.heap.max_size=16g
dbms.memory.pagecache.size=24g

dbms.connector.bolt.listen_address=10.0.2.1:7687
dbms.connector.http.listen_address=10.0.2.1:7474

Пример Cypher-запроса для look-alike аудитории:

// Найти пользователей, похожих на конвертировавшихся
// (общие интересы + похожая география)
MATCH (converter:User)-[:CONVERTED]->(campaign:Campaign {id: 'camp_123'})
MATCH (converter)-[:INTERESTED_IN]->(interest:Interest)
MATCH (lookalike:User)-[:INTERESTED_IN]->(interest)
WHERE NOT (lookalike)-[:SAW]->(campaign)
  AND lookalike.geo_country = converter.geo_country
WITH lookalike, COUNT(DISTINCT interest) AS common_interests
WHERE common_interests >= 3
RETURN lookalike.id, common_interests
ORDER BY common_interests DESC
LIMIT 10000;

Этот запрос на MySQL с JOIN через 5 таблиц выполнялся 45 секунд. В Neo4j — 800 миллисекунд.

Миграция с MySQL: поэтапный переход

Мы не выключали MySQL в один день. Миграция заняла 8 недель и шла в три этапа:

Этап 1 (недели 1-2): Dual-write. Приложение пишет и в MySQL, и в Cassandra. Читает из MySQL. Сравниваем данные скриптом-верификатором каждые 15 минут.

# Скрипт верификации dual-write
#!/bin/bash
MYSQL_COUNT=$(mysql -N -e "SELECT COUNT(*) FROM events WHERE created_at > NOW() - INTERVAL 15 MINUTE")
CASSANDRA_COUNT=$(cqlsh -e "SELECT COUNT(*) FROM events.impressions WHERE event_date = '$(date +%Y-%m-%d)' AND event_time > '$(date -d '15 minutes ago' +%Y-%m-%dT%H:%M:%S)';" | grep -oP '\d+' | tail -1)

DIFF=$((MYSQL_COUNT - CASSANDRA_COUNT))
if [ ${DIFF#-} -gt 100 ]; then
    echo "ALERT: расхождение $DIFF записей" | telegram-send --config /etc/telegram-send.conf
fi

Этап 2 (недели 3-5): Переключение чтения на Cassandra. MySQL остаётся как fallback. Мониторим задержки и ошибки.

Этап 3 (недели 6-8): Отключение dual-write в MySQL. Перенос исторических данных через Apache Spark:

# Миграция исторических данных MySQL → Cassandra через Spark
spark-submit \
  --class com.adtech.MigrateEvents \
  --master spark://10.0.3.1:7077 \
  --executor-memory 16g \
  --num-executors 8 \
  --conf spark.cassandra.connection.host=10.0.1.1 \
  --conf spark.cassandra.output.batch.size.rows=500 \
  --conf spark.cassandra.output.concurrent.writes=16 \
  migration-job.jar \
  --source-jdbc "jdbc:mysql://10.0.0.1:3306/adtech" \
  --target-keyspace events \
  --target-table impressions \
  --date-from 2025-01-01 \
  --date-to 2026-03-01

Миграция 2 миллиардов строк заняла 14 часов при скорости ~40 000 строк/сек.

Результаты и выводы

Итоговая архитектура «РекламаПро» — классический пример polyglot persistence, где каждая база решает свою задачу:

КомпонентБазаЗадача
Реальное времяRedis Cluster (6 нод)Счётчики, рейт-лимит, кэш
Профили и кампанииMongoDB Replica Set (3 ноды)Документы с гибкой схемой
События и аналитикаCassandra (6 нод)500K событий/сек, time-series
РекомендацииNeo4j (1 нода)Look-alike аудитории, графы

Ключевые метрики после миграции:

  • Пропускная способность — с 80K до 520K событий/сек (рост в 6.5 раз).
  • Время построения отчёта за день — с 12 минут до 8 секунд.
  • Время добавления кастомного поля — с 6 часов (ALTER TABLE) до мгновенного (schema-less).
  • Стоимость инфраструктуры — выросла на 40%, но стоимость обработки одного события снизилась в 4 раза за счёт объёма.

Главный вывод: не существует универсальной NoSQL-базы. Каждая решает свой класс задач. Мы в itfresh.ru рекомендуем начинать с анализа паттернов доступа к данным, а не с хайпа вокруг конкретной технологии.

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

В большинстве случаев нет. NoSQL не заменяет реляционные базы, а дополняет их. Для транзакционных данных (платежи, заказы, пользователи) SQL по-прежнему лучший выбор. NoSQL решает задачи, с которыми SQL справляется плохо: высокая скорость записи, горизонтальное масштабирование, гибкая схема.
MongoDB — самый безопасный выбор для старта. У неё самый низкий порог входа, хорошая документация, гибкая схема и возможность работы как в standalone-режиме, так и в кластере. Если у вас write-heavy нагрузка с предсказуемыми запросами — смотрите на Cassandra.
CAP определяет, какие гарантии вы получите при сетевых сбоях. Для финансовых данных выбирайте CP (согласованность + устойчивость к разделению) — MongoDB, HBase. Для аналитики и логов выбирайте AP (доступность + устойчивость) — Cassandra, DynamoDB. На практике большинство баз позволяют настраивать уровень согласованности.
Основные затраты — не на лицензии (большинство NoSQL open-source), а на обучение команды и рефакторинг приложения. В нашем кейсе миграция заняла 8 недель работы двух инженеров. Инфраструктурные расходы выросли на 40%, но окупились за 3 месяца за счёт масштабирования бизнеса.
Да, но с другим профилем. NoSQL-администратор должен разбираться в распределённых системах, репликации, шардировании и мониторинге кластеров. Знание SQL не поможет — нужно понимать модели данных конкретной базы и уметь проектировать схему под паттерны запросов.

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

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

📞 Связаться с нами
#nosql#redis#mongodb#cassandra#neo4j#cap теорема#базы данных#выбор базы данных
Комментарии 0

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

загрузка...