7 mejores prácticas para optimizar consultas SQL en producción
Este post va directo al grano: técnicas prácticas y comprobables para consultas SQL más rápidas y estables en entornos reales. Incluye ejemplos, por qué funcionan y cuándo aplicarlos.
1) Evita SELECT * — solicita solo columnas necesarias
SELECT * carga datos extra, impide que el optimizador use índices cubriendo y aumenta I/O de red y memoria.
-- Malo (trae columnas innecesarias):
SELECT * FROM orders WHERE customer_id = 42;
-- Mejor (trae solo lo que necesitas):
SELECT id, total_amount, created_at FROM orders WHERE customer_id = 42;
Por qué: reduce ancho de banda, permite index-only scans si las columnas están en el índice, y hace más claras las dependencias del código con la DB.
2) Índices: crea índices bien pensados y evita los automáticos
Regla general: indexa columnas usadas en WHERE, JOIN y ORDER BY. Pero recuerda: cada índice penaliza INSERT/UPDATE/DELETE.
- Índice simple: para igualdad en una columna.
- Índice compuesto: útil cuando filtras por varias columnas en un orden consistente. Ejemplo:
-- Postgres/MySQL
CREATE INDEX idx_orders_customer_created ON orders (customer_id, created_at DESC);
Consejo: el orden importa. Si consultas WHERE customer_id = ? AND created_at < ?, el índice compuesto funciona bien. Si usas ORDER BY created_at DESC y filtras por customer_id, combina ambos.
3) Usa EXPLAIN/EXPLAIN ANALYZE y entiende planes
No adivines: inspecciona el plan. Busca scans completos de tabla, nested loop costosos y conversiones de tipos.
-- Postgres
EXPLAIN ANALYZE
SELECT id FROM orders WHERE customer_id = 42 AND status = 'paid';
Qué revisar:
- ¿Usa índice o seq scan?
- Coste estimado vs real (si están muy distintos, stats obsoletas)
- Nested Loop vs Hash Join (elige según cardinalidad)
4) Evita funciones en columnas indexadas y conversiones implícitas
Si aplicas funciones sobre columnas (por ejemplo, LOWER(name) = 'x') el índice no se suele usar, salvo que crees un índice funcional.
-- Malo: esto puede prevenir uso de índice
SELECT * FROM users WHERE LOWER(email) = 'foo@example.com';
-- Mejor 1: almacenar el valor normalizado en otra columna
ALTER TABLE users ADD COLUMN email_lower text;
UPDATE users SET email_lower = LOWER(email);
CREATE INDEX idx_users_email_lower ON users (email_lower);
-- y mantenerlo en triggers o en la app
-- Mejor 2 (Postgres): índice funcional
CREATE INDEX idx_users_lower_email ON users (LOWER(email));
También evita comparar tipos distintos (e.g., varchar con text o int con text) para que no haya conversiones implícitas.
5) Reescribe consultas: JOINs vs subconsultas, EXISTS vs IN
Correlated subqueries pueden ser muy costosas. Considera JOINs o window functions.
-- Malo (correlated subquery por fila):
SELECT o.id, (SELECT count(*) FROM order_items oi WHERE oi.order_id = o.id) AS items
FROM orders o;
-- Mejor (aggregation + join):
SELECT o.id, COALESCE(c.items, 0) AS items
FROM orders o
LEFT JOIN (
SELECT order_id, COUNT(*) AS items FROM order_items GROUP BY order_id
) c ON c.order_id = o.id;
IN vs EXISTS: para grandes conjuntos, EXISTS suele ser más eficiente en muchos motores:
-- Prefiere EXISTS si la subconsulta es grande
SELECT * FROM customers c
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id AND o.total_amount > 100);
6) Paginación eficiente: evita OFFSET si buscas rendimiento
OFFSET N obliga a la DB a recorrer N filas. Para paginación «infinita», usa el método seek (keyset pagination).
-- Malo (OFFSET costoso en páginas largas)
SELECT id, created_at FROM events ORDER BY created_at DESC LIMIT 20 OFFSET 100000;
-- Mejor (paginación por marcador):
SELECT id, created_at FROM events
WHERE (created_at < :last_seen_created_at)
ORDER BY created_at DESC LIMIT 20;
Keyset es más rápido y más estable cuando los datos cambian durante la paginación.
7) Mantén estadísticas, configura parámetros y monitoriza
Un optimizador con estadísticas obsoletas genera planes pobres. En Postgres ejecuta ANALYZE regularmente; en MySQL, asegúrate de que ANALYZE TABLE y innodb_stats estén saneados.
-- Postgres (manual)
VACUUM ANALYZE;
-- o para tabla específica
ANALYZE orders;
Monitoriza:
- Consultas lentas (slow query log / pg_stat_statements)
- Locks y transacciones largas
- IO, CPU y contención en índices
Prácticas complementarias (rápidas)
- Usa prepared statements / parametrización para el plan cache y seguridad (evita SQL injection).
- Batch inserts/updates en vez de N filas individuales.
- Considera particionado por rango (fecha) si la tabla crece mucho; facilita pruning de particiones.
- Materialized views para cargas analíticas con actualización programada.
- Evita triggers complejos en rutas críticas de escritura.
Ejemplo práctico: optimización paso a paso
Escenario: tabla orders con millones de filas. Consulta lenta:
SELECT id, customer_id, total_amount
FROM orders
WHERE customer_id = 12345
ORDER BY created_at DESC
LIMIT 50;
Pasos:
- EXPLAIN ANALYZE → vemos Seq Scan.
- Crear índice compuesto para filtrar y ordenar:
CREATE INDEX idx_orders_cust_created ON orders (customer_id, created_at DESC); - Volver a ejecutar EXPLAIN ANALYZE → ahora usa index scan o index-only scan si incluimos total_amount en índice.
-- Índice cubriendo (si total_amount es pequeño y quieres evitar visitar la tabla) CREATE INDEX idx_orders_cover ON orders (customer_id, created_at DESC, total_amount); - Medir latencia antes/después, revisar impacto en INSERTs; si la carga de escritura aumenta mucho, discutir con el equipo el trade-off.
Advertencia: no todos los índices compuestos son benéficos; prioriza consultas críticas y prueba en staging.
Consejo avanzado: si ves que el plan cambia drásticamente según parámetros (parameter sniffing), prueba forcing plans de forma controlada (pg_hint_plan en Postgres, hints en MySQL/Oracle) o restructura la consulta para estabilidad del plan. Mantén un pipeline de pruebas de rendimiento automatizado para validar cambios en índices o consultas.
Si quieres, puedo revisar una consulta lenta concreta: comparte la tabla (DDL), el EXPLAIN ANALYZE y la consulta y te propongo cambios concretos.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación