MySQL vs MariaDB max_connections: evita los fallos OOM en servidores pequeños

¿Te fue útil?

Siempre ocurre en el peor momento posible: una pequeña VM, un pico repentino de tráfico, unas pocas consultas lentas y luego la base de datos desaparece como si tuviera algo mejor que hacer. Te conectas y lo ves: el OOM killer de Linux eliminó mysqld. Otra vez.

Cuando alguien dice “Simplemente aumenta max_connections para que los usuarios dejen de recibir errores”, normalmente te está pidiendo que intercambies una falla visible (demasiadas conexiones) por una falla invisible (explosión de memoria), y la falla invisible gana matando el proceso. Este artículo trata de hacer explícito ese intercambio, dimensionarlo correctamente y no llevarse sorpresas.

Qué significa realmente max_connections (y por qué es una promesa de memoria)

max_connections no es una perilla de rendimiento. Es una perilla de riesgo.

A simple vista, es el número máximo de conexiones de cliente simultáneas que el servidor permitirá. En un día tranquilo, podrías pensar que solo trata de concurrencia: más conexiones significa más usuarios. En un día de producción, se trata de cuántos threads hambrientos de memoria está dispuesto a alojar tu base de datos a la vez antes de quedarse sin RAM y ser eliminado.

Cada conexión puede llevar asignaciones por sesión y por hilo. Algunas son pequeñas. Otras son sorprendentemente grandes. Algunas se asignan solo cuando se necesitan (ordenamientos, joins, tablas temporales). El problema es que “cuando se necesitan” es exactamente cuando el sistema ya está estresado: las consultas lentas se amontonan, las conexiones se acumulan y esas asignaciones caras ocurren justo cuando menos las quieres.

Así que el modelo mental correcto es:

  • Memoria global (compartida por todos): innodb_buffer_pool, buffers de logs, caches, estructuras internas.
  • Línea base por conexión: stack de thread, buffers de red, estructuras de sesión.
  • Picos por consulta: sort buffers, join buffers, memoria de tablas temporales, read buffers.

Si configuras max_connections demasiado alto en un servidor pequeño, efectivamente le has prometido al kernel: “Podría crear hasta N threads, y cada uno podría hacer trabajo caro en memoria al mismo tiempo.” El kernel te cree. El OOM killer tiene otra opinión.

Un chiste corto, porque hace falta: Aumentar max_connections para arreglar ‘too many connections’ es como comprar un cubo de basura más grande para apagar un incendio en la cocina. Atiende el síntoma visible e ignora el calor.

Datos interesantes y contexto (porque este debate tiene historia)

  1. El modelo thread-per-connection de MySQL es antiguo y resistente. Escala sorprendentemente bien con patrones de carga buenos, pero hace que “demasiadas conexiones inactivas” sea un problema de memoria y de planificación de CPU.
  2. MariaDB se bifurcó de MySQL en 2009 tras la era Sun/Oracle; la compatibilidad se mantuvo alta, pero los valores por defecto y las características divergieron con el tiempo.
  3. MySQL 5.6 popularizó la era del “gran innodb_buffer_pool”; antes de eso, muchas implantaciones confiaban demasiado en la caché del SO y MyISAM. Eso cambió la cultura de planificación de memoria.
  4. InnoDB se convirtió en el motor por defecto en MySQL 5.5. Esa decisión aumentó la importancia de dimensionar la memoria global y redujo la tolerancia a “ajustes al azar”.
  5. Performance Schema (MySQL) y la instrumentación similar cambiaron cómo se puede observar la memoria y las esperas—si lo activas y lo usas.
  6. El comportamiento del OOM killer en Linux no es “aleatorio”, pero no es intuitivo: elige víctimas según una puntuación de gravedad, uso de memoria y restricciones. Las bases de datos son procesos grandes, por eso suelen ser objetivos atractivos.
  7. “Swap hace las bases de datos lentas” se volvió una leyenda; pero en realidad, “sin swap en una tiny VM” a menudo convierte un pico recuperable en un kill de proceso.
  8. El pooling de conexiones se volvió mainstream en los años 2010 para stacks web porque las bases de datos thread-per-connection no adoran miles de sesiones TCP mayormente inactivas.

MySQL vs MariaDB: las diferencias que importan para el riesgo de OOM

Si buscas una respuesta simple como “MySQL usa menos memoria” o “MariaDB es más seguro”, no la tendrás. Ambos pueden ser estables o catastróficos. La diferencia está en dónde están las minas propias y qué valores por defecto heredaste.

Valores por defecto y empaquetado: tu base de datos real es “MySQL + decisiones del distro”

En servidores pequeños, el mayor factor de riesgo no es la bifurcación; es la configuración empacada y lo que tu equipo copió de un blog de 2016.

