Архитектура

MySQL и Elasticsearch: гибридная архитектура поиска для высоконагруженных приложений в 2026 году

Ruslan Ismailov Опубликовано 14 мин чтения
M

Введение: почему MySQL одного недостаточно для поиска в 2026 году

MySQL остаётся одной из самых популярных реляционных СУБД в 2026 году — надёжной, хорошо изученной, с богатой экосистемой. Но когда речь заходит о полнотекстовом поиске в высоконагруженных приложениях, его встроенные возможности быстро упираются в потолок.

Встроенный FULLTEXT-индекс MySQL работает через движок InnoDB и поддерживает базовые операции поиска. Однако он не умеет ранжировать результаты с учётом релевантности на уровне Elasticsearch, не поддерживает нечёткий поиск (fuzzy search), не обрабатывает синонимы, не масштабируется горизонтально и начинает деградировать по производительности уже при десятках миллионов строк с поисковыми запросами.

Гибридная схема MySQL + Elasticsearch оправдана, когда одновременно выполняются несколько условий:

  • Объём данных превышает 5–10 миллионов записей с активными поисковыми запросами.
  • Требуется ранжирование по релевантности, автодополнение, фасетный поиск или нечёткое совпадение.
  • Нагрузка на поисковые запросы составляет сотни RPS и выше.
  • MySQL служит системой записи (source of truth), а поиск — вспомогательной функцией.

В этой статье разберём архитектуру такой системы от синхронизации данных до мониторинга, с реальными примерами конфигураций.

Архитектурный обзор: роль MySQL и Elasticsearch

Ключевой принцип гибридной архитектуры: MySQL — источник истины, Elasticsearch — поисковый слой. Данные всегда пишутся в MySQL, а в Elasticsearch попадают только через механизм синхронизации. Это даёт ACID-гарантии для операций записи и горизонтальное масштабирование для операций чтения и поиска.

Типичный поток запросов выглядит так:

  • Операции создания, обновления, удаления → MySQL.
  • Поисковые запросы с фильтрацией по тексту → Elasticsearch.
  • Точечные выборки по ID, агрегации с JOIN → MySQL.
  • Фасетный поиск, автодополнение, геопоиск → Elasticsearch.

Приложение не должно смешивать эти слои: каждый компонент решает задачу, для которой он оптимизирован. Данные в Elasticsearch — это проекция данных из MySQL, денормализованная под нужды поиска.

Синхронизация данных: стратегии и их сравнение

Выбор стратегии синхронизации — самое важное архитектурное решение в этой схеме. Рассмотрим три основных подхода.

CDC (Change Data Capture) с Debezium

Debezium читает бинарный лог MySQL (binlog) и публикует события об изменениях в Kafka. Это наиболее надёжный и масштабируемый подход для продакшна.

Плюсы: минимальная нагрузка на MySQL, точное отслеживание каждого изменения, возможность воспроизведения событий, низкий лаг (секунды).

Минусы: требует Kafka и Kafka Connect в инфраструктуре, сложнее в отладке, необходимо включить binlog_format=ROW в MySQL.

Логическая репликация через триггеры или outbox-паттерн

При каждой записи в основную таблицу приложение также пишет событие в таблицу outbox. Отдельный воркер читает эту таблицу и индексирует изменения в Elasticsearch.

Плюсы: не требует сторонних инструментов, полный контроль над логикой трансформации.

Минусы: дополнительная нагрузка на MySQL, риск «забыть» добавить запись в outbox при изменении бизнес-логики, сложнее гарантировать exactly-once семантику.

Периодический (batch) sync

Scheduler запускает запрос вида SELECT * FROM products WHERE updated_at > :last_sync и переиндексирует изменившиеся записи.

Плюсы: простота реализации, минимум зависимостей.

Минусы: лаг от секунд до минут, не отслеживает удаления без soft-delete, при большой нагрузке может не успевать за потоком изменений.

