StackPractices
intermediate Por Mathias Paulenko

Optimizar Queries con Indexación de Bases de Datos

Cómo crear, analizar y mantener índices para acelerar queries de base de datos y evitar errores comunes de indexación.

Visión general

Los índices de bases de datos son estructuras de datos que aceleran operaciones de lectura proporcionando rutas rápidas a las filas sin escanear tablas enteras. Sin índices apropiados, incluso cláusulas WHERE simples fuerzan a la base de datos a examinar cada fila secuencialmente — un full table scan que se vuelve insoportablemente lento a medida que los datos crecen.

Sin embargo, los índices no son gratuitos. Cada escritura (INSERT, UPDATE, DELETE) debe actualizar todos los índices relevantes, y cada índice consume espacio en disco y memoria. El objetivo es crear los índices correctos para tus patrones de lectura mientras se minimiza el overhead en escrituras.

Cuándo usarlo

Usa esta receta cuando:

  • Los queries se ralentizan a medida que el tamaño de la tabla crece
  • El análisis de logs de queries lentos o planes de ejecución revela escaneos secuenciales
  • Agregas paginación o filtros de búsqueda a una tabla existente
  • Diseñas un nuevo schema y predices patrones de acceso
  • Troubleshooteas contención de locks causada por lecturas de larga duración

Solución

Índice básico (columna única)

-- Crear un índice en la columna email
CREATE INDEX idx_users_email ON users(email);

-- El query ahora usa el índice en lugar de escanear toda la tabla
SELECT * FROM users WHERE email = 'alice@example.com';

Índice compuesto (múltiples columnas)

-- El orden de columnas importa: filtros de igualdad primero, filtros de rango segundo
CREATE INDEX idx_orders_user_created
ON orders(user_id, created_at DESC);

-- Soporta:
-- WHERE user_id = 1
-- WHERE user_id = 1 AND created_at > '2025-01-01'
-- ORDER BY user_id, created_at DESC

Índice parcial

-- Solo indexa usuarios activos — más pequeño y rápido para este query específico
CREATE INDEX idx_active_users_email
ON users(email)
WHERE active = true;

Analizando planes de ejecución

-- PostgreSQL
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE user_id = 42
ORDER BY created_at DESC
LIMIT 10;

Busca:

  • Seq Scan = escaneo secuencial de tabla (lento en tablas grandes, necesita un índice)
  • Index Scan o Index Only Scan = usando un índice (rápido)
  • Bitmap Heap Scan = usando múltiples índices o un match parcial

Explicación

  • Índices B-tree: El tipo de índice por defecto. Excelente para queries de igualdad y rango (=, <, >, BETWEEN). La mayoría de bases de datos usan B-tree para claves primarias automáticamente.
  • Índices compuestos: La base de datos puede usar el índice para cualquier prefijo de la lista de columnas. Un índice en (a, b, c) soporta queries en (a), (a, b), y (a, b, c), pero no (b) o (c) solos.
  • Índices covering: Si todas las columnas que un query necesita están en el índice, la base de datos puede responder el query sin tocar la tabla. Esto se llama “index-only scan” y es dramáticamente más rápido.
  • Índices parciales: Índices más pequeños que solo cubren un subconjunto de filas. Útiles para tablas donde la mayoría de queries filtran por una condición específica (ej. active = true).

Variantes

Tipo de índiceMejor paraTrade-off
B-treeIgualdad, rango, ordenamientoPropósito general, mayor costo de escritura
HashIgualdad exacta solamenteLookups más rápidos, sin soporte de rango
GiST / GINFull-text search, JSON, arraysMás grandes, más lentos de construir
BRINTablas muy grandes, naturalmente ordenadasTamaño mínimo, resultados aproximados

Lo que funciona

  • Indexa las columnas de tu cláusula WHERE: si un query filtra por user_id y status, un índice en (user_id, status) es lo primero que probar.
  • Pon columnas de igualdad antes que columnas de rango: en (a, b) donde a = 1 y b > 100, el índice en (a, b) es mucho más útil que (b, a).
  • Evita indexar columnas de baja cardinalidad solas: una columna status con solo 3 valores (active, pending, archived) no se beneficia de un índice standalone. Combínala con una columna de alta cardinalidad.
  • Elimina índices no usados: cada índice ralentiza escrituras.
  • Indexa columnas de foreign key: las bases de datos no siempre indexan automáticamente foreign keys. Índices faltantes en columnas JOIN causan escaneos costosos de nested loop. Consulta diseño de bases de datos. Consulta SQL Joins para optimización de joins.

