Índices: la diferencia entre 3 segundos y 30 milisegundos
Un índice bien puesto puede acelerar mil veces una consulta. Diez índices mal puestos pueden hacer más lenta toda tu base.
La misma consulta, la misma máquina, los mismos datos. Sin índice tarda 3.2 segundos y lee cuatro millones de filas para devolver treinta y siete. Con el índice correcto tarda 31 milisegundos y lee cinco páginas. No hay optimización de hardware que produzca esa diferencia; hay una estructura de datos bien elegida.
Y sin embargo, los índices son también una de las formas más eficientes de degradar una base de datos. Cada índice que agregas se paga en cada inserción, cada actualización y cada borrado, para siempre. Una tabla con doce índices y tres consultas distintas está pagando nueve impuestos que no le sirven a nadie.
Esta guía cubre lo que hay que entender para tomar buenas decisiones: cómo leer un plan sin ser especialista, la regla de orden de columnas que decide si un índice sirve o no, cuándo conviene cubrir una consulta, y cómo encontrar los índices que solo cuestan.
Sumario
- 01Qué cambia realmente un índice
- 02Leer un plan sin ser especialista
- 03El orden de las columnas
- 04Índices que cubren la consulta
- 05Lo que impide usar un índice
- 06El costo de escritura
- 07Duplicados y redundantes
- 08Los que nadie lee
Qué cambia realmente un índice
Sin índice, el motor recorre la tabla completa y descarta lo que no cumple. Con el índice adecuado, salta directo al rango de filas que importan. Esto es lo que se ve en el plan de ejecución de una consulta real sobre una tabla de cuatro millones de pedidos:
SELECT id, total, fecha
FROM pedidos
WHERE cliente_id = 8841 AND estatus = 'ABIERTO';
─── SIN ÍNDICE ─────────────────────────────────────────
|--Clustered Index Scan (pedidos)
filas leídas ......... 4,120,000
filas devueltas .............. 37
lecturas lógicas ....... 38,410
tiempo .................. 3,180 ms
─── CON IX_pedidos_cliente_estatus ─────────────────────
|--Index Seek (IX_pedidos_cliente_estatus)
filas leídas ................. 37
filas devueltas .............. 37
lecturas lógicas ............... 5
tiempo ...................... 31 ms
No el tiempo: las lecturas lógicas. El tiempo varía con la carga del servidor y con lo que ya esté en caché; las lecturas lógicas son estables y comparables entre ejecuciones. Cuando optimices, mide ahí. Si las lecturas bajaron de 38,410 a 5, mejoraste de verdad, aunque el reloj diga otra cosa ese día.
Leer un plan sin ser especialista
No hace falta dominar los treinta operadores. Con reconocer seis se resuelve la enorme mayoría de los casos.
| Operador | Qué está pasando | ¿Preocupa? |
|---|---|---|
| Index Seek | Va directo al rango de filas por el índice | No. Es lo que buscas |
| Index Scan | Recorre el índice completo, pero al menos es más chico que la tabla | A veces. Revisa si falta una columna en el índice |
| Clustered Index Scan o Table Scan | Recorre la tabla entera | Sí, en tablas grandes. Casi siempre falta un índice |
| Key Lookup o RID Lookup | Encontró la fila por el índice y ahora va a la tabla por las columnas que faltan | Sí, si se repite muchas veces. Se resuelve cubriendo |
| Sort | Está ordenando en memoria porque el índice no venía ordenado como se pidió | Sí, si es costoso. Un índice con el orden correcto lo elimina |
| Hash Match | Une conjuntos grandes construyendo una tabla temporal | Normal en reportes; sospechoso en consultas que devuelven pocas filas |
En SQL Server el plan se pide con SET STATISTICS IO, TIME ON y el plan real desde el cliente; en PostgreSQL con EXPLAIN (ANALYZE, BUFFERS); en MySQL con EXPLAIN ANALYZE. Los nombres cambian, los conceptos son los mismos.
El orden de las columnas lo decide todo
Un índice compuesto se usa de izquierda a derecha, como el directorio telefónico: ordenado por apellido y luego por nombre. Puedes buscar por apellido, o por apellido y nombre. Pero buscar solo por nombre te obliga a leerlo completo.
La regla práctica: primero las columnas comparadas por igualdad, después la de rango, y al final las que solo aparecen en el ORDER BY. El índice deja de aprovecharse en cuanto encuentra el primer rango.
SELECT id, total, cliente_id
FROM pedidos
WHERE sucursal_id = 12 -- igualdad
AND estatus = 'ABIERTO' -- igualdad
AND fecha >= '2028-06-01' -- rango
ORDER BY fecha DESC;
-- BIEN: igualdades primero, rango al final
CREATE INDEX IX_pedidos_suc_est_fecha
ON pedidos (sucursal_id, estatus, fecha);
-- MAL: el rango va primero y bloquea el resto
CREATE INDEX IX_malo
ON pedidos (fecha, sucursal_id, estatus);
Un índice sobre (sucursal_id, estatus, fecha) también sirve para consultas que filtran solo por sucursal_id, o por sucursal_id y estatus. Por eso casi siempre puedes consolidar tres índices en uno bien ordenado, en lugar de crear uno por consulta.
Índices que cubren la consulta
El índice encontró las 37 filas, pero la consulta pide columnas que el índice no tiene. El motor tiene entonces que ir a la tabla una vez por cada fila: eso es el Key Lookup. Con 37 filas no se nota; con 40,000 es el cuello de botella entero.
La solución es agregar esas columnas al índice como incluidas: viajan con el índice pero no forman parte de la llave, así que no lo hacen más lento de buscar.
CREATE INDEX IX_pedidos_suc_est_fecha
ON pedidos (sucursal_id, estatus, fecha) -- llave: para buscar y ordenar
INCLUDE (total, cliente_id, vendedor_id); -- incluidas: para no ir a la tabla
-- PostgreSQL 11+ soporta la misma sintaxis INCLUDE.
-- En MySQL/InnoDB no existe INCLUDE: se agregan a la llave,
-- con el costo de tamaño que eso implica.
Es tentador incluir todas las columnas de la tabla y acabar con una copia completa que hay que mantener en cada escritura. Cubre las consultas frecuentes y de pocas columnas. Si una consulta necesita quince columnas, probablemente el Key Lookup no sea el problema real.
Lo que impide usar un índice que sí existe
Este es el hallazgo más frustrante y más común: el índice está, es el correcto, y la consulta no lo usa. Casi siempre porque algo en el WHERE impide comparar directamente contra la columna indexada.
-- 1. Función sobre la columna
WHERE YEAR(fecha) = 2028 ✗ recorre todo
WHERE fecha >= '2028-01-01' AND fecha < '2029-01-01' ✓
-- 2. Conversión de tipo
WHERE CONVERT(varchar, cliente_id) = '8841' ✗
WHERE cliente_id = 8841 ✓
-- 3. Comodín al inicio
WHERE nombre LIKE '%lopez%' ✗
WHERE nombre LIKE 'lopez%' ✓
-- 4. Cálculo del lado de la columna
WHERE total * 1.16 > 5000 ✗
WHERE total > 5000 / 1.16 ✓
La conversión implícita entre varchar y nvarchar, o entre texto y número, no genera error ni advertencia: solo hace que el índice deje de usarse. Es habitual cuando el ORM manda parámetros con un tipo distinto al de la columna. Si un índice «no se usa sin razón», revisa los tipos primero.
Si de verdad necesitas filtrar por una expresión, la mayoría de los motores permiten indexarla: columnas calculadas persistidas e indexadas en SQL Server, índices sobre expresiones en PostgreSQL. Es mejor que reescribir media aplicación.
El costo que nadie mira: escribir
Cada índice es una estructura adicional que el motor debe mantener sincronizada. Un INSERT en una tabla con ocho índices no escribe una vez: escribe nueve. Un UPDATE que toca una columna indexada tiene que reacomodar esa entrada, y si la fila cambia de posición, mover más de lo que parece.
Por eso la pregunta correcta antes de crear un índice no es «¿ayuda a esta consulta?» sino «¿ayuda lo suficiente para justificar el impuesto que le cobra a todas las escrituras de esta tabla?».
En tablas muy transaccionales, más de cinco o seis índices no agrupados empieza a ser señal de revisión —no una prohibición, una señal—. En tablas de consulta o histórico puedes tener más sin problema, porque casi no se escriben. Y siempre revisa el reporte de escrituras contra lecturas antes de agregar uno más.
Duplicados y redundantes
Aparecen solos con el tiempo: alguien agrega un índice para una consulta sin revisar los existentes, y a los tres años la tabla tiene cuatro variantes del mismo. Un índice sobre (cliente_id) es completamente redundante si ya existe uno sobre (cliente_id, fecha), porque el segundo cubre todo lo que hacía el primero.
SELECT OBJECT_NAME(i.object_id) AS tabla,
i.name AS indice,
STUFF((SELECT ', ' + c.name
FROM sys.index_columns ic
JOIN sys.columns c
ON c.object_id = ic.object_id
AND c.column_id = ic.column_id
WHERE ic.object_id = i.object_id
AND ic.index_id = i.index_id
AND ic.is_included_column = 0
ORDER BY ic.key_ordinal
FOR XML PATH('')), 1, 2, '') AS columnas_clave
FROM sys.indexes i
WHERE i.type_desc = 'NONCLUSTERED'
AND OBJECTPROPERTY(i.object_id, 'IsUserTable') = 1
ORDER BY tabla, columnas_clave;
Ordenado así, los redundantes quedan juntos y saltan a la vista: cliente_id junto a cliente_id, fecha junto a cliente_id, fecha, estatus. El de más columnas suele absorber a los otros dos. Antes de eliminarlos, verifica que ninguno sea único ni respalde una restricción.
Los índices que nadie lee
El motor lleva la cuenta de cuántas veces se usó cada índice para leer y cuántas veces hubo que actualizarlo. Un índice con cero lecturas y doscientas mil escrituras es puro costo.
SELECT OBJECT_NAME(i.object_id) AS tabla,
i.name AS indice,
ISNULL(us.user_seeks,0) + ISNULL(us.user_scans,0)
+ ISNULL(us.user_lookups,0) AS lecturas,
ISNULL(us.user_updates,0) AS escrituras
FROM sys.indexes i
LEFT JOIN sys.dm_db_index_usage_stats us
ON i.object_id = us.object_id
AND i.index_id = us.index_id
AND us.database_id = DB_ID()
WHERE i.type_desc = 'NONCLUSTERED'
AND OBJECTPROPERTY(i.object_id, 'IsUserTable') = 1
ORDER BY lecturas ASC, escrituras DESC;
Estas estadísticas se reinician cuando reinicia el servicio. Si el servidor se reinició la semana pasada, un índice con cero lecturas puede ser el que usa el cierre mensual. Confirma desde cuándo se acumulan y, ante la duda, deshabilita el índice en lugar de eliminarlo: si algo se rompe, se rehabilita en segundos; si lo borraste, hay que reconstruirlo y en tablas grandes eso duele.
Método para trabajar una tabla
Revisión de índices, tabla por tabla
Imprimir o guardar como PDF- Identificar las 5 consultas más frecuentes o costosas contra esa tabla sys.dm_exec_query_stats ordenado por costo total
- Capturar el plan de cada una y anotar las lecturas lógicas SET STATISTICS IO ON · es tu línea base
- Marcar los recorridos completos, los Key Lookup repetidos y los Sort costosos Son los tres síntomas que un índice resuelve
- Listar los índices existentes con sus columnas clave, ordenados alfabéticamente Los redundantes quedan juntos y se ven solos
- Diseñar el conjunto mínimo: igualdades, luego rango, luego orden Consolidar, no agregar uno por consulta
- Agregar columnas incluidas solo donde el Key Lookup sea realmente costoso Cubrir de más se paga en cada escritura
- Deshabilitar —no borrar— los índices sin lecturas Y esperar un ciclo completo de negocio, incluido el cierre mensual
- Volver a medir las mismas consultas y comparar lecturas lógicas Sin el antes y el después, no sabes si mejoraste
- Verificar que las escrituras no se degradaron Tiempo de los INSERT y UPDATE más frecuentes de esa tabla
Trabaja una tabla a la vez y en un ambiente de pruebas con volumen realista. Optimizar contra una tabla de mil filas produce conclusiones que no se sostienen en producción.
El error de aplicar las sugerencias tal cual
Todos los motores sugieren índices, y la tentación de aplicar la lista completa es fuerte. No lo hagas. Esas sugerencias se generan consulta por consulta, sin considerar las demás, sin ver los índices ya existentes y sin contar el costo de escritura. Aplicarlas todas es la forma más rápida de terminar con veinte índices donde cabían cinco.
Úsalas como lo que son: una pista de qué columnas le hicieron falta al optimizador. El diseño lo haces tú, consolidando.
Si estás llegando aquí porque una instancia va lenta y todavía no sabes si el problema son los índices, empieza por el diagnóstico de los ocho puntos: te dice en veinte minutos hacia dónde mirar.
Un índice es una apuesta: pagas en cada escritura para cobrar en cada lectura. Vale la pena solo si sabes cuántas veces vas a cobrar.
¿Tienes una tabla con doce índices y nadie sabe cuáles sirven?
Hacemos la revisión completa: capturamos las consultas reales, medimos la línea base, consolidamos el conjunto de índices al mínimo necesario y verificamos que las escrituras no se degradaron. Te entregamos el antes y el después en lecturas lógicas, que es la única medida que no miente.
- —Optimización de índices y consultas
- —Diagnóstico de rendimiento en SQL Server y PostgreSQL
- —Mantenimiento gestionado de bases de datos
- —Observabilidad para aplicaciones .NET