Planes de consulta en MariaDB vs PostgreSQL: detecta el verdadero cuello de botella, no los síntomas

¿Te fue útil?

Tienes una “consulta lenta”. Todos dicen que es la base de datos. Alguien publica una captura de EXPLAIN en el chat con un círculo rojo alrededor de “Seq Scan” o “Using temporary”, y la sala decide colectivamente: “Añade un índice.” Dos horas después el índice está creado, la latencia de escritura sube y la consulta sigue lenta. Ahora tienes una consulta lenta y un sistema lento.

Los planes de consulta no mienten, pero tampoco dicen toda la verdad. Son una historia que el optimizador se cuenta a sí mismo. Tu trabajo —especialmente en producción— es averiguar qué parte de esa historia se corresponde con la realidad: CPU, I/O, presión de memoria, esperas por bloqueos, deriva de estadísticas, selectividad dependiente de parámetros, o el culpable verdaderamente aburrido: latencia de almacenamiento.

Un modelo mental práctico: los planes son hipótesis

Trata un plan de ejecución como tratas una línea de tiempo de un incidente. Es evidencia, no un veredicto. Tanto los optimizadores de MariaDB como de PostgreSQL eligen planes basados en un modelo de costes. Los modelos de costes dependen de estadísticas, supuestos de configuración y detalles de implementación. A menudo aciertan. A veces se equivocan de forma hilarante, y fallan de maneras previsibles: cambia la distribución de datos, cambian los parámetros o cambia la concurrencia.

El flujo de trabajo que realmente funciona en producción tiene tres capas:

  1. Confirmar el síntoma: tiempo, frecuencia, concurrencia y qué significa “lento” (¿p95? ¿p99? ¿tiempo de pared? ¿tiempo de CPU?).
  2. Encontrar el cuello de botella de recursos: CPU, I/O, memoria, bloqueos, red o algo fuera de la base de datos.
  3. Usar el plan para explicar el cuello de botella: no al revés.

Si empiezas por el plan y buscas una línea para “arreglar”, a menudo tratarás síntomas. También desplegarás una “mejora de rendimiento” que aumenta el riesgo: bloat de índices nuevos, peor amplificación de escrituras o una regresión de plan diferente el lunes.

Idea parafraseada (atribuida): Werner Vogels lleva tiempo promoviendo la noción de que “todo falla, diseña para ello.” La misma actitud aplica a los planes de consulta: asume que a veces estarán equivocados y construye guardarraíles.

Hechos e historia interesantes (porque explican las rarezas de hoy)

Algunos misterios de rendimiento tienen más sentido si conoces de dónde vienen los motores. Aquí hay piezas concretas de contexto que importan cuando lees planes bajo presión:

  • El optimizador de PostgreSQL está fuertemente impulsado por estadísticas, y con gusto elegirá un plan “correcto” que sea equivocado para tus valores de parámetros a menos que fuerzas mejores estimaciones de selectividad.
  • MariaDB heredó el vocabulario de EXPLAIN de MySQL (“Using temporary”, “Using filesort”), que describe tácticas de implementación más que causas raíz. Es útil, pero no es un diagnóstico completo.
  • InnoDB se convirtió en el motor por defecto de MySQL hace años, y MariaDB mantuvo esa línea. Mucho del “tuning de consultas” es en realidad “comportamiento del buffer pool de InnoDB” disfrazado.
  • Postgres tiene MVCC en su núcleo; el “bloat” y el comportamiento del vacuum pueden cambiar dramáticamente los patrones de I/O sin que cambie el texto de la consulta.
  • Postgres soporta múltiples tipos de índices (B-tree, GIN, GiST, BRIN, hash). Eso significa que “añadir un índice” no es una decisión única; es una elección de diseño.
  • MariaDB tiene trazas del optimizador y flags de switch que se pueden alternar por sesión, lo cual es genial para experimentar y terrible para la consistencia si se abusa.
  • Postgres introdujo la consulta paralela con el tiempo; si tu plan de repente muestra workers, puede que estés midiendo la planificación de CPU y la presión de memoria, no la “velocidad de SQL”.
  • MySQL/MariaDB históricamente se apoyaron en joins por bucle anidado como pan de cada día, mientras que Postgres tiene una gama más amplia de estrategias de join y las elegirá más fácilmente cuando las estadísticas lo sugieran.
  • Postgres puede cachear un plan genérico para statements preparados, lo cual puede ser fantástico hasta que la selectividad de los parámetros varie mucho.

Broma #1: El optimizador es como un compañero de trabajo confiado: siempre tiene un plan, y siempre está seguro de que tiene razón.

Guía rápida de diagnóstico (primeras/segundas/terceras comprobaciones)

Cuando producción está caliente y el panel del CEO está frío, no tienes tiempo para análisis filosóficos del plan. Necesitas una secuencia corta que encuentre el cuello de botella real rápidamente y evite “arreglos” que empeoren las cosas.