Patrones comunes:

  • Los paquetes de MariaDB suelen traer valores conservadores para instancias pequeñas, pero tu imagen en la nube puede sobrescribirlos con presets “útiles”.
  • MySQL en plataformas gestionadas (o builds de proveedor) a veces activa instrumentación o caches extra que aumentan la memoria base.
  • Ambos pueden configurarse hasta el descontrol si se aumentan demasiado los buffers por hilo y luego se sube max_connections.

Manejo de hilos y límites de conexión

Ambos generalmente usan un thread por conexión cliente. La sobrecarga de scheduling y memoria por thread escala con el número de conexiones.

Donde la gente se quema:

  • Alto max_connections con largo wait_timeout crea una gran población de sesiones inactivas. Inactivo no es gratis.
  • Alta rotación de conexiones (conectar/desconectar por petición) provoca gasto de CPU y a veces patrones de fragmentación de memoria que parecen “fugas”.
  • El thread cache puede ayudar, pero también puede mantener muchos recursos de threads si lo dejas.

Diferencias en observabilidad de memoria

Performance Schema de MySQL ha mejorado para desglosar uso de memoria en versiones modernas, pero no es automáticamente “barato”. MariaDB tiene su propia instrumentación y variables de estado; la diferencia práctica es: qué herramienta usa realmente tu equipo.

Para prevenir OOM, el ganador es el sistema que:

  • te permite estimar picos por conexión y por consulta,
  • muestra la distribución actual del estado de conexiones (Sleep vs activas),
  • te ayuda a encontrar consultas lentas que causan acumulación,
  • facilita imponer límites sensatos.

La única diferencia real de “fork” que importa: no asumas que los ajustes mapean 1:1

La mayoría de claves de configuración son parecidas, pero no idénticas entre versiones y forks. No puedes aplicar de forma segura una guía de ajuste escrita para “MySQL” sin comprobar qué servidor estás ejecutando. Algunas variables existen en ambos pero con valores por defecto o comportamientos distintos. La memoria no perdona; una pequeña discrepancia basta.

Guía rápida de diagnóstico

Este es el orden que uso cuando un servidor pequeño está tambaleándose y la gente grita.

1) Confirma que fue OOM e identifica el patrón del kill

  • Revisa los logs del kernel en busca de entradas del OOM killer que nombren mysqld.
  • Decide si tratas un evento puntual (consulta mala) o un sobrecompromiso crónico (demasiadas conexiones + buffers sobredimensionados).

2) Mide el estado actual de conexiones y la causa de la acumulación

  • ¿Cuántas conexiones están activas frente a cuántas están en sleep?
  • ¿Los threads están atascados en “Sending data”, “Copying to tmp table”, “Sorting result” o esperando locks?
  • ¿Tienes una estampida desde una mala configuración del pool?

3) Calcula rápidamente la “peor memoria creíble”

  • Global: innodb_buffer_pool + buffers de log + otros caches globales.
  • Línea base por conexión: stack de thread + buffers de red + sesión.
  • Picos: sort/join buffers y tablas temporales (memoria) para consultas activas.

4) Aplica un tope estabilizador

  • Reduce max_connections a un valor que puedas permitir hoy.
  • Reduce buffers por hilo a valores sensatos si alguien los infló.
  • Acelera timeouts para que las sesiones inactivas no ocupen sitio eternamente.
  • Implementa o arregla el pooling de conexiones en el nivel de la aplicación.

5) Arregla la causa raíz: consultas lentas y contención por locks

  • Encuentra a los peores culpables y haz que dejen de retener recursos.
  • Añade índices, reescribe consultas, reduce tamaños de resultados o procesa en lotes.

Si haces los pasos 1–4, detienes la hemorragia. El paso 5 evita que vuelva a suceder.

El modelo de memoria: buffers globales, buffers por hilo y la trampilla

Las bases de datos hacen OOM en servidores pequeños porque la gente razona sobre la memoria como si fuera una sola perilla. No lo es. Es un montón de perillas, y algunas se multiplican por el número de conexiones.

Memoria global: lo que siempre está ahí

En sistemas dominados por InnoDB (que son la mayoría), lo grande es:

  • innodb_buffer_pool_size: normalmente el mayor consumidor. Excelente para rendimiento. Fatal cuando está sobredimensionado.

Otros consumidores globales o mayormente globales incluyen:

  • innodb_log_buffer_size y otras asignaciones internas de InnoDB,
  • varios caches (table cache, etc.),
  • sobrecarga de instrumentación si está activada a escala.

La memoria global es, al menos, predecible. La defines y se mantiene mayormente estable.

Línea base por conexión: el multiplicador silencioso

Cada conexión tiene sobrecarga. La huella exacta depende de versión, compilación y carga, pero el comportamiento multiplicador no cambia: más sesiones significa más memoria y más sobrecarga de scheduling.

Contribuyentes clave:

  • thread_stack: asignado por thread.
  • Buffers de red y estructuras por sesión.
  • Efectos del thread cache: puede mantener recursos de thread vivos después de desconexiones.

Picos por consulta: donde ocurre el OOM durante “tormentas de consultas lentas”

Las asignaciones en picos son las verdaderas villanas. Las clásicas:

  • sort_buffer_size: usado para ordenamientos que no puede satisfacer un índice.
  • join_buffer_size: usado para joins cuando los índices no se usan eficazmente.
  • read_buffer_size y read_rnd_buffer_size para ciertos patrones de acceso.
  • Tablas temporales: pueden usar memoria hasta un límite y luego volcar a disco.

He aquí la trampilla: esos buffers a menudo se asignan por hilo por operación. Si tienes 200 conexiones activas haciendo sorts concurrentemente, incluso tamaños modestos de buffer pueden volverse catastróficos.

Por qué “tenía memoria libre ayer” no significa que estés seguro

Los eventos OOM suelen correlacionarse con picos de concurrencia y latencia en la cola. Una consulta lenta aumenta su propio tiempo de ejecución, lo que incrementa el número de consultas concurrentes, lo que incrementa el uso de memoria, lo que ralentiza todo más. Obtienes un bucle de retroalimentación que termina cuando la carga baja… o cuando el kernel elimina tu base de datos.

Segundo chiste corto, porque se permiten exactamente dos: El OOM killer es tu colega SRE más decisivo: nunca debate, simplemente borra el proceso más grande y se va a almorzar.

Una cita operativa

Idea parafraseada de John Allspaw: “En sistemas complejos, los incidentes provienen de la interacción de trabajos normales, no de una sola pieza rota.”

Esto importa porque el OOM en servidores pequeños rara vez es “una fuga”. Es buffers normales + conexiones normales + una cosa lenta, todo a la vez.

Dimensionar correctamente max_connections en servidores pequeños

Aquí va la guía con opinión: en servidores pequeños deberías preferir menos conexiones a la base de datos, mantenidas calientes y multiplexadas por un pool, en lugar de “dejar que todos se conecten directamente”.

Paso 1: elige un presupuesto de memoria para MySQL/MariaDB

En una VM pequeña no posees toda la máquina. El SO necesita page cache, el gestor de servicios necesita margen, y si tienes apps co-localizadas, también necesitan espacio.

Un enfoque práctico:

  • Decide cuánta RAM puedes dar al proceso de base de datos bajo presión. No “disponible”, sino “segura”.
  • Reserva margen para picos, caché del sistema de archivos y lo inesperado.

Si ejecutas sin swap, sé especialmente conservador. La diferencia entre “lento” y “muerto” a veces es 512MB.

Paso 2: calcula tu línea base global

Empieza con innodb_buffer_pool_size. Añade otras asignaciones globales. No sobrepienses las más pequeñas; deja simplemente margen.

Paso 3: estima el coste por conexión (línea base + pico creíble)

En servidores pequeños no necesitas el número perfecto. Necesitas un límite superior conservador.

Divídelo en dos categorías:

  • Línea base por conexión: stack de thread + sesión + buffers de red. Asume unos pocos MB por conexión salvo que tengas evidencia en contrario.
  • Sobrecarga por consulta activa: para la fracción de conexiones que pueden estar ejecutando operaciones pesadas en memoria, asume que se usarán sort/join buffers.

La parte clave: no todas las sesiones están activas. Si tienes 300 conexiones pero solo 20 están verdaderamente activas a la vez, dimensionas para 300 en línea base + 20 picos. Si tienes 300 activas porque la app trabaja sincrónicamente y todo está lento, ya tienes problemas y max_connections no debería ser 300.

Paso 4: fija max_connections al valor que realmente puedas permitir

En un servidor pequeño, el valor más seguro suele estar en el rango de 50–200, dependiendo de la carga y los buffers. Si te tienta ponerlo en 1000 “por si acaso”, estás tratando la base de datos como una cola de mensajes. No lo es.

Decide también si necesitas un hueco reservado para administración:

  • max_connections limita el total.
  • super_read_only y patrones de acceso administrativo varían, pero la práctica general es dejar espacio para acceso de emergencia y automatización.

Paso 5: arregla el patrón de conexión en la aplicación

Si tienes muchas conexiones de corta duración, sufrirás por la sobrecarga de handshake/autenticación y churn de threads. Usa un pool. Mantenlo modesto. Establece un límite duro por instancia de la app. Luego escala horizontalmente si hace falta.

Bonus: el pooling de conexiones mejora la latencia en la cola porque elimina “estampidas de conexiones” que ocurren durante fallos parciales.

Tareas prácticas: 14 comandos que te dicen qué hacer a continuación

Estas son tareas reales que puedes ejecutar en un servidor Linux. Cada una tiene: un comando, salida típica, qué significa y la decisión que tomas.

Task 1: confirm que el OOM killer eliminó mysqld

