Arquitectura

MySQL y Elasticsearch: arquitectura híbrida de búsqueda para aplicaciones de alta carga en 2026

Ruslan Ismailov Publicado 14 min de lectura
M

Introducción: por qué MySQL no es suficiente para la búsqueda en 2026

MySQL sigue siendo uno de los sistemas de gestión de bases de datos relacionales más populares en 2026: fiable, bien documentado y con un ecosistema rico. Sin embargo, cuando se trata de búsqueda de texto completo en aplicaciones de alta carga, sus capacidades integradas alcanzan rápidamente sus límites.

El índice FULLTEXT integrado de MySQL funciona a través del motor InnoDB y soporta operaciones de búsqueda básicas. Sin embargo, no puede clasificar resultados por relevancia al nivel de Elasticsearch, no admite búsqueda difusa (fuzzy search), no procesa sinónimos, no escala horizontalmente y comienza a degradar su rendimiento con decenas de millones de filas bajo consultas de búsqueda.

El esquema híbrido MySQL + Elasticsearch está justificado cuando se cumplen simultáneamente varias condiciones:

  • El volumen de datos supera los 5–10 millones de registros con consultas de búsqueda activas.
  • Se requiere clasificación por relevancia, autocompletado, búsqueda facetada o coincidencia difusa.
  • La carga de consultas de búsqueda es de cientos de RPS o más.
  • MySQL actúa como sistema de registro (source of truth) y la búsqueda es una función auxiliar.

En este artículo analizaremos la arquitectura de dicho sistema desde la sincronización de datos hasta el monitoreo, con ejemplos reales de configuraciones.

Visión general de la arquitectura: el rol de MySQL y Elasticsearch

El principio clave de la arquitectura híbrida: MySQL es la fuente de verdad, Elasticsearch es la capa de búsqueda. Los datos siempre se escriben en MySQL y llegan a Elasticsearch únicamente a través del mecanismo de sincronización. Esto proporciona garantías ACID para las operaciones de escritura y escalado horizontal para las operaciones de lectura y búsqueda.

El flujo típico de solicitudes es el siguiente:

  • Operaciones de creación, actualización y eliminación → MySQL.
  • Consultas de búsqueda con filtrado por texto → Elasticsearch.
  • Consultas puntuales por ID, agregaciones con JOIN → MySQL.
  • Búsqueda facetada, autocompletado, búsqueda geográfica → Elasticsearch.

La aplicación no debe mezclar estas capas: cada componente resuelve la tarea para la que está optimizado. Los datos en Elasticsearch son una proyección de los datos de MySQL, desnormalizada para las necesidades de búsqueda.

Sincronización de datos: estrategias y comparación

Elegir la estrategia de sincronización es la decisión arquitectónica más importante en este esquema. Veamos los tres enfoques principales.

CDC (Change Data Capture) con Debezium

Debezium lee el registro binario de MySQL (binlog) y publica eventos de cambios en Kafka. Este es el enfoque más fiable y escalable para producción.

Ventajas: carga mínima sobre MySQL, seguimiento preciso de cada cambio, posibilidad de reproducción de eventos, baja latencia (segundos).

Desventajas: requiere Kafka y Kafka Connect en la infraestructura, más complejo de depurar, es necesario habilitar binlog_format=ROW en MySQL.

Replicación lógica mediante triggers o patrón outbox

Con cada escritura en la tabla principal, la aplicación también escribe un evento en la tabla outbox. Un worker independiente lee esta tabla e indexa los cambios en Elasticsearch.

Ventajas: no requiere herramientas de terceros, control total sobre la lógica de transformación.

Desventajas: carga adicional sobre MySQL, riesgo de olvidar agregar un registro al outbox al cambiar la lógica de negocio, más difícil garantizar semántica exactly-once.

Sincronización periódica (batch)

El scheduler ejecuta una consulta del tipo SELECT * FROM products WHERE updated_at > :last_sync y reindexea los registros modificados.

Ventajas: simplicidad de implementación, dependencias mínimas.

Desventajas: latencia de segundos a minutos, no rastrea eliminaciones sin soft-delete, bajo alta carga puede no seguir el ritmo del flujo de cambios.

Para sistemas de alta carga se recomienda CDC con Debezium. Veamos su configuración en detalle.

Ejemplo práctico: configuración MySQL → Elasticsearch con Debezium

Supongamos que tenemos una tabla products en MySQL que necesitamos indexar en Elasticsearch. El entorno: MySQL 8.0, Kafka 3.x, Debezium 2.x, Elasticsearch 8.x.

