Cuando una página tarda demasiado, es tentador aumentar la memoria de MySQL o añadir índices a todas las columnas. A veces el problema es más sencillo: la aplicación hace demasiadas consultas, una consulta examina muchos registros para devolver veinte o estamos ordenando un resultado que un índice podría recorrer directamente.
Empezaría por una consulta concreta y mediría su comportamiento antes de cambiar la configuración del servidor. Vamos a trabajar con un listado de pedidos, revisar su plan de ejecución y construir un índice que responda a esa búsqueda. Los ejemplos utilizan MySQL 8 y una tabla de pruebas, no la base de datos de producción.

Identificar qué estamos intentando mejorar
Dentro del cliente de MySQL podemos comprobar la versión y el estado del registro de consultas lentas:
SELECT VERSION();
SHOW VARIABLES WHERE Variable_name IN (
'slow_query_log', 'long_query_time', 'log_output'
);
Estos comandos consultan la configuración, no activan el registro. Si necesitamos habilitarlo en un servidor real, decidimos antes el umbral, el espacio disponible y quién puede leerlo. Las consultas registradas pueden contener datos sensibles. El manual del slow query log explica sus opciones.
También interesa contar cuántas veces se ejecuta cada consulta. Una que tarda poco pero aparece cientos de veces en cada petición puede costar más que otra lenta y ocasional. Si tenemos Performance Schema recogiendo esas estadísticas y nuestra base de datos seleccionada en el cliente, podemos consultar:
SELECT DIGEST_TEXT, COUNT_STAR,
ROUND(SUM_TIMER_WAIT / 1000000000000, 3) AS segundos_totales,
SUM_ROWS_EXAMINED, SUM_ROWS_SENT
FROM performance_schema.events_statements_summary_by_digest
WHERE SCHEMA_NAME = DATABASE()
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 10;
Si la tabla no recoge datos o no tenemos permisos, no significa que la base de datos no tenga consultas lentas. Tendremos que revisar la instrumentación o utilizar la medición de la aplicación. Estas estadísticas por consulta normalizada ayudan a encontrar dónde se acumula el trabajo.
Preparar un ejemplo fuera de producción
En una base de datos de pruebas creamos esta tabla. No la ejecutamos sobre una tabla real de pedidos ni añadimos índices a producción por copiar un ejemplo:
CREATE TABLE pedidos_ejemplo (
id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
cliente_id BIGINT UNSIGNED NOT NULL,
estado VARCHAR(20) NOT NULL,
creado_en DATETIME NOT NULL,
total DECIMAL(10, 2) NOT NULL
) ENGINE=InnoDB;
INSERT INTO pedidos_ejemplo VALUES
(1, 7, 'pagado', '2026-09-20 10:00:00', 29.90),
(2, 7, 'pagado', '2026-09-20 10:00:00', 45.00),
(3, 8, 'pagado', '2026-09-20 11:00:00', 18.50),
(4, 7, 'pendiente', '2026-09-21 09:00:00', 60.00),
(5, 7, 'pagado', '2026-09-21 10:00:00', 15.00),
(6, 7, 'pagado', '2026-09-21 10:00:00', 32.00),
(7, 7, 'pagado', '2026-09-22 08:00:00', 20.00),
(8, 9, 'pagado', '2026-09-22 09:00:00', 80.00);
Con ocho filas podemos comprobar los resultados y los empates de fecha. No podemos sacar de aquí una conclusión sobre el rendimiento de un comercio con millones de pedidos. Para medir costes necesitaremos un volumen y una distribución de datos representativos.
La pantalla quiere mostrar los últimos pedidos pagados del cliente 7:
SELECT id, creado_en, total
FROM pedidos_ejemplo
WHERE cliente_id = 7 AND estado = 'pagado'
ORDER BY creado_en DESC, id DESC
LIMIT 20;
Pedimos las columnas que vamos a mostrar, no todas por costumbre. El id resuelve el empate cuando dos pedidos tienen la misma fecha. Sin un criterio estable, dos páginas de resultados podrían repartir esos registros de forma diferente.
Leer EXPLAIN antes de añadir el índice
Podemos ver el plan estimado anteponiendo EXPLAIN:
EXPLAIN
SELECT id, creado_en, total
FROM pedidos_ejemplo
WHERE cliente_id = 7 AND estado = 'pagado'
ORDER BY creado_en DESC, id DESC
LIMIT 20;
Miramos key, el acceso elegido y la estimación de filas. possible_keys muestra candidatos, no demuestra cuál se está utilizando. Tampoco un acceso ALL es siempre un error: recorrer una tabla muy pequeña puede ser una decisión razonable.
Para observar la ejecución real podemos utilizar EXPLAIN ANALYZE:
EXPLAIN ANALYZE
SELECT id, creado_en, total
FROM pedidos_ejemplo
WHERE cliente_id = 7 AND estado = 'pagado'
ORDER BY creado_en DESC, id DESC
LIMIT 20;
Este comando ejecuta la consulta. Lo utilizamos aquí con un SELECT de pruebas. No lo tratamos como una simulación inocua de cualquier sentencia o de una consulta enorme en producción. El manual de EXPLAIN detalla la diferencia entre estimaciones y ejecución.
Nos interesa ver cuántas filas pasan por cada operación y cuántas veces se repite. No sumamos todos los tiempos del árbol como si fueran independientes, porque unas operaciones incluyen trabajo de sus hijas.
Diseñar el índice para esta consulta
La búsqueda fija cliente_id y estado, y después recorre los pedidos por fecha e identificador. Un candidato para probar es:
CREATE INDEX idx_cliente_estado_fecha_id
ON pedidos_ejemplo (cliente_id, estado, creado_en DESC, id DESC);
Repetimos el plan y comprobamos que devuelve los mismos resultados. El índice está planteado para este filtro y esta ordenación, no porque debamos poner siempre primero la columna con más valores distintos.
Si hemos cargado muchos datos y las estimaciones del plan siguen describiendo una tabla pequeña, revisamos las estadísticas. En nuestra tabla de pruebas podemos recalcularlas con:
ANALYZE TABLE pedidos_ejemplo;
Después volvemos a mirar el plan. No es una orden para ejecutar sobre todas las tablas en producción sin revisar su impacto. La guía de diagnóstico con EXPLAIN incluye esta comprobación cuando el optimizador no utiliza el índice que esperábamos.
El prefijo de un índice compuesto importa para las búsquedas que puede atender. Este índice puede servir para localizar registros de un cliente, pero no es automáticamente la mejor solución para listar todos los pedidos pagados de todos los clientes.
La ordenación mediante índices también depende del sentido y de las columnas utilizadas. No basta con tener un índice separado en cada columna. Aun así, el optimizador puede elegir otro plan si estima que cuesta menos.
Para ver los índices definidos:
SHOW INDEX FROM pedidos_ejemplo;
Esto muestra qué índices existen y sus características, no su historial de uso. Antes de quitar uno en una aplicación real comprobamos otras consultas y restricciones. Un índice que no ayuda a este listado puede ser necesario en otra operación.
Buscar por fecha sin envolver la columna en DATE
Si queremos los pedidos de un día, podemos escribir un intervalo:
SELECT id, creado_en, total
FROM pedidos_ejemplo
WHERE cliente_id = 7
AND estado = 'pagado'
AND creado_en >= '2026-09-21 00:00:00'
AND creado_en < '2026-09-22 00:00:00'
ORDER BY creado_en DESC, id DESC;
Así dejamos la columna disponible para una búsqueda por rango con el índice adecuado. No necesitamos inventar una última hora como 23:59:59, que podría excluir valores si utilizamos fracciones de segundo.
Aplicar DATE(creado_en) cambia la expresión que buscamos. Existen índices funcionales y columnas generadas para ciertos casos, por lo que tampoco es correcto decir que cualquier función impide siempre usar índices. Para este listado, el intervalo es más sencillo. MySQL documenta la optimización de accesos por rango.
Paginar sin perder los registros que empatan
Un OFFSET grande obliga a localizar y descartar resultados anteriores. No lo eliminaría de todas las pantallas, pero sí revisaría un listado que llega a páginas muy profundas. El manual de LIMIT explica parte de ese trabajo y la importancia de una ordenación estable.
Para avanzar desde el último pedido de una página, guardamos tanto su fecha como su identificador. Si terminamos en fecha 2026-09-21 10:00:00, identificador 6, la página siguiente será:
SELECT id, creado_en, total
FROM pedidos_ejemplo
WHERE cliente_id = 7 AND estado = 'pagado'
AND (
creado_en < '2026-09-21 10:00:00'
OR (creado_en = '2026-09-21 10:00:00' AND id < 6)
)
ORDER BY creado_en DESC, id DESC
LIMIT 20;
Así el pedido 5, que tiene la misma fecha, no desaparece de la navegación. Si filtramos sólo por una fecha menor, lo saltaríamos. En la aplicación esos valores se pasan como parámetros, no concatenando texto recibido del usuario en el SQL.
Este cursor resuelve cómo avanzar, pero no ofrece por sí solo saltar directamente a «página 200». También hay que definir qué ocurre si llegan pedidos nuevos entre peticiones. Elegimos la navegación según la pantalla, no sólo por el nombre de la técnica.
Revisar memoria y configuración después de las consultas

Si el servidor comparte memoria con PHP, Node u otros procesos, no le daría a InnoDB un porcentaje de la RAM copiado de una guía para un servidor dedicado. Necesitamos margen para el sistema, las conexiones y las demás aplicaciones. La configuración del buffer pool requiere conocer ese reparto.
También miramos la versión antes de copiar parámetros de redo logs. En MySQL moderno se utiliza innodb_redo_log_capacity, con diferencias respecto a recetas basadas en innodb_log_file_size. La documentación de redo logs es la referencia para nuestra versión.
No reduciría la durabilidad de las transacciones como un ajuste de rendimiento genérico. Si una mejora puede hacer perder operaciones ante un fallo, es una decisión de producto y recuperación, no un pequeño truco sin consecuencias.
Por último, revisamos la aplicación. Si una página hace una consulta por cada pedido, quizá estamos ante un problema de acceso a datos que un índice no elimina. Y si reorganizamos el dominio sólo para satisfacer un listado, conviene separar ambas decisiones, como explico en cuando la base de datos secuestra el dominio.
Me quedaría con un cambio cada vez, resultados iguales y una medición comparable. El objetivo no es tener muchos índices, sino que la consulta que necesita nuestra aplicación haga menos trabajo sin introducir otro problema.
