Rendimiento
7 min de lectura·

Optimización de consultas SQL: 10 formas de acelerar una consulta lenta

Una consulta lenta casi nunca se arregla con «un servidor más potente». En nueve de cada diez casos la causa está en la propia consulta o en un índice que falta, y se soluciona en minutos. Aquí tienes una lista de comprobación con diez técnicas, en el orden en el que conviene aplicarlas.

1. Empieza por EXPLAIN: averigua qué hace el SGBD

Optimizar a ciegas no tiene sentido. La orden EXPLAIN muestra el plan de ejecución: cómo exactamente piensa ejecutar la consulta el SGBD:

MySQL 8.4
EXPLAIN SELECT * FROM orders WHERE user_id = 1;
Seq Scan on orders  (cost=0.00..10.70 rows=60 width=44)
  Filter: (user_id = 1)

Seq Scan significa un recorrido completo de la tabla: el SGBD lee todas las filas una tras otra y comprueba la condición en cada una. En una tabla de mil filas no se nota; en una de diez millones, son segundos. El objetivo de la mayoría de las optimizaciones es convertir un Seq Scan sobre una tabla grande en un Index Scan, un acceso directo a través del índice.

EXPLAIN ANALYZE además ejecuta la consulta y muestra el tiempo real y el número de filas de cada paso: es por donde conviene empezar cualquier análisis de una consulta lenta.

2. Pon índices en las columnas de WHERE y JOIN

El índice es la herramienta principal para acelerar la lectura: en lugar de recorrer toda la tabla, el SGBD baja por una estructura ordenada directamente hasta las filas que necesita. Las candidatas a índice son las columnas que aparecen a menudo en WHERE, en las condiciones de JOIN y en ORDER BY:

MySQL 8.4
CREATE INDEX idx_orders_user_id ON orders (user_id);

Para ver cuánto cambia esto, lo hemos medido en una tabla de un millón de pedidos (PostgreSQL 17). La búsqueda de los pedidos de un cliente antes y después de crear el índice:

sin índice:  Seq Scan, recorridas las 1 000 000 filas — 23.0 ms
con índice:  Index Scan, leídas solo las 20 filas necesarias — 0.1 ms

Doscientas veces de diferencia con exactamente la misma consulta, y la diferencia crece con la tabla. Las cifras absolutas dependen del hardware; el orden de magnitud, no.

Revisa aparte las claves foráneas: las claves primarias se indexan automáticamente, pero las columnas que hacen referencia a otras tablas (user_id en la tabla de pedidos) no, aunque precisamente por ellas se hacen los JOIN. Cómo funcionan los índices por dentro lo tienes en la lección Índices en SQL.

La otra cara de la moneda: cada índice ralentiza las inserciones y las actualizaciones, porque hay que mantenerlo. Indexa aquello por lo que buscas, no todo por si acaso.

3. No envuelvas una columna indexada en una función

Una condición en la que la columna está envuelta en una función impide al SGBD usar el índice: tiene que calcular la función para cada fila:

MySQL 8.4
-- el índice sobre order_date no se usa
SELECT * FROM orders WHERE EXTRACT(YEAR FROM order_date) = 2024;
Seq Scan on orders  (cost=0.00..11.84 rows=2 width=44)
  Filter: (EXTRACT(year FROM order_date) = '2024'::numeric)

Reescribe la condición para que la columna quede «desnuda», con un rango:

MySQL 8.4
-- el índice sobre order_date funciona
SELECT * FROM orders
WHERE order_date >= '2024-01-01' AND order_date < '2025-01-01';

La misma regla la rompen WHERE LOWER(email) = ..., WHERE price * 1.2 > 100 y WHERE CAST(...). El truco universal es pasar el cálculo de la columna a la constante (price > 100 / 1.2).

Lo que cuesta este error en un millón de filas con índice sobre order_date, filtrando un solo día de dos maneras:

WHERE order_date::date = '2025-03-01'                plan: Seq Scan — 24.1 ms
WHERE order_date >= '2025-03-01'
  AND order_date < '2025-03-02'                      plan: Index Only Scan — 0.16 ms

Las dos consultas devuelven las mismas filas, pero la primera es unas ciento cincuenta veces más lenta: la conversión ::date es igualmente una función alrededor de la columna.

La consulta SELECT * FROM users WHERE YEAR(created_at) = 2026 va lenta, aunque hay un índice sobre created_at. ¿Cuál es la causa?

4. Quita el SELECT *

SELECT * arrastra todas las columnas: columnas de texto pesadas, JSON, todo lo que no necesitas. Eso supone E/S de disco innecesaria, tráfico innecesario hasta la aplicación y una oportunidad perdida: si a la consulta le bastan las columnas que ya están en el índice, el SGBD puede responder sin abrir siquiera la tabla. Enumera solo lo que usas:

MySQL 8.4
SELECT order_id, total_amount FROM orders WHERE user_id = 1;

5. Cuidado con los LIKE que empiezan por porcentaje

El patrón LIKE 'SUMMER%' usa el índice: es una búsqueda por prefijo, como en un diccionario. El patrón LIKE '%SUMMER%' no: la coincidencia puede empezar en cualquier sitio, y al SGBD solo le queda recorrer todas las filas.

Si necesitas buscar subcadenas con frecuencia y la tabla es grande, eso pide búsqueda de texto completo (FULLTEXT en MySQL, tsvector/pg_trgm en PostgreSQL), no LIKE.

¿Cuál de estos patrones LIKE puede usar un índice normal sobre la columna name?

6. Revisa las subconsultas: EXISTS frente a IN

Para comprobar «si existe un registro relacionado», usa preferentemente EXISTS: se detiene en la primera coincidencia, mientras que IN primero materializa toda la lista:

MySQL 8.4
SELECT u.user_id
FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.user_id);

Una trampa aparte es NOT IN con subconsulta: si en la lista aparece aunque sea un solo NULL, la condición no devolverá ninguna fila. NOT EXISTS no tiene este problema y suele ser más rápido.

7. Filtra antes de agregar, no después

WHERE descarta filas antes de agrupar; HAVING, después. Las condiciones que no dependen de agregados ponlas siempre en WHERE:

MySQL 8.4
-- trabajo de más: se agrupa todo y luego se tira la mitad
SELECT user_id, SUM(total_amount)
FROM orders
GROUP BY user_id
HAVING MIN(order_date) >= '2024-01-01';

-- si la condición es sobre filas individuales, filtra desde el principio
SELECT user_id, SUM(total_amount)
FROM orders
WHERE order_date >= '2024-01-01'
GROUP BY user_id;

Ojo: estas dos consultas no significan lo mismo, porque la segunda solo suma los pedidos de 2024. El principio general es que cuanto antes se libre el SGBD de las filas que sobran, menos trabajo habrá en todos los pasos siguientes.

8. Paginación: OFFSET lee todo lo que se salta

La paginación clásica LIMIT 20 OFFSET 100000 obliga al SGBD a leer y descartar cien mil filas para devolver veinte. Cuanto más lejos está la página, más lenta es la consulta.

La solución es la paginación por clave (keyset): guardamos el último valor mostrado y seguimos a partir de él usando el índice:

MySQL 8.4
SELECT order_id, order_date
FROM orders
WHERE order_id > 100020
ORDER BY order_id
LIMIT 20;

Esta consulta es igual de rápida para la primera página que para la milésima. En la misma tabla de un millón de filas: la página tras OFFSET 500000 tarda 79,6 ms; la consulta keyset con WHERE order_id > 500000, 0,05 ms.

9. Índice compuesto: el orden de las columnas manda

Si una consulta filtra por dos columnas a la vez, dos índices separados son peores que uno compuesto:

MySQL 8.4
CREATE INDEX idx_orders_user_status ON orders (user_id, status);

La regla: un índice sobre (user_id, status) sirve para condiciones sobre user_id y sobre el par user_id + status, pero no ayuda a una consulta solo por status, igual que una guía telefónica ordenada por apellido no sirve para buscar por nombre de pila. Pon primero la columna que filtra con más frecuencia y descarta más filas.

10. Acaba con el N+1 y con los cambios fila a fila

A veces el SQL lento no es una consulta, sino mil pequeñas. El clásico es un bucle en la aplicación: se saca la lista de pedidos y luego, para cada uno, el cliente con una consulta aparte. La base de datos ejecuta N+1 consultas en lugar de un solo JOIN:

MySQL 8.4
SELECT o.order_id, u.email
FROM orders o
JOIN users u ON u.user_id = o.user_id;

La misma lógica vale para la escritura: insertar cien filas con un solo INSERT INTO ... VALUES (...), (...), ... es varias veces más rápido que cien INSERT por separado, porque cada consulta paga un viaje de ida y vuelta por la red y la sobrecarga de la transacción.

Por dónde profundizar

Esta lista de comprobación cubre los casos típicos, pero detrás de cada punto hay mucho más: la selectividad de los índices, los índices de cobertura y parciales, el modelo de costes del optimizador, la optimización de JOIN y de ordenaciones. A todo esto está dedicado el curso Optimización de consultas SQL en PostgreSQL, con planes de ejecución reales y mediciones de cada técnica.

Siguientes pasos

Artículos relacionados