MySQL vs PostgreSQL: «CPU 100%» — cómo demostrar que son las consultas, no el hardware

¿Te fue útil?

CPU 100% es el equivalente en producción de una alarma de humo fuera de alcance: ruidosa, persistente y todos quieren que pare de inmediato. El primer instinto es culpar «la máquina» (o la instancia en la nube, o el hipervisor, o los vecinos). Ese instinto a menudo está equivocado.

Esta es una guía de campo para demostrar qué está ocurriendo cuando MySQL o PostgreSQL saturan la CPU: si estás limitado por CPU por las consultas, por trabajo en segundo plano, bloqueos, o simplemente midiendo lo equivocado. Está escrita para quien tiene que explicar sus conclusiones a ingenieros escépticos y a finanzas aún más escépticas.

Qué significa realmente “CPU 100%” para bases de datos

Cuando alguien dice “la CPU está al 100%”, pregunta: ¿de quién CPU? ¿Del sistema total? ¿Un núcleo? ¿Tiempo de usuario? ¿Tiempo en kernel? ¿Steal? ¿Un hilo caliente? ¿Docenas de trabajadores a 80%? La diferencia decide si optimizas consultas, cambias configuración o llamas al proveedor de nube con cara seria.

CPU-bound se ve distinto de I/O-bound

Las bases de datos consumen CPU por unas razones previsibles:

  • Trabajo de ejecución: escanear filas, evaluar predicados, calcular agregados, ordenar, hacer hashing, unir.
  • Trabajo de planificación: generar y costear planes (normalmente pequeño, pero puede explotar en consultas complejas o estadísticas inválidas).
  • Trabajo de concurrencia: locks, latches, MVCC bookkeeping, bucles de spin, overhead de cambios de contexto.
  • Trabajo en segundo plano: vacuuming, purge/undo cleanup, checkpointing, aplicar replicación, analizar estadísticas.
  • Cripto/compresión: TLS, cifrado en reposo, compresión, hashing para checksums.

CPU-bound significa que la base de datos está ejecutando instrucciones activamente. I/O-bound significa que está mayormente esperando lecturas/escrituras. Ambos pueden mostrar “alta carga”, pero sólo uno se arregla con “más CPU”.

El load average no es uso de CPU. Load cuenta tareas en runnable plus tareas en sleep no interrumpible (a menudo I/O). Así que una carga alta con CPU moderada suele ser un tema de I/O. Una CPU alta con load moderado puede ser un trabajador caliente anclando un núcleo.

Una trampa más: steal time (virtualización). Tu VM cree que está ocupada, pero el hipervisor le quita ciclos. Eso no son “consultas”, pero parecerá presión de CPU hasta que lo midas.

Broma #1: Si tu base de datos está al 100% de CPU y la primera solución es “añadir un índice”, felicidades—te has unido a la antigua religión del cargo-cult del rendimiento.

Mi estándar de prueba

No necesitas un doctorado en microarquitectura. Necesitas evidencia repetible que conecte el consumo de CPU con sesiones específicas y, finalmente, con huellas de consulta específicas. Si puedes producir:

  1. Prueba a nivel SO que la CPU la consume el proceso de base de datos (no irq, no kernel, no steal),
  2. Prueba a nivel BD de qué sesiones y patrones SQL son responsables,
  3. Prueba a nivel de plan que explique por qué esas consultas son caras ahora,

…entonces puedes decir “son las consultas” sin sonar como si adivinaras.

Una cita operacional vale más que diez debates en Slack. Como dijo Werner Vogels (CTO de Amazon): “Everything fails, all the time.” Tu trabajo es fallar con recibos.

MySQL vs PostgreSQL: cómo difiere la saturación de CPU

MySQL y PostgreSQL pueden quemar CPUs. Simplemente lo hacen de forma diferente, y las palancas de diagnóstico no son las mismas.

PostgreSQL: procesos por sesión, introspección clara

PostgreSQL normalmente ejecuta un proceso backend por conexión (más procesos de fondo). Eso significa que cuando la CPU está al máximo, a menudo puedes mapearlo rápido: “PID 12345 está caliente” → “ese PID es un backend” → “está ejecutando la consulta X”. Esto es un regalo. Úsalo.

Postgres también tiende a exponer telemetría rica centrada en consultas: pg_stat_activity, pg_stat_statements y auto_explain pueden decirte qué se está ejecutando, qué es caro a lo largo del tiempo y cómo son los planes. Si vas en serio, habilítalos en producción (con cuidado).

MySQL: ejecución basada en hilos, performance_schema es el suero de verdad

MySQL es más centrado en hilos dentro de un número menor de procesos, así que “PID caliente” es menos específico. Te apoyas en performance_schema, el slow query log y los digests de sentencias. Si todavía confías en “SHOW PROCESSLIST y vibras”, estás volando con los instrumentos apagados.

