MySQL и Elasticsearch: гибридная архитектура поиска для высоконагруженных приложений в 2026 году
Введение: почему 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
- Полнотекстовый поиск по нескольким полям с ранжированием.
- Фасетная фильтрация (категория + цена + бренд + рейтинг).
- Автодополнение и поиск по префиксу через
completionsuggester. - Нечёткий поиск (
fuzziness: AUTO) для исправления опечаток. - Геопоиск (
geo_distancequery).
Запросы в 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/OFFSET—OFFSETдеградирует на больших таблицах. - Для полного снимка применяйте
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 году. Ниже чеклист для команды, которая внедряет эту схему:
- MySQL: включён
binlog_format=ROW, создан пользователь с минимальными правами для Debezium (REPLICATION SLAVE,REPLICATION CLIENT), добавлены индексы(updated_at, id)на экспортируемых таблицах. - Kafka: настроена retention достаточная для replay (минимум 7 дней), включён мониторинг consumer lag.
- Debezium: настроен snapshot для начальной загрузки, сконфигурирован DLQ, проверены трансформации (SMT).
- Elasticsearch: маппинг создан явно (не через dynamic mapping), настроен анализатор под язык данных, выбраны правильное количество шардов и реплик.
- Приложение: реализован паттерн «Search then Fetch», поиск идёт только в Elasticsearch, запись — только в MySQL, добавлен fallback на MySQL при недоступности Elasticsearch для критичных операций.
- Observability: настроены метрики лага синхронизации, алерты на DLQ и деградацию, добавлена трассировка запросов.
- Тестирование: написаны интеграционные тесты на сценарии 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. Подробнее обо мне →