Для highload-систем рекомендуется CDC с Debezium. Рассмотрим его настройку подробнее.

Практический пример: настройка MySQL → Elasticsearch через Debezium

Предположим, у нас есть таблица products в MySQL, которую нужно индексировать в Elasticsearch. Схема окружения: MySQL 8.0, Kafka 3.x, Debezium 2.x, Elasticsearch 8.x.

Шаг 1: настройка MySQL

Убедитесь, что в my.cnf включены необходимые параметры:

[mysqld]
server-id = 1
log_bin = /var/log/mysql/mysql-bin.log
binlog_format = ROW
binlog_row_image = FULL
expire_logs_days = 7
gtid_mode = ON
enforce_gtid_consistency = ON

Шаг 2: конфигурация Debezium Connector

Создайте JSON-конфигурацию для Kafka Connect:

{
  "name": "mysql-products-connector",
  "config": {
    "connector.class": "io.debezium.connector.mysql.MySqlConnector",
    "tasks.max": "1",
    "database.hostname": "mysql-host",
    "database.port": "3306",
    "database.user": "debezium",
    "database.password": "secret",
    "database.server.id": "184054",
    "topic.prefix": "myapp",
    "database.include.list": "shop",
    "table.include.list": "shop.products",
    "schema.history.internal.kafka.bootstrap.servers": "kafka:9092",
    "schema.history.internal.kafka.topic": "schema-changes.shop",
    "include.schema.changes": "true",
    "snapshot.mode": "initial",
    "transforms": "route",
    "transforms.route.type": "org.apache.kafka.connect.transforms.ReplaceField$Value",
    "transforms.route.whitelist": "id,name,description,price,category_id,updated_at"
  }
}

Шаг 3: Elasticsearch Sink Connector

Используем Kafka Connect Elasticsearch Sink для записи событий в индекс:

{
  "name": "elasticsearch-products-sink",
  "config": {
    "connector.class": "io.confluent.connect.elasticsearch.ElasticsearchSinkConnector",
    "tasks.max": "2",
    "topics": "myapp.shop.products",
    "connection.url": "http://elasticsearch:9200",
    "type.name": "_doc",
    "key.ignore": "false",
    "schema.ignore": "true",
    "behavior.on.null.values": "DELETE",
    "transforms": "extractKey,unwrap",
    "transforms.extractKey.type": "org.apache.kafka.connect.transforms.ExtractField$Key",
    "transforms.extractKey.field": "id",
    "transforms.unwrap.type": "io.debezium.transforms.ExtractNewRecordState",
    "transforms.unwrap.drop.tombstones": "false",
    "transforms.unwrap.delete.handling.mode": "rewrite",
    "transforms.unwrap.add.fields": "op,ts_ms"
  }
}

Маппинг схемы: трансформация реляционной модели в документы

Реляционная модель плохо ложится в документы напрямую. Поисковый документ должен быть денормализован: данные из нескольких таблиц объединяются в один документ, чтобы избежать JOIN в момент поиска.

Пример: таблицы products, categories и brands в MySQL превращаются в один документ Elasticsearch:

PUT /products
{
  "mappings": {
    "properties": {
      "id": { "type": "integer" },
      "name": {
        "type": "text",
        "analyzer": "russian",
        "fields": {
          "keyword": { "type": "keyword" },
          "suggest": { "type": "completion" }
        }
      },
      "description": { "type": "text", "analyzer": "russian" },
      "price": { "type": "scaled_float", "scaling_factor": 100 },
      "category": {
        "properties": {
          "id": { "type": "integer" },
          "name": { "type": "keyword" },
          "slug": { "type": "keyword" }
        }
      },
      "brand": {
        "properties": {
          "id": { "type": "integer" },
          "name": { "type": "keyword" }
        }
      },
      "tags": { "type": "keyword" },
      "is_active": { "type": "boolean" },
      "updated_at": { "type": "date" }
    }
  },
  "settings": {
    "number_of_shards": 3,
    "number_of_replicas": 1,
    "analysis": {
      "analyzer": {
        "russian": {
          "type": "custom",
          "tokenizer": "standard",
          "filter": ["lowercase", "russian_stop", "russian_stemmer"]
        }
      },
      "filter": {
        "russian_stop": { "type": "stop", "stopwords": "_russian_" },
        "russian_stemmer": { "type": "stemmer", "language": "russian" }
      }
    }
  }
}