InnoDB introduce su propio sabor de CPU: contención de mutex, churn del buffer pool, purge/undo cleanup y flushing de páginas pueden crear presión de CPU que no es directamente “mi SELECT es lento”, pero sigue siendo causada por carga y decisiones de esquema.

La gran diferencia práctica: qué tan rápido puedes aislar al culpable

En Postgres, aislar a los mayores ofensores suele empezar con pg_stat_statements y acabar con EXPLAIN (ANALYZE, BUFFERS). En MySQL, a menudo empiezas con digests de sentencias y terminas con EXPLAIN ANALYZE (8.0+) más trabajo de índices y esquema. Ambos requieren aceptar una verdad incómoda: la mayoría de “problemas de hardware” son en realidad una amplificación de trabajo: tus consultas haciendo más trabajo del que piensas.

Guion de diagnóstico rápido (primero/segundo/tercero)

Primero: confirma que es realmente CPU y de quién

  1. Comprueba la descomposición de CPU: user vs system vs iowait vs steal.
  2. Identifica procesos/hilos calientes: ¿es mysqld/postgres, o algo más?
  3. Revisa la cola de ejecución: ¿están los hilos ejecutables (CPU-bound) o bloqueados (I/O/bloqueos)?

Segundo: mapea el calor del SO a la actividad BD

  1. Postgres: PID caliente → pg_stat_activity para obtener consulta y estado.
  2. MySQL: hilo caliente → performance_schema para obtener digest de sentencia, usuario, host.
  3. Comprueba la concurrencia: ¿estás saturado por CPU debido a muchas consultas medianas o por una consulta monstruosa?

Tercero: decide si es regresión de plan, estadísticas, contención o trabajo en segundo plano

  1. Regresión de plan: misma consulta obtuvo un peor plan tras crecimiento de datos, deriva de stats o cambios de parámetros.
  2. Índices faltantes/no usados: “escanea porque puede”, o “usa el índice equivocado porque las stats mienten”.
  3. Contención de locks/latches: la CPU puede subir por bucles de spin, más la tasa de transferencia colapsa.
  4. Tareas en segundo plano: autovacuum (Postgres), purge/flushing (InnoDB), aplicar replicación.

Si no puedes responder esos tres pasos en 10 minutos, no tienes un “problema de rendimiento”. Tienes un problema de observabilidad.

Construir una cadena de evidencia: del SO al texto de la consulta

Cuando la CPU se dispara, la gente propone arreglos aleatorios: reiniciar la BD, hacer failover, añadir réplicas, escalar vertical/horizontalmente, “tunear el kernel”, sacrificar una cabra. No hagas cosas aleatorias. Construye una cadena de evidencia que puedas mostrar después.

Qué cuenta como prueba

  • SO: la CPU está ocupada en espacio de usuario (o kernel) y es el proceso de base de datos.
  • BD: los mayores consumidores de CPU se correlacionan con patrones SQL específicos (digests) y clientes.
  • Plan: la consulta cara lo es por una razón (filas leídas, bucles, sorts, desbordes de hash, orden de join malo).
  • Correlación de cambio: un despliegue, drop de índice, deriva de stats, crecimiento de datos o cambio de configuración precedió el pico.

Qué no cuenta como prueba

  • “La CPU está alta, así que la instancia es demasiado pequeña.”
  • “Mi amigo dijo que Postgres es más lento que MySQL para lecturas.”
  • “No cambiamos nada.” (Sí cambiaron. Los datos cambiaron. El tráfico cambió. El mundo cambió.)

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

Todo lo siguiente está pensado para ejecutarse en un host Linux típico con MySQL o PostgreSQL. Cada tarea incluye: el comando, qué buscas en la salida y qué decisión desencadena. No las ejecutes todas a la vez en una máquina moribunda. Elige el martillo más pequeño que responda la siguiente pregunta.

Task 1: Confirma la descomposición de CPU (user/system/iowait/steal)

cr0x@server:~$ mpstat -P ALL 1 5
Linux 6.1.0 (db01)  12/31/2025  _x86_64_  (16 CPU)

12:01:10 PM  CPU   %usr %nice  %sys %iowait %irq %soft %steal %idle
12:01:11 PM  all   92.40  0.00  6.80   0.20 0.00  0.10   0.00  0.50
12:01:11 PM    7   99.50  0.00  0.40   0.00 0.00  0.00   0.00  0.10

Significado: Alto %usr implica ejecución de consultas/cómputo. Alto %sys sugiere trabajo en kernel (red, sistema de archivos, contención). Alto %steal sugiere vecinos ruidosos / oversubscription.

Decisión: Si %steal es no trivial, deja de discutir sobre SQL y valida la contención del host. Si %iowait domina, es probable que estés I/O-bound, no CPU-bound.

Task 2: Encuentra el proceso más caliente