Errores comunes

  • Indexar cada columna: esto desperdicia espacio en disco, ralentiza dramáticamente escrituras, y confunde al optimizador de queries con demasiadas opciones.
  • Orden incorrecto de columnas en índices compuestos: un índice en (created_at, user_id) no puede ayudar a un query que filtra solo por user_id.
  • Indexar columnas que nunca se consultan: revisa tus logs de queries antes de crear índices.
  • Ignorar el mantenimiento de índices: los índices fragmentados en tablas de alta rotación se degradan con el tiempo. Consulta tuning SQL. Programa REINDEX o OPTIMIZE TABLE periódicamente.
  • Usar índices en tablas pequeñas: tablas con menos de unos miles de filas a menudo son más rápidas con escaneos secuenciales porque leer el índice y luego la tabla es más overhead que un escaneo completo.

Lectura Adicional

  • Documentación oficial: consulta la referencia actualizada del framework o herramienta utilizada.
  • Guías relacionadas: explora las guías de performance y database para profundizar.
  • Patrones complementarios: revisa los patrones de diseño aplicables a tu stack tecnológico.
  • Postmortems públicos: estudia incidentes reales de equipos que enfrentaron problemas similares en producción.

Notas de Producción

  • Despliega gradualmente usando canary o blue-green para detectar regresiones temprano.
  • Configura alertas para errores, latencia p99 y tasa de fallos antes de habilitar en producción.
  • Documenta el rollback en el runbook; prueba el procedimiento en staging al menos una vez por trimestre.
  • Revisa logs estructurados con correlation IDs para trazar requests end-to-end en incidentes.

Puntos Clave

  • Aplica optimizar queries con indexación de bases de datos cuando necesites una solución práctica para tu caso de uso.
  • Monitorea el rendimiento después de implementar; mide latencia, errores y uso de recursos antes y después.
  • Revisa la sección de Troubleshooting ante errores comunes; la mayoría tienen causa raíz documentada con solución.
  • Mantén dependencias actualizadas y ejecuta tests en CI para prevenir regresiones en producción.

Ver También

Troubleshooting

  • Largest Contentful Paint is high: optimize images, preload critical resources, and reduce server response time.
  • JavaScript bundle size grows: analyze the bundle, split code by route, and tree-shake unused dependencies. Lazy-load non-critical components.
  • Cache hit rate is low: review cache keys, TTLs, and invalidation patterns.
  • Database CPU spikes: find the top queries by execution time and frequency. Add indexes, rewrite queries, or cache results.
  • Throughput drops under load: profile for contention, garbage collection, and blocked threads. Scale horizontally only after optimizing the hot path.

Errores Comunes en Producción

  • Copiar el ejemplo sin adaptarlo a volúmenes y modos de fallo reales.
  • Saltar tests de carga e inyección de errores antes del primer despliegue productivo.
  • Codificar valores fijos que deberían ser configurables por entorno.
  • Olvidar agregar logging y monitoreo en cada paso.
  • Desplegar sin plan de rollback ni estrategia de backup probada.
  • Asumir que el ejemplo mínimo escalará sin agregar caché o procesamiento por lotes.
  • No documentar la versión y configuración usadas en producción.
  • Dejar la receta sin cambios cuando evolucionan las dependencias o la escala.

Preguntas frecuentes

¿Cómo identifico índices faltantes en producción?

Habilita slow query logging: en PostgreSQL setea log_min_duration_statement = 100 para loguear queries más lentas que 100ms. En MySQL setea long_query_time = 0.1 y habilita slow_query_log. Usa pg_stat_statements en PostgreSQL para trackear frecuencia de queries y tiempo promedio de ejecución: SELECT query, calls, mean_exec_time FROM pg_stat_statements ORDER BY mean_exec_time DESC LIMIT 20. Busca queries con seq_scan alto en tablas grandes en pg_stat_user_tables. Ejecuta EXPLAIN (ANALYZE, BUFFERS) en queries lentas para confirmar sequential scans. Herramientas como pgBadger analizan logs de PostgreSQL y generan reportes con las queries más lentas y sus planes de ejecución.