cr0x@server:~$ sudo journalctl -k -b | egrep -i 'oom-killer|out of memory|killed process|mysqld' | tail -n 20
Dec 29 09:12:01 server kernel: Out of memory: Killed process 1423 (mysqld) total-vm:3187420kB, anon-rss:1456820kB, file-rss:0kB, shmem-rss:0kB, UID:110 pgtables:4120kB oom_score_adj:0
Dec 29 09:12:01 server kernel: oom_reaper: reaped process 1423 (mysqld), now anon-rss:0kB, file-rss:0kB, shmem-rss:0kB

Qué significa: Este es un kill a nivel de kernel, no un crash interno de la base de datos. Tu BD solicitó memoria; el kernel dijo que no.

Decisión: Trátalo primero como un problema de capacidad/límites de memoria, no como un “bug de MySQL”. No puedes ajustar un proceso muerto.

Task 2: verifica la memoria del sistema y la postura de swap

cr0x@server:~$ free -h
               total        used        free      shared  buff/cache   available
Mem:           3.8Gi       3.4Gi       120Mi        52Mi       310Mi       140Mi
Swap:          0B          0B          0B

Qué significa: Estás funcionando con prácticamente sin margen y sin swap. Cualquier pico es un kill.

Decisión: Añade swap (aunque sea pequeña) o reduce de inmediato la huella de memoria de la BD. Preferiblemente ambas cosas.

Task 3: ve el RSS y los hilos del proceso mysqld (cuando está arriba)

cr0x@server:~$ ps -o pid,cmd,rss,vsz,nlwp --sort=-rss -C mysqld
  PID CMD                          RSS    VSZ NLWP
 2189 /usr/sbin/mysqld          1684200 3278800  198

Qué significa: RSS es memoria real en uso. NLWP te da una pista sobre la escala de threads/conexiones.

Decisión: Si NLWP se dispara con el conteo de conexiones, necesitas pooling/timeouts y reducir max_connections.

Task 4: revisa max_connections actual y uso de conexiones

cr0x@server:~$ mysql -e "SHOW VARIABLES LIKE 'max_connections'; SHOW GLOBAL STATUS LIKE 'Max_used_connections'; SHOW GLOBAL STATUS LIKE 'Threads_connected';"
+-----------------+-------+
| Variable_name   | Value |
+-----------------+-------+
| max_connections | 600   |
+-----------------+-------+
+----------------------+-------+
| Variable_name        | Value |
+----------------------+-------+
| Max_used_connections | 412   |
+----------------------+-------+
+-------------------+-------+
| Variable_name      | Value |
+-------------------+-------+
| Threads_connected  | 389   |
+-------------------+-------+

Qué significa: Has permitido 600 y estás activamente cerca de 400. En una VM pequeña, eso suele ser una mala señal.

Decisión: Limita max_connections más cerca de lo que puedes permitir (y reduce la concurrencia real vía pooling).

Task 5: desglosa “Sleep” vs conexiones activas

cr0x@server:~$ mysql -N -e "SELECT COMMAND, COUNT(*) FROM information_schema.PROCESSLIST GROUP BY COMMAND ORDER BY COUNT(*) DESC;"
Sleep	340
Query	28
Connect	3
Binlog Dump	1

Qué significa: La mayoría de conexiones están inactivas. Esto suele ser un problema de dimensionamiento del pool de la app o timeouts demasiado largos.

Decisión: Reduce wait_timeout y arregla el tamaño máximo del pool por instancia de servicio. No “resuelvas” esto subiendo max_connections.

Task 6: identifica qué están haciendo las consultas activas (distribución de estados)

cr0x@server:~$ mysql -N -e "SELECT STATE, COUNT(*) FROM information_schema.PROCESSLIST WHERE COMMAND='Query' GROUP BY STATE ORDER BY COUNT(*) DESC;"
Sending data	11
Copying to tmp table	6
Sorting result	4
Waiting for table metadata lock	3
executing	2

Qué significa: Se están creando tablas temporales y ordenamientos ahora mismo. Ahí tienden a ocurrir los picos de memoria.

Decisión: Busca las consultas culpables y reduce la necesidad de sorts/tablas temporales (índices, reescritura de consultas) y ajusta buffers por hilo.

Task 7: encuentra las consultas en ejecución más largas

cr0x@server:~$ mysql -e "SELECT ID,USER,HOST,DB,TIME,STATE,LEFT(INFO,120) AS INFO FROM information_schema.PROCESSLIST WHERE COMMAND='Query' ORDER BY TIME DESC LIMIT 10;"
+------+-------+-----------------+------+------|-------------------------------+----------------------------------------------------------+
| ID   | USER  | HOST            | DB   | TIME | STATE                         | INFO                                                     |
+------+-------+-----------------+------+------|-------------------------------+----------------------------------------------------------+
| 7712 | app   | 10.0.2.15:53342  | prod |  188 | Copying to tmp table          | SELECT ... ORDER BY created_at DESC LIMIT 1000           |
| 7761 | app   | 10.0.2.15:53388  | prod |  163 | Sorting result                | SELECT ... JOIN ... WHERE ...                            |
| 7799 | app   | 10.0.2.18:40210  | prod |  142 | Waiting for table metadata lock | ALTER TABLE ...                                         |
+------+-------+-----------------+------+------|-------------------------------+----------------------------------------------------------+

Qué significa: La “tormenta” es visible. Tienes procesos largos e incluso DDL esperando locks.

Decisión: Considera matar a los peores (con precaución) y separar cambios de esquema online de las horas pico. Arregla los patrones de consulta tras estabilizar.

Task 8: revisa variables por hilo sensibles a memoria

cr0x@server:~$ mysql -e "SHOW VARIABLES WHERE Variable_name IN ('sort_buffer_size','join_buffer_size','read_buffer_size','read_rnd_buffer_size','thread_stack','tmp_table_size','max_heap_table_size');"
+--------------------+----------+
| Variable_name      | Value    |
+--------------------+----------+
| join_buffer_size   | 4194304  |
| max_heap_table_size| 67108864 |
| read_buffer_size   | 2097152  |
| read_rnd_buffer_size| 4194304 |
| sort_buffer_size   | 8388608  |
| thread_stack       | 262144   |
| tmp_table_size     | 67108864 |
+--------------------+----------+

Qué significa: Alguien infló los buffers. Con alta concurrencia, esto es una receta para OOM.

Decisión: Reduce estos a valores conservadores salvo que tengas pruebas de que ayudan. Buffers grandes ayudan a consultas únicas; empeoran la estabilidad global.

Task 9: compara innodb_buffer_pool_size vs RAM

cr0x@server:~$ mysql -e "SHOW VARIABLES LIKE 'innodb_buffer_pool_size';"
+-------------------------+------------+
| Variable_name           | Value      |
+-------------------------+------------+
| innodb_buffer_pool_size | 3221225472 |
+-------------------------+------------+

Qué significa: Eso son 3GiB. En un host ~4GiB deja casi nada para conexiones, tablas temporales y el SO.

Decisión: Reduce el buffer pool en máquinas pequeñas (a menudo 50–70% de la RAM dependiendo de co-tenant y swap). Luego reevalúa tasas de aciertos en cache y rendimiento.

Task 10: comprueba si las tablas temporales están yendo a disco (pistas de rendimiento y memoria)

cr0x@server:~$ mysql -e "SHOW GLOBAL STATUS LIKE 'Created_tmp%tables';"
+-------------------------+---------+
| Variable_name           | Value   |
+-------------------------+---------+
| Created_tmp_disk_tables | 183492  |
| Created_tmp_files       | 2281    |
| Created_tmp_tables      | 512044  |
+-------------------------+---------+

Qué significa: Un número significativo de tablas temporales vuelca a disco. Eso puede ralentizar consultas y causar acumulación de conexiones; la memoria aún puede subir antes del volcado.

Decisión: Arregla consultas/índices primero. No aumentes a ciegas tmp_table_size/max_heap_table_size en un servidor pequeño; eso aumenta el riesgo de picos de memoria.

Task 11: revisa el cache de threads y la rotación de creación de threads

cr0x@server:~$ mysql -e "SHOW VARIABLES LIKE 'thread_cache_size'; SHOW GLOBAL STATUS LIKE 'Threads_created'; SHOW GLOBAL STATUS LIKE 'Connections';"
+-------------------+-------+
| Variable_name     | Value |
+-------------------+-------+
| thread_cache_size | 50    |
+-------------------+-------+
+-----------------+--------+
| Variable_name   | Value  |
+-----------------+--------+
| Threads_created | 924812 |
+-----------------+--------+
+--------------+---------+
| Variable_name| Value   |
+--------------+---------+
| Connections  | 985004  |
+--------------+---------+

Qué significa: Si Threads_created sube rápido respecto a Connections, tienes churn de conexiones y caching insuficiente.

Decisión: Prefiere arreglar la app para reutilizar conexiones. Ajusta thread_cache_size modestamente, pero no lo uses para ocultar un patrón de conexión malo.

Task 12: revisa timeouts que controlan la acaparación de conexiones inactivas

cr0x@server:~$ mysql -e "SHOW VARIABLES LIKE 'wait_timeout'; SHOW VARIABLES LIKE 'interactive_timeout';"
+---------------+--------+
| Variable_name | Value  |
+---------------+--------+
| wait_timeout  | 28800  |
+---------------+--------+
+-------------------+--------+
| Variable_name     | Value  |
+-------------------+--------+
| interactive_timeout| 28800 |
+-------------------+--------+

Qué significa: Ocho horas. En servidores pequeños con apps ocupadas, esta es una causa común de acumulación de conexiones en Sleep.

Decisión: Reduce timeouts (para usuarios no interactivos) y aplica dimensionamiento del pool. Si realmente necesitas conexiones de larga duración, necesitas un servidor más grande o menos de ellas.