cr0x@server:~$ top -b -n 1 | head -20
top - 12:01:22 up 12 days,  3:12,  2 users,  load average: 18.32, 17.90, 16.01
Tasks: 412 total,   2 running, 410 sleeping,   0 stopped,   0 zombie
%Cpu(s): 93.1 us,  6.5 sy,  0.0 ni,  0.3 id,  0.1 wa,  0.0 hi,  0.0 si,  0.0 st
MiB Mem :  64218.3 total,   2311.7 free,  21342.5 used,  40564.1 buff/cache

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
 5123 mysql     20   0 6432108  12.8g  49288 S 1342.0  20.5  93:21.11 mysqld

Significado: En realidad es el proceso de la base de datos consumiendo CPU. También nota: %CPU puede superar 100% porque es por núcleo.

Decisión: Si no es el proceso BD, para. Sigue al verdadero culpable (herramienta de backups, agente, hilos del kernel).

Task 3: CPU a nivel de hilo para ver si es “un actor malo” o “muerte por mil cortes”

cr0x@server:~$ pidstat -t -p 5123 1 5
Linux 6.1.0 (db01)  12/31/2025  _x86_64_  (16 CPU)

12:01:31 PM   UID      TGID       TID    %usr %system  %CPU   CPU  Command
12:01:32 PM   112      5123      6120   98.00    1.00 99.00     7  mysqld
12:01:32 PM   112      5123      6121   76.00    2.00 78.00     3  mysqld
12:01:32 PM   112      5123      6177   10.00    0.00 10.00     9  mysqld

Significado: Unos pocos hilos están al límite. Eso a menudo se mapea a conexiones específicas o trabajadores internos.

Decisión: Si un hilo domina, buscas una sola consulta/sesión. Si muchos hilos están calientes, buscas un cambio de carga o contención global.

Task 4: Revisa la presión de la cola de ejecución y los cambios de contexto

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
18  0      0 236812  91872 41231840  0    0     3    44 4211 9802 92  6  1  0  0
21  0      0 232104  91872 41241432  0    0     0    32 4877 12120 93  6  1  0  0

Significado: r son hilos ejecutables. Si r está consistentemente por encima del número de CPUs, estás haciendo cola para CPU. Alto cs puede indicar concurrencia excesiva o contención de locks.

Decisión: Si los hilos ejecutables son enormes, considera límites de conexión, dimensionado de pools y concurrencia de consultas. Si aparece swapping, eso es otra emergencia.

Task 5: Comprueba steal time y throttling de CPU (realidad cloud)

cr0x@server:~$ sar -u 1 3
Linux 6.1.0 (db01)  12/31/2025  _x86_64_  (16 CPU)

12:01:55 PM     CPU     %user     %nice   %system   %iowait    %steal     %idle
12:01:56 PM     all     89.12      0.00      7.01      0.20      3.10      0.57
12:01:57 PM     all     88.90      0.00      7.30      0.10      3.20      0.50

Significado: %steal es tiempo de CPU que querías pero no obtuviste. Eso puede imitar “la BD se volvió más lenta” sin ningún cambio en las consultas.

Decisión: Si el steal está elevado, valida la colocación del host, la clase de instancia y si los créditos de burst están agotados. No optimices consultas para compensar mala vecindad.

Task 6: Postgres—mapear PID caliente a la consulta y estado de espera

cr0x@server:~$ sudo -u postgres psql -x -c "select pid, usename, application_name, client_addr, state, wait_event_type, wait_event, now()-query_start as age, left(query,200) as query from pg_stat_activity where state<>'idle' order by age desc limit 5;"
-[ RECORD 1 ]-----+--------------------------------------------
pid               | 28741
usename           | app_user
application_name  | api
client_addr       | 10.20.3.41
state             | active
wait_event_type   |
wait_event        |
age               | 00:02:14.12031
query             | SELECT o.customer_id, count(*) FROM orders o JOIN order_items i ON i.order_id=o.id WHERE o.created_at >= now()-interval '30 days' GROUP BY o.customer_id;

Significado: wait_event vacío + state=active significa que está en CPU (o al menos no esperando un wait rastreado). El texto de la consulta es tu sospechosa.

Decisión: Si ves muchas actives ejecutando el mismo patrón, probablemente tienes un endpoint caliente. Si los wait events muestran locks, trátalo como contención, no como “necesita más CPU”.

Task 7: Postgres—obtén los mayores ofensores estilo CPU por tiempo total

cr0x@server:~$ sudo -u postgres psql -c "select calls, round(total_exec_time::numeric,1) as total_ms, round(mean_exec_time::numeric,2) as mean_ms, rows, left(query,120) as query from pg_stat_statements order by total_exec_time desc limit 10;"
 calls | total_ms | mean_ms | rows  | query
-------+----------+---------+-------+------------------------------------------------------------
  8231 | 912345.4 | 110.81  | 15321 | SELECT o.customer_id, count(*) FROM orders o JOIN order_items...
 12001 | 610112.7 | 50.84   | 12001 | SELECT * FROM sessions WHERE token = $1