Primero: determina si la consulta está esperando o trabajando

  • Esperando: esperas por bloqueos, esperas de I/O, esperas de red, cola de ejecución de CPU, stalls por checkpoints.
  • Trabajando: consumiendo CPU, leyendo muchas páginas, ordenando, hasheando, materializando resultados intermedios.

Segundo: comprueba concurrencia y contención

  • ¿Cuántas sesiones ejecutan la misma consulta?
  • ¿Hay una manada que se dispara tras un despliegue o la expiración de una caché?
  • ¿Las escrituras bloquean lecturas (o viceversa) por el alcance de transacciones?

Tercero: confirma si el plan coincide con la realidad

  • Postgres: compara EXPLAIN (ANALYZE, BUFFERS) filas estimadas vs filas reales y lecturas de buffers.
  • MariaDB: usa EXPLAIN FORMAT=JSON, verifica índices elegidos y valida conteos de lecturas de handler.

Cuarto: decide tu palanca

  • Reescritura de consulta (reducir filas temprano, evitar casts implícitos, evitar funciones sobre columnas indexadas).
  • Cambio de índice (orden correcto de la clave, índice parcial/funcional en Postgres, índice compuesto vs simple).
  • Estadísticas y mantenimiento (ANALYZE, histogramas en MariaDB, VACUUM en Postgres).
  • Limitadores operativos (pool de conexiones, work_mem/tmp_table_size, tope de IOPS, vecino ruidoso).

MariaDB vs PostgreSQL: lo que un plan te dice (y no te dice)

Planes de PostgreSQL: explícitos, medibles y ocasionalmente engañosos

Postgres te da un árbol de ejecución con costes, estimaciones de filas y (con ANALYZE) tiempos reales y conteos por nodo. Esto es oro. También tienta a la gente a tratar el plan como verdad determinista. No lo es.

En Postgres, la comparación más valiosa es filas estimadas vs filas reales. Cuando divergen fuertemente, el optimizador está tomando decisiones con entradas malas: estadísticas obsoletas, correlaciones que no puede modelar, selectividad dependiente de parámetros o sesgo en los datos. Ahí es donde el plan se convierte en una pista hacia el cuello de botella real.

Otra superpotencia de Postgres es BUFFERS. Te dice si la consulta está golpeando páginas cacheadas o tirando desde almacenamiento. “Consulta lenta” más muchos shared read buffers es una historia de I/O. “Consulta lenta” con pocas lecturas pero alta CPU es una historia de cómputo (o un bloqueo que se disfraza de cómputo).

Planes de MariaDB: pragmáticos, con internos del optimizador disponibles si los pides

La salida EXPLAIN tradicional de MariaDB es compacta: orden de joins, índice elegido y flags como “Using where”, “Using temporary”, “Using filesort”. Esos flags no son juicios morales. Son pistas de implementación. “Using filesort” no significa “está ordenando en disco”. Significa que usa el algoritmo filesort, que puede o no desbordarse.

Para trabajo serio, prefiere EXPLAIN FORMAT=JSON y —cuando necesites saber por qué se eligió un plan— OPTIMIZER_TRACE. MariaDB puede mostrar las alternativas consideradas y las decisiones de coste. Así es como capturas casos donde el motor eligió un plan porque creyó que un filtro era selectivo cuando no lo era.

Lo que comparten ambos motores: los sospechosos habituales

En MariaDB y Postgres, la mayoría de los “problemas de plan” caen en una lista corta:

  • Estimaciones de cardinalidad malas: sesgo, correlación, estadísticas obsoletas, selectividad dependiente de parámetros.
  • Camino de acceso equivocado: index scan cuando debería ser sequential scan (sí, en serio), o al revés.
  • Desajuste de estrategia de join: nested loops hacia una tabla grande, hash join desbordando, merge join ordenando demasiado.
  • Desbordes de sort/hash: settings de memoria demasiado bajos o concurrencia demasiado alta.
  • Bloqueos: “consulta lenta” que no está lenta — solo bloqueada.
  • Latencia de almacenamiento: los planes parecen correctos; el disco no lo está.

Tareas prácticas: comandos, salidas y decisiones (12+)

Estos son movimientos probados en campo. Cada tarea incluye un comando ejecutable, un fragmento de salida de ejemplo, qué significa y qué decisión tomas a continuación. Úsalos como una caja de herramientas, no como una religión.

Task 1 (Postgres): find the sessions and whether they’re waiting

cr0x@server:~$ psql -d appdb -c "SELECT pid, usename, state, wait_event_type, wait_event, now()-query_start AS age, left(query,120) AS q FROM pg_stat_activity WHERE datname='appdb' AND state<>'idle' ORDER BY age DESC LIMIT 10;"
 pid  | usename | state  | wait_event_type | wait_event |   age    | q