Важные правила маппинга: используйте keyword для полей, по которым делаете агрегации и точную фильтрацию. Используйте text с нужным анализатором для полнотекстового поиска. Для числовых диапазонов применяйте правильные числовые типы — не text.

Обработка расхождений данных: eventual consistency и dead letter queue

Гибридная архитектура по природе eventual consistent — между записью в MySQL и появлением данных в Elasticsearch проходит время. Это нормально, но нужно контролировать.

Dead Letter Queue (DLQ)

Настройте DLQ в Kafka Connect для обработки ошибок индексации. Если документ не удалось записать в Elasticsearch (например, из-за конфликта маппинга), сообщение попадает в отдельный топик для ручного анализа:

"errors.tolerance": "all",
"errors.deadletterqueue.topic.name": "dlq-elasticsearch-products",
"errors.deadletterqueue.context.headers.enable": "true",
"errors.log.enable": "true",
"errors.log.include.messages": "true"

Мониторинг лага синхронизации

Отслеживайте Consumer Group Lag в Kafka — разницу между последним записанным оффсетом и текущей позицией консьюмера. Лаг более 1000 сообщений при нормальной нагрузке — сигнал к расследованию.

Добавьте в каждый документ Elasticsearch поле indexed_at (время индексации) и updated_at (время изменения в MySQL). Разница между ними — реальный лаг синхронизации для конкретной записи.

Reconciliation job

Раз в сутки или при обнаружении аномалий запускайте задачу сверки: выгружайте из MySQL список ID с updated_at за последние N часов и сравнивайте с тем, что есть в Elasticsearch. Расхождения переиндексируйте принудительно.

Паттерны запросов: когда идти в MySQL, когда в Elasticsearch

Чёткое разделение ответственности между хранилищами — залог производительности системы.

Запросы в Elasticsearch

  • Полнотекстовый поиск по нескольким полям с ранжированием.
  • Фасетная фильтрация (категория + цена + бренд + рейтинг).
  • Автодополнение и поиск по префиксу через completion suggester.
  • Нечёткий поиск (fuzziness: AUTO) для исправления опечаток.
  • Геопоиск (geo_distance query).

Запросы в MySQL

  • Получение полной записи по ID после поиска в Elasticsearch.
  • Сложные транзакционные операции с несколькими таблицами.
  • Финансовые и критичные агрегации, требующие ACID.
  • Административные выборки с произвольными JOIN.

Комбинированные запросы

Паттерн «Search then Fetch»: сначала получаем список релевантных ID из Elasticsearch, затем загружаем полные объекты из MySQL по этим ID. Важно ограничивать размер выборки ID — не более 1000 за раз, иначе MySQL-запрос WHERE id IN (...) начинает тормозить.

-- После получения ids = [42, 17, 891, ...] из Elasticsearch
SELECT p.*, c.name as category_name, b.name as brand_name
FROM products p
JOIN categories c ON p.category_id = c.id
JOIN brands b ON p.brand_id = b.id
WHERE p.id IN (42, 17, 891)
ORDER BY FIELD(p.id, 42, 17, 891); -- сохраняем порядок релевантности

Производительность: бенчмарки и тюнинг

Тюнинг Elasticsearch

Для индексов с высокой нагрузкой на запись применяйте следующие настройки:

PUT /products/_settings
{
  "index": {
    "refresh_interval": "5s",
    "number_of_replicas": 0,
    "translog": {
      "durability": "async",
      "sync_interval": "5s"
    }
  }
}