Significado: Ahora tienes huellas de consultas ordenadas por tiempo total de ejecución (un proxy del consumo de CPU, pero no idéntico).

Decisión: Alto total_exec_time impulsa incidentes. Alto mean_exec_time impulsa latencia en la cola. Decide cuál arreglar primero.

Task 8: Postgres—prueba a dónde va el tiempo con EXPLAIN (ANALYZE, BUFFERS)

cr0x@server:~$ sudo -u postgres psql -c "explain (analyze, buffers, verbose) SELECT o.customer_id, count(*) FROM orders o JOIN order_items i ON i.order_id=o.id WHERE o.created_at >= now()-interval '30 days' GROUP BY o.customer_id;"
HashAggregate  (cost=... rows=... width=16) (actual time=2150.113..2150.901 rows=4821 loops=1)
  Buffers: shared hit=120344 read=8123
  ->  Hash Join  (cost=... ) (actual time=310.123..2011.221 rows=241233 loops=1)
        Hash Cond: (i.order_id = o.id)
        Buffers: shared hit=120344 read=8123
        ->  Seq Scan on public.order_items i  (actual time=0.021..1190.332 rows=5200000 loops=1)
              Buffers: shared hit=99844 read=6500
        ->  Hash  (actual time=305.110..305.111 rows=402113 loops=1)
              ->  Seq Scan on public.orders o  (actual time=0.030..221.900 rows=402113 loops=1)
                    Filter: (created_at >= (now() - '30 days'::interval))

Significado: Escaneos secuenciales y grandes agregados hash consumen CPU. Los Buffers muestran aciertos en caché vs lecturas; esta consulta hace ambos y aún gasta mucho tiempo en cómputo.

Decisión: Si escanea millones de filas para responder una pregunta pequeña, añade/ajusta índices, reescribe la consulta o pre-agrega. Si ya está mayormente en caché pero sigue lento, es lógica intensiva en CPU (hashing, sorting, funciones).

Task 9: MySQL—identificar digests de sentencias superiores por tiempo de CPU

cr0x@server:~$ mysql -e "SELECT DIGEST_TEXT, COUNT_STAR, ROUND(SUM_TIMER_WAIT/1e12,2) AS total_s, ROUND(SUM_LOCK_TIME/1e12,2) AS lock_s, ROUND(SUM_ROWS_EXAMINED/COUNT_STAR,0) AS avg_rows_examined FROM performance_schema.events_statements_summary_by_digest ORDER BY SUM_TIMER_WAIT DESC LIMIT 5\G"
*************************** 1. row ***************************
DIGEST_TEXT: SELECT o.customer_id, COUNT ( * ) FROM orders o JOIN order_items i ON i.order_id = o.id WHERE o.created_at >= ? GROUP BY o.customer_id
COUNT_STAR: 8120
total_s: 945.21
lock_s: 2.11
avg_rows_examined: 510220

Significado: Este digest consume la mayor parte del tiempo de sentencias. avg_rows_examined es una gran señal de “hola, estoy escaneando demasiado”.

Decisión: Apunta a los peores digests primero. Si lock_s es alto relativo al total, podría ser contención de locks, no CPU. Si rows examined es enorme, persigue índices y caminos de acceso.

Task 10: MySQL—ver hilos activos y qué hacen

cr0x@server:~$ mysql -e "SHOW FULL PROCESSLIST;"
Id	User	Host	db	Command	Time	State	Info
9312	app	10.20.3.41:51844	prod	Query	132	Sending data	SELECT o.customer_id, count(*) FROM orders o JOIN order_items i ON i.order_id=o.id WHERE o.created_at >= NOW()-INTERVAL 30 DAY GROUP BY o.customer_id
9441	app	10.20.3.52:52011	prod	Query	98	Copying to tmp table	SELECT ... ORDER BY ...

Significado: Consultas activas y de larga duración son visibles. “Copying to tmp table” y “Sending data” a menudo significan grandes resultados intermedios, sorts o indexación pobre.

Decisión: Si una consulta domina, puedes matarla quirúrgicamente (con coordinación). Si hay muchas consultas largas similares, la solución es sistémica.

Task 11: MySQL—explica el plan y confirma si estás escaneando

cr0x@server:~$ mysql -e "EXPLAIN SELECT o.customer_id, count(*) FROM orders o JOIN order_items i ON i.order_id=o.id WHERE o.created_at >= NOW()-INTERVAL 30 DAY GROUP BY o.customer_id\G"
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: o
type: range
possible_keys: idx_orders_created_at
key: idx_orders_created_at
rows: 401233
Extra: Using where; Using temporary; Using filesort
*************************** 2. row ***************************
id: 1
select_type: SIMPLE
table: i
type: ref
possible_keys: idx_order_items_order_id
key: idx_order_items_order_id
rows: 12
Extra:

Significado: “Using temporary; Using filesort” es el clásico impuesto de CPU+memoria por agrupar/ordenar sin un buen camino.