------+--------+--------+-----------------+------------+----------+----------------------------------------------------------
 8421 | app    | active | Lock            | relation   | 00:01:12 | UPDATE orders SET status='paid' WHERE id=$1
 9110 | app    | active | IO              | DataFileRead | 00:00:43 | SELECT ... FROM order_items WHERE order_id=$1

Significado: Una consulta está bloqueada por un lock de relación, otra está esperando una lectura.

Decisión: Si es Lock, deja de tunear la consulta y encuentra el bloqueador. Si es IO, valida la latencia de almacenamiento y las lecturas de buffers antes de indexar.

Task 2 (Postgres): identify blockers and victims

cr0x@server:~$ psql -d appdb -c "SELECT blocked.pid AS blocked_pid, blocking.pid AS blocking_pid, blocked.query AS blocked_query, blocking.query AS blocking_query FROM pg_locks blocked_locks JOIN pg_stat_activity blocked ON blocked.pid = blocked_locks.pid JOIN pg_locks blocking_locks ON blocking_locks.locktype = blocked_locks.locktype AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid AND blocking_locks.pid != blocked_locks.pid JOIN pg_stat_activity blocking ON blocking.pid = blocking_locks.pid WHERE NOT blocked_locks.granted;"
 blocked_pid | blocking_pid |         blocked_query         |         blocking_query
------------+--------------+-------------------------------+----------------------------------
       8421 |         8333 | UPDATE orders SET status=...  | ALTER TABLE orders ADD COLUMN...

Significado: DDL bloquea DML. Esto no es un problema de plan de consulta.

Decisión: Para el DDL o muévelo a una ventana segura. Luego revisa la consulta original si sigue lenta.

Task 3 (Postgres): get a measured plan with buffer activity

cr0x@server:~$ psql -d appdb -c "EXPLAIN (ANALYZE, BUFFERS, VERBOSE) SELECT * FROM order_items WHERE order_id=12345;"
                                                             QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------
 Index Scan using order_items_order_id_idx on public.order_items  (cost=0.43..42.10 rows=12 width=128) (actual time=0.085..12.611 rows=9800 loops=1)
   Output: id, order_id, sku, qty, price
   Index Cond: (order_items.order_id = 12345)
   Buffers: shared hit=12 read=8102
 Planning Time: 0.214 ms
 Execution Time: 13.004 ms

Significado: Se usa el índice, pero se están leyendo miles de páginas desde disco. El predicado no es muy selectivo o la tabla está bloated/no está ordenada por la clave.

Decisión: Considera un índice cubriente (incluir columnas), reducir las columnas seleccionadas o abordar el bloat de la tabla. También revisa la latencia de I/O.

Task 4 (Postgres): compare estimated vs actual rows to spot stats problems

cr0x@server:~$ psql -d appdb -c "EXPLAIN (ANALYZE) SELECT * FROM users WHERE email LIKE '%@example.com';"
                                                    QUERY PLAN
------------------------------------------------------------------------------------------------------------------
 Seq Scan on users  (cost=0.00..18250.00 rows=100 width=256) (actual time=0.040..210.221 rows=48210 loops=1)
   Filter: (email ~~ '%@example.com'::text)
   Rows Removed by Filter: 151790
 Planning Time: 0.251 ms
 Execution Time: 213.904 ms

Significado: La estimación (100) está muy equivocada (48k). Además, un wildcard al inicio desactiva un índice B-tree normal.

Decisión: Si esta consulta importa, rediseña el predicado (almacena el dominio por separado) o usa un índice de trigramas (extensión de Postgres). No culpes al “seq scan” aquí; culpa al predicado.

Task 5 (Postgres): check whether temp files (spills) are happening

cr0x@server:~$ psql -d appdb -c "SELECT datname, temp_files, temp_bytes FROM pg_stat_database WHERE datname='appdb';"
 datname | temp_files | temp_bytes
---------+------------+------------
 appdb   |       1821 |  987654321

Significado: La base de datos está volcándose a disco para sorts/hashes o resultados intermedios grandes. A menudo es una historia de work_mem y concurrencia.

Decisión: Encuentra las consultas que hacen grandes sorts/hashes; ajusta la memoria por sesión cuidadosamente o reescribe para reducir filas antes. No subas work_mem globalmente y reces.

Task 6 (Postgres): see the biggest time consumers (requires pg_stat_statements)

cr0x@server:~$ psql -d appdb -c "SELECT left(query,100) AS q, calls, total_exec_time, mean_exec_time, rows FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 5;"
                         q                         | calls | total_exec_time | mean_exec_time |  rows
---------------------------------------------------+-------+-----------------+----------------+-------
 SELECT ... FROM order_items WHERE order_id=$1      | 92000 |       812345.12 |          8.83  | 901234
 SELECT ... FROM users WHERE email LIKE $1          |  5000 |       401122.88 |         80.22  |  48210

Significado: No todas las consultas lentas importan. El tiempo total te muestra qué consume tu presupuesto.

Decisión: Empieza por el mayor tiempo total (y p95/p99 desde tu APM). Optimiza donde muevas la aguja, no donde quede mal.

Task 7 (MariaDB): find active threads and what they’re waiting on

cr0x@server:~$ mariadb -e "SHOW FULL PROCESSLIST;"
Id	User	Host	db	Command	Time	State	Info
231	app	10.0.2.41:51012	appdb	Query	65	Waiting for table metadata lock	ALTER TABLE orders ADD COLUMN note TEXT
244	app	10.0.2.77:52910	appdb	Query	42	Statistics	SELECT ... FROM order_items WHERE order_id=12345

Significado: Un metadata lock está bloqueando a otros. Observa también estados como “Statistics” que pueden indicar trabajo del optimizador o comportamiento del motor de almacenamiento.

Decisión: Resuelve el metadata lock primero. Luego mide la ejecución de la consulta; los ajustes de plan no arreglarán hilos bloqueados.

Task 8 (MariaDB): show the plan in JSON (more detail than tabular EXPLAIN)

cr0x@server:~$ mariadb -e "EXPLAIN FORMAT=JSON SELECT * FROM order_items WHERE order_id=12345\G"
EXPLAIN
{
  "query_block": {
    "select_id": 1,
    "table": {
      "table_name": "order_items",
      "access_type": "ref",
      "possible_keys": ["order_id_idx"],
      "key": "order_id_idx",
      "key_length": "8",
      "rows": 12000,
      "filtered": 100,
      "attached_condition": "(`order_items`.`order_id` = 12345)"
    }
  }
}

Significado: MariaDB espera ~12k filas. Si las filas reales son 9.8k, bien. Si son 900k, las estadísticas mienten o el predicado no es lo que piensas.

Decisión: Valida los conteos con una consulta dirigida y refresca estadísticas si es necesario.

Task 9 (MariaDB): enable optimizer trace to see why the plan was chosen

cr0x@server:~$ mariadb -e "SET optimizer_trace='enabled=on'; SELECT * FROM order_items WHERE order_id=12345; SELECT TRACE FROM INFORMATION_SCHEMA.OPTIMIZER_TRACE\G"
TRACE
{
  "steps": [
    {
      "join_optimization": {
        "table_dependencies": [
          {"table": "order_items", "row_may_be_null": false}
        ],
        "rows_estimation": [
          {"table": "order_items", "range_analysis": {"chosen_range_access_summary": "ref on order_id_idx"}}
        ]
      }
    }
  ]
}

Significado: Puedes ver las alternativas consideradas. Así pruebas “el optimizador creyó X”.

Decisión: Si la elección del optimizador se basa en una estimación equivocada, arregla estadísticas/distribución (histogramas) o reescribe. No empieces a forzar índices como primera medida.

Task 10 (MariaDB): check handler reads to spot table scans and random I/O

cr0x@server:~$ mariadb -e "SHOW SESSION STATUS LIKE 'Handler_read%';"
Variable_name	Value
Handler_read_first	0
Handler_read_key	12045
Handler_read_next	980000
Handler_read_rnd_next	4500000

Significado: Muchos Handler_read_rnd_next suele implicar barridos de tabla completos. Muchos read_next puede indicar scans por rango que recorren muchas filas.

Decisión: Si los escaneos son grandes, reduce filas antes (mejores predicados, mejor índice) o acepta el scan si es más barato que I/O aleatorio con un camino de índice malo.

Task 11 (System): confirm whether the host is I/O-bound

cr0x@server:~$ iostat -x 1 3
Linux 6.1.0 (db01) 	12/30/2025 	_x86_64_	(8 CPU)

avg-cpu:  %user   %nice %system %iowait  %steal   %idle
          12.10    0.00    6.20   31.50    0.00   50.20

Device            r/s     w/s   rkB/s   wkB/s  await  svctm  %util
nvme0n1         820.0   240.0 65440.0 18200.0  18.2   0.8   98.0

Significado: Alto %iowait, alto await y %util cerca de 100%: el almacenamiento está saturado. Tu plan puede estar “bien” y aun así ser lento.

Decisión: Reduce lecturas (índices/cubrientes, menos columnas, mejores filtros), arregla bloat o añade IOPS. Si sigues afinando SQL mientras el disco está al 100%, estás moviendo los muebles durante un simulacro de incendio.

Task 12 (System): check CPU run queue and saturation

cr0x@server:~$ vmstat 1 5
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 8  0      0 512000  12000 980000    0    0   200   500 1200 2400 55 10 30  5  0
 9  0      0 508000  12000 978000    0    0   180   420 1180 2500 58 12 25  5  0

Significado: Cola de ejecución r cerca o por encima del recuento de CPU y alto us significa saturación de CPU. Bajo wa significa que no es principalmente espera de I/O.

Decisión: Busca funciones costosas, demasiados workers paralelos (Postgres), bucles calientes o índices faltantes que causen filtrado pesado por CPU. También revisa compresión/cripto y picos de conexiones.

Task 13 (Postgres): check autovacuum/vacuum health and bloat signals

cr0x@server:~$ psql -d appdb -c "SELECT relname, n_dead_tup, n_live_tup, last_vacuum, last_autovacuum, last_analyze, last_autoanalyze FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 5;"
  relname    | n_dead_tup | n_live_tup | last_vacuum | last_autovacuum     | last_analyze        | last_autoanalyze
------------+------------+------------+-------------+----------------------+---------------------+------------------------
 order_items |    812345  |   9023410  |             | 2025-12-30 09:12:01  | 2025-12-30 08:05:10 | 2025-12-30 09:12:10

Significado: Muchas tuplas muertas: riesgo de bloat, más páginas para leer, peor comportamiento de caché, scans de índice más lentos.

Decisión: Ajusta autovacuum para tablas calientes, considera VACUUM (o estrategia de reescritura) si vas atrasado. Los planes no te rescatarán de una tabla que es efectivamente el doble de su tamaño útil.

Task 14 (MariaDB/InnoDB): check buffer pool pressure and reads

cr0x@server:~$ mariadb -e "SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%'; SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_pages%';"
Variable_name	Value
Innodb_buffer_pool_read_requests	987654321
Innodb_buffer_pool_reads	12345678
Variable_name	Value
Innodb_buffer_pool_pages_total	1048576
Innodb_buffer_pool_pages_free	1024

Significado: Páginas libres muy bajas y lecturas físicas no triviales pueden significar que el working set no entra. Bajo carga, las lecturas golpearán disco.

Decisión: Reduce el working set (elimina índices no usados, estrecha selects), aumenta buffer pool si es seguro, o arregla la consulta para dejar de tocar tantas páginas.

Task 15 (Postgres): test whether prepared statement plan caching is hurting you

cr0x@server:~$ psql -d appdb -c "SHOW plan_cache_mode;"
 plan_cache_mode
-----------------
 auto

Significado: En auto, Postgres puede elegir un plan genérico para statements preparados cuando cree que es más barato en general.

Decisión: Si sospechas sensibilidad a parámetros, prueba con SET plan_cache_mode = force_custom_plan; en una sesión y compara rendimiento. Si ayuda, arregla a nivel de app/driver o de estructura de consulta.

Tres mini-historias corporativas desde el terreno

1) Incidente causado por una suposición equivocada: “Seq Scan siempre es malo”

Una compañía mediana tenía una página analítica respaldada por Postgres. El on-call recibió una alerta: el p95 del endpoint pasó de “bien” a “problema”, y la CPU de la base de datos subió. El desarrollador publicó un EXPLAIN mostrando un sequential scan sobre una tabla de tamaño moderado y declaró victoria: “Necesitamos un índice.”

Añadieron un índice compuesto que coincidía con el WHERE y el ORDER BY. Las escrituras se volvieron más lentas de inmediato. La consulta mejoró en una ejecución tranquila en staging. En producción, el p95 apenas mejoró y la CPU se mantuvo alta. Mientras tanto, la construcción del índice compitió por I/O y agravó el incidente.

El problema real no era el scan. Era la concurrencia: tras un despliegue, el endpoint empezó a dispararse dos veces por vista debido a una regresión en el frontend. La consulta hacía un sequential scan razonable porque la tabla era lo bastante pequeña, el predicado no era selectivo y las páginas cacheadas hacían barato el scan —hasta que dejó de estar cacheada bajo la carga duplicada.

Arreglar el bug del frontend redujo el QPS a la mitad. El índice “de mejora de rendimiento” se convirtió en deuda técnica: aumentó la amplificación de escrituras y el trabajo de autovacuum, y no era necesario. Finalmente lo eliminaron cuando el postmortem dejó de estar emocionalmente apegado al seq scan.

2) Optimización que salió mal: forzar el comportamiento del plan

Otro equipo usaba MariaDB para una API orientada a clientes. Una consulta ocasionalmente se volvía patológica tras grandes importaciones. Alguien utilizó FORCE INDEX para obligar al optimizador a usar lo que en EXPLAIN parecía el “índice correcto”. Los benchmarks mejoraron. Todos se relajaron.

Dos meses después, una nueva característica añadió un predicado, y el índice forzado se volvió el índice equivocado. La consulta ahora hacía un gran range scan y luego filtraba la mayoría de filas. El optimizador quería cambiar a una clave diferente, pero no pudo. La latencia se disparó y la API empezó a hacer timeouts en tráfico pico.