После завершения массовой индексации верните number_of_replicas: 1 и refresh_interval: 1s. Используйте _bulk API для пакетной индексации — оптимальный размер батча 500–2000 документов при размере документа до 5 КБ.

Ориентировочные показатели на кластере из 3 нод по 32 ГБ RAM: скорость индексации — 15 000–25 000 документов/сек, время поискового запроса с фасетами — p95 < 50 мс при индексе 50 миллионов документов.

Оптимизация MySQL для экспорта

При первоначальной индексации или reconciliation используйте следующие рекомендации:

  • Добавьте составной индекс (updated_at, id) для инкрементальных выборок.
  • Используйте курсорную пагинацию через WHERE id > :last_id вместо LIMIT/OFFSETOFFSET деградирует на больших таблицах.
  • Для полного снимка применяйте mysqldump с --single-transaction или читайте данные из реплики.
  • Ограничивайте количество колонок в SELECT — не тащите в индекс поля, которые не нужны для поиска.

Мониторинг и observability гибридной системы

Гибридная система требует мониторинга на нескольких уровнях одновременно.

Метрики Kafka и Debezium

  • kafka.consumer.lag — лаг консьюмера по каждому топику и партиции.
  • debezium.mysql.milliseconds_behind_master — отставание от binlog в миллисекундах.
  • Количество сообщений в DLQ — должно стремиться к нулю.

Метрики Elasticsearch

  • Latency поисковых запросов (p50, p95, p99) через Kibana или Prometheus ES Exporter.
  • indexing_pressure.memory.total.primary_bytes — давление на индексирование.
  • GC pause time — должно быть менее 200 мс, иначе нужно тюнить heap JVM.
  • Shard size — оптимально 10–50 ГБ на шард.

Метрики приложения

Добавьте трассировку (OpenTelemetry) для каждого поискового запроса с метками: источник данных (mysql или elasticsearch), время выполнения, количество результатов. Это позволяет строить SLO по доступности и латентности поиска независимо от каждого хранилища.

Настройте алерты на: лаг синхронизации > 30 сек, рост DLQ, деградацию p95 поисковых запросов выше порога, сбой Debezium connector (статус FAILED в Kafka Connect REST API).

Итоги и чеклист для внедрения

Гибридная архитектура MySQL + Elasticsearch — зрелое решение для высоконагруженных поисковых систем в 2026 году. Ниже чеклист для команды, которая внедряет эту схему:

  1. MySQL: включён binlog_format=ROW, создан пользователь с минимальными правами для Debezium (REPLICATION SLAVE, REPLICATION CLIENT), добавлены индексы (updated_at, id) на экспортируемых таблицах.
  2. Kafka: настроена retention достаточная для replay (минимум 7 дней), включён мониторинг consumer lag.
  3. Debezium: настроен snapshot для начальной загрузки, сконфигурирован DLQ, проверены трансформации (SMT).
  4. Elasticsearch: маппинг создан явно (не через dynamic mapping), настроен анализатор под язык данных, выбраны правильное количество шардов и реплик.
  5. Приложение: реализован паттерн «Search then Fetch», поиск идёт только в Elasticsearch, запись — только в MySQL, добавлен fallback на MySQL при недоступности Elasticsearch для критичных операций.
  6. Observability: настроены метрики лага синхронизации, алерты на DLQ и деградацию, добавлена трассировка запросов.
  7. Тестирование: написаны интеграционные тесты на сценарии eventual consistency, протестирован сценарий восстановления после сбоя Elasticsearch и Kafka.

Начните с малого: возьмите одну таблицу, настройте синхронизацию, убедитесь в стабильности, затем масштабируйте на остальные сущности. Правильно выстроенная MySQL Elasticsearch синхронизация даёт прирост производительности поиска в 10–50 раз по сравнению с FULLTEXT-индексами MySQL при сохранении надёжности реляционного хранилища как источника истины.

Технологии

Теги

Руслан Исмаилов

Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →