crtdrops
NOTA · ESPAÑOL ·

La consulta que tardaba

Filas que toca la primera página de 50 en una tabla de 8,040,017 filas, según un modelo: 8,040,217 sin índice útil, 55,098 con un índice sólo por operación y 250 con el índice por operación y fecha. A la derecha, 4.5 ms medidos con EXPLAIN ANALYZE dentro de MySQL.

Los endpoints de consulta de una plataforma transaccional devolvían HTTP 504: timeouts, con paginación y sin ella. La tabla era grande —más de ocho millones de filas sólo en 2026—, y la primera tentación era pensar que eso lo explicaba todo: más filas, más tiempo, más servidor.

No fue así. El problema no era leer una tabla grande, sino cómo MySQL relacionaba, filtraba y ordenaba la información para responder a esa consulta. De eso trata esta nota.

Las cifras de los ejemplos son ilustrativas: los planes del lector de planes son hipotéticos y el modelo de crecimiento es un modelo, no una medición. Del caso son los 8,040,017 registros; las 1,792 y las 54,898 filas, estimaciones de EXPLAIN (las segundas, de la variante con pistas que se explica abajo); los 17.05 s que tardó crear el índice, y los 4.5 ms que midió EXPLAIN ANALYZE en una prueba de laboratorio, no en producción.

Un 504 no dice dónde está el problema

Un 504 lo devuelve el balanceador o el proxy que está delante de la aplicación cuando se cansa de esperar. No dice si el tiempo se fue en la base de datos, en la red, en la aplicación o en armar la respuesta.

Por eso la consulta se analizó aparte del resto de la aplicación, con el SQL que de verdad llegaba a la base. En este caso era SQL escrito a mano, no generado por un ORM, pero la regla es la misma: para diagnosticar hay que ver lo que ejecuta MySQL, no lo que uno cree que ejecuta.

La tabla y la consulta

MySQL 8.0.46 sobre Ubuntu. Las transacciones viven en una tabla por año, de 2015 a 2030; la de 2026 tenía 8,040,017 registros cuando hicimos las pruebas. Alrededor hay catálogos: operaciones, productos, proveedores y las direcciones de entrada y de salida.

La consulta recupera las transacciones de una operación para un reporte operativo: fecha, hora, teléfono enmascarado, identificadores, estado y tiempo de proceso, con los nombres que vienen de los catálogos, ordenadas por fecha, de la más antigua a la más reciente, en páginas de 50. Una operación tenía entre 52,000 y 55,000 filas estimadas, en una tabla de más de ocho millones.

El plan que estimaba menos

En el EXPLAIN de la consulta original, la fila de la tabla de transacciones mostraba el índice IdProductos, unas 1,792 filas estimadas y Using where. Y el plan traía Using temporary y Using filesort.

A primera vista, 1,792 parece poco. Pero la columna de filas de EXPLAIN es lo que MySQL espera leer cada vez que ejecuta ese paso, y la tabla de transacciones iba dentro del join: ese paso se repite tantas veces como filas le entrega el anterior. Using where dice que de lo que lee se queda sólo con lo que cumple la condición, y un índice de productos no lleva a las filas de una operación en orden de fecha. Una estimación chica en un paso no dice cuánto trabajo es el plan completo.

Lo otro eran las dos banderas. En el EXPLAIN tabular, cuando aparecen juntas, Using temporary y Using filesort se anotan en la fila de la primera tabla no constante del plan, pero no hablan de esa tabla: dicen que el resultado del join se guarda aparte y se ordena. No son errores: ordenar o agrupar pocas filas es lo razonable. Aquí, según el plan, significaban que para devolver 50 filas en orden de fecha MySQL tenía que juntar todas las de la operación, guardarlas aparte y ordenarlas antes de cortar: unas 52,000 a 55,000 filas estimadas para devolver 50.

El índice que sigue el orden de la consulta

La consulta filtra por operación y ordena por fecha. Un índice con esas dos columnas en ese orden le permite a MySQL encontrar las filas de la operación ya ordenadas, leerlas en orden y parar en cuanto llena la página, siempre que la tabla de transacciones sea la primera del join. Fue un índice compuesto, idx_transacciones_operacion_fecha (IdOperaciones, FechaHora).