Task 13: revisa archivos abiertos y presión del table cache (síntoma secundario)

cr0x@server:~$ mysql -e "SHOW VARIABLES LIKE 'table_open_cache'; SHOW GLOBAL STATUS LIKE 'Open_tables'; SHOW GLOBAL STATUS LIKE 'Opened_tables';"
+------------------+-------+
| Variable_name    | Value |
+------------------+-------+
| table_open_cache | 4000  |
+------------------+-------+
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| Open_tables   | 3980  |
+---------------+-------+
+----------------+---------+
| Variable_name  | Value   |
+----------------+---------+
| Opened_tables  | 784920  |
+----------------+---------+

Qué significa: Si Open_tables está cerca de table_open_cache y Opened_tables sube rápido, estás reabriendo tablas con frecuencia. Esto puede añadir sobrecarga y latencia, aumentando indirectamente la concurrencia de conexiones.

Decisión: Ajusta table cache y límites de archivos del SO, pero no confundas esto con el driver primario de OOM. Arregla primero los multiplicadores de memoria.

Task 14: verifica límites de systemd y restricciones de cgroup de memoria

cr0x@server:~$ systemctl show mysql -p MemoryMax -p TasksMax -p LimitNOFILE
MemoryMax=infinity
TasksMax=4915
LimitNOFILE=1048576

Qué significa: Aquí no estás limitado por límites de memory de systemd. Si MemoryMax estuviera establecido, la BD podría estar siendo eliminada por restricciones de cgroup en lugar del OOM del sistema.

Decisión: Si ejecutas en contenedores o cgroups estrictos, dimensiona para ese límite, no para la RAM del host, y considera ajustar oom_score_adj y reservas de memoria cuidadosamente.

Tres micro-historias corporativas desde las trincheras OOM

Micro-historia #1: el incidente causado por una suposición equivocada

Tenían un entorno de producción pequeño para una herramienta “no crítica”. La herramienta se volvió crítica el momento en que dependió de ella la nómina. La base de datos vivía en una VM de 4GB, y “había estado bien durante meses”. Esa frase siempre es el planteamiento.

Un desarrollador notó errores “Too many connections” ocasionales durante una ventana de actividad y hizo un cambio: max_connections pasó de unos pocos cientos a mil. La suposición era simple: más conexiones significan menos errores. Nadie comprobó las cuentas de memoria. Nadie preguntó qué hacían las conexiones en sleep.

El lunes siguiente, una consulta de informe inocua se volvió más lenta por un índice faltante. Las peticiones se acumularon, la app abrió más sesiones y la BD cumplió porque ahora se lo permitía. La memoria subió de forma constante. Entonces el kernel mató mysqld. La herramienta cayó, el informe de nómina falló y mucha gente aprendió qué significa “radio de explosión”.

El postmortem no fue dramático. Fue peor: fue aburrido. “Asumimos que los errores de límite de conexión eran la causa, no el síntoma.” Arreglaron la consulta, implementaron un pool con cap duro, bajaron max_connections y redujeron timeouts. La misma VM sobrevivió futuros picos con la misma RAM. La diferencia fue que la base de datos dejó de ser voluntariamente la pelota de estrés de la app.

Micro-historia #2: la optimización que salió mal

Un equipo perseguía latencia en una API orientada a clientes. Habían leído que aumentar sort_buffer_size y join_buffer_size podía acelerar consultas complejas. Aumentaron ambos sustancialmente, probaron con un benchmark de un solo usuario y se veía genial. El cambio se desplegó.

Bajo concurrencia real en producción fue otra historia. Múltiples workers ejecutaron consultas de reporte similares al mismo tiempo, cada uno asignando buffers más grandes. El consumo de memoria no solo subió—subió en forma de escalera cuando aumentaba la concurrencia. No chocaron contra un muro de rendimiento; chocaron con el OOM killer.

Lo desagradable fue que el crash fue intermitente. A veces el patrón de carga hacía que los buffers se asignaran concurrentemente; a veces no. Eso lo hacía parecer un “crash aleatorio” o una “fuga”. Pasaron días mirando gráficas y culpando la capa de almacenamiento.

La solución fue humillante: revertir los tamaños de buffers más cerca de los valores por defecto, añadir un índice que eliminó el sort y limitar la concurrencia en la app (pool) para que las consultas pesadas no estampiden. El rendimiento mejoró en general porque la base de datos dejó de thrashear y la app dejó de reintentar. La moraleja no fue “nunca ajustar buffers”. Fue “nunca ajustar buffers por hilo usando un benchmark de un solo hilo y darlo por terminado”.

Micro-historia #3: la práctica aburrida pero correcta que salvó el día