El incidente fue más divertido porque el plan parecía “estable”. La estabilidad puede ser una trampa: fijaste una decisión que solo era correcta para la distribución y la forma de consulta antiguas. En el postmortem, el equipo eliminó la hint de índice forzada y en su lugar arregló estadísticas y diseño de índices. También añadieron una prueba de regresión en tiempo de consulta usando datos representativos, porque “funciona con los datos del mes pasado” no es una estrategia.

3) Práctica aburrida pero correcta que salvó el día: despliegues medidos + líneas base

Una gran plataforma interna tenía clusters de Postgres y MariaDB. Les habían quemado las manos con regresiones de plan antes, así que hicieron algo agresivamente poco glamuroso: para cada consulta sensible al rendimiento, mantenían una línea base de (a) texto de consulta normalizado, (b) conteos de filas esperados, (c) forma del plan y (d) latencia bajo un snapshot de dataset fijo.

Durante una actualización rutinaria de Postgres, una consulta cambió de un nested loop basado en índice a un hash join. La latencia se dobló en staging. Nadie entró en pánico, porque tenían un diff: el nuevo plan desbordaba a disco por un cambio en el comportamiento de memoria bajo mayor paralelismo.

Ajustaron work_mem para ese rol, restringieron los parallel workers para la sesión y validaron con EXPLAIN (ANALYZE, BUFFERS) que los desbordes desaparecieron. La actualización se desplegó sin drama. Lo mejor: nadie tuvo que argumentar desde las sensaciones. La regresión de plan se detectó antes de que los usuarios la notaran.

Broma #2: Nada dice “empresa” como pasarse una semana para evitar pasarse un fin de semana.

Patrones de cuello de botella: cómo identificar el limitador real

1) Esperas por bloqueos haciéndose pasar por consultas lentas

Si una consulta es lenta porque está bloqueada, tu plan es en su mayoría irrelevante. En Postgres, mira wait_event_type. En MariaDB, comprueba estados del processlist como metadata locks. La trampa clásica es tunear un UPDATE que está “lento” porque está esperando detrás de una transacción larga, un DDL o un job por lotes que olvidó hacer commit.

La causa raíz suele ser el alcance de la transacción. La solución habitualmente no es un índice. Es transacciones más pequeñas, comportamiento de bloqueo distinto o programar DDL de forma segura.

2) Ejecución limitada por I/O: el plan está bien, el almacenamiento no

Puedes tener un index scan perfecto que aun así lea miles de páginas porque los datos están dispersos y no están en caché. Los planes muestran qué debería pasar; BUFFERS y métricas del sistema muestran qué pasó.

Patrones I/O-bound:

  • El plan de Postgres muestra muchos shared read buffers, tiempo de CPU modesto.
  • El host muestra alto iowait, alto await y alto device util.
  • La latencia empeora con la concurrencia (cola en el dispositivo).

Las soluciones incluyen índices cubrientes, reducir columnas seleccionadas, particionar para localidad, vacuum para bloat o admitir que necesitas más IOPS. A veces la mejor optimización es comprar el almacenamiento que pensabas que ya tenías.

3) Ejecución limitada por CPU: predicados costosos y dolor fila-a-fila

Las consultas limitadas por CPU suelen implicar:

  • Funciones sobre columnas en WHERE (especialmente no inmutables en Postgres; o que impiden el uso de índices).
  • Parseo de JSON, regex, LIKE con wildcards, comparaciones costosas por colación.
  • Explosión de joins debido a orden de join erróneo o cardinalidad subestimada.

Los planes te ayudan a identificar dónde se disparan las filas. Pero no pases por alto los consumidores de CPU no obvios: reglas de colación, descompresión o casts por fila debido a tipos incompatibles.

4) Presión de memoria: sorts/hashes se desbordan y todo parece “lentamente aleatorio”

En Postgres, los desbordes aparecen como archivos temporales y en detalles por nodo para sorts/hashes en la salida de ANALYZE. En MariaDB, “Using temporary” y “Using filesort” son pistas, pero aún necesitas confirmar si hubo desborde. Las configuraciones de memoria son por hilo/por consulta; la concurrencia las multiplica. Aquí es donde el tuning ingenuo causa outages.

5) Mentiras de cardinalidad: cuando las estimaciones son ficción

Si las filas estimadas difieren de las reales por 10× o 100×, trata eso como diagnóstico primario. Lleva a malas elecciones de join, malos caminos de acceso y rendimiento impredecible.

Causas comunes:

  • Estadísticas obsoletas (cargas masivas, churn, sesgo).
  • Correlación entre columnas (el optimizador asume independencia).
  • Selectividad dependiente de parámetros (prepared statements, ORMs).
  • Predicados no sargables (funciones, casts).

Arreglar estadísticas no es glamuroso, pero a menudo es la primera acción correcta porque mejora todas las consultas que dependen de ellas.

Errores comunes (síntoma → causa raíz → solución)

“Vemos Seq Scan, así que debe ser el problema”