¿Cómo elijo entre índices B-tree, Hash, GIN y BRIN?

B-tree es el default y maneja igualdad (=), rango (<, >, BETWEEN) y sorting (ORDER BY). Usa B-tree para la mayoría de columnas. Hash indexes solo soportan igualdad y son más rápidos que B-tree para = pero no pueden manejar range queries o sorting — úsalos para lookup tables. GIN (Generalized Inverted Index) es para valores compuestos como arrays, JSONB y full-text search — usa GIN para queries @> (contains) en JSONB. BRIN (Block Range Index) es para datos naturalmente ordenados como time-series — almacena min/max por block range, haciéndolo tiny (kilobytes vs gigabytes para B-tree). Usa BRIN en columnas timestamp en tablas append-only. GiST es para datos geométricos y nearest-neighbor searches.

¿Cómo funcionan los partial indexes y cuándo debería usarlos?

Los partial indexes indexan solo rows que matchean un WHERE clause: CREATE INDEX idx_active_users ON users(email) WHERE active = true. Son más pequeños y más rápidos de mantener que full indexes. Úsalos cuando las queries siempre filtran por una condición específica: registros soft-deleted (WHERE deleted_at IS NULL), suscripciones activas (WHERE status = 'active'), o datos recientes (WHERE created_at > '2025-01-01'). El query planner usa un partial index solo cuando el WHERE clause de la query matchea el predicado del índice. Si la query filtra por active = true pero el predicado del índice es active = true AND deleted_at IS NULL, el planner puede no usarlo — los predicados deben matchear exactamente o ser implícitos.

¿Cómo monitoreo el uso de índices y elimino índices no usados?

Query pg_stat_user_indexes en PostgreSQL: SELECT schemaname, indexname, idx_scan FROM pg_stat_user_indexes WHERE idx_scan = 0 ORDER BY indexname. Un idx_scan de 0 significa que el índice nunca se usó desde el último reset de estadísticas. Compara el tamaño del índice con pg_size_pretty(pg_relation_size('index_name')). Elimina índices no usados: DROP INDEX CONCURRENTLY idx_name. Usa CONCURRENTLY para evitar lockear la tabla durante el drop. En MySQL, query sys.schema_unused_indexes o revisa INFORMATION_SCHEMA.STATISTICS combinado con data del performance schema. Programa auditorías mensuales de índices — índices no usados por 30+ días son candidatos para eliminación. Ten cuidado con índices usados por batch jobs infrecuentes o reportes trimestrales.

¿Cómo manejo fragmentación y bloat de índices?

Los índices de PostgreSQL acumulan dead tuples después de operaciones UPDATE y DELETE. Verifica bloat con la extensión pgstattuple: SELECT * FROM pgstattuple('index_name'). Rebuilda índices con bloat con REINDEX INDEX CONCURRENTLY idx_name — la opción CONCURRENTLY evita bloquear writes. Para índices B-tree, considera pg_repack o pg_squeeze para rebuilds online sin locks exclusivos. En MySQL, ejecuta ANALYZE TABLE para actualizar estadísticas y OPTIMIZE TABLE para rebuildar la tabla e índices. Programa reindexing durante ventanas de bajo tráfico. Monitorea la columna n_dead_tup en pg_stat_user_tables para determinar cuándo se necesita reindexing — un ratio dead-to-live arriba del 20% es un buen threshold.

¿Cómo afectan los composite indexes al rendimiento de queries?

El orden de columnas en composite indexes importa: el índice es usable solo cuando la query filtra en las columnas más a la izquierda. CREATE INDEX idx_a_b_c ON orders(user_id, status, created_at) soporta WHERE user_id = 1, WHERE user_id = 1 AND status = 'paid', y WHERE user_id = 1 AND status = 'paid' ORDER BY created_at. NO soporta WHERE status = 'paid' solo — user_id debe ir primero. Pon columnas de igualdad antes de columnas de rango: (status, created_at) para WHERE status = 'active' AND created_at > '2025-01-01'. Para optimización de ORDER BY, matchea el orden del índice al orden del sort. Limita composite indexes a 3-4 columnas — índices más anchos consumen más disco y memoria con returns decrecientes.