Decisión: Considera índices compuestos que soporten el agrupamiento, reduce filas tempranamente o cambia la forma de la consulta. Si el range scan aún toca cientos de miles de filas por llamada, pagas por petición.

Task 12: MySQL 8—usa EXPLAIN ANALYZE para ver comportamiento real

cr0x@server:~$ mysql -e "EXPLAIN ANALYZE SELECT o.customer_id, count(*) FROM orders o JOIN order_items i ON i.order_id=o.id WHERE o.created_at >= NOW()-INTERVAL 30 DAY GROUP BY o.customer_id;"
-> Group aggregate: count(0)  (actual time=0.333..2150.221 rows=4821 loops=1)
    -> Nested loop inner join  (actual time=0.112..2010.011 rows=241233 loops=1)
        -> Index range scan on orders using idx_orders_created_at  (actual time=0.041..220.333 rows=402113 loops=1)
        -> Index lookup on order_items using idx_order_items_order_id (order_id=o.id)  (actual time=0.003..0.004 rows=12 loops=402113)

Significado: El nested loop multiplicado por 402k filas es la bala humeante. Así es cómo incluso índices “finos” crean facturas enormes de CPU.

Decisión: Considera reescribir para pre-agregar order_items, o reducir el conjunto de orders. En algunos casos, una estrategia de join distinta o un índice covering puede reducir drásticamente los loops.

Task 13: Comprueba contención de locks que se hace pasar por problema de CPU (Postgres)

cr0x@server:~$ sudo -u postgres psql -c "select wait_event_type, wait_event, count(*) from pg_stat_activity where state='active' group by 1,2 order by 3 desc;"
 wait_event_type |     wait_event      | count
-----------------+---------------------+-------
 Lock            | relation            |    18
                 |                     |     6

Significado: Muchas actives esperando locks de relación significa que puedes ver colapso de throughput y la CPU hace cosas raras (spin, cambios de contexto, reintentos).

Decisión: Deja de tunear SELECTs. Identifica sesiones bloqueantes y el DDL o transacción larga que lo causa.

Task 14: Revisa presión interna de InnoDB (MySQL)

cr0x@server:~$ mysql -e "SHOW ENGINE INNODB STATUS\G" | sed -n '1,140p'
------------
TRANSACTIONS
------------
Trx id counter 93211245
History list length 81234
...
--------
SEMAPHORES
--------
OS WAIT ARRAY INFO: reservation count 221234, signal count 221100
Mutex spin waits 912334, rounds 1823344, OS waits 22344

Significado: Una history list length enorme puede implicar purge lag (a menudo por transacciones largas). Altos mutex spin waits pueden indicar contención que quema CPU sin que las consultas parezcan obvias.

Decisión: Si el purge está retrasado, encuentra transacciones largas y arregla el comportamiento de la app. Si la contención es alta, reduce concurrencia, revisa índices/tablas calientes y considera cambios de configuración—después de identificar el disparador de carga.

Task 15: Demuestra si hay swapping o thrashing de memoria (ambos)

cr0x@server:~$ free -m
               total        used        free      shared  buff/cache   available
Mem:           64218       21342        2311        1024       40564       40112
Swap:              0           0           0

Significado: No usar swap es bueno. Poco “free” no es automáticamente malo; “available” importa más.

Decisión: Si el swap está en uso, la CPU puede subir por overhead de paging y las consultas se ralentizan de forma impredecible. Arregla la presión de memoria antes de reescribir SQL.

Task 16: Captura un snapshot de perfil de CPU (poco arriesgado) con perf

cr0x@server:~$ sudo perf top -p 5123
Samples: 42K of event 'cycles', 4000 Hz, Event count (approx.): 12000000000
Overhead  Shared Object        Symbol
  18.21%  mysqld               Item_func_like::val_int
  11.03%  mysqld               JOIN::exec
   9.88%  libc.so.6            __memcmp_avx2_movbe

Significado: Ves dónde se gasta la CPU. Aquí es matching de cadenas y ejecución de joins, no “disco.” Los perfiles pueden validar rápidamente patrones de consulta (LIKE ‘%…%’) y joins malos.

Decisión: Si los hotspots de CPU se alinean con operadores caros conocidos (sort, hash, LIKE, extracción JSON), prioriza reescrituras e índices. Si ves hotspots en kernel (stack de red), revisa storms de clientes o overhead de TLS.

