Existe un tipo especial de interrupción donde la base de datos no está “caída”. Está activa, aceptando conexiones, devolviendo algunas consultas y mostrando comprobaciones de salud satisfechas. Mientras tanto, tu API está agotando tiempos de espera, las réplicas se retrasan hacia el futuro y todos los ingenieros miran un panel que dice que todo está “verde”.
Aquí es donde el MySQL que crees que estás ejecutando y el MySQL que realmente ejecutas—RDS MySQL—dejan de ser sinónimos. Las diferencias son sutiles hasta que se vuelven catastróficas. La mayoría de los incidentes no son causados por un gran error; son causados por una suposición incorrecta encontrándose con un límite oculto a las 2:13 a.m.
La discordancia central: “MySQL” no es un modelo de despliegue
MySQL autogestionado es un montón de decisiones. Sistema de archivos, dispositivo de bloque, nivel de RAID (o no), CPU pinning, comportamiento del caché de páginas del SO, peculiaridades del planificador del kernel, ajustes TCP, herramientas de backup y el tipo de cron extraño que un excompañero escribió en 2019 y nadie quiere tocar. Es flexible. También es tu responsabilidad, lo que significa que es tu culpa durante incidentes.
RDS MySQL es un producto. Se comporta como MySQL en la capa SQL, pero vive dentro de barandillas: almacenamiento gestionado, backups gestionados, parcheo gestionado, failover gestionado, y observabilidad gestionada. Esa gestión viene con restricciones que no controlas y que a veces ni siquiera puedes ver. Durante una semana tranquila, eso es una ventaja. Durante un incidente, es una negociación.
El modo de fallo típico no es “RDS es peor”. Es “planeaste como si fuera autogestionado”, o viceversa. En producción, el sistema que asumes es el sistema que depuras. Si tus suposiciones son incorrectas, depurarás con confianza y no arreglarás nada.
Una cita que deberías tener pegada en el monitor viene de Werner Vogels: Si lo construyes, lo operas.
Es corta y duele porque es verdad.
Hechos e historia que explican los bordes afilados de hoy
- El motor de almacenamiento predeterminado de MySQL cambió el panorama. InnoDB se convirtió en el predeterminado en MySQL 5.5, y de repente “ACID” dejó de ser una característica premium. Además convirtió el dimensionamiento de I/O y de los redo logs en una preocupación operativa de primera clase.
- Amazon introdujo RDS en 2009. La promesa fue simple: deja de vigilar servidores. La contrapartida fue más simple: no obtienes root y aceptas una infraestructura con opinión.
- Performance Schema no siempre fue práctica estándar. Maduró con los años; muchos “expertos en MySQL” aprendieron en una era donde se revisaban slow logs y se adivinaba. RDS hace más difícil el uso profundo de herramientas a nivel de SO, así que deberías aprender la instrumentación moderna de MySQL.
- El “doublewrite buffer” de InnoDB existe porque el almacenamiento miente. Las páginas rotas ocurren. El almacenamiento gestionado reduce algún riesgo pero no elimina la necesidad de decisiones de diseño consistentes ante fallos.
- La replicación tiene múltiples personalidades. La replicación asíncrona clásica es diferente de la semi-sync, y ambas son distintas de group replication. RDS soporta algunos modos, restringe otros y añade sus propios comportamientos operativos alrededor del failover.
- “General purpose SSD” no siempre fue suficiente. gp2/gp3 e io1/io2 existen porque IOPS es ahora una decisión de producto, no solo un detalle de ingeniería. En local luchas contra la física; en la nube luchas con tu última orden de compra.
- Online DDL cambió la respuesta a incidentes. El DDL de MySQL ha mejorado, pero no todos los ALTER son iguales, y RDS añade restricciones alrededor de operaciones de larga duración y ventanas de mantenimiento.
- Los backups pasaron de “archivos” a “snapshots”. Los volcado lógicos son portables pero lentos; los snapshots son rápidos pero acoplados al modelo de almacenamiento de la plataforma. RDS fomenta los snapshots; tu estrategia de recuperación debe tener en cuenta ese acoplamiento.
Límites ocultos que duelen durante incidentes (y por qué)
1) El almacenamiento no es solo “tamaño”: es latencia, ráfaga y amplificación de escritura
En MySQL autogestionado, puedes adjuntar discos más rápidos, ajustar el controlador RAID o lanzar NVMe al problema. En RDS, eliges una clase de almacenamiento respaldada por EBS y vives dentro de sus restricciones de IOPS y rendimiento. Si elegiste gp2 hace años y nunca lo revisaste, podrías estar viviendo con “créditos de ráfaga” sin darte cuenta.
Patrón de incidente: la latencia sube, la CPU parece estar bien, las consultas se ralentizan y todos culpan “un despliegue malo”. Mientras tanto, el verdadero villano es la profundidad de la cola de disco. InnoDB necesita I/O cuando su conjunto de trabajo excede el buffer pool o cuando está vaciando páginas sucias bajo presión.
Lo que está oculto: el subsistema de almacenamiento está gestionado. No puedes ejecutar iostat en el host. Debes fiarte de métricas de RDS y variables de estado de MySQL. Esto no es peor, es diferente: necesitas reflejos distintos.
2) “Almacenamiento gratuito” es una mentira durante derrames de tablas temporales y online DDL
RDS puede autoescalar almacenamiento en algunas configuraciones, pero no autoescala el tipo de espacio que necesitas ahora mismo para un derrame masivo de tabla temporal, un ALTER grande que construye una copia, o una transacción larga que hincha undo. Puedes terminar con mucho almacenamiento asignado y aun así chocar contra una pared de “espacio temporal” que se siente aleatoria si solo has corrido en metal desnudo.
Los entornos autogestionados a menudo ponen tmpdir en un volumen separado y lo dimensionan intencionalmente. En RDS, el uso de tmp interactúa con el espacio efímero local de la instancia y con la configuración. Necesitas saber qué hace tu motor bajo presión de espacio y cuánto margen tienes antes de que el sistema empiece a fallar de formas creativas.
3) max_connections no es un número; es un radio de daño
RDS establece valores por defecto y a veces liga valores permitidos a la clase de instancia vía parameter groups. En una máquina que posees, puedes subir max_connections hasta quedarte sin RAM y luego preguntarte por qué el kernel OOM killer está escribiendo tu informe postmortem. En RDS, aún puedes matar la instancia con conexiones—solo de forma más educada.
El límite oculto usualmente no es el max_connections configurado sino lo que ocurre antes de alcanzarlo: memoria por conexión (sort buffers, join buffers), sobrecarga de programación de hilos y contención de mutex dentro de MySQL. RDS añade otra vuelta: tormentas de conexiones durante failover, reintentos de aplicaciones y configuración errónea de pooling pueden acumularse y convertir un pequeño evento de latencia en un atasco total.
4) Failover es una característica de producto, no una comida gratis
El failover autogestionado puede ser instantáneo o terrible, dependiendo de cuánto invirtiste en automatización. El failover de RDS es generalmente bueno, pero no es mágico. Hay tiempo de detección, tiempo de promoción, tiempo de propagación de DNS y comportamiento de reconexión de la aplicación. Durante esa ventana, tu app puede golpear el endpoint con reintentos como un niño que apreta el botón del ascensor.
Límite oculto: la abstracción del “writer endpoint” puede ocultar cambios de topología, pero no elimina la necesidad de que tu aplicación maneje errores transitorios, conexiones obsoletas e idempotencia. Si tu app no tolera un apagón de 30–120 segundos, no tienes alta disponibilidad. Tienes una arquitectura de esperanza y oración.
5) La latencia de replicación suele ser una historia de I/O disfrazada de SQL
La gente trata la latencia de replicación como un problema de configuración de replicación. A veces lo es. A menudo es que la réplica es más lenta aplicando escrituras porque está saturada en almacenamiento, CPU o por restricciones de aplicación single-threaded. RDS te da métricas y algunos ajustes; no te permite ssh y “solo chequear algo”.
Límite oculto: la clase de instancia de la réplica puede ser más pequeña, la clase de almacenamiento puede diferir, o el formato de binlog y la carga de trabajo pueden hacer que la replicación paralela sea inefectiva. En incidentes, la corrección equivocada suele ser “reiniciar la replicación.” La corrección adecuada suele ser “reducir la presión de escritura” o “arreglar la ruta lenta de aplicación”.
6) Los parameter groups hacen la configuración más segura—y más lenta de cambiar
En una máquina autogestionada, editas my.cnf, recargas y sigues. En RDS, los cambios de parámetros pueden requerir un reboot, ser dinámicos solamente, o estar totalmente prohibidos. También tienes que rastrear qué parameter group está adjunto a qué instancia, lo cual suena aburrido hasta que descubres que prod y staging son “casi” iguales.
Límite oculto: latencia operativa. Durante un incidente, “podemos afinar X” no es un plan a menos que sepas si X es modificable sin downtime y si puedes hacerlo rápido y seguro.
7) Observabilidad: no obtienes el host, así que debes dominar el motor
En MySQL autogestionado, puedes usar herramientas a nivel de SO: perf, strace, tcpdump, estadísticas de cgroup, histogramas de latencia del sistema de archivos y la comodidad de saber que siempre puedes profundizar. En RDS, usas métricas de CloudWatch, Enhanced Monitoring (si está habilitado), Performance Insights (si está habilitado) y las propias tablas y variables de estado de MySQL.
Si no habilitas esas funciones por adelantado, tu yo futuro durante un incidente estará mirando una foto borrosa de la escena del crimen. Broma #1: La observabilidad que no habilitaste es como un extintor todavía en el carrito de Amazon—muy asequible, muy inútil.
8) Backups y restauraciones: los snapshots son rápidos; la recuperación sigue siendo un proceso
Los snapshots de RDS son convenientes y la recuperación a un punto en el tiempo es potente. Pero la trampa del incidente es asumir “podemos restaurar rápido”. Las restauraciones toman tiempo, y la instancia nueva necesita calentarse, verificar parámetros, revisar security groups y hacer el corte de la aplicación. Si estás acostumbrado a restaurar un backup local a una VM de repuesto, podrías sorprenderte del overhead de orquestación.
Límite oculto: tiempo hasta la operatividad, no tiempo para crear la instancia.
9) No puedes arreglarlo todo con “instancia más grande”
Aumentar escala ayuda cargas limitadas por CPU y te da más RAM para el buffer pool. No arregla automáticamente límites de I/O si el cuello de botella es el throughput de almacenamiento. No arregla contención en filas calientes o locks de metadata. No arregla consultas patológicas. Y durante un incidente, escalar puede ser lento o disruptivo.
Los entornos autogestionados a menudo tienen más opciones “peligrosas” (vaciar caches, reiniciar servicios, sacar volúmenes, ejecutar scripts de emergencia). RDS reduce la cantidad de herramientas filosas con las que puedes hacerte daño. También reduce la cantidad de herramientas filosas con las que puedes salvarte. Planea en consecuencia.
Playbook de diagnóstico rápido: qué chequear primero/segundo/tercero
Primero: decide si estás limitado por CPU, I/O o locks
- Señales de CPU-bound: CPU alta, muchas “active sessions” en Performance Insights, consultas lentas incluso cuando el working set cabe en memoria, muchas funciones/sorts, índices pobres.
- Señales de I/O-bound: CPU baja/moderada, latencia de consultas en aumento, incremento de lecturas/escrituras de InnoDB, cola de disco elevada (CloudWatch), misses del buffer pool, picos de flushing de páginas sucias.
- Señales de lock-bound: La CPU puede estar baja, pero los hilos están “waiting for” locks; muchas conexiones atascadas; pocas transacciones bloquean todo; la latencia de replicación aumenta por bloqueo en apply.
Segundo: confirma el cuello de botella con dos vistas independientes
No confíes en una métrica. Empareja datos internos del motor (status, processlist, performance_schema) con métricas de la plataforma (CloudWatch/Enhanced Monitoring) o evidencia a nivel de consulta (top digests, slow log).
Tercero: elige la intervención más segura
- Si es lock-bound: identifica el bloqueador, mata la sesión correcta, reduce el alcance de las transacciones y arregla el comportamiento de la app. Evita “reiniciar MySQL” a menos que estés eligiendo downtime a propósito.
- Si es I/O-bound: deja de hacer lo que lee/escribe demasiado (job por lotes, reporte grande), reduce la concurrencia, escala temporalmente IOPS/clase de almacenamiento y luego arregla el esquema/consultas.
- Si es CPU-bound: encuentra el SQL top por tiempo, añade o arregla índices, reduce consultas costosas, considera escalar la instancia y luego arregla el plan de consulta de forma permanente.
Cuarto: evita el rebote
A los incidentes les encantan los rebotes: matas la consulta bloqueante, la latencia baja, los autoscalers o las tormentas de reintentos reintroducen la carga y vuelves al inicio. Limita la tasa de reintentos, pausa los workers por lotes y controla el comportamiento del connection pool.
Tareas prácticas: comandos, salidas y decisiones (12+)
Estas están diseñadas para la respuesta a incidentes. Cada tarea incluye un comando, qué significa la salida y la decisión que tomas a partir de ella. Ejecútalas contra MySQL autogestionado o RDS MySQL (desde un bastión o host de app). Reemplaza nombres de host/usuario según sea necesario.
Task 1: Confirmar conectividad básica e identidad del servidor
cr0x@server:~$ mysql -h prod-mysql.cluster-aaaa.us-east-1.rds.amazonaws.com -u ops -p -e "SELECT @@hostname, @@version, @@version_comment, @@read_only\G"
Enter password:
*************************** 1. row ***************************
@@hostname: ip-10-11-12-13
@@version: 8.0.36
@@version_comment: MySQL Community Server - GPL
@@read_only: 0
Significado: Estás en el writer (read_only=0) y conoces la versión mayor. La versión importa porque el comportamiento y la instrumentación difieren.
Decisión: Si esperabas una réplica y obtuviste el writer (o viceversa), detente y corrige el objetivo antes de hacer cambios “útiles”.
Task 2: Ver qué están haciendo los hilos ahora mismo
cr0x@server:~$ mysql -h prod-mysql.cluster-aaaa.us-east-1.rds.amazonaws.com -u ops -p -e "SHOW FULL PROCESSLIST;"
...output...
10231 appuser 10.0.8.21:51244 appdb Query 38 Sending data SELECT ...
10244 appuser 10.0.7.19:49812 appdb Query 38 Waiting for table metadata lock ALTER TABLE orders ...
10261 appuser 10.0.8.23:50111 appdb Sleep 120 NULL
...
Significado: Puedes detectar estancamientos obvios: “Waiting for table metadata lock” es una flecha roja gigante apuntando a DDL o transacciones largas.
Decisión: Si ves esperas de locks dominando, pivota a diagnóstico de locks en lugar de perseguir CPU o I/O.
Task 3: Identificar la transacción bloqueadora (esperas de lock InnoDB)
cr0x@server:~$ mysql -h prod-mysql.cluster-aaaa.us-east-1.rds.amazonaws.com -u ops -p -e "SELECT * FROM sys.innodb_lock_waits\G"
*************************** 1. row ***************************
wait_started: 2025-12-30 01:12:18
wait_age: 00:01:42
waiting_trx_id: 321889203
waiting_pid: 10244
waiting_query: ALTER TABLE orders ADD COLUMN ...
blocking_trx_id: 321889199
blocking_pid: 10198
blocking_query: UPDATE orders SET ...
blocking_lock_mode: X
Significado: Esto muestra quién está bloqueado y quién está bloqueando, con PIDs sobre los que puedes actuar.
Decisión: Si el bloqueador es una transacción de la app atascada en un bucle, mata al bloqueador (no a la víctima) y mitiga en la capa de app.
Task 4: Matar la sesión correcta (quirúrgico, no emocional)
cr0x@server:~$ mysql -h prod-mysql.cluster-aaaa.us-east-1.rds.amazonaws.com -u ops -p -e "KILL 10198;"
Query OK, 0 rows affected (0.01 sec)
Significado: El hilo bloqueador se termina. El lock debería liberarse; las consultas bloqueadas deberían continuar o fallar rápido.
Decisión: Observa inmediatamente por tormentas de reconexión y picos de reintentos. Matar una consulta puede invitar a cien reemplazos.
Task 5: Revisar el estado del motor InnoDB para deadlocks, flushing y history length
cr0x@server:~$ mysql -h prod-mysql.cluster-aaaa.us-east-1.rds.amazonaws.com -u ops -p -e "SHOW ENGINE INNODB STATUS\G"
...output...
History list length 51234
Log sequence number 89433222111
Log flushed up to 89433111822
Pending writes: LRU 0, flush list 128, single page 0
...
Significado: Un history list length grande sugiere transacciones de larga duración que impiden el purge. Pending flush list sugiere presión de escritura.
Decisión: Si el history list length explota, encuentra y termina transacciones largas; si pending flush es alto, reduce la carga de escritura y considera ajustar storage/IOPS.
Task 6: Confirmar salud del buffer pool (memoria vs presión I/O)
cr0x@server:~$ mysql -h prod-mysql.cluster-aaaa.us-east-1.rds.amazonaws.com -u ops -p -e "SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%';"
+---------------------------------------+------------+
| Variable_name | Value |
+---------------------------------------+------------+
| Innodb_buffer_pool_read_requests | 9812234432 |
| Innodb_buffer_pool_reads | 22334455 |
+---------------------------------------+------------+
Significado: Innodb_buffer_pool_reads son lecturas físicas. Si se disparan respecto a requests, estás perdiendo cache y golpeando almacenamiento.
Decisión: Si las lecturas físicas suben, considera aumentar el buffer pool (clase de instancia mayor) y/o reducir el working set de la carga (índices, arreglos de consultas).
Task 7: Detectar derrames de tablas temporales (el asesino silencioso del disco)
cr0x@server:~$ mysql -h prod-mysql.cluster-aaaa.us-east-1.rds.amazonaws.com -u ops -p -e "SHOW GLOBAL STATUS LIKE 'Created_tmp%tables';"
+-------------------------+----------+
| Variable_name | Value |
+-------------------------+----------+
| Created_tmp_disk_tables | 18443321 |
| Created_tmp_tables | 22300911 |
+-------------------------+----------+
Significado: Muchas tablas temporales en disco generalmente indican sorts/group by/joins que derraman. En RDS, esto puede chocar con restricciones de espacio temporal y throughput de I/O.
Decisión: Identifica las consultas ofensivas (Performance Insights / slow log / statement digests) y arréglalas; no “solo subas tmp_table_size” y listo.
Task 8: Encontrar las consultas top por tiempo total usando digests de Performance Schema
cr0x@server:~$ mysql -h prod-mysql.cluster-aaaa.us-east-1.rds.amazonaws.com -u ops -p -e "SELECT DIGEST_TEXT, COUNT_STAR, ROUND(SUM_TIMER_WAIT/1e12,1) AS total_s, ROUND(AVG_TIMER_WAIT/1e9,1) AS avg_ms FROM performance_schema.events_statements_summary_by_digest ORDER BY SUM_TIMER_WAIT DESC LIMIT 5\G"
*************************** 1. row ***************************
DIGEST_TEXT: SELECT * FROM orders WHERE customer_id = ? ORDER BY created_at DESC LIMIT ?
COUNT_STAR: 188233
total_s: 6221.4
avg_ms: 33.1
...
Significado: Obtienes una lista ordenada por las formas SQL que consumen tiempo. Normalmente es la ruta más rápida hacia la realidad.
Decisión: Toma los 1–2 digests principales y analiza sus planes. No optimices la consulta número 19 porque es “fea”. Optimiza lo que está quemando tiempo.
Task 9: EXPLAIN del plan (y detectar índices faltantes)
cr0x@server:~$ mysql -h prod-mysql.cluster-aaaa.us-east-1.rds.amazonaws.com -u ops -p -e "EXPLAIN FORMAT=TRADITIONAL SELECT * FROM orders WHERE customer_id = 123 ORDER BY created_at DESC LIMIT 50;"
+----+-------------+--------+------+---------------+------+---------+------+--------+-----------------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+--------+------+---------------+------+---------+------+--------+-----------------------------+
| 1 | SIMPLE | orders | ALL | idx_customer | NULL | NULL | NULL | 932112 | Using where; Using filesort |
+----+-------------+--------+------+---------------+------+---------+------+--------+-----------------------------+
Significado: type=ALL y “Using filesort” indican un escaneo completo más un ordenamiento. En tablas grandes, eso es un incidente esperando ocurrir.
Decisión: Agrega un índice compuesto (por ejemplo, (customer_id, created_at)) y verifica que coincida con los patrones de consulta. Planea el cambio de forma segura (el comportamiento de online DDL importa).
Task 10: Chequear lag de replicación y estado de apply (lado réplica)
cr0x@server:~$ mysql -h prod-mysql-replica.aaaa.us-east-1.rds.amazonaws.com -u ops -p -e "SHOW REPLICA STATUS\G"
...output...
Replica_IO_Running: Yes
Replica_SQL_Running: Yes
Seconds_Behind_Source: 187
Retrieved_Gtid_Set: ...
Executed_Gtid_Set: ...
Significado: Los hilos IO y SQL están corriendo, pero el lag es 187s. Eso es rendimiento, no un enlace roto.
Decisión: Revisa la saturación de recursos de la réplica y los cuellos de botella al aplicar; considera escalar la réplica, mejorar el throughput de almacenamiento o reducir la carga de escritura temporalmente.
Task 11: Validar si estás llegando a saturación de conexiones
cr0x@server:~$ mysql -h prod-mysql.cluster-aaaa.us-east-1.rds.amazonaws.com -u ops -p -e "SHOW GLOBAL STATUS WHERE Variable_name IN ('Threads_connected','Threads_running','Max_used_connections');"
+---------------------+-------+
| Variable_name | Value |
+---------------------+-------+
| Max_used_connections| 1980 |
| Threads_connected | 1750 |
| Threads_running | 220 |
+---------------------+-------+
Significado: Muchas conexiones existen, pero solo 220 están ejecutándose. Eso a menudo indica esperas por locks, esperas de I/O o mal uso del connection pool.
Decisión: Si connected está cerca del máximo, protege la instancia: limita la app, habilita pooling, y considera bajar límites de conexión por servicio para evitar que el cliente más ruidoso gane.
Task 12: Buscar transacciones largas que hinchan undo y bloquean purge
cr0x@server:~$ mysql -h prod-mysql.cluster-aaaa.us-east-1.rds.amazonaws.com -u ops -p -e "SELECT trx_id, trx_started, trx_rows_locked, trx_rows_modified, trx_query FROM information_schema.innodb_trx ORDER BY trx_started LIMIT 5\G"
*************************** 1. row ***************************
trx_id: 321889101
trx_started: 2025-12-30 00:02:11
trx_rows_locked: 0
trx_rows_modified: 812331
trx_query: UPDATE orders SET ...
Significado: Una transacción abierta desde 00:02 que modificó 800k filas no es “ruido de fondo normal”. Es un impuesto a la durabilidad y la latencia.
Decisión: Trabaja con los propietarios de la app para trocear el trabajo, confirmar con más frecuencia o mover actualizaciones pesadas fuera de pico. Durante el incidente, considera matarla si está bloqueando o desestabilizando.
Task 13: Confirmar presión de binlog y comportamiento de retención
cr0x@server:~$ mysql -h prod-mysql.cluster-aaaa.us-east-1.rds.amazonaws.com -u ops -p -e "SHOW BINARY LOGS;"
+------------------+-----------+
| Log_name | File_size |
+------------------+-----------+
| mysql-bin.012331 | 1073741824|
| mysql-bin.012332 | 1073741824|
| mysql-bin.012333 | 1073741824|
...
Significado: Muchos binlogs grandes sugieren actividad de escritura intensa. En RDS, la retención y el crecimiento de almacenamiento pueden convertirse en una factura sorpresa y en un incidente inesperado.
Decisión: Si los binlogs están inflándose, verifica la salud de las réplicas, la configuración de retención y si una réplica atascada está impidiendo el purge de logs.
Task 14: Comprobar bloat de tablas/índices y deriva de cardinalidad (sanidad rápida)
cr0x@server:~$ mysql -h prod-mysql.cluster-aaaa.us-east-1.rds.amazonaws.com -u ops -p -e "SELECT table_name, engine, table_rows, data_length, index_length FROM information_schema.tables WHERE table_schema='appdb' ORDER BY (data_length+index_length) DESC LIMIT 5;"
+------------+--------+-----------+------------+-------------+
| table_name | engine | table_rows| data_length| index_length|
+------------+--------+-----------+------------+-------------+
| orders | InnoDB | 93211234 | 90194313216| 32112201728 |
...
Significado: Las tablas grandes dominan tu destino. Si los índices de la tabla más grande son enormes o no coinciden con las consultas, pagarás en I/O y misses de cache.
Decisión: Prioriza indexado y políticas de ciclo de vida de datos (partitioning, archivado) en las tablas principales, no en las que la gente más se queja.
Tres micro-historias del mundo corporativo desde la trinchera de incidentes
Micro-historia 1: El incidente causado por una suposición equivocada
Una compañía SaaS mediana migró de MySQL autogestionado en una máquina NVMe RAID afinada a RDS MySQL. La migración fue limpia. La latencia de la app mejoró. Todos declararon victoria y volvieron a lanzar features.
Tres meses después, una campaña de ventas funcionó demasiado bien. El tráfico de escritura se dobló por unas horas. Nada “se rompió” inmediatamente; en su lugar, p95 subió y p99 explotó. La CPU primaria se mantuvo alrededor del 35%. Los ingenieros la miraban como si fuera una mentirosa.
La suposición equivocada fue simple: “Si la CPU está bien, la base de datos está bien.” En sus servidores antiguos, esa suposición era a menudo válida porque el almacenamiento tenía suficiente margen y se monitorizaba directamente. En RDS, el equipo no había habilitado Performance Insights y apenas vigilaba métricas de almacenamiento. El volumen gp2 estaba consumiendo créditos de ráfaga, la latencia de I/O subió y InnoDB empezó a vaciar páginas sucias con más agresividad. El sistema estaba I/O-bound mientras la CPU parecía relajada.
Intentaron escalar la clase de instancia. Ayudó un poco pero no arregló el problema raíz: la configuración de almacenamiento y la amplificación de escritura de su carga. La solución fue pasar a una opción de almacenamiento con IOPS predecible, afinar patrones de escritura (transacciones más pequeñas, menos índices secundarios en tablas calientes) y agregar dashboards que hicieran visible la “deuda I/O” antes de que se convirtiera en incidente.
Después adoptaron una regla: cada migración incluye un “ensayo de nuevo cuello de botella”. Si no puedes explicar cómo falla, no has terminado la migración.
Micro-historia 2: La optimización que salió mal
Una compañía del ámbito financiero tenía un job nocturno que recalculaba agregados. Era lento, así que un ingeniero lo “optimizó” aumentando la concurrencia: más workers, batches más grandes y un connection pool mayor. En staging se veía genial. En producción se convirtió en un generador de brownouts.
El job hacía principalmente updates en una tabla caliente con varios índices secundarios. Más workers significaron más mantenimiento de índices simultáneo y más conflictos de locking a nivel de fila. El redo log y el comportamiento de flushing se convirtieron en el cuello de botella, y las tablas temporales derramaron a disco porque el job también hacía group-bys intermedios.
En MySQL autogestionado solían mirar iostat y ajustar el host. En RDS miraban la CPU y asumieron que era el limitador. No lo era. La “optimización” aumentó la amplificación de escritura y la contención de locks, empujando al subsistema de almacenamiento a latencias sostenidas altas. El lag de replicación subió y el tráfico de lectura empezó a golpear el writer porque las réplicas estaban demasiado retrasadas para ser confiables.
La solución aburrida fue reducir la concurrencia, trocear las actualizaciones por rangos de clave primaria y añadir un índice que convirtió un join caro en una búsqueda barata. El job terminó un poco más lento que la versión “optimizadda” en aislamiento, pero dejó de destrozar el resto de la plataforma.
Broma #2: En bases de datos, “más paralelismo” a veces significa “más gente intentando pasar por la misma puerta a la vez”.
Micro-historia 3: La práctica tediosa pero correcta que salvó el día
Una plataforma de e-commerce corría RDS MySQL con Multi-AZ y una réplica de lectura usada para analytics. Sus ingenieros no eran famosos por una arquitectura emocionante. Eran famosos por runbooks, game days rutinarios y una obsesión casi irritante con los parameter groups.
Una tarde, una migración de esquema introdujo una regresión en el plan de consulta. La latencia del writer se disparó. Luego el lag de la réplica se disparó. Luego la app empezó a agotar tiempos de espera y a reintentar. Espiral clásica.
El on-call no empezó por “afinar MySQL”. Empezó ejecutando un playbook practicado: confirmar si es lock/I/O/CPU, identificar top digests, verificar si los reintentos amplifican y luego tomar la acción de mitigación menos riesgosa. Deshabilitaron temporalmente el consumidor analytics, redujeron la concurrencia de workers y aplicaron un feature flag a nivel de consulta para detener al peor agresor. El lag de replicación se estabilizó.
Aquí es donde la práctica tediosa pagó: habían probado restaurar desde snapshot y promover réplicas, y tenían un procedimiento documentado para desvincular el tráfico de lectura del writer. No necesitaron hacer failover, pero podrían haberlo hecho con calma si fuera necesario. El incidente duró menos de una hora y el postmortem fue mayormente sobre disciplina de revisión de consultas, no heroicos.
Errores comunes: síntoma → causa raíz → solución
1) Síntoma: CPU baja, latencia alta
Causa raíz: Saturación de I/O (throughput/IOPS del almacenamiento) o esperas de locks. Común en RDS cuando volúmenes gp pierden ráfagas o cuando tablas temporales derraman.
Solución: Verifica misses del buffer pool y tablas temporales en disco; revisa la latencia/cola de almacenamiento en CloudWatch; reduce presión de escritura/lectura; migra a IOPS predecibles; arregla consultas e índices.
2) Síntoma: “Demasiadas conexiones” aparece después de un failover
Causa raíz: Tormenta de conexiones debida al comportamiento de reintentos de la app y falta de pooling; conexiones antiguas no recicladas; max_connections configurado sin entender memoria por hilo.
Solución: Habilita pooling (ProxySQL/RDS Proxy/pooling en la app), limita conexiones por servicio, añade reintentos con jitter y establece timeouts sensatos. Prefiere menos conexiones sanas sobre miles de conexiones inactivas.
3) Síntoma: Réplicas se atrasan constantemente y nunca se ponen al día
Causa raíz: Throughput de apply de la réplica inferior a la carga de escritura, frecuentemente por almacenamiento o restricciones de apply single-threaded, o una clase de instancia más débil.
Solución: Escala recursos y throughput de almacenamiento de la réplica, reduce el volumen de escrituras (throttling de batches) y revisa por transacciones largas o esperas de locks en la réplica.
4) Síntoma: ALTER TABLE “cuelga” y todo lo demás se ralentiza
Causa raíz: Locks de metadata y transacciones largas; o online DDL que crea presión fuerte de temp/redo; a veces un DDL bloqueado esperando que una transacción termine.
Solución: Identifica bloqueadores con sys.innodb_lock_waits y processlist; programa DDL fuera de pico; usa métodos seguros de schema change online; acorta transacciones.
5) Síntoma: Alarmas de espacio en disco pero el tamaño de datos no creció mucho
Causa raíz: Binlogs, crecimiento de undo por transacciones largas, derrames de tablas temporales o efectos secundarios de snapshots/retención.
Solución: Inspecciona volumen y retención de binlogs; mata o refactoriza transacciones largas; arregla consultas que derraman; confirma políticas de retención de backups.
6) Síntoma: Después de escalar la instancia, el rendimiento mejora poco
Causa raíz: El cuello de botella es throughput de almacenamiento/IOPS o contención de locks, no CPU/RAM.
Solución: Migra a almacenamiento con mayor throughput, reduce amplificación de escritura y arregla puntos calientes de contención (índices, patrones de transacción de la app).
7) Síntoma: “Memoria libre” parece alta pero el sistema va lento
Causa raíz: La memoria de MySQL no es toda la historia; InnoDB puede estar sub-allocado o la carga puede ser I/O-bound. Las métricas de memoria de RDS pueden engañar si no inspeccionas buffer pool y working set.
Solución: Revisa indicadores de hit ratio del buffer pool, examina las consultas top y los índices, y ajusta la clase de instancia y innodb_buffer_pool_size apropiadamente (cuando esté permitido).
8) Síntoma: Una réplica de lectura está “saludable” pero devuelve datos obsoletos
Causa raíz: Lag de replicación o apply retrasado; la aplicación asume consistencia read-after-write desde réplicas.
Solución: Rutea read-after-write al writer (session stickiness), aplica consistencia en rutas críticas, monitoriza lag y define umbrales para el uso de réplicas.
Listas de verificación / plan paso a paso
Antes de migrar (o antes del próximo incidente, si ya migraste)
- Habilita la telemetría correcta ahora: Performance Insights, slow query log (con muestreo sensato) y Enhanced Monitoring donde sea apropiado.
- Define SLOs y las acciones de “detener la hemorragia”: qué jobs por lotes pueden pausarse, qué endpoints pueden degradarse y quién puede accionar esos switches.
- Mapea límites explícitamente: max connections, tipo/IOPS de almacenamiento, comportamiento temporal, expectativas de failover y semántica de cambios en parameter groups (dinámico vs reboot).
- Realiza un ensayo de carga: simula los dos picos de tráfico más altos que hayas visto históricamente y confirma cómo degrada el sistema.
- Escribe el runbook como si lo fueras a leer medio dormido: pasos cortos, queries exactas, puntos de decisión y criterios de rollback.
Durante un incidente (secuencia de triage que funciona en el mundo real)
- Detén la amplificación: limita la tasa de reintentos, pausa workers por lotes y capea connection pools. Si no haces otra cosa, haz esto.
- Clasifica el cuello de botella: CPU vs I/O vs locks usando processlist + variables de estado clave + métricas de plataforma.
- Identifica formas SQL top: digests / PI top waits / slow log. Escoge el offender principal, no el equipo más ruidoso.
- Aplica mitigación de menor riesgo: feature-flag off de una consulta, reduce concurrencia o redirige lecturas/escrituras con cuidado.
- Solo entonces ajusta o escala: escalar la instancia y cambiar parámetros son herramientas válidas, pero no son primeros auxilios.
- Registra timestamps: cada acción, cada cambio métrico, cada modificación. Tu postmortem depende de ello.
Después del incidente (prevenir recurrencia, no solo vergüenza)
- Convierte la causa raíz en una barandilla: linting de consultas, revisión de migraciones, alertas de capacidad en el verdadero cuello de botella (a menudo I/O) y automatización de load-shedding.
- Arregla el ciclo de vida de los datos: archivado, particionado e higiene de índices en las tablas principales.
- Practica restauraciones y failovers: mídelo fin a fin, incluyendo el corte y verificación de la aplicación.
- Haz más difícil que se envíen regresiones de rendimiento: captura planes de consulta para consultas críticas y compáralos entre releases.
FAQ
1) ¿RDS MySQL es “MySQL real”?
En la capa SQL, sí. Operacionalmente, es MySQL dentro de un entorno gestionado con restricciones: sin acceso root, comportamientos de almacenamiento gestionados y mecánicas de failover y backup definidas por el producto.
2) ¿Cuál es la mayor diferencia de “límite oculto”?
La predictibilidad del rendimiento del almacenamiento. En hosts autogestionados a menudo puedes ver y ajustar toda la pila; en RDS debes elegir la clase de almacenamiento correcta y monitorizar I/O con las herramientas que RDS ofrece.
3) ¿Por qué los incidentes en RDS parecen más difíciles de depurar?
Porque no puedes acceder al host y ejecutar herramientas a nivel de SO. Debes confiar en la instrumentación de MySQL y en la telemetría de RDS. Si no las habilitaste, estás depurando con la mitad de la iluminación apagada.
4) ¿Deberíamos simplemente subir max_connections para evitar errores de conexión?
No. Así transformas un pico de tráfico pequeño en un desastre de memoria y contención. Usa connection pooling, limita conexiones por servicio y trata los errores de conexión como una señal de backpressure.
5) ¿Por qué escalar la clase de instancia a veces no arregla el rendimiento?
Porque podrías estar I/O-bound o lock-bound. Más CPU no arregla latencia de disco, y más RAM no arregla esperas de metadata. Diagnostica primero, luego escala para el cuello de botella real.
6) ¿Son las réplicas de lectura una forma segura de escalar lecturas durante incidentes?
Sólo si monitorizas lag y tu app está diseñada para ello. Las réplicas son geniales hasta que se retrasan; entonces son un problema de consistencia, no una solución de capacidad.
7) ¿Cuál es la intervención segura más rápida cuando la latencia se dispara?
Detén la amplificación: pausa jobs por lotes, reduce la concurrencia de workers y limita reintentos. Luego identifica si estás bloqueado, I/O-bound o CPU-bound antes de hacer cambios profundos.
8) ¿Los cambios en parameter groups siempre requieren downtime?
No, pero muchos sí requieren reboot. Durante un incidente, “cambiaremos un parámetro” sólo es útil si ya sabes si es dinámico y qué efectos secundarios tiene.
9) ¿Cómo evitamos incidentes por tablas temporales en RDS?
Arregla las formas de consulta que derraman (índices, reducir el coste de sort/group by), limita la concurrencia de reportes pesados y vigila Created_tmp_disk_tables como una alerta temprana, no como trivia.
10) ¿Multi-AZ es suficiente para alta disponibilidad?
Es necesario, no suficiente. Tu app debe manejar errores transitorios, reconectar correctamente, evitar tormentas de reintentos y tolerar breves brownouts. HA es una propiedad del sistema, no una casilla para marcar.
Conclusión: qué cambiar el lunes
MySQL y RDS MySQL ejecutan el mismo SQL, pero fallan de manera diferente. Las fallas autogestionadas suelen implicar un host que puedes hurgar hasta que confiese. Las fallas en RDS suelen implicar un límite al que accediste meses atrás y olvidaste monitorizar.
Próximos pasos que realmente reducen el tiempo de incidente:
- Habilita y verifica visibilidad a nivel de motor (Performance Insights y uso de performance_schema), y practica su uso bajo carga.
- Construye dashboards que respondan rápido a una pregunta: ¿el cuello de botella es CPU, I/O o locks?
- Escribe un runbook de load-shedding: qué jobs se pausan, qué endpoints se degradan y cómo detener tormentas de reintentos.
- Revisa decisiones de almacenamiento y topología de replicación basadas en la carga real, no en valores por defecto heredados.
- Convierte tus 5 formas de consulta más caras en elementos de propiedad con índices, comprobaciones de estabilidad de plan y rutas de despliegue seguras.
Si haces esos cinco, el próximo incidente seguirá siendo estresante. Pero no será misterioso. Y los incidentes misteriosos son los que te envejecen.