🎨 Sysprovider Code
Sysprovider LogoWiki
🇪🇸Hosting español para ecommerce

Optimización de consultas MySQL mediante índices compuestos y particionamiento

Actualizado el 1 de enero de 2026

Cuando una base de datos MySQL comienza a ralentizarse, el primer instinto es lanzar más hardware al problema. Sin embargo, la verdadera optimización reside en la arquitectura de datos y en cómo el motor de almacenamiento InnoDB (el estándar en la actualidad) accede a la información. Dos de las técnicas más poderosas y, a menudo, malentendidas, son la creación de índices compuestos y el particionamiento de tablas.

Este artículo no es una introducción a SQL. Es una inmersión profunda para SysAdmins y DevOps que necesitan exprimir cada milisegundo de latencia de sus servidores de bases de datos. Abordaremos desde la teoría del árbol B+ hasta la implementación práctica con particionamiento por rango, incluyendo estrategias de mantenimiento.

La Anatomía de un Índice Compuesto: Más Allá del WHERE

Un índice compuesto es un índice sobre dos o más columnas. La clave para entender su poder es el concepto de orden de columnas. MySQL (InnoDB) almacena los índices como árboles B+. En un índice compuesto (col1, col2, col3), los datos se ordenan primero por col1, luego por col2 dentro de cada valor de col1, y finalmente por col3.

El Principio del Prefijo más a la Izquierda

La regla de oro es: MySQL puede usar un índice compuesto para optimizar consultas que filtran por las columnas del índice en orden, desde la izquierda, sin saltos.

-- Tabla de ejemplo para un sistema de logs de aplicaciones

CREATE TABLE logs_sistema (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    nivel ENUM('DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL') NOT NULL,
    aplicacion VARCHAR(50) NOT NULL,
    fecha_hora DATETIME NOT NULL,
    mensaje TEXT,
    user_id INT UNSIGNED NULL,
    INDEX idx_logs_compuesto (aplicacion, fecha_hora, nivel)
) ENGINE=InnoDB;

¿Qué consultas se benefician de idx_logs_compuesto?

ConsultaUsa el índice?Explicación
WHERE aplicacion = 'api-gateway' AND fecha_hora > '2023-10-01'Usa el prefijo (aplicacion, fecha_hora).
WHERE aplicacion = 'api-gateway'Usa el prefijo (aplicacion).
WHERE aplicacion = 'api-gateway' AND nivel = 'ERROR'Usa el prefijo (aplicacion). No puede usar nivel porque se salta fecha_hora en la cláusula WHERE.
WHERE fecha_hora > '2023-10-01'NoNo comienza por la columna más a la izquierda (aplicacion). Será un full table scan.
WHERE aplicacion = 'api-gateway' AND fecha_hora > '2023-10-01' AND nivel = 'ERROR'Usa el prefijo (aplicacion, fecha_hora) y luego filtra nivel como un filtro de paso (no como búsqueda de rango).

Nota Crítica: El orden de las columnas en el WHERE no importa para el optimizador de MySQL. Lo que importa es el orden de las columnas en el índice. El optimizador reordena las condiciones del WHERE para intentar coincidir con el prefijo más a la izquierda.

Diseñando el Índice Compuesto Perfecto: El Ejercicio de la Selectividad

El objetivo es maximizar la selectividad (número de filas únicas / número total de filas) lo antes posible en el índice.

Caso Práctico: Sistema de E-commerce (Tabla pedidos)

Columnas candidatas a indexar:

  • id_cliente (Alta selectividad)
  • fecha_pedido (Alta selectividad en rangos)
  • estado (Baja selectividad: 'pendiente', 'pagado', 'enviado')

Mala práctica:


INDEX idx_malo (estado, fecha_pedido, id_cliente)
  • estado tiene baja selectividad. El índice empezará ordenando miles de filas con el mismo estado antes de poder filtrar por fecha.

Buena práctica:


INDEX idx_bueno (id_cliente, fecha_pedido, estado)
  • id_cliente tiene alta selectividad. La búsqueda se reduce a un puñado de filas casi instantáneamente. Luego, fecha_pedido acota el rango. estado es un filtro final.

Regla de diseño: Coloca las columnas con mayor selectividad (o las que se usan en condiciones de igualdad =) primero. Las columnas de rango (>, <, BETWEEN) deben ir al final del prefijo del índice para que la búsqueda por rango no detenga el uso de las columnas siguientes.

Estrategias Avanzadas con Índices Compuestos

Índices de Cobertura (Covering Index)

Un índice se llama "de cobertura" cuando todas las columnas que necesita una consulta están dentro del propio índice. En este caso, MySQL no necesita acceder a las filas de datos (el clúster de la tabla), lo que reduce drásticamente el I/O.

-- Consulta que solo necesita dos columnas

SELECT aplicacion, COUNT(*) as total

FROM logs_sistema

WHERE fecha_hora > '2023-01-01'

GROUP BY aplicacion;

-- Índice de cobertura para esta consulta