Hechos interesantes y contexto histórico (por qué existe el comportamiento actual)

  • PostgreSQL empezó como POSTGRES en UC Berkeley en los años 80, con ideas tempranas sobre extensibilidad y tipos de datos complejos—por eso aún favorece indexación rica y operadores personalizados.
  • MySQL se creó con simplicidad y velocidad para cargas web comunes a mediados de los 90—por eso históricamente enfatizó defaults pragmáticos y facilidad de despliegue.
  • InnoDB se convirtió en el motor por defecto de MySQL porque la integridad transaccional y la recuperación tras fallo ganaron en el mundo real, aunque significó más maquinaria interna (y más formas de quemar CPU).
  • MVCC de Postgres hace las lecturas no bloqueantes en muchos casos, pero traslada el coste a vacuuming y comprobaciones de visibilidad de tuplas—la CPU puede subir cuando autovacuum se atrasa o crece el bloat.
  • performance_schema de MySQL evolucionó de “agradable de tener” a esencial cuando los sistemas crecieron y “SHOW STATUS” dejó de bastar para la atribución.
  • Los planificadores de consultas se hicieron más inteligentes y complejos en ambos sistemas; planificadores más inteligentes requieren estadísticas, y las malas stats son deuda de rendimiento que se compone silenciosamente.
  • La replicación cambió la forma de los incidentes: las réplicas de lectura redujeron CPU de lectura en primarios pero introdujeron nuevos hotspots de CPU en réplicas (replay/apply, vacuum o mantenimiento de índices secundarios).
  • El hardware se hizo más rápido, y las consultas más vagas: los equipos dejaron de sentir el coste de escanear hasta que el crecimiento de datos hizo permanentes los planes “temporales”.

Tres mini-historias corporativas desde las trincheras

Incidente #1: la caída provocada por una suposición errónea (“CPU alta significa que necesitamos máquinas más grandes”)

La empresa tenía un cluster MySQL estable y un ritual trimestral: si la latencia subía, aumentar el tamaño de la instancia. Funcionó hasta que dejó de hacerlo. Un viernes, la CPU se ancló y las tasas de error subieron. La primera respuesta fue el reflejo corporativo clásico: escalar.

Escalar ayudó unos 20 minutos. Luego la CPU subió otra vez, más rápido. Los ingenieros empezaron a culpar al proveedor de nube. Alguien sugirió deshabilitar el binlog “solo para ver”. Las cabezas frías prevalecieron—por poco.

Sacamos CPU a nivel de hilo y vimos un puñado de hilos calientes, no una sobrecarga uniforme. performance_schema mostró un digest dominado por un endpoint: una función de “export” que recientemente cambió de paginación incremental a “dame todo lo más reciente del último mes.” Hacía el trabajo correcto, de la forma equivocada.

El matiz: el dataset se había duplicado y el plan de consulta derivó a “Using temporary; Using filesort.” La instancia no era demasiado pequeña; la consulta se había convertido en un multiplicador de CPU. Limitaron la tasa del endpoint, añadieron un índice compuesto y reescribieron la consulta para pre-agregar. La CPU bajó y el tamaño de la instancia volvió a reducirse al día siguiente.

La suposición errónea no era técnica. Era psicológica: tratar al hardware como disculpa por no medir.

Incidente #2: la optimización que salió mal (un “índice útil” que empeoró la CPU)

Otro equipo corría PostgreSQL y sufrieron picos periódicos de CPU en horas punta. Vieron una consulta lenta, añadieron un índice y la vieron más rápida en staging. Cambio aprobado, desplegado y celebrado. Brevemente.

En producción, la CPU empeoró y la latencia p99 subió. El índice se usó, sí—pero causó un plan que hizo muchas accesos aleatorios y luego ordenó un resultado intermedio mucho mayor. El plan antiguo era un seq scan con un filtro ajustado que se beneficiaba del calor de caché.

Usamos EXPLAIN (ANALYZE, BUFFERS) sobre datos tipo producción (no juguetes de staging). El nuevo índice hizo que el planificador subestimara selectividad. Eligió nested loops donde hash join era más barato. La CPU se fue a procesamiento de joins y comparaciones de sort, no a I/O.

Arreglarlo supuso admitir que “índice == más rápido” no es una ley de la física. Actualizamos objetivos de estadísticas en columnas clave, ejecutamos analyze, y reemplazamos el índice por uno compuesto que coincidiera con el patrón de filtro y join. Los picos de CPU no desaparecieron—se volvieron proporcionales otra vez, que es lo más agradable que puedes decir sobre comportamiento en producción.

Incidente #3: la práctica aburrida que salvó el día (evidencia de consultas lenta disponible al instante)

Una plataforma de pagos usaba MySQL y PostgreSQL por razones históricas. Tenían un hábito que parecía aburrido en reuniones: mantenían telemetría ligera de consultas siempre activada. No registro de cada statement en texto completo—solo lo suficiente para atribuir tiempo a patrones.

Cuando llegó un pico de CPU, no hubo prisa por “encender el slow log y esperar”. MySQL ya tenía digests de sentencias en performance_schema. Postgres tenía pg_stat_statements. Pudieron responder, en minutos, qué patrones de consulta habían cambiado en tiempo total y tasa de llamadas.

El culpable no era exótico ni glamoroso: un scheduler de jobs falló y lanzó demasiados workers en paralelo. Cada worker ejecutaba una consulta “razonable”. Juntos crearon una tormenta de CPU y thrash de caché. El equipo no discutió sobre hardware. Demostraron un cambio de concurrencia.