¿Cómo indexo para queries LIKE y full-text search?

Los B-tree indexes no soportan LIKE '%pattern%' (wildcard inicial). Para suffix matching, usa un trigram index en PostgreSQL: CREATE EXTENSION pg_trgm; CREATE INDEX idx_name_trgm ON users USING gin (name gin_trgm_ops). Query con WHERE name % 'john' o WHERE name ILIKE '%john%'. Para full-text search, crea un GIN index en una columna tsvector: CREATE INDEX idx_search ON articles USING gin(to_tsvector('english', title || ' ' || body)). Query con WHERE to_tsvector('english', title || ' ' || body) @@ plainto_tsquery('english', 'search term'). En MySQL, usa full-text indexes: CREATE FULLTEXT INDEX idx_search ON articles(title, body) y query con MATCH(title, body) AGAINST('search term' IN NATURAL LANGUAGE MODE).

¿Cómo afectan los índices al rendimiento de escritura?

Cada índice agrega overhead a operaciones INSERT, UPDATE y DELETE — la base de datos debe actualizar cada índice en la tabla. Una tabla con 10 índices hace writes ~10x más lentos que un write solo heap. Mide el impacto en writes con scripts de benchmark: inserta 10K rows con y sin índices y compara throughput. Para tablas write-heavy (logs, events, metrics), minimiza índices — considera escribir a una tabla staging sin índices y batch-mergear a la tabla indexada. Usa COPY en PostgreSQL en lugar de INSERTs individuales para bulk loads. Considera diferir la creación de índices hasta después de bulk data loads: drop índices, load data, recreate índices. Monitorea write latency con pg_stat_user_tables — compara n_tup_ins, n_tup_upd y n_tup_del contra el tamaño de la tabla.

¿Cómo uso covering indexes (cláusula INCLUDE) para optimización de queries?

Los covering indexes incluyen columnas adicionales en el índice sin hacerlas parte de la search key. En PostgreSQL: CREATE INDEX idx_orders_covering ON orders(user_id, status) INCLUDE (total_amount, created_at). La query SELECT total_amount, created_at FROM orders WHERE user_id = 1 AND status = 'paid' puede servirse enteramente desde el índice (Index-Only Scan) sin tocar el heap. Las columnas INCLUDE no son parte de la B-tree sort key, así que no afectan el ordenamiento del índice — solo almacenan valores en las leaf pages. Esto reduce I/O para queries que seleccionan pocas columnas. En MySQL, usa un composite index que incluya todas las columnas seleccionadas: CREATE INDEX idx_covering ON orders(user_id, status, total_amount, created_at). El InnoDB de MySQL almacena secondary indexes con el valor del primary key, así que si el PK es id, un covering index en (user_id, status) ya incluye id en las leaf pages. Monitorea el uso de Index-Only Scan en EXPLAIN (ANALYZE, BUFFERS) — busca Heap Fetches: 0 que significa que todos los datos vinieron del índice.

¿Cómo manejo indexación para bases de datos multi-tenant?

Para aislamiento schema-per-tenant, cada tenant tiene su propio schema con índices idénticos — no se necesita manejo especial. Para multi-tenancy shared-schema con una columna tenant_id, prefija todos los composite indexes con tenant_id: CREATE INDEX idx_orders_tenant ON orders(tenant_id, user_id, status). Esto asegura que el planner pueda filtrar por tenant primero, reduciendo el rango del index scan. Para RLS (Row-Level Security) en PostgreSQL, el planner agrega el filtro de tenant automáticamente — asegúrate de que tenant_id sea la columna inicial de todos los índices en tablas multi-tenant. Considera partial indexes por tenant para tenants de alto volumen: CREATE INDEX idx_tenant_a_orders ON orders(user_id) WHERE tenant_id = 'tenant_a'. Esto mantiene el índice pequeño para el tenant específico. Monitorea el tamaño del índice por tenant con pg_stat_user_indexes y considera particionar la tabla por tenant_id cuando los datos de un solo tenant excedan el 20% de la tabla.