ALTER TABLE logs_sistema ADD INDEX idx_cover (fecha_hora, aplicacion);
-- Ahora el EXPLAIN mostrará "Using index" en la columna Extra.

Índices sobre Columnas con Funciones (Índices Virtuales/Generados)

¿Necesitas buscar por el año de una fecha? No hagas WHERE YEAR(fecha) = 2023. MySQL no usará un índice sobre fecha porque la columna está "envuelta" en una función.

Solución en MySQL 5.7+: Columnas generadas e índices virtuales.


ALTER TABLE logs_sistema

ADD COLUMN year_log YEAR GENERATED ALWAYS AS (YEAR(fecha_hora)) VIRTUAL,

ADD INDEX idx_year_aplicacion (year_log, aplicacion);

-- Ahora esta consulta usará el índice:

SELECT * FROM logs_sistema WHERE year_log = 2023 AND aplicacion = 'api-gateway';

Nota de Rendimiento: Las columnas VIRTUAL no ocupan espacio en disco en la tabla, pero el motor de almacenamiento puede materializarlas en el índice. Es un balance entre espacio de índice y velocidad de escritura.

Particionamiento: Escalabilidad Horizontal dentro de una Tabla

El particionamiento divide una tabla lógica en múltiples tablas físicas (particiones), pero se comporta como una sola para el usuario. La clave es la columna de particionamiento. El motor de MySQL (InnoDB) usará el pruning de particiones, es decir, solo leerá las particiones que contienen los datos relevantes para la consulta.

Tipos de Particionamiento y Cuándo Usarlos

TipoSintaxisCaso de Uso Ideal
RANGEPARTITION BY RANGE (columna)Datos históricos o temporales (logs, facturas). Fácil de gestionar (archivar/dropear particiones viejas).
LISTPARTITION BY LIST (columna)Datos categóricos con valores conocidos (regiones, estados, tipos de producto).
HASHPARTITION BY HASH (columna)Distribuir datos uniformemente para balancear carga de I/O. No es bueno para pruning en consultas de rango.
KEYPARTITION BY KEY (columna)Similar a HASH, pero usa el propio hashing interno de MySQL. Útil para claves primarias.

Caso Práctico: Particionamiento por Rango en logs_sistema

Nuestra tabla de logs crece 10GB al mes. Sin particionamiento, las consultas de hace 6 meses escanean 60GB de datos.

-- Crear la tabla con particionamiento por RANGE en la columna de fecha

CREATE TABLE logs_sistema (
    id BIGINT UNSIGNED AUTO_INCREMENT,
    nivel ENUM('DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL') NOT NULL,
    aplicacion VARCHAR(50) NOT NULL,
    fecha_hora DATETIME NOT NULL,
    mensaje TEXT,
    user_id INT UNSIGNED NULL,
    PRIMARY KEY (id, fecha_hora) -- La columna de partición DEBE estar en la PK
) ENGINE=InnoDB

PARTITION BY RANGE (TO_DAYS(fecha_hora)) (
    PARTITION p_2023_q1 VALUES LESS THAN (TO_DAYS('2023-04-01')),
    PARTITION p_2023_q2 VALUES LESS THAN (TO_DAYS('2023-07-01')),
    PARTITION p_2023_q3 VALUES LESS THAN (TO_DAYS('2023-10-01')),
    PARTITION p_2023_q4 VALUES LESS THAN (TO_DAYS('2024-01-01')),
    PARTITION p_futuro VALUES LESS THAN MAXVALUE
);

Puntos Críticos:

  1. La columna de partición debe ser parte de todas las claves únicas (incluyendo la Primary Key). Por eso fecha_hora está en la PRIMARY KEY (id, fecha_hora). Esta es una de las limitaciones más dolorosas del particionamiento en MySQL.
  2. Pruning de Particiones: Una consulta como SELECT * FROM logs_sistema WHERE fecha_hora BETWEEN '2023-05-01' AND '2023-05-31' solo escaneará la partición p_2023_q2. Podemos verificarlo con EXPLAIN PARTITIONS.

EXPLAIN PARTITIONS SELECT * FROM logs_sistema WHERE fecha_hora = '2023-06-15';
-- Resultado: partitions: p_2023_q2

Mantenimiento de Particiones: La Ventaja Mortal

El particionamiento por RANGE permite operaciones DDL casi instantáneas sobre particiones completas, algo imposible en tablas no particionadas de gran tamaño.

Archivar datos del Q1 de 2023 (sin borrar filas una a una):

-- 1. Intercambiar la partición a una tabla independiente (casi instantáneo)

ALTER TABLE logs_sistema EXCHANGE PARTITION p_2023_q1 WITH TABLE logs_archivo_2023_q1;

-- 2. Ahora logs_archivo_2023_q1 es una tabla física normal. Podemos hacer un mysqldump y luego:

DROP TABLE logs_archivo_2023_q1;

-- 3. Reorganizar las particiones para cerrar el hueco

ALTER TABLE logs_sistema REORGANIZE PARTITION p_futuro INTO (
    PARTITION p_2024_q1 VALUES LESS THAN (TO_DAYS('2024-04-01')),
    PARTITION p_futuro VALUES LESS THAN MAXVALUE
);