Síntoma: Postgres muestra Seq Scan; alguien quiere un índice.

Causa raíz: El predicado no es selectivo, o el índice causaría I/O aleatorio peor que escanear, o la consulta devuelve una gran porción de la tabla.

Solución: Mide con EXPLAIN (ANALYZE, BUFFERS). Si la mayoría de páginas se tocan de todos modos, acepta el seq scan o rediseña la consulta. Indexar no es una virtud por sí misma.

“Using filesort significa que está ordenando en disco”

Síntoma: EXPLAIN de MariaDB muestra Using filesort, consulta lenta.

Causa raíz: Usa el algoritmo filesort; puede estar en memoria o puede desbordarse porque la memoria es insuficiente o el conjunto de resultados es grande.

Solución: Confírmalo con status del servidor y métricas de tablas temporales, reduce el conjunto de resultados, añade un índice compuesto que coincida con ORDER BY + WHERE cuando proceda y evita ordenar resultados intermedios enormes.

“Añadimos el índice y empeoró”

Síntoma: Las lecturas mejoraron ligeramente, las escrituras y la replicación empeoraron.

Causa raíz: La amplificación de escrituras y mantenimiento extra (vacuum/autovacuum, page splits, redo/undo) superaron la ganancia de lectura; también puede haber una regresión de plan usando el nuevo índice incorrectamente.

Solución: Valida con pruebas con carga representativa. Prefiere índices más estrechos y cubrientes; elimina índices no usados; considera índices parciales (Postgres) para subconjuntos calientes.

“El plan no cambió, pero hoy está más lento”

Síntoma: Misma forma de plan, mayor latencia.

Causa raíz: Cambió la latencia de almacenamiento, aumentó el churn de caché, aumentó el bloat, aumentó la concurrencia o hay presión de checkpoints.

Solución: Revisa iostat/vmstat, lecturas de buffers vs hits, autovacuum y tuplas muertas, y concurrencia. Trátalo primero como un problema de sistema.

“Rápido en staging pero lento en prod”

Síntoma: El benchmark se ve bien; en producción es un desastre.

Causa raíz: Distribución de datos diferente, falta de sesgo, diferente calor de caché, distinta concurrencia, settings distintos (work_mem/tmp_table_size), almacenamiento distinto.

Solución: Usa snapshots de datos representativos, ejecuta con concurrencia, captura planes reales con ANALYZE/JSON y compara lecturas de buffers y uso de temp.

“Forzamos el optimizador y ahora es estable”

Síntoma: Hint de índice / switch del optimizador “arregla” el rendimiento.

Causa raíz: Congelaste una elección que depende de la distribución de datos; cambios futuros la harán incorrecta.

Solución: Arregla estadísticas y diseño de esquema; mantén hints como último recurso con monitorización y un plan para eliminarlos.

“Añadir RAM lo arreglará”

Síntoma: Alta latencia, alguien culpa a la caché.

Causa raíz: A veces es cierto; a menudo son consultas malas que hacen trabajo innecesario, o contención por bloqueos, o saturación de IOPS.

Solución: Confirma con métricas de buffers y espera de I/O. Añade RAM solo cuando el working set esté demostrado que no cabe y el patrón de acceso se beneficie.

Listas de verificación / plan paso a paso

Triado paso a paso (15–30 minutos)

  1. Elige el objetivo correcto: identifica las consultas con mayor tiempo total o mayor p95/p99 (pg_stat_statements / slow log).
  2. Comprueba si espera o trabaja: Postgres pg_stat_activity; MariaDB SHOW FULL PROCESSLIST.
  3. Comprueba la saturación del sistema: iostat -x, vmstat.
  4. Captura un plan medido: Postgres EXPLAIN (ANALYZE, BUFFERS); MariaDB EXPLAIN FORMAT=JSON más handler reads y trace si hace falta.
  5. Compara estimaciones: si las estimaciones son erróneas, arregla estadísticas primero (ANALYZE, histogramas).
  6. Comprueba desbordes: archivos temporales en Postgres; tablas tmp y comportamiento de sort en MariaDB mediante contadores de status.
  7. Decide la palanca: reescritura de consulta, diseño de índice, estadísticas/mantenimiento o infraestructura/concurrencia.
  8. Valida con plan de rollback: mide antes/después, confirma que no hay regresión en escrituras, replicación y almacenamiento.

Checklist para decisiones sobre índices (no adivines)

  • ¿El predicado reduce filas significativamente? Si no, un índice puede perjudicar más de lo que ayuda.
  • ¿La consulta necesita ordenación/limit? Los índices compuestos pueden convertir sorts en scans por orden de índice.
  • ¿Seleccionas muchas columnas? Considera índices cubrientes (Postgres INCLUDE; MariaDB los índices secundarios en InnoDB incluyen PK, pero no todas las columnas).
  • ¿La consulta es sensible a parámetros? Evita suposiciones basadas en un valor “típico”.
  • ¿Cuál es el coste en escrituras? Considera la tasa de insert/update, no solo la latencia de lectura.

