Optimizar consultas con índices de base de datos
Cómo crear, analizar y mantener índices para acelerar las consultas y evitar los errores de indexación más comunes.
Visión general
Una consulta que tarda 40ms en una tabla de 10.000 filas puede tardar 40 segundos con 10 millones — mismo código, misma base de datos, solo más datos. La diferencia casi siempre es si el planificador puede usar un índice o tiene que recorrer la tabla entera. Los índices son estructuras de búsqueda ordenadas que permiten saltar directamente a las filas que coinciden en lugar de leerlo todo.
Pero no son gratis. Cada INSERT, UPDATE y DELETE tiene que mantener todos los índices de la tabla, y cada uno ocupa disco y memoria. La habilidad real no es “agregar índices” — es indexar las columnas que tus lecturas realmente usan, y eliminar las que no.
Cuándo usarlo
Usa esta receta cuando:
- Las consultas se vuelven lentas a medida que la tabla crece
- Los logs de consultas lentas o
EXPLAINmuestran escaneos secuenciales en tablas grandes - Vas a agregar paginación o filtros de búsqueda a una tabla existente
- Diseñas un esquema nuevo y quieres anticipar los patrones de acceso
- Las lecturas largas provocan contención de bloqueos
Solución
Índice básico (una columna)
-- Crear un índice en la columna email
CREATE INDEX idx_users_email ON users(email);
-- La consulta usa el índice en lugar de leer toda la tabla
SELECT * FROM users WHERE email = 'alice@example.com';
Índice compuesto (varias columnas)
-- El orden de las columnas importa: igualdad primero, rango después
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 esta consulta
CREATE INDEX idx_active_users_email
ON users(email)
WHERE active = true;
Analizar 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 la tabla (lento en tablas grandes, necesita un índice)Index ScanoIndex Only Scan= usa un índice (rápido)Bitmap Heap Scan= usa varios índices o una coincidencia parcial
Código complementario: el repo companion de database-indexing tiene el esquema de demo, todas las variantes de índice y las queries de EXPLAIN listas para ejecutar.
Explicación
- Índices B-tree: el tipo por defecto y la opción correcta para la mayoría de columnas. Cubren igualdad, rangos (
=,<,>,BETWEEN) yORDER BY. La mayoría de bases de datos crean uno para la clave primaria automáticamente. - Índices compuestos: se pueden usar con cualquier prefijo de la lista de columnas. Un índice en
(a, b, c)sirve para consultas sobre(a),(a, b)y(a, b, c)— pero no sobre(b)o(c)solas. - Índices covering: si todas las columnas que una consulta necesita están en el índice, el motor responde sin tocar la tabla — un “index-only scan”, a menudo un orden de magnitud más rápido.
- Índices parciales: más pequeños porque solo cubren un subconjunto de filas; útiles cuando casi todas las consultas comparten un filtro (por ejemplo,
active = true).
Variantes
| Tipo de índice | Mejor para | Contrapeso |
|---|---|---|
| B-tree | Igualdad, rangos, ordenación | Uso general, mayor coste de escritura |
| Hash | Solo igualdad exacta | Búsquedas más rápidas, sin rangos |
| GiST / GIN | Texto completo, JSON, arrays | Más grandes, más lentos de construir |
| BRIN | Tablas enormes y ordenadas | Mínimo tamaño, resultados aproximados |
Lo que funciona
- Indexa las columnas de tu WHERE: si una consulta filtra por
user_idystatus, lo primero a probar es(user_id, status). - Igualdad antes que rango: para
WHERE a = 1 AND b > 100, el índice(a, b)sirve mucho más que(b, a). - No indexes columnas de baja cardinalidad solas: una columna
statuscon 3 valores no gana nada con un índice propio. Combínala con una de alta cardinalidad. - Elimina índices sin uso: cada índice ralentiza las escrituras.
- Indexa las claves foráneas: no todas las bases de datos indexan las foreign keys automáticamente, y su ausencia provoca nested loops costosos. Consulta diseño de bases de datos y SQL Joins para la parte de joins.
Errores comunes
- Indexar todas las columnas: desperdicia disco, frena las escrituras y da demasiadas opciones al optimizador.
- Orden de columnas equivocado en índices compuestos:
(created_at, user_id)no sirve para una consulta que solo filtra poruser_id. - Indexar columnas que nadie consulta: revisa los logs de consultas antes de crear índices.
- Ignorar el mantenimiento: los índices fragmentados en tablas con mucha rotación se degradan con el tiempo. Consulta optimización de rendimiento SQL.
- Indexar tablas diminutas: con menos de unos miles de filas, un escaneo secuencial suele ser más rápido — la indirección del índice cuesta más de lo que ahorra.
Detectar índices faltantes
Encontrar índices faltantes en producción
Activa el log de consultas lentas: en PostgreSQL, log_min_duration_statement = 100 registra lo que supere 100ms; en MySQL, long_query_time = 0.1 con slow_query_log activado. Después ordena por coste con pg_stat_statements:
SELECT query, calls, mean_exec_time
FROM pg_stat_statements
ORDER BY mean_exec_time DESC LIMIT 20;
Revisa el contador seq_scan de las tablas grandes en pg_stat_user_tables — un valor alto indica filas leídas sin índice. pgBadger analiza los logs de PostgreSQL y reporta las consultas más lentas con sus planes.
Verificar el uso de índices con EXPLAIN ANALYZE
Ejecuta EXPLAIN (ANALYZE, BUFFERS) SELECT ... para ver el plan real con tiempos y E/S. Seq Scan es un escaneo completo; Index Scan usa un índice; Index Only Scan responde solo desde el índice sin tocar la tabla. Otras señales útiles:
Heap Fetches: 0→ el índice covering funciona del todo.Rows Removed by Filteralto → el índice no es suficientemente selectivo.Planning Timealto → las estadísticas pueden estar desactualizadas; ejecutaANALYZE tabla.Buffers: shared hit=N read=N→ muchoreadimplica disco, muchohitimplica caché.
En MySQL 8.0.18+ usa EXPLAIN ANALYZE o EXPLAIN FORMAT=JSON; busca type: ref o type: range (usa índice) frente a type: ALL (escaneo completo).
Vigilar el uso y eliminar índices sin uso
Consulta pg_stat_user_indexes para los que nadie toca:
SELECT schemaname, indexname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0 ORDER BY indexname;
Un idx_scan en 0 significa sin uso desde el último reinicio de estadísticas. Compara su tamaño con pg_size_pretty(pg_relation_size('idx_name')) y elimínalos con DROP INDEX CONCURRENTLY idx_name para no bloquear la tabla. En MySQL revisa sys.schema_unused_indexes. Audita mensualmente, con cuidado: los índices que solo sirven a procesos batch o informes trimestrales pueden marcar 0 una semana entera sin ser prescindibles.
Ajustar estadísticas cuando el planificador elige mal
El planificador decide según las estadísticas de la tabla. Ejecuta ANALYZE tabla tras cargas masivas o cambios de esquema. Inspecciona distribuciones con SELECT * FROM pg_stats WHERE tablename = 'orders' — mira most_common_vals en columnas sesgadas y sube el objetivo donde haga falta:
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000; -- por defecto 100
Si los planes siguen mal en discos SSD, SET random_page_cost = 1.1 (por defecto 4.0) refleja lecturas aleatorias baratas, y effective_cache_size debe acercarse a la memoria disponible. La extensión auto_explain registra planes de consultas lentas automáticamente.
Probar índices hipotéticos sin construirlos
En una tabla enorme, crear un índice solo para ver si el planificador lo usa es caro. La extensión hypopg simula índices en memoria:
CREATE EXTENSION hypopg;
SELECT * FROM hypopg_create_index('CREATE INDEX idx_test ON orders(user_id, status)');
EXPLAIN SELECT * FROM orders WHERE user_id = 1 AND status = 'paid';
Si el plan usa el índice hipotético, crea el real; si no, descártalo con hypopg_reset() — solo existe en tu sesión.
Medir antes de llevar a producción
Copia una muestra representativa de producción a staging (pg_dump --table=orders --data-only prod_db | psql staging_db) y mide antes y después con EXPLAIN (ANALYZE, BUFFERS) y pgbench:
pgbench -c 10 -j 4 -T 60 -f consultas.sql # carga de lectura
pgbench -i -c 10 -T 60 # carga de escritura
Compara latencia p99, throughput (TPS) y ratio de aciertos en buffer. Estima el tiempo de construcción del índice en un subconjunto primero, y deja los números documentados en el runbook de despliegue.
Tipos de índice avanzados
Índices covering con INCLUDE
INCLUDE guarda columnas extra en el índice sin meterlas en la clave de ordenación:
CREATE INDEX idx_orders_covering
ON orders(user_id, status) INCLUDE (total_amount, created_at);
Así SELECT total_amount, created_at FROM orders WHERE user_id = 1 AND status = 'paid' se responde entero desde el índice — Heap Fetches: 0 en EXPLAIN lo confirma. MySQL no tiene INCLUDE; el equivalente en InnoDB es un índice compuesto con todas las columnas seleccionadas (los índices secundarios ya llevan la clave primaria en las hojas).
Índices de expresión para columnas calculadas
Indexa la expresión, no la columna, cuando las consultas envuelven la columna en una función:
CREATE INDEX idx_lower_email ON users(LOWER(email));
SELECT * FROM users WHERE LOWER(email) = 'john@example.com';
La expresión de la consulta tiene que coincidir exactamente con la del índice. En MySQL necesitas una columna generada:
ALTER TABLE users ADD COLUMN email_lower VARCHAR(255)
GENERATED ALWAYS AS (LOWER(email)) STORED;
CREATE INDEX idx_email_lower ON users(email_lower);
Índices parciales para soft-delete
Las filas eliminadas lógicamente (deleted_at) inflan los índices con datos que nadie consulta. Un índice parcial las excluye:
CREATE INDEX idx_active_orders ON orders(user_id) WHERE deleted_at IS NULL;
El planificador solo lo usa cuando la consulta incluye el predicado exacto — si alguna consulta olvida WHERE deleted_at IS NULL, cae al escaneo completo. Impón el filtro en la aplicación o con una vista. Los predicados deben coincidir exactamente o quedar implicados: una consulta con active = true no usará un índice predicado en active = true AND deleted_at IS NULL.
Orden de columnas en índices compuestos
Un índice (user_id, status, created_at) sirve para WHERE user_id = 1, para WHERE user_id = 1 AND status = 'paid' y para lo mismo con ORDER BY created_at — pero no para WHERE status = 'paid' a secas. Ordena las columnas igualdad-primero, rango-después, y haz coincidir el orden del índice con tu ORDER BY. Quédate en 3-4 columnas; índices más anchos cuestan más disco y memoria con rendimiento decreciente.
Consultas LIKE y búsqueda de texto completo
Un B-tree no ayuda con LIKE '%patrón%' (comodín al inicio). En PostgreSQL, los índices trigram cubren las búsquedas de subcadena:
CREATE EXTENSION pg_trgm;
CREATE INDEX idx_name_trgm ON users USING gin (name gin_trgm_ops);
SELECT * FROM users WHERE name ILIKE '%john%';
Para búsqueda de texto completo, indexa un tsvector y consulta con @@:
ALTER TABLE articles ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED;
CREATE INDEX idx_articles_search ON articles USING gin(search_vector);
SELECT * FROM articles WHERE search_vector @@ websearch_to_tsquery('english', 'postgres index');
En MySQL, CREATE FULLTEXT INDEX + MATCH ... AGAINST cubre lo mismo.
Contención y existencia en JSONB
Los índices GIN soportan los operadores JSONB de PostgreSQL (@>, ?, ?|, ?&):
CREATE INDEX idx_data_gin ON events USING gin(data);
SELECT * FROM events WHERE data @> '{"type": "click"}';
Si las consultas siempre tocan la misma ruta JSON, un índice de expresión sobre data->>'type' es más pequeño y rápido. jsonb_path_ops produce un GIN más compacto pero solo soporta @>.
Consultas geoespaciales con PostGIS
PostGIS añade índices GiST para columnas geometry/geography:
CREATE INDEX idx_locations_geom ON locations USING gist(geom);
SELECT * FROM locations
WHERE ST_DWithin(geom, ST_MakePoint(-73.9, 40.7)::geography, 1000);
El mismo índice acelera el vecino más cercano con el operador <-> (ORDER BY geom <-> punto LIMIT 10). En datasets grandes, CLUSTER locations USING idx_locations_geom reescribe la tabla en el orden del índice para mejorar la localidad.
Restricciones de exclusión
Las exclusion constraints evitan rangos solapados — reservas, planificación, periodos de precio:
CREATE EXTENSION btree_gist;
ALTER TABLE bookings ADD EXCLUDE USING gist (room_id WITH =, time_range WITH &&);
PostgreSQL respalda la restricción con un índice GiST automáticamente. Un insert conflictivo falla con conflicting key value violates exclusion constraint.
Tipos de índice de un vistazo
B-tree cubre igualdad, rangos y ordenación — el default para casi todo. Hash solo soporta =, sin rangos ni orden — útil en tablas de lookup. GIN indexa valores compuestos (arrays, JSONB, texto completo) y contención @>. BRIN guarda min/max por rango de bloques — kilobytes en vez de gigabytes — para tablas append-only ordenadas, como series temporales. GiST cubre datos geométricos y búsquedas de vecinos cercanos.
Tablas multi-tenant
En esquema compartido, antepone tenant_id a los índices compuestos para que el planificador filtre por tenant primero:
CREATE INDEX idx_orders_tenant ON orders(tenant_id, user_id, status);
Con Row-Level Security de PostgreSQL el filtro de tenant se inyecta solo, así que tenant_id debe encabezar todos los índices de la tabla. Para un tenant muy grande, un índice parcial WHERE tenant_id = 'tenant_a' se mantiene pequeño; más allá del ~20% de la tabla, mejor particionar por tenant.
Columnas enum y booleanas
Las columnas de baja cardinalidad rara vez justifican un B-tree propio. Para booleanos o enums sesgados, indexa solo los valores minoritarios:
CREATE INDEX idx_pending_orders ON orders(user_id) WHERE status = 'pending';
Mira primero la distribución (SELECT status, COUNT(*) FROM orders GROUP BY status) — si un valor supera el 80%, un índice parcial sobre el resto sale más barato que indexar todo.
Rendimiento de escritura y mantenimiento
Cómo afectan los índices a las escrituras
Cada índice suma trabajo a INSERT, UPDATE y DELETE — la fila y cada entrada de índice se escriben juntas. En tablas de mucha escritura (logs, eventos, métricas), mantén los índices al mínimo: escribe en una tabla de staging sin índices y fusiona en lotes, o elimina los índices antes de una carga masiva y recréalos después. Usa COPY en lugar de INSERT fila a fila. Mide el impacto real con pgbench — comparar n_tup_ins, n_tup_upd y n_tup_del en pg_stat_user_tables muestra dónde cae el volumen de escritura.
Bloat en tablas con muchos updates
Cada UPDATE en PostgreSQL escribe una versión nueva de la fila y marca la anterior como dead tuple; los índices solo se ahorran trabajo si el update es HOT (Heap-Only Tuple). HOT necesita espacio libre en la página — ALTER TABLE orders SET (fillfactor = 80) reserva un 20% para updates in-place — y las columnas modificadas no pueden estar indexadas. Comprueba el ratio HOT:
SELECT n_tup_hot_upd::float / NULLIF(n_tup_upd, 0) AS hot_ratio
FROM pg_stat_user_tables WHERE relname = 'orders';
Cerca de 1.0 significa que la mayoría de updates no tocan índices. Si una columna cambia constantemente (last_seen, status), quitarle el índice puede habilitar más HOT updates de lo que el índice aporta.
Fragmentación y reparación de bloat
Mide el bloat con pgstattuple (SELECT * FROM pgstattuple('idx_name') — vigila free_percent). Reconstruye con REINDEX INDEX CONCURRENTLY idx_name sin bloquear escrituras, o con pg_repack/pg_squeeze para rebuilds en línea. En MySQL, ANALYZE TABLE refresca estadísticas y OPTIMIZE TABLE reconstruye tabla e índices. Vigila n_dead_tup en pg_stat_user_tables — un ratio dead/live por encima del ~20% es un buen umbral para reindexar.
Ajuste de autovacuum para la salud de índices
Autovacuum recupera dead tuples y mantiene el visibility map al día; ambos afectan al rendimiento de índices. Ajuste por tabla:
ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.05); -- vacuum al 5% dead (por defecto 20%)
En tablas de mucha escritura baja a 0.02. Sube autovacuum_vacuum_cost_limit (p.ej. 2000) y baja autovacuum_vacuum_cost_delay (1ms) si no da abasto, y programa VACUUM (ANALYZE) manual en horas valle como respaldo.
Index-only scans, visibility map y hint bits
Un index-only scan solo evita el heap cuando el visibility map marca las páginas como all-visible y los hint bits están activos en las tuplas. Tras cargas masivas ambos pueden estar desactualizados, y el índice “covering” sigue haciendo Heap Fetches. Se corrige con VACUUM (ANALYZE, FREEZE) orders — FREEZE marca las tuplas como visibles para siempre — y se comprueba la cobertura con la extensión pg_visibility. Un autovacuum frecuente lo mantiene así.
Memoria de índices en shared_buffers
PostgreSQL cachea páginas de índice en shared_buffers (25% de la RAM es un buen punto de partida; 30-40% en cargas con muchos índices). Inspecciona el uso con pg_buffercache y el hit ratio por índice con pg_statio_user_indexes:
SELECT indexname, idx_blks_hit::float / NULLIF(idx_blks_hit + idx_blks_read, 0) AS hit_ratio
FROM pg_statio_user_indexes WHERE idx_blks_read > 0;
Por debajo del ~90% el working set no cabe en memoria. pg_prewarm recarga los índices calientes tras un reinicio.
Creación de índices online y despliegues concurrentes
CREATE INDEX toma un bloqueo que detiene las escrituras. Usa CREATE INDEX CONCURRENTLY — más lento, pero la tabla sigue escribible. Un build concurrente que falla deja un índice inválido: elimínalo y reintenta. En MySQL usa ALTER TABLE ... ADD INDEX ..., ALGORITHM=INPLACE, LOCK=NONE, o pt-online-schema-change en tablas grandes. Estima siempre el tiempo de construcción primero en una réplica de staging.
Detectar y reparar corrupción
La corrupción aparece como resultados incorrectos o errores tipo index "idx_name" contains unexpected page. Verifica con amcheck (bt_index_check('idx_name'), o bt_index_parent_check para cruzar contra el heap) y repara con REINDEX. Si el propio heap está corrupto, restaura desde backup — e investiga la causa raíz, porque la corrupción repetida indica problemas de hardware o filesystem.
Monitoreo y alertas de salud de índices
Revisa tres cosas con regularidad: índices sin uso (idx_scan = 0 semanalmente), bloat (free_percent de pgstattuple, alerta sobre ~30%) y hit ratio de caché (pg_statio_user_indexes). Con postgres_exporter todo esto entra en Prometheus, y pg_cron puede programar los propios trabajos de mantenimiento. Registra una línea base tras cada despliegue para detectar regresiones.
Automatización del mantenimiento
pg_cron gestiona los vacuum/reindex recurrentes dentro de la base de datos:
CREATE EXTENSION pg_cron;
SELECT cron.schedule('vacuum_analyze_weekly', '0 2 * * 0', 'VACUUM (ANALYZE) orders');
SELECT cron.schedule('reindex_monthly', '0 3 1 * *', 'REINDEX INDEX CONCURRENTLY idx_orders_user_id');
En MySQL, pt-index-usage de Percona Toolkit identifica índices sin uso a partir del log de consultas lentas.
Escenarios de producción
Columnas de clave foránea
PostgreSQL no indexa las foreign keys automáticamente. Sin índice, borrar una fila padre escanea toda la tabla hija y puede provocar esperas de bloqueo. Indexa cada FK:
CREATE INDEX idx_orders_user_id ON orders(user_id);
Detecta las que falten con:
SELECT conrelid::regclass AS table, conname AS constraint
FROM pg_constraint WHERE contype = 'f'
AND NOT EXISTS (
SELECT 1 FROM pg_index WHERE indrelid = conrelid AND conkey @> indkey
);
En MySQL, InnoDB crea el índice de la FK por su cuenta.
Índices en migraciones
Versiona los cambios de índices en los archivos de migración junto al esquema (flyway, liquibase, sqitch — cada uno con UP/DOWN). CREATE INDEX CONCURRENTLY no puede correr dentro de una transacción, así que configura la herramienta en consecuencia (executeInTransaction: false en Flyway). Ensaya en una réplica de staging con volumen realista.
Réplicas de lectura y lag de replicación
Los índices se replican vía WAL a las réplicas físicas automáticamente — pero CREATE INDEX CONCURRENTLY en el primario genera WAL extra y aumenta el lag temporalmente. Vigila pg_stat_replication (write_lag, flush_lag, replay_lag) durante los builds y pg_stat_progress_create_index para el progreso. Con replicación lógica, crea los índices en cada suscriptor por separado.
ORDER BY y paginación
Un índice en el orden de ordenación devuelve las primeras N filas sin paso de sort:
CREATE INDEX idx_orders_created_desc ON orders(created_at DESC);
Para paginación por cursor — que gana a OFFSET en páginas profundas — indexa las columnas del cursor y compara como tupla:
CREATE INDEX idx_orders_cursor ON orders(created_at DESC, id DESC);
SELECT * FROM orders
WHERE (created_at, id) < ('2025-01-15', 12345)
ORDER BY created_at DESC, id DESC LIMIT 20;
GROUP BY y agregados
Un índice sobre las columnas de agrupación permite Index-Only Scans en GROUP BY, y un (user_id, created_at DESC) responde MAX(created_at) WHERE user_id = 1 en O(1) con un backward scan. COUNT(*) sobre toda la tabla siempre escanea el heap — usa pg_class.reltuples para estimaciones o una tabla de contadores para valores exactos.
Claves primarias UUID
Los UUID aleatorios (v4) dispersan los inserts por todo el B-tree — mala localidad de caché y page splits constantes. Prefiere UUIDv7 o ULID, ordenados por tiempo, que se anexan al final del índice (PostgreSQL 18+ incluye uuidv7() nativo; en versiones anteriores usa gen_random_uuid() o genera v7 en la aplicación). Si el throughput de inserts es crítico, bigint GENERATED ALWAYS AS IDENTITY sigue siendo lo más barato.
Particionamiento de tablas
El particionamiento declarativo divide una tabla grande por una clave, y cada partición lleva su propio índice:
CREATE TABLE orders (id serial, created_at timestamp, user_id int)
PARTITION BY RANGE (created_at);
CREATE TABLE orders_2025_01 PARTITION OF orders
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
CREATE INDEX ON orders(user_id); -- se propaga a todas las particiones
Las consultas que filtran la clave de partición descartan particiones enteras. Particiona por lo que la mayoría de consultas filtra — normalmente fecha o tenant_id.
Eliminación segura de índices
Antes de eliminar un índice, mira qué depende de él: SELECT conname, contype FROM pg_constraint WHERE conindid = 'idx_name'::regclass encuentra las restricciones que respalda (elimina la restricción si es PK/UNIQUE). Elimina en horas valle con DROP INDEX CONCURRENTLY y vigila pg_stat_statements durante 24-48h — si una consulta empeora, recrea el índice.
Solución de problemas
- El índice existe pero el planificador lo ignora: las estadísticas suelen estar desactualizadas (
ANALYZE tabla), el predicado no coincide exactamente con el del índice parcial/de expresión, o la tabla es tan pequeña que un seq scan es realmente más barato. Revisapg_statsy prueba conhypopg. - Las escrituras se volvieron lentas tras agregar índices: cuenta los índices por tabla — cada uno multiplica el trabajo de escritura. Elimina los que no se usan (
idx_scan = 0) y valora si un índice covering puede sustituir varios de una columna. Seq Scanpersiste en una tabla enorme aun con índice: el índice puede no ser suficientemente selectivo para esa consulta (un valor presente en el 40% de las filas), o la consulta envuelve la columna en una función que el índice no cubre — para eso están los índices de expresión.- El index-only scan sigue leyendo el heap (
Heap Fetches > 0): el visibility map está desactualizado. EjecutaVACUUM (ANALYZE)y verifica conpg_visibility. - Crear el índice bloquea la tabla: usa
CREATE INDEX CONCURRENTLY; si falla a mitad, elimina el índice inválido y reintenta.
Ver también
- Optimización de consultas — reescribir consultas lentas más allá de los índices
- Optimización de rendimiento SQL — el flujo de tuning completo
- Guía de estrategias de indexación — elegir la estrategia conceptualmente
- SQL Joins — tipos de join y sus requisitos de índice
Lectura adicional
- Documentación de índices de PostgreSQL — tipos de índice, INCLUDE, parciales y de expresión
- Use The Index, Luke — guía gratuita en profundidad sobre indexación y el planificador
- MySQL Optimization and Indexes — comportamiento de índices en InnoDB
Preguntas frecuentes
¿Cuántos índices debería tener una tabla?
No hay regla universal, pero 3-5 índices para tablas de menos de un millón de filas y 5-10 para las grandes es una heurística razonable. Bastante más de eso suele indicar índices redundantes o sin uso.
¿Los índices ralentizan INSERT y UPDATE?
Sí — cada índice suma trabajo de escritura porque la base de datos actualiza la estructura del índice junto a la fila. Mide el throughput de escritura antes y después en tablas de mucha escritura.
¿Puedo indexar columnas JSON o arrays?
Sí. Los índices GIN de PostgreSQL cubren JSONB y arrays; MySQL 8+ tiene índices multi-valor para arrays JSON. Son especializados — úsalos cuando las consultas lo justifiquen.
¿Índice UNIQUE o índice normal?
UNIQUE cuando la combinación deba ser única — es restricción e índice a la vez. No apiles un índice normal encima; es redundante.
¿Diferencia entre índice parcial y covering?
Un índice parcial guarda menos filas (filtradas por un WHERE); un covering guarda más columnas (vía INCLUDE) para que las consultas no toquen la tabla. Resuelven problemas distintos y se pueden combinar.
Recursos Relacionados
SQL Joins — Guía Visual con Ejemplos
Guía visual de SQL joins: INNER, LEFT, RIGHT, FULL OUTER, CROSS y SELF joins con ejemplos prácticos, tips de rendimiento y errores comunes.
RecipeCrear y usar vistas y vistas materializadas
Cómo crear y usar vistas de base de datos y vistas materializadas para simplificar consultas y mejorar el rendimiento de lectura.
RecipeConfigurar connection pooling para bases de datos y HTTP
Configura connection pooling para PostgreSQL, MySQL, Redis y clientes HTTP en Python, JavaScript y Java. Reduce latencia y evita agotamiento de conexiones.
RecipeImplementar Estrategias de Invalidación de Caché
Cómo mantener la caché consistente con las bases de datos usando TTL, write-through, write-behind y patrones de invalidación event-driven.
RecipeOptimizar Queries Lentas de Base de Datos
Cómo identificar, analizar y corregir queries SQL lentos usando EXPLAIN, refactoring de queries y técnicas de optimización específicas por base de datos.
PatternRepository Pattern con Generics de TypeScript
Implementa el Repository pattern con generics de TypeScript para desacoplar la lógica de acceso a datos de los servicios de dominio usando interfaces.