Crearlo en la tabla de 2026 tardó 17.05 s en nuestra medición. Como las transacciones están repartidas por año, el índice se aplicó también a las demás tablas anuales.

Para evaluar un plan que empezara por las transacciones, probamos una variante con FORCE INDEX (idx_transacciones_operacion_fecha) y STRAIGHT_JOIN. Las dos son herramientas de investigación: FORCE INDEX hace que MySQL use ese índice siempre que pueda y STRAIGHT_JOIN obliga a unir las tablas en el orden en que están escritas. Sirven para comparar planes; dejarlas en el código congela una decisión que el optimizador ya no podrá corregir cuando cambien los datos.

El EXPLAIN de esa variante estimaba de entrada unas 54,898 filas, ya sin tabla temporal ni filesort, y los catálogos se unían con eq_ref: una búsqueda exacta por llave por cada fila. El plan que estimaba más filas resolvía mejor el trabajo completo.

Medir: 4.5 ms, y lo que no incluye

EXPLAIN es el plan antes de ejecutar: lo que MySQL calcula con sus estadísticas. Para saber cuánto tarda hay que ejecutarlo, y para eso está EXPLAIN ANALYZE, que corre la consulta y reporta por cada paso el tiempo, las filas reales y las vueltas.

Con LIMIT 50, el EXPLAIN ANALYZE de la variante con las pistas registró entre 4.37 y 4.50 ms. Es el tiempo de ejecutar el plan dentro de MySQL, en una prueba de laboratorio. No es una medición de extremo a extremo de la API: no incluye la red, la aplicación ni mandar y serializar la respuesta. Tampoco vale para la consulta sin paginar ni para otros filtros, que tienen su propio plan.

Y no es un resultado de producción. Al terminar las pruebas faltaba aplicar el cambio en el servicio Node.js y validarlo ahí. Además, no demuestra que las pistas puedan retirarse: sin FORCE INDEX y STRAIGHT_JOIN, no sabemos si MySQL escogería el mismo plan.

Lo que no sabemos

Hay preguntas que esta historia no contesta, y conviene decirlo:

Cuando la tabla crece

El lector de planes trae un modelo para ver la forma del problema. Es una cuenta de filas con supuestos de microsegundos por paso, no una medición de MySQL: sirve para comparar curvas, no para predecir milisegundos. Por eso, para el índice compuesto, el modelo da décimas de milisegundo y la medición de laboratorio del caso dio 4.5 ms.

Con una tabla de 8,040,017 filas, una operación de 54,898 —la estimación del caso— y la tabla de transacciones primero, para la primera página de 50 el modelo da:

Cómo se resuelve Filas tocadas
Sin índice útil: recorre todo y ordena 8,040,267
Índice sólo por operación: encuentra todas y las ordena 55,148
Índice (operación, fecha): lee en orden y para 300

Cada una crece con algo distinto. La primera, con la tabla: el doble de filas, el doble de trabajo. La segunda, con la operación, aunque la tabla no crezca. La tercera, sólo con la página que se pide: para la página 100, con OFFSET, lee también las 4,950 anteriores, une los catálogos a todas y toca 30,000 filas. Paginar por la última fecha y el último id vistos, con el id también en el ORDER BY, en lugar de saltar filas, evita leerlas.

Lo que haría desde el día uno

Para leer un plan propio, comparar el de antes con el de después o ver cómo crece el trabajo con la tabla, está el lector de planes de MySQL: corre en el navegador, distingue lo que MySQL estima de lo que midió, y lo que pegues no sale de tu equipo.

El aprendizaje no fue que FORCE INDEX y STRAIGHT_JOIN deban utilizarse siempre, sino que el plan de ejecución debe comprobarse con datos reales, contrastando sus estimaciones con la ejecución efectiva.

No fue simplemente cuestión de crear un índice. Fue necesario comprender cómo MySQL relacionaba, filtraba y ordenaba la información.

Una base de datos no se vuelve lenta únicamente porque crece. También puede volverse lenta porque nuestras consultas le piden hacer cada vez más trabajo del necesario.

Israel Negrete Lepe es ingeniero en electrónica. Veinticinco años haciendo que sistemas distintos funcionen juntos en condiciones reales: del sensor, el vehículo o la transacción al backend, los datos, la seguridad y la operación. Hoy, cada vez más, IA en el borde.