Checklist de estadísticas y mantenimiento

  • Postgres: ¿autovacuum y analyze están al día en las tablas más calientes?
  • Postgres: ¿tienes muchas tuplas muertas y recencia baja de last_autovacuum?
  • MariaDB: ¿necesitas histogramas para columnas sesgadas?
  • Ambos: ¿cambió la distribución de datos (imports, backfills, crecimiento de tenants)?

FAQ

1) ¿Cuál es más fácil de leer: EXPLAIN de MariaDB o EXPLAIN de Postgres?

Postgres, porque te da tiempos reales y conteos de filas por nodo con ANALYZE. MariaDB también es legible, pero a menudo necesitas el formato JSON y contadores de estado adicionales para confirmar la realidad.

2) ¿Debería ejecutar siempre EXPLAIN ANALYZE en producción?

No. Ejecuta la consulta. Para depuración segura en producción, úsalo en una réplica o en una muestra controlada. Cuando debas ejecutarlo, limita y acota con predicados y hazlo en horas no pico.

3) ¿Por qué Postgres elige un seq scan aunque exista un índice?

Porque escanear puede ser más barato que I/O aleatorio cuando una gran fracción de la tabla coincide, o cuando la tabla es pequeña, o cuando las páginas en caché hacen rápido el scan. El optimizador suele tener razón.

4) En MariaDB, ¿qué significa realmente “Using temporary”?

Significa que se usa una tabla temporal interna para agrupar, ordenar o resultados intermedios. Puede estar en memoria o en disco según el tamaño y la configuración. Trátalo como “hay un resultado intermedio” y mide si desborda.

5) ¿Cómo detecto una regresión de plan después de un despliegue?

Compara fingerprints de consulta normalizados y planes medidos antes/después. En Postgres, captura EXPLAIN (ANALYZE, BUFFERS) y comprueba la precisión de estimaciones. En MariaDB, compara plan JSON más handler reads y tiempo de ejecución bajo carga representativa.

6) ¿Cuándo es “añadir un índice” la respuesta correcta?

Cuando el predicado es selectivo, la consulta es lo suficientemente frecuente para justificar el coste de escritura y el índice soporta un patrón de acceso que no obtienes de otra forma (filtrado, ordenación, clave de join). Si es una consulta por lotes que se ejecuta una vez al día, tu “arreglo de índice” podría ser un impuesto para siempre.

7) ¿Por qué mi optimización falló con concurrencia?

Porque la memoria por consulta se multiplica entre sesiones, porque la caché se calienta de forma distinta y porque la cola de I/O es no lineal. Un plan que parece rápido con concurrencia 1 puede colapsar a concurrencia 200.

8) ¿Cuál es la forma más rápida de saber si es almacenamiento?

Mira métricas del host: iostat -x await/util, más evidencia a nivel de base de datos: lecturas de buffers en Postgres y eventos de espera; lecturas físicas e presión del buffer pool en InnoDB. Si el dispositivo está saturado, el tuning de consultas tiene límites sin reducir lecturas.

9) MariaDB o Postgres: ¿qué optimizador es “mejor”?

Ninguno es mágicamente mejor; ambos son potentes y ambos tienen puntos ciegos. Postgres es más fácil de validar porque expone más verdad en tiempo de ejecución en el plan. MariaDB te da optimizer trace que puede ser muy revelador cuando necesitas saber el “por qué”.

Conclusión: pasos prácticos siguientes

Si tomas un hábito operativo de esto: deja de tratar los planes como una lista de tareas. Empieza a tratarlos como una hipótesis que confirmas con esperas, buffers y saturación del sistema. La forma más rápida de arreglar rendimiento es arreglar lo correcto.

Pasos siguientes que puedes hacer esta semana:

  1. Habilita y usa lineas base a nivel de consulta (pg_stat_statements para Postgres; slow log + digesting para MariaDB) para optimizar lo que realmente te cuesta.
  2. Adopta la guía rápida de diagnóstico: esperar vs trabajar, luego contención, luego planes medidos.
  3. Añade una política de “no índices sin revisión”: cada índice necesita una ganancia medida y una verificación del coste en escrituras.
  4. Para tus 5 consultas principales, captura un plan conocido-bueno y un perfil de runtime medido (uso de buffers/temp). Revisa tras cambios de esquema y upgrades.

Tu yo futuro, de guardia a las 02:00, te lo agradecerá. No emocionalmente. Prácticamente.

← Anterior
MySQL vs MariaDB en NVMe: redo logs, política de flush y capacidad de E/S bien hechas
Siguiente →
MySQL vs MariaDB: registro de consultas lentas — convierte una hora de registros en un acelerón de 2×

Deja un comentario