Arreglaron el scheduler, limitaron la concurrencia en el layer de pool y añadieron una alerta: “mismo digest, salto repentino en llamadas por segundo.” El incidente fue anticlimático. Ese es el punto. Las prácticas aburridas no hacen buenas historias bélicas, pero te mantienen empleado.

Errores comunes: síntoma → causa raíz → arreglo

1) Síntoma: CPU 100%, pero la latencia sólo se dispara a veces

Causa raíz: Consultas bursty o jobs por lotes compitiendo con tráfico OLTP; la saturación de CPU está compartida por timeslices.

Arreglo: Separar cargas (colar batchs), imponer límites de concurrencia e identificar los digests principales por tiempo total y tasa de llamadas.

2) Síntoma: Alta CPU y alto load average, pero mpstat muestra iowait

Causa raíz: Sistema I/O-bound con muchos hilos bloqueados en sleep no interrumpible. Load no es CPU.

Arreglo: Mide latencia de disco, tasas de acierto de buffer cache, presión de checkpoints y planes que provocan lecturas masivas.

3) Síntoma: CPU alta en tiempo kernel (%sys), no en user

Causa raíz: Overhead de red (connection storms), coste de TLS, contención del sistema de archivos o cambios de contexto excesivos.

Arreglo: Usa connection pooling, reduce conexiones por petición, valida configuración TLS y busca patrones que hagan muchas syscalls.

4) Síntoma: Postgres CPU alta, muchas sesiones “active”, pero wait_event muestra Lock

Causa raíz: Contención de locks más tormentas de reintentos o backends bloqueados acumulándose.

Arreglo: Identificar bloqueadores, acortar transacciones, evitar DDL largos en pico y considerar timeouts de locks y patrones de migración más seguros.

5) Síntoma: MySQL CPU alta, history list length de InnoDB crece

Causa raíz: Transacciones largas impiden el purge; limpieza interna se queda atrás y desperdicia CPU.

Arreglo: Encontrar y arreglar transacciones largas, asegurar commits, y tunear la carga. A veces la solución es “dejar de hacer analytics en el primario”.

6) Síntoma: Una consulta que antes iba bien ahora es cara

Causa raíz: Crecimiento de datos + deriva de stats + regresión de plan.

Arreglo: Refrescar estadísticas, validar cambios de plan, añadir índices compuestos alineados con predicados/keys de join y considerar reescrituras.

7) Síntoma: CPU alta después de añadir un índice

Causa raíz: El planificador elige un peor plan, o el índice aumenta la amplificación de escritura y overhead de mantenimiento.

Arreglo: Comparar planes antes/después sobre datos tipo producción; ajustar stats; eliminar/reemplazar índice con la forma correcta; medir overhead de escritura.

8) Síntoma: CPU alta principalmente en réplicas

Causa raíz: Aplicación de replicación, vacuum, mantenimiento de índices, o carga de lectura movida sin planificación de capacidad.

Arreglo: Mide apply lag y actividad de workers en segundo plano; dimensiona réplicas correctamente; no asumas que las réplicas son “gratis”.

Broma #2: Un gráfico de CPU al 100% es como una reunión general corporativa: todos están ocupados y, de algún modo, nada avanza más rápido.

Listas de verificación / plan paso a paso

Paso a paso: demuestra que son las consultas (no el hardware) en un incidente

  1. Captura snapshot del SO: mpstat, top, pidstat, vmstat.
  2. Confirma que la BD es el proceso caliente: identifica PID/TGID y hilos principales.
  3. Comprueba ruido de virtualización: steal time; confirma que no hay throttling de CPU/créditos agotados.
  4. Ruta Postgres: mapea PID caliente → pg_stat_activity consulta; revisa wait events; usa pg_stat_statements para los mayores ofensores.
  5. Ruta MySQL: usa resúmenes de digests de performance_schema; correlaciona con processlist; confirma rows examined.
  6. Decide contención vs ejecución: locks/waits vs puro cómputo.
  7. Haz una prueba de plan: EXPLAIN (ANALYZE, BUFFERS) o EXPLAIN ANALYZE; captura row counts y loops.
  8. Haz la mitigación de menor riesgo: limitar tasa, matar consultas peores, reducir concurrencia o desactivar temporalmente el endpoint.
  9. Implementa la solución duradera: índice/reescritura de consulta/estadísticas, y valida con métricas antes/después.
  10. Escribe el postmortem con la cadena de evidencia: “SO → BD → digest de consulta → plan → cambio.”

Guardrails operacionales que recomiendo (y por qué)

  • Connection pooling: porque storms de threads/process son un impuesto de CPU y de scheduling.
  • Telemetría de consultas siempre activada (ligera): para que tu próximo incidente no sea “espera 30 minutos por logs”.
  • Topes de concurrencia por carga: porque el throughput aumenta hasta que deja de hacerlo, y luego cae en picado.
  • Disciplina de cambios para índices y estadísticas: porque los cambios de rendimiento son cambios de producción, no “solo esquema”.
  • Datos tipo producción en staging para pruebas de plan: porque al planificador no le importan tus datasets sintéticos.