Otra organización ejecutaba muchas instancias pequeñas de MySQL/MariaDB, cada una ligada a un servicio. Nada sofisticado. El truco era el proceso: cada instancia tenía una “ficha de capacidad” ligera en el repo junto a la configuración. Listaba RAM, tamaño de buffer pool, supuestos de buffers por hilo y el tope seguro de conexiones. No era perfecto. Era consistente.

Una noche, un pico de tráfico golpeó un servicio de baja prioridad que compartía un nodo con otras cosas. El pool del servicio se comportó mal y trató de abrir muchas más conexiones de lo normal. La base de datos se negó una vez que alcanzó max_connections, y la app empezó a fallar peticiones. Molesto, visible y recuperable.

Porque la base de datos no hizo OOM, otros servicios en el host siguieron vivos. El on-call tuvo tiempo para responder como humano: reducir el tamaño del pool, reiniciar algunos workers y desplegar un arreglo de configuración. Sin recuperación de sistema de archivos. Sin crash loops. Sin estado corrupto.

Ese fue el punto: una negativa limpia en el límite de la BD suele ser mejor que dejar que el SO dispare a la base de datos. La práctica aburrida—límites documentados y topes conservadores—convirtió un posible incidente multi-servicio en un simple tropiezo de un servicio.

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

1) Síntoma: “Too many connections” seguido de OOM tras aumentar max_connections

Causa raíz: Acumulación de conexiones por consultas lentas o mal comportamiento del pool; aumentar max_connections incrementa la exposición de memoria.

Solución: Mantén max_connections dentro del presupuesto de memoria. Implementa pooling con un tope estricto. Encuentra y arregla la consulta lenta o la contención de locks que causó la acumulación.

2) Síntoma: Cientos de conexiones sleeping; la memoria sigue alta

Causa raíz: wait_timeout largo más pools sobredimensionados, más sobrecarga de thread/sesión.

Solución: Baja wait_timeout (para usuarios de la app), fija el tamaño máximo del pool por instancia y reduce conexiones inactivas. Si necesitas muchas sesiones de larga duración, redimensiona el servidor.

3) Síntoma: OOM solo durante trabajos de reporting o batch de fin de mes

Causa raíz: Concurrencia de consultas pesadas en memoria (sorts, joins, tablas temporales) que disparan picos por hilo.

Solución: Ajusta consultas e índices para reducir sorts/tablas temporales, limita la concurrencia de esos trabajos y mantén buffers por hilo conservadores. Considera ejecutar batchs en una réplica separada.

4) Síntoma: OOM después de “tuning de rendimiento” con buffers grandes

Causa raíz: Tamaños de buffers por hilo multiplicados por sesiones activas exceden la RAM.

Solución: Vuelve a tamaños sensatos de buffers; mide con concurrencia parecida a producción. Prefiere arreglar consultas/índices antes que inflar buffers.

5) Síntoma: DB reinicia en bucle; logs no muestran nada obvio

Causa raíz: Eliminado por OOM antes de poder volcar logs útiles, o eliminado por límites de memoria de cgroup/contenedor.

Solución: Revisa logs del kernel y límites de cgroup. Reduce la huella de memoria y/o aumenta el límite de memoria del contenedor. Añade swap cuando sea apropiado.

6) Síntoma: Uso de memoria crece lentamente en días; crashes ocasionales

Causa raíz: A menudo no es una fuga, sino cambios en la carga: más sesiones concurrentes, resultados más grandes, aumento de uso de tablas temporales o presión por fragmentación.

Solución: Sigue la distribución del conteo de conexiones, logs de consultas lentas y métricas de tablas temporales. Aplica topes duros de concurrencia y corrige los drivers de la carga.

Listas de verificación / plan paso a paso

Estabilizar ahora (mismo día)

  1. Confirma OOM killer vía logs del kernel. Si es OOM, deja de debatir.
  2. Reduce max_connections a un número seguro basado en la RAM actual y tamaños de buffers. Sí, los usuarios verán errores. Errores controlados son mejores que recuperación de datos.
  3. Reduce buffers por hilo si fueron inflados (sort_buffer_size, join_buffer_size, read buffers).
  4. Reduce wait_timeout para usuarios de la app y evita acaparamiento de conexiones.
  5. Habilita o verifica el pooling y fija topes duros por instancia.
  6. Mata o reprograma las consultas peores solo si entiendes el radio de explosión (consultas de reporting suelen ser seguras para detener; migraciones/DDL son más delicadas).

Hacerlo robusto (esta semana)

  1. Captura métricas base: máximo de conexiones usadas, conteos activas vs sleep, tablas temporales, conteo de consultas lentas.
  2. Activa logging de consultas lentas con un umbral razonable para tu carga y revisa los peores.
  3. Añade índices faltantes para las consultas que causan sorts y tablas temporales.
  4. Establece un “contrato de capacidad” en la config: tamaño de buffer pool documentado, buffers por hilo y tope seguro de conexiones.
  5. Decide sobre swap: un swap pequeño en servidores pequeños suele aumentar la supervivencia. Monitoriza el swap-in; no lo conviertas en permanente.

