La consulta que tardaba
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 ANALYZEen 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:
- Producción. No hay confirmación de que el cambio quedara desplegado, ni una medición comparativa entre desarrollo y producción. Sí sabemos que el volumen real y la distribución de registros por operación importaron para el optimizador: un índice bueno para un patrón puede no serlo para otro.
- El plan sin pistas. No comprobamos qué plan escoge MySQL con el índice
compuesto y sin
FORCE INDEXniSTRAIGHT_JOIN. Antes de decidir si se quedan en el código, eso es lo que hay que medir. - Bloqueos. No hay evidencia de que la consulta bloqueara escrituras ni detuviera transacciones. Una consulta lenta y un bloqueo son problemas distintos, y no hay que confundirlos.
- El costo del índice en las escrituras. Cada índice se actualiza en cada inserción, en cada borrado y cuando cambian sus columnas. No lo medimos.
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
- Identificar las consultas críticas y su patrón de acceso: qué filtra, qué une, en qué orden devuelve, cómo pagina y con qué volumen.
- Diseñar los índices por cómo se recupera la información, no sólo por las relaciones entre tablas.
- Separar el procesamiento transaccional de las consultas analíticas y los reportes, para que no compitan por los mismos recursos.
- Medir la latencia, revisar los planes periódicamente y probar con volúmenes representativos. Una consulta no está optimizada porque respondió rápido en una prueba aislada.
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.