Paso 1: configuración de MySQL

Asegúrese de que en my.cnf estén habilitados los parámetros necesarios:

[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

Paso 2: configuración del conector Debezium

Cree la configuración JSON para 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"
  }
}

Paso 3: Elasticsearch Sink Connector

Usamos Kafka Connect Elasticsearch Sink para escribir eventos en el índice:

{
  "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"
  }
}

Mapeo de esquemas: transformación del modelo relacional en documentos

El modelo relacional no encaja directamente en documentos. El documento de búsqueda debe estar desnormalizado: los datos de varias tablas se combinan en un solo documento para evitar JOIN en el momento de la búsqueda.

Ejemplo: las tablas products, categories y brands en MySQL se convierten en un único documento de Elasticsearch:

PUT /products
{
  "mappings": {
    "properties": {
      "id": { "type": "integer" },
      "name": {
        "type": "text",
        "analyzer": "spanish",
        "fields": {
          "keyword": { "type": "keyword" },
          "suggest": { "type": "completion" }
        }
      },
      "description": { "type": "text", "analyzer": "spanish" },
      "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": {
        "spanish": {
          "type": "custom",
          "tokenizer": "standard",
          "filter": ["lowercase", "spanish_stop", "spanish_stemmer"]
        }
      },
      "filter": {
        "spanish_stop": { "type": "stop", "stopwords": "_spanish_" },
        "spanish_stemmer": { "type": "stemmer", "language": "spanish" }
      }
    }
  }
}

Reglas importantes de mapeo: use keyword para campos sobre los que realiza agregaciones y filtrado exacto. Use text con el analizador adecuado para búsqueda de texto completo. Para rangos numéricos, utilice los tipos numéricos correctos, no text.

Manejo de inconsistencias de datos: eventual consistency y dead letter queue

La arquitectura híbrida es por naturaleza eventualmente consistente: entre la escritura en MySQL y la aparición de los datos en Elasticsearch transcurre un tiempo. Esto es normal, pero debe controlarse.

Dead Letter Queue (DLQ)

Configure DLQ en Kafka Connect para manejar errores de indexación. Si un documento no pudo escribirse en Elasticsearch (por ejemplo, debido a un conflicto de mapeo), el mensaje va a un topic separado para análisis manual:

"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"

Monitoreo del lag de sincronización

Monitoree el Consumer Group Lag en Kafka: la diferencia entre el último offset escrito y la posición actual del consumidor. Un lag superior a 1000 mensajes bajo carga normal es una señal de investigación.

Agregue a cada documento de Elasticsearch el campo indexed_at (tiempo de indexación) y updated_at (tiempo de modificación en MySQL). La diferencia entre ambos es el lag real de sincronización para cada registro.

Reconciliation job

Una vez al día o al detectar anomalías, ejecute una tarea de reconciliación: exporte desde MySQL la lista de IDs con updated_at de las últimas N horas y compárela con lo que hay en Elasticsearch. Las discrepancias se reindexan de forma forzada.

Patrones de consultas: cuándo ir a MySQL y cuándo a Elasticsearch

Una clara separación de responsabilidades entre los almacenes es la clave del rendimiento del sistema.

Consultas en Elasticsearch

  • Búsqueda de texto completo en varios campos con clasificación por relevancia.
  • Filtrado facetado (categoría + precio + marca + valoración).
  • Autocompletado y búsqueda por prefijo mediante el suggester completion.
  • Búsqueda difusa (fuzziness: AUTO) para corrección de errores tipográficos.
  • Búsqueda geográfica (consulta geo_distance).

Consultas en MySQL

  • Obtención del registro completo por ID tras la búsqueda en Elasticsearch.
  • Operaciones transaccionales complejas con varias tablas.
  • Agregaciones financieras y críticas que requieren ACID.
  • Consultas administrativas con JOIN arbitrarios.

Consultas combinadas

Patrón "Search then Fetch": primero obtenemos la lista de IDs relevantes de Elasticsearch, luego cargamos los objetos completos desde MySQL usando esos IDs. Es importante limitar el tamaño de la selección de IDs a no más de 1000 a la vez; de lo contrario, la consulta MySQL WHERE id IN (...) empieza a degradarse.

-- Tras obtener ids = [42, 17, 891, ...] de 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); -- conservamos el orden de relevancia

Rendimiento: benchmarks y ajuste fino

Ajuste de Elasticsearch