Prevenir recurrencia (este trimestre)

  1. Prueba carga con concurrencia parecida a producción, no con benchmarks de una sola conexión.
  2. Separa OLTP y reporting: réplicas, instancias dedicadas o al menos límites de concurrencia en reporting.
  3. Automatiza guardarraíles: alertas en Threads_connected, Max_used_connections acercándose al máximo y presión de memoria.
  4. Revisa ajustes de pool con los equipos de aplicación como revisas reglas de firewall: explícita y regularmente.

Preguntas frecuentes

1) ¿Debo fijar max_connections al número de hilos de la app?

No. Debes fijar los tamaños máximos de los pools de la app de modo que la suma de todos los pools esté por debajo de un tope seguro de la BD, con margen para administración y tareas batch.

2) ¿Es MariaDB más segura que MySQL en cuanto a memoria?

No inherentemente. Ambos pueden hacer OOM de la misma manera: demasiados threads y demasiada memoria por thread. La seguridad viene del dimensionamiento y los límites, no del logo.

3) ¿Por qué la BD hace OOM cuando la mayoría de conexiones están en sleep?

Las conexiones sleeping aún consumen memoria por sesión y threads. Además, tu población “en sleep” puede ocultar un número menor de conexiones activas que realizan trabajo pesado en memoria y disparan el pico.

4) Si bajo max_connections, ¿no tendré más errores?

Sí, pero son errores controlados. Una BD capada falla rápido y de forma predecible. Una BD eliminada por OOM falla todo, incluyendo tareas de recuperación, y puede disparar reintentos en cascada.

5) ¿Es buena idea añadir swap para bases de datos?

En servidores pequeños, a menudo sí—si se hace intencionalmente. Un swap pequeño puede absorber picos cortos y evitar kills por OOM. Si ves swap-in sostenido bajo carga normal, estás mal aprovisionado o mal configurado.

6) ¿Qué variables son los mayores “multiplicadores de memoria por conexión” a vigilar?

Los culpables comunes son sort_buffer_size, join_buffer_size y límites de memoria para tablas temporales (tmp_table_size/max_heap_table_size). No siempre se asignan al completo, pero definen el peor caso bajo concurrencia.

7) ¿Cuál es la mejor solución para “demasiadas conexiones”: pooling o aumentar max_connections?

Pooling, casi siempre. Aumentar max_connections a veces es apropiado después de hacer cálculos de memoria y probar que tienes margen, pero no es la primera medida en hardware pequeño.

8) ¿Cómo sé si consultas lentas están causando la acumulación de conexiones?

Mira TIME/STATE en processlist, logs de consultas lentas y si las consultas activas se concentran en tablas temporales, ordenamientos o esperas por locks. El aumento del conteo de conexiones junto con la latencia es la pista.

9) ¿Debo reducir innodb_buffer_pool_size para evitar OOM?

Si el buffer pool está desplazando todo lo demás en un servidor pequeño, sí. El rendimiento puede bajar, pero una base de datos estable es más rápida que una muerta. Luego arregla consultas y carga para recuperar rendimiento.

10) ¿Puedo “simplemente poner la base de datos en un contenedor” para resolver esto?

Los contenedores no resuelven las cuentas de memoria. Las hacen cumplir. Si estableces un límite de memoria bajo, obtendrás kills más rápidos a menos que también ajustes buffers y topes de conexiones para ese límite.

Conclusión: próximos pasos que puedes hacer esta semana

Si tu servidor pequeño se está bloqueando, deja de tratar max_connections como un dial de satisfacción del cliente. Es un compromiso de memoria. Hazlo conservador.

Haz estos pasos en orden:

  1. Prueba que fue OOM con logs del kernel y mide RSS/hilos cuando la BD esté viva.
  2. Redimensiona la memoria global (especialmente innodb_buffer_pool_size) para que el SO y las conexiones tengan margen de respiración.
  3. Limita max_connections a lo que el servidor realmente puede soportar, no a lo que tu app puede spamear.
  4. Mantén buffers por hilo conservadores salvo que tengas pruebas sólidas y tests con concurrencia parecida a producción.
  5. Arregla el patrón de conexiones con pooling y timeouts sensatos. La mayoría de problemas de “capacidad BD” son realmente problemas de comportamiento de la app con disfraz de base de datos.
  6. Elimina las consultas lentas que causan acumulaciones y tormentas de tablas temporales. Menos consultas concurrentes es la actualización de memoria más barata.

Cuando llegue el próximo pico, quieres que la base de datos diga “no” pronto y claramente, no que el kernel la mate. Eso no es pesimismo. Es operaciones.

← Anterior
Controladores como armas: cómo el software puede “cambiar” tu GPU
Siguiente →
ZFS Scrub: con qué frecuencia hacerlo y qué demuestra

Deja un comentario