Cómo elegir entre “arreglar consultas” y “escalar hardware”

Escala hardware cuando tienes consultas estables y eficientes y carga legítima en aumento. Arregla consultas cuando la CPU por petición sube, los planes regresan, las filas examinadas explotan o la contención aumenta. El momento en que ves “rows examined por llamada” dispararse, escalar es un anestésico temporal. Se agota.

Preguntas frecuentes

1) ¿Cómo sé que no es una CPU mala o hardware fallando?

Los CPUs modernos rara vez “fallan a medias” de forma que solo perjudiquen MySQL. Si mpstat muestra alto steal time, eso es un problema de tenancy. Si los logs del kernel muestran machine check errors, eso es hardware. De lo contrario, asume carga primero y prueba lo contrario.

2) ¿Por qué la CPU está alta pero las consultas no parecen lentas individualmente?

Porque el throughput puede matarte. Una consulta rápida llamada 50.000 veces por minuto puede dominar la CPU más que una consulta lenta única. Mira el tiempo total y las llamadas, no solo la latencia media.

3) ¿Por qué Postgres muestra muchas sesiones “active” sin wait event?

A menudo realmente está ejecutándose en CPU. A veces está en un estado no representado como wait event. Usa herramientas del SO (pidstat/perf) para confirmar que el backend está consumiendo ciclos.

4) ¿La contención de locks puede causar 100% de CPU?

Sí. Spinlocks, reintentos y thrash del scheduler pueden empujar la CPU hacia arriba aun cuando el trabajo útil baja. En MySQL, revisa métricas de semáforos de InnoDB; en Postgres, revisa wait events y cadenas de bloqueados.

5) ¿Debo habilitar el slow query log de MySQL durante un incidente?

Sólo si puedes asumir el overhead y pones umbrales sensatos. Prefiere digests de performance_schema para atribución. Los slow logs son útiles, pero activarlos en medio del fuego a menudo produce más humo que señal.

6) ¿PostgreSQL es inherentemente más consumidor de CPU que MySQL?

No hay una respuesta categórica. Postgres puede gastar CPU en checks MVCC y comportamiento de vacuum; MySQL puede gastar CPU en contención de InnoDB y patrones de join. La carga y el esquema deciden.

7) ¿Cuándo debo usar perf?

Cuando necesitas probar a dónde van los ciclos (sorting, hashing, funciones de cadena, procesamiento JSON) y las métricas BD no cuentan una historia coherente. Úsalo para validar hipótesis, no como reflejo inicial.

8) ¿Cuál es la mitigación segura más rápida cuando la CPU está anclada?

Reduce la concurrencia y detén la hemorragia: limita la tasa de un endpoint caliente, pausa un job, reduce el tamaño del pool de conexiones o mata el peor ofensor. Luego haz el arreglo duradero después de que la gráfica deje de gritar.

9) ¿Por qué añadir un índice no redujo la CPU?

Porque los índices no reducen trabajo si la consulta aún toca grandes cantidades de filas, o si el índice provoca una estrategia de ejecución peor para tu distribución de datos. Valida con EXPLAIN ANALYZE y conteos reales de filas.

10) ¿Cómo demuestro una regresión de plan?

Captura el plan antiguo (de logs, explains almacenados o un entorno baseline) y compáralo con el plan nuevo con filas reales y loops. En Postgres, pg_stat_statements más auto_explain ayudan. En MySQL, compara EXPLAIN/EXPLAIN ANALYZE y timings de digests antes/después de la ventana de cambio.

Conclusión: pasos prácticos siguientes

Si quieres demostrar “CPU 100% es por consultas, no por hardware”, hazlo como operador, no como filósofo. Mide la descomposición de CPU. Identifica procesos/hilos calientes. Mapea a sesiones y digests de consulta. Obtén una prueba a nivel de plan con conteos reales de filas. Luego elige la mitigación más pequeña que compre tiempo.

Pasos siguientes que rinden inmediatamente:

  1. Asegúrate de poder consultar pg_stat_statements (Postgres) o performance_schema statement digests (MySQL) durante un incidente en vivo.
  2. Escribe un runbook de una página con el Guion de diagnóstico rápido arriba y tenlo cerca de las notas de on-call.
  3. Añade una alerta por salto en llamadas por digest; detecta malos despliegues y tormentas de scheduler antes de que la CPU llegue al techo.
  4. Haz obligatorio “EXPLAIN con forma de datos real” para arreglos de rendimiento. Índices sin planes son solo opiniones caras.
← Anterior
Una actualización de controlador mató mis FPS: cómo diagnosticarlo correctamente
Siguiente →
Proxmox Ceph Slow Ops: localizar el cuello de botella (disco, red o CPU)

Deja un comentario