Para índices con alta carga de escritura, aplique las siguientes configuraciones:

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

Tras completar la indexación masiva, restaure number_of_replicas: 1 y refresh_interval: 1s. Use la API _bulk para indexación por lotes: el tamaño óptimo del batch es de 500–2000 documentos con un tamaño de documento de hasta 5 KB.

Valores de referencia en un clúster de 3 nodos con 32 GB de RAM: velocidad de indexación de 15 000–25 000 documentos/seg, tiempo de consulta de búsqueda con facetas de p95 < 50 ms con un índice de 50 millones de documentos.

Optimización de MySQL para exportación

Durante la indexación inicial o la reconciliación, siga estas recomendaciones:

  • Agregue un índice compuesto (updated_at, id) para selecciones incrementales.
  • Use paginación por cursor mediante WHERE id > :last_id en lugar de LIMIT/OFFSET: OFFSET se degrada en tablas grandes.
  • Para una instantánea completa, use mysqldump con --single-transaction o lea los datos desde una réplica.
  • Limite el número de columnas en SELECT: no indexe campos que no son necesarios para la búsqueda.

Monitoreo y observabilidad del sistema híbrido

Un sistema híbrido requiere monitoreo en varios niveles simultáneamente.

Métricas de Kafka y Debezium

  • kafka.consumer.lag: lag del consumidor por cada topic y partición.
  • debezium.mysql.milliseconds_behind_master: retraso respecto al binlog en milisegundos.
  • Número de mensajes en DLQ: debe tender a cero.

Métricas de Elasticsearch

  • Latencia de consultas de búsqueda (p50, p95, p99) a través de Kibana o Prometheus ES Exporter.
  • indexing_pressure.memory.total.primary_bytes: presión de indexación.
  • Tiempo de pausa de GC: debe ser inferior a 200 ms; de lo contrario, es necesario ajustar el heap de la JVM.
  • Tamaño de shard: óptimo entre 10 y 50 GB por shard.

Métricas de la aplicación

Agregue trazado distribuido (OpenTelemetry) para cada consulta de búsqueda con etiquetas: fuente de datos (mysql o elasticsearch), tiempo de ejecución, número de resultados. Esto permite construir SLOs de disponibilidad y latencia de búsqueda de forma independiente para cada almacén.

Configure alertas sobre: lag de sincronización > 30 seg, crecimiento del DLQ, degradación de p95 de consultas de búsqueda por encima del umbral, fallo del conector Debezium (estado FAILED en la API REST de Kafka Connect).

Conclusiones y checklist de implementación

La arquitectura híbrida MySQL + Elasticsearch es una solución madura para sistemas de búsqueda de alta carga en 2026. A continuación, el checklist para el equipo que implementa este esquema:

  1. MySQL: habilitado binlog_format=ROW, creado usuario con permisos mínimos para Debezium (REPLICATION SLAVE, REPLICATION CLIENT), agregados índices (updated_at, id) en las tablas exportadas.
  2. Kafka: configurada una retención suficiente para replay (mínimo 7 días), habilitado el monitoreo del consumer lag.
  3. Debezium: configurado el snapshot para la carga inicial, configurado DLQ, verificadas las transformaciones (SMT).
  4. Elasticsearch: mapeo creado explícitamente (no mediante dynamic mapping), configurado el analizador según el idioma de los datos, elegido el número correcto de shards y réplicas.
  5. Aplicación: implementado el patrón "Search then Fetch", la búsqueda va únicamente a Elasticsearch, la escritura únicamente a MySQL, agregado fallback a MySQL cuando Elasticsearch no está disponible para operaciones críticas.
  6. Observabilidad: configuradas métricas de lag de sincronización, alertas sobre DLQ y degradación, agregado trazado de consultas.
  7. Pruebas: escritas pruebas de integración para escenarios de eventual consistency, probado el escenario de recuperación tras un fallo de Elasticsearch y Kafka.

Empiece en pequeño: tome una tabla, configure la sincronización, verifique la estabilidad y luego escale al resto de entidades. Una sincronización MySQL Elasticsearch correctamente implementada ofrece una mejora del rendimiento de búsqueda de 10 a 50 veces en comparación con los índices FULLTEXT de MySQL, manteniendo la fiabilidad del almacén relacional como fuente de verdad.

Tecnologías

Etiquetas

Ruslan Ismailov

Desarrollador Senior Web / Backend. Desarrollador senior web/backend con 9 años de experiencia. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservicios, CI/CD. Más sobre mí →