Alerta de Rendimiento: REORGANIZE PARTITION puede ser una operación costosa que mueve datos y bloquea la tabla con un bloqueo de metadatos (MDL). Ejecútala siempre en horas de baja actividad o usa herramientas como pt-online-schema-change si es posible.

Combinando Índices Compuestos y Particionamiento: La Sinergia

La combinación no es mágica, sino estratégica. Un índice compuesto local a una partición es mucho más pequeño que un índice global sobre toda la tabla.

Flujo de trabajo para diseñar la solución final:

  1. Analizar la carga de trabajo (Query Log o Slow Query Log):

    • Identificar las consultas más lentas y frecuentes.
    • Extraer el WHERE, JOIN y ORDER BY.
  2. Definir la estrategia de particionamiento:

    • Si la consulta más común filtra por fecha_hora, ese es un excelente candidato para PARTITION BY RANGE.
    • Si la consulta filtra por id_cliente, PARTITION BY HASH (id_cliente) podría distribuir mejor la carga.
  3. Diseñar los índices compuestos locales:

    • El índice debe diseñarse dentro de cada partición.
    • Si particionamos por fecha_hora, la primera columna del índice compuesto debería ser la de alta selectividad (ej. id_cliente), ya que el pruning ya nos ha librado de las fechas viejas.
-- Tabla final optimizada para consultas por cliente y fecha

CREATE TABLE logs_sistema (
    id BIGINT UNSIGNED AUTO_INCREMENT,
    nivel ENUM('DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL') NOT NULL,
    aplicacion VARCHAR(50) NOT NULL,
    fecha_hora DATETIME NOT NULL,
    mensaje TEXT,
    user_id INT UNSIGNED NULL,
    PRIMARY KEY (id, fecha_hora),
    INDEX idx_cliente_fecha (user_id, fecha_hora), -- Índice compuesto local a cada partición
    INDEX idx_aplicacion_nivel (aplicacion, nivel)
) ENGINE=InnoDB

PARTITION BY RANGE (TO_DAYS(fecha_hora)) (
    PARTITION p_2023_q1 VALUES LESS THAN (TO_DAYS('2023-04-01')),
    PARTITION p_2023_q2 VALUES LESS THAN (TO_DAYS('2023-07-01')),
    PARTITION p_2023_q3 VALUES LESS THAN (TO_DAYS('2023-10-01')),
    PARTITION p_2023_q4 VALUES LESS THAN (TO_DAYS('2024-01-01')),
    PARTITION p_futuro VALUES LESS THAN MAXVALUE
);

Consulta beneficiada:


SELECT *

FROM logs_sistema

WHERE user_id = 12345
  AND fecha_hora BETWEEN '2023-05-01' AND '2023-05-31';
  • Paso 1 (Particionamiento): MySQL identifica que fecha_hora pertenece a p_2023_q2. Solo escanea esa partición.
  • Paso 2 (Índice Compuesto): Dentro de p_2023_q2, el índice idx_cliente_fecha localiza rápidamente las filas para user_id = 12345 y luego aplica el rango de fecha.

Monitorización y Debugging con EXPLAIN

Como SysAdmin, tu herramienta principal es EXPLAIN. No te fíes de las suposiciones.


EXPLAIN FORMAT=JSON

SELECT *

FROM logs_sistema

WHERE user_id = 12345
  AND fecha_hora BETWEEN '2023-05-01' AND '2023-05-31'\G

Busca en la salida JSON:

  • "partition_list": Debe mostrar solo "p_2023_q2" (o la que corresponda). Si muestra todas, el pruning no funciona.
  • "key": Debe ser "idx_cliente_fecha". Si es NULL, no se usa ningún índice.
  • "ref": Debe mostrar "const" para user_id.
  • "rows_examined_per_scan": Debe ser un número bajo (idealmente < 100). Si es > 100,000, el índice no es lo suficientemente selectivo.

Conclusión: Estrategia > Herramienta

La optimización de MySQL no es una tarea de "añadir un índice y listo". Es un proceso iterativo de análisis, diseño y monitorización.

  • Los índices compuestos son para acelerar consultas específicas. Diseñarlos mal (ignorando el prefijo más a la izquierda) desperdicia espacio y ralentiza las escrituras.
  • El particionamiento es para la gestión del ciclo de vida de los datos y para permitir que consultas de rango masivas sean eficientes. No es una solución mágica para consultas de punto único.

Regla de oro final: Si una consulta no filtra por la columna de partición, el particionamiento no solo no ayuda, sino que empeora el rendimiento (más overhead de metadatos). Aplica estas técnicas con conocimiento de causa, monitoriza con EXPLAIN y SHOW PROFILE, y tu servidor MySQL te lo agradecerá con latencias de sub-milisegundo.

¿Necesitas ayuda?Son dos de nuestros técnicos, Agustín y Mikel, y están disponibles para resolver cualquier problema.

Hablar con ellos ahora
Agustín y Mikel