Saltar al contenido principal
Teramont Logo
Error “Too many connections” en MySQL/MariaDB: diagnostica antes de subir el límite
Volver al blog

Error “Too many connections” en MySQL/MariaDB: diagnostica antes de subir el límite

Mizael Segovia

20/8/2026 ·Mizael Segovia· 25 min de lectura ·

1 visualizaciones

El error Too many connections (1040) significa que MySQL o MariaDB alcanzó el número de conexiones que admite. Subir max_connections puede dar margen durante un incidente, pero no corrige por sí solo una fuga, un pool sobredimensionado, consultas que retienen conexiones o un VPS sin memoria. Conserva una sesión administrativa, captura evidencia y reduce la presión antes de cambiar el límite.

Esta guía cubre MySQL 8.x y versiones actuales de MariaDB en Linux con systemd. Los nombres de unidades, rutas, privilegios y variables pueden cambiar según el paquete, el contenedor o el proveedor. Los comandos son ejemplos documentados y revisados de forma estática: no se ejecutaron contra un servidor real. La guía oficial de MariaDB para gestionar demasiadas conexiones también parte de observar el límite y la carga antes de dimensionar.

Qué indica cada síntoma

Síntoma, lectura provisional y siguiente prueba
SíntomaLecturaSiguiente prueba
Threads_connected roza max_connections y Threads_running es bajo.Muchas sesiones están conectadas pero pocas trabajan.Agrupa por usuario, host y COMMAND; contrasta el total de pools.
Ambos contadores son altos.Hay concurrencia real, esperas o consultas lentas.Revisa estados, tiempos, consultas lentas y carga de CPU/I/O.
Max_used_connections alcanzó el límite solo en un pico.Puede ser capacidad legítima o una ráfaga de reintentos.Correlaciona hora, tráfico, despliegues y errores de aplicación.
Muchas conexiones aparecen como Sleep.Puede ser reutilización normal o retención excesiva; no prueba una fuga.Compara antigüedad, tasa de creación y configuración del pool.
El ajuste desaparece tras reiniciar.Solo cambió el valor global en memoria o se editó un archivo no efectivo.Identifica producto y origen efectivo de configuración.

Antes de intervenir

Necesitas consola del proveedor o acceso local, una sesión administrativa ya abierta si existe, una ventana de cambio y un backup recuperable. Mantén una segunda sesión de sistema; no cierres la única conexión SQL útil. Si administras por primera vez un servidor, repasa qué controla un VPS.

No pases contraseñas con -pCONTRASEÑA ni las pegues en el historial. Usa socket, un login-path ya configurado o un archivo de opciones protegido. No publiques SHOW FULL PROCESSLIST: host, base y SQL pueden contener datos sensibles.

Prepara y prueba la vía SQL antes del incidente. MariaDB reserva una conexión por encima de max_connections para una cuenta con SUPER o CONNECTION ADMIN; algunas instalaciones también permiten un puerto extra preconfigurado. MySQL reserva una conexión en la interfaz normal para CONNECTION_ADMIN o el antiguo SUPER. Además, su interfaz administrativa solo existe si admin_address se configuró al arrancar y exige SERVICE_CONNECTION_ADMIN. Confirma versión, permisos, TLS/red y credenciales protegidas; un servicio gestionado puede restringir estas opciones. La referencia de variables de MariaDB documenta sus mecanismos.

Una consola de sistema no crea por sí sola un slot SQL. Si no conservas una sesión ni probaste una vía reservada, úsala para reducir o detener primero el tráfico de aplicación y liberar una conexión; no reinicies la base a ciegas.

1. Confirma producto, límite y presión actual

Ejecuta primero consultas de solo lectura desde la sesión segura. La versión y version_comment evitan aplicar sintaxis de MySQL a MariaDB. Max_used_connections es un máximo desde el arranque o reinicio de estadísticas, no la concurrencia actual. Connection_errors_max_connections cuenta rechazos por el límite cuando la versión lo expone; si no devuelve fila, consulta la variable equivalente disponible y los logs, sin asumir un cero.

SELECT VERSION() AS server_version, @@version_comment AS product_comment;
SHOW GLOBAL VARIABLES LIKE 'max_connections';
SHOW GLOBAL STATUS
WHERE Variable_name IN (
  'Threads_connected',
  'Threads_running',
  'Max_used_connections',
  'Connection_errors_max_connections'
);
SHOW FULL PROCESSLIST;

La referencia de variables de estado de MariaDB documenta alcance y significado; mide varias muestras durante un intervalo comparable, no una captura aislada. Sin el privilegio PROCESS o su equivalente verás solo sesiones propias: una lista corta no demuestra que el servidor esté libre.

Este resumen evita mostrar el texto SQL y permite localizar qué combinación concentra sesiones. El host puede seguir siendo sensible; conserva la salida en un canal administrativo.

SELECT
  USER,
  HOST AS client_host,
  DB, COMMAND, STATE,
  COUNT(*) AS connections,
  MAX(TIME) AS oldest_seconds
FROM INFORMATION_SCHEMA.PROCESSLIST
GROUP BY USER, client_host, DB, COMMAND, STATE
ORDER BY connections DESC, oldest_seconds DESC;

2. Distingue pool, fuga, lentitud y pico legítimo

Calcula el techo del pool de toda la aplicación, no el de una sola instancia: réplicas × procesos o workers × máximo del pool, más tareas, migraciones, monitoreo y margen administrativo. PHP-FPM puede abrir conexiones por proceso; un servicio Node.js suele mantener un pool por proceso. Un despliegue que duplica réplicas temporalmente también duplica su demanda potencial.

Sleep solo significa que la sesión espera una nueva instrucción. Es sospechoso si el número o la antigüedad crecen sin volver a una línea base, si la aplicación crea más conexiones de las que reutiliza o si el timeout supera la necesidad real. Si Threads_running permanece alto, busca bloqueos y consultas lentas: acelerar la consulta o reducir concurrencia puede liberar conexiones. En WordPress, separa este incidente de un diagnóstico general de WordPress lento.

3. Recupera servicio con el cambio mínimo

  1. Reduce o encola tráfico no esencial y detén reintentos agresivos; no provoques una tormenta de reconexiones.
  2. Corrige o reduce el pool y despliega gradualmente. Cuenta todas las réplicas antes de fijar el máximo.
  3. Si una operación está bloqueada o tarda de forma anómala, identifica ID, propietario, host, base, estado, segundos y sentencia actual en una vista privada.
  4. Confirma con el propietario qué operación es, si hay una transacción abierta y qué efectos admite. Solo después elige terminar consulta o conexión.

Revalida inmediatamente antes de actuar. INFO es sensible y el extracto no debe salir del canal administrativo. Incluso así existe una carrera: la sesión puede empezar otra sentencia entre el SELECT y KILL; pausa la fuente o abandona la acción si propietario u operación cambian.

SELECT ID, USER, HOST, DB, COMMAND, TIME, STATE,
       LEFT(INFO, 512) AS current_statement_excerpt
FROM INFORMATION_SCHEMA.PROCESSLIST
WHERE ID = <CONFIRMED_PROCESSLIST_ID>;

KILL QUERY <CONFIRMED_PROCESSLIST_ID>;
-- Alternativa distinta, no la ejecutes junto con la anterior:
-- KILL CONNECTION <CONFIRMED_PROCESSLIST_ID>;

KILL QUERY cancela la sentencia, pero deja la sesión —y posiblemente su transacción— abierta, por lo que no libera un slot de max_connections. KILL CONNECTION sí cierra la sesión después de la limpieza, pero exige confirmación explícita de ese efecto. Sin PROCESS solo ves tus hilos; para afectar sesiones ajenas MySQL exige CONNECTION_ADMIN o el antiguo SUPER. La referencia oficial de KILL detalla permisos y demoras.

No uses el ejemplo sobre mantenimiento o DDL no identificado. MySQL advierte que interrumpir REPAIR TABLE u OPTIMIZE TABLE en MyISAM puede dejar la tabla corrupta, y que cambios no transaccionales pueden no revertirse. No generes listas ni bucles de KILL; tampoco uses kill -9, borres sockets o reinicies a ciegas.

4. Decide si el límite puede subir

Antes de aumentar max_connections, observa RSS del proceso, memoria disponible, swap, OOM, CPU, I/O y comportamiento bajo carga comparable. MySQL y MariaDB combinan memoria global con buffers que ciertas operaciones asignan por hilo o conexión; no existe una cifra universal de RAM por conexión. La guía oficial de asignación de memoria de MariaDB recomienda dimensionar el límite junto con la carga y los buffers.

set -euo pipefail

UNIT='REPLACE_WITH_EXACT_DATABASE_UNIT'
[[ -n "$UNIT" && "$UNIT" != *REPLACE_WITH* ]] || { echo 'Replace UNIT after identifying it' >&2; exit 1; }
systemctl show "$UNIT" --property=MainPID,MemoryCurrent,MemoryPeak,ActiveState
ps -C mysqld -C mariadbd -o pid,etimes,rss,vsz,%mem,%cpu,cmd
free -h
vmstat 1 5
journalctl --unit="$UNIT" --since='-30 min' --no-pager | tail -n 200
KERNEL_LOG="$(journalctl --dmesg --since='-30 min' --no-pager)" || { echo 'journalctl failed' >&2; exit 1; }
if ! grep -Ei 'oom|out of memory|killed process' <<<"$KERNEL_LOG"; then
  echo 'No OOM pattern found in the selected kernel window' >&2
fi

En un contenedor, mide también el límite y uso del cgroup; la memoria libre del host puede ser irrelevante. Un proxy como ProxySQL o un pool externo cambia la relación entre conexiones de aplicación y sesiones del servidor.

5. Aplica y revierte un aumento temporal

SET GLOBAL cambia el valor en ejecución para conexiones nuevas y funciona en ambos productos actuales, con el privilegio correspondiente; la referencia de variables del servidor de MariaDB documenta el alcance dinámico de max_connections. Registra el valor anterior y el ticket. El placeholder produce error si no se reemplaza por un entero confirmado; el nuevo límite debe basarse en memoria medida y en un techo total de pools corregido.

SELECT @@GLOBAL.max_connections AS previous_runtime_limit;
SET GLOBAL max_connections = <CONFIRMED_NEW_INTEGER_LIMIT>;
SELECT @@GLOBAL.max_connections AS active_runtime_limit;

Si empeoran RSS, swap, latencia o OOM, revierte al entero registrado; no uses “el valor que recuerdas”.

SET GLOBAL max_connections = <RECORDED_PREVIOUS_RUNTIME_LIMIT>;
SELECT @@GLOBAL.max_connections AS restored_runtime_limit;

¿Tu base de datos necesita más margen?

Compara planes VPS y dimensiona RAM, CPU y almacenamiento con base en la carga real de tu aplicación.

Premium Character
Ver planes VPS

6. Persistencia y rollback en MySQL

Solo en MySQL que soporte la operación, SET PERSIST cambia el valor global y escribe el ajuste en mysqld-auto.cnf. La referencia de SET de MySQL 8.4 explica esa doble acción. No uses esta ruta en MariaDB y no edites mysqld-auto.cnf a mano.

Antes de mutar, registra versión, valor en ejecución y si ya existe una fila persistida. Después verifica la fila y el archivo del directorio de datos.

SELECT VERSION(), @@version_comment, @@GLOBAL.max_connections, @@GLOBAL.datadir;
SELECT VARIABLE_NAME, VARIABLE_VALUE
FROM performance_schema.persisted_variables
WHERE VARIABLE_NAME = 'max_connections';

SET PERSIST max_connections = <CONFIRMED_NEW_INTEGER_LIMIT>;

SELECT @@GLOBAL.max_connections;
SELECT VARIABLE_NAME, VARIABLE_VALUE
FROM performance_schema.persisted_variables
WHERE VARIABLE_NAME = 'max_connections';
set -euo pipefail

DATADIR='/REPLACE/WITH/EXACT/DATADIR_FROM_SQL'
[[ "$DATADIR" = /* && "$DATADIR" != *REPLACE_WITH* ]] || { echo 'Replace DATADIR with the SQL result' >&2; exit 1; }
PERSIST_FILE="${DATADIR%/}/mysqld-auto.cnf"
sudo test -f "$PERSIST_FILE" && sudo test ! -L "$PERSIST_FILE" || { echo 'mysqld-auto.cnf was not verified' >&2; exit 1; }
sudo stat -- "$PERSIST_FILE"
sudo sha256sum -- "$PERSIST_FILE"

El rollback depende del estado inicial. Si no había fila persistida, elimina solo la que creaste y restaura el valor global anterior. Si ya existía, restablece aquel valor persistido y después el runtime registrado, porque SET PERSIST cambia ambos estados; no lo borres. Los placeholders mantienen ambos casos cerrados por defecto; en el caso A, reemplaza también el marcador del nombre por el literal max_connections solo después de confirmar que no existía la fila.

-- Caso A: antes NO existía una fila persistida.
RESET PERSIST <CONFIRM_VARIABLE_NAME_max_connections>;
SET GLOBAL max_connections = <RECORDED_PREVIOUS_RUNTIME_LIMIT>;
SELECT @@GLOBAL.max_connections AS restored_runtime_limit;
SELECT VARIABLE_NAME, VARIABLE_VALUE
FROM performance_schema.persisted_variables
WHERE VARIABLE_NAME = 'max_connections';

-- Caso B: antes SÍ existía; ejecuta este caso en lugar del A.
SET PERSIST max_connections = <RECORDED_PREVIOUS_PERSISTED_LIMIT>;
SET GLOBAL max_connections = <RECORDED_PREVIOUS_RUNTIME_LIMIT>;
SELECT @@GLOBAL.max_connections AS restored_runtime_limit;
SELECT VARIABLE_NAME, VARIABLE_VALUE
FROM performance_schema.persisted_variables
WHERE VARIABLE_NAME = 'max_connections';

7. Persistencia y rollback en MariaDB

MariaDB no usa SET PERSIST de MySQL. Su documentación de archivos de opciones explica rutas, orden y grupos. Obtén el archivo efectivo y los selectores --defaults-* del ExecStart real; si hay wrapper, variables sin resolver o selectores que no puedes reproducir, detente. La última definición de max_connections es la que debe coincidir.

[mariadbd]
max_connections = CONFIRMED_NEW_INTEGER_LIMIT

El procedimiento siguiente se ejecuta en una shell root dedicada durante una ventana exclusiva. Edita una copia candidata, usa un flock cooperativo, conserva hashes de base, backup y candidato, y usa un archivo cliente protegido para la verificación SQL. Ajusta el grupo al que el binario confirme que lee. Si cualquier selector no es reproducible, el bloque falla antes de reemplazar.

set -euo pipefail

[[ $EUID -eq 0 ]] || { echo 'Run from a dedicated root shell in the change window' >&2; exit 1; }

CONFIG='/REPLACE/WITH/EFFECTIVE/mariadb-server.cnf'
UNIT='REPLACE_WITH_EXACT_MARIADB_UNIT'
SERVER_BIN='/REPLACE/WITH/EXECSTART/mariadbd'
CLIENT_BIN='/REPLACE/WITH/EXACT/mariadb'
ADMIN_CNF='/REPLACE/WITH/PROTECTED/admin.cnf'
EDITOR_BIN='/REPLACE/WITH/EXACT/editor'
NEW_LIMIT='REPLACE_WITH_VERIFIED_INTEGER'
SELECTOR_MODE='REPLACE_WITH_none_OR_exact'
DEFAULTS_ARGS=()
# For exact mode, reproduce only the selectors found in ExecStart, in order:
# DEFAULTS_ARGS=('--defaults-file=/exact/file' '--defaults-group-suffix=exact')

LOCK_DIR='/run/teramont-mariadb-guide'
LOCK_FILE="${LOCK_DIR}/max-connections.lock"

die() { echo "$*" >&2; exit 1; }
hash_file() { sha256sum -- "$1" | awk '{print $1}'; }
hash_text() { sha256sum | awk '{print $1}'; }
get_defaults() { "$SERVER_BIN" "${DEFAULTS_ARGS[@]}" --print-defaults; }
validate_reader() { "$SERVER_BIN" "${DEFAULTS_ARGS[@]}" --help --verbose >/dev/null; }
last_max() {
  tr ' ' '\n' | awk -F= '/^--max[-_]connections=/{value=$2} END{if(value!="") print value}'
}
read_runtime() {
  "$CLIENT_BIN" --defaults-extra-file="$ADMIN_CNF" --protocol=socket \
    --batch --skip-column-names -e 'SELECT @@GLOBAL.max_connections;'
}
set_runtime() {
  local value="$1"
  [[ "$value" =~ ^[1-9][0-9]*$ ]] || return 1
  "$CLIENT_BIN" --defaults-extra-file="$ADMIN_CNF" --protocol=socket \
    --batch --skip-column-names \
    -e "SET GLOBAL max_connections = ${value}; SELECT @@GLOBAL.max_connections;"
}

[[ "$CONFIG" = /* && "$CONFIG" != *REPLACE* && -f "$CONFIG" && ! -L "$CONFIG" ]] || die 'Replace CONFIG with an effective regular file'
[[ "$UNIT" != *REPLACE* && -n "$UNIT" ]] || die 'Replace UNIT'
[[ "$SERVER_BIN" = /* && "$SERVER_BIN" != *REPLACE* && -x "$SERVER_BIN" ]] || die 'Replace SERVER_BIN'
[[ "$CLIENT_BIN" = /* && "$CLIENT_BIN" != *REPLACE* && -x "$CLIENT_BIN" ]] || die 'Replace CLIENT_BIN'
[[ "$EDITOR_BIN" = /* && "$EDITOR_BIN" != *REPLACE* && -x "$EDITOR_BIN" ]] || die 'Replace EDITOR_BIN'
[[ "$ADMIN_CNF" = /* && "$ADMIN_CNF" != *REPLACE* && -f "$ADMIN_CNF" && ! -L "$ADMIN_CNF" ]] || die 'Replace ADMIN_CNF'
[[ "$NEW_LIMIT" =~ ^[1-9][0-9]*$ ]] || die 'Replace NEW_LIMIT with a verified integer'
ADMIN_MODE="$(stat -c '%a' -- "$ADMIN_CNF")" || die 'Cannot read ADMIN_CNF mode'
[[ "$(stat -c '%u' -- "$ADMIN_CNF")" == '0' ]] || die 'ADMIN_CNF must be owned by root'
(( (8#$ADMIN_MODE & 077) == 0 )) || die 'ADMIN_CNF must not be accessible by group or others'
for TRUSTED_BIN in "$SERVER_BIN" "$CLIENT_BIN" "$EDITOR_BIN"; do
  [[ "$(stat -Lc '%u' -- "$TRUSTED_BIN")" == '0' ]] || die 'A trusted binary is not owned by root'
  BIN_MODE="$(stat -Lc '%a' -- "$TRUSTED_BIN")" || die 'Cannot read trusted binary mode'
  (( (8#$BIN_MODE & 022) == 0 )) || die 'A trusted binary is writable by group or others'
done
command -v flock >/dev/null || die 'flock is required'

install -d -o root -g root -m 700 -- "$LOCK_DIR"
[[ -d "$LOCK_DIR" && ! -L "$LOCK_DIR" ]] || die 'Unsafe lock directory'
[[ "$(stat -c '%u:%g:%a' -- "$LOCK_DIR")" == '0:0:700' ]] || die 'Unsafe lock directory ownership or mode'
umask 077
exec {LOCK_FD}<>"$LOCK_FILE"
chmod 600 -- "$LOCK_FILE"
[[ -f "$LOCK_FILE" && ! -L "$LOCK_FILE" ]] || die 'Unsafe lock file'
[[ "$(stat -c '%u:%g:%a' -- "$LOCK_FILE")" == '0:0:600' ]] || die 'Unsafe lock ownership or mode'
flock -n "$LOCK_FD" || die 'Another cooperative MariaDB change holds the lock'

systemctl is-active --quiet "$UNIT" || die 'The database unit is not active before the change'
EXEC_START="$(systemctl show "$UNIT" --property=ExecStart --value)" || die 'Cannot read ExecStart'
[[ -n "$EXEC_START" && "$EXEC_START" != *'$'* ]] || die 'ExecStart is empty or contains unresolved variables; stop'
grep -F -- "path=$SERVER_BIN" <<<"$EXEC_START" >/dev/null || die 'SERVER_BIN does not match ExecStart'
grep -Eq -- '--max[-_]connections(=|[[:space:]])' <<<"$EXEC_START" && die 'ExecStart overrides max_connections; do not edit an option file'
grep -Eq -- '--defaults-(file|extra-file|group-suffix)[[:space:]]' <<<"$EXEC_START" && die 'A defaults selector lacks = and cannot be reproduced safely'
mapfile -t EXEC_SELECTORS < <(grep -oE -- '--defaults-(file|extra-file|group-suffix)=[^ ;]+' <<<"$EXEC_START" || true)
case "$SELECTOR_MODE" in
  none)
    ((${#DEFAULTS_ARGS[@]} == 0 && ${#EXEC_SELECTORS[@]} == 0)) || die 'ExecStart has defaults selectors; use exact mode'
    ;;
  exact)
    ((${#DEFAULTS_ARGS[@]} > 0 && ${#DEFAULTS_ARGS[@]} == ${#EXEC_SELECTORS[@]})) || die 'Selectors are incomplete'
    for i in "${!DEFAULTS_ARGS[@]}"; do
      [[ "${DEFAULTS_ARGS[$i]}" == "${EXEC_SELECTORS[$i]}" ]] || die 'Selector order does not reproduce ExecStart'
    done
    ;;
  *) die 'Set SELECTOR_MODE to none or exact after inspecting ExecStart' ;;
esac

validate_reader || die 'The starting effective options do not parse'
BASE_DEFAULTS="$(get_defaults)" || die 'Cannot capture starting defaults'
BASE_DEFAULTS_HASH="$(printf '%s' "$BASE_DEFAULTS" | hash_text)"
OLD_RUNTIME="$(read_runtime)" || die 'Cannot read the starting SQL value'
[[ "$OLD_RUNTIME" =~ ^[1-9][0-9]*$ ]] || die 'Starting SQL value is not an integer'

STATE_DIR="$(mktemp -d /root/mariadb-max-connections.XXXXXXXX)"
chmod 700 "$STATE_DIR"
BACKUP="${STATE_DIR}/$(basename "$CONFIG").before"
BACKUP_HASH_FILE="${STATE_DIR}/backup.hash"
BASE_DEFAULTS_HASH_FILE="${STATE_DIR}/base-defaults.hash"
OLD_RUNTIME_FILE="${STATE_DIR}/old-runtime.value"
APPLIED_COPY="${STATE_DIR}/$(basename "$CONFIG").applied"
APPLIED_HASH_FILE="${STATE_DIR}/applied.hash"
CANDIDATE_DEFAULTS_HASH_FILE="${STATE_DIR}/candidate-defaults.hash"
NEW_LIMIT_FILE="${STATE_DIR}/new-limit.value"

BASE_HASH="$(hash_file "$CONFIG")"
cp --archive -- "$CONFIG" "$BACKUP"
BACKUP_HASH="$(hash_file "$BACKUP")"
[[ "$BASE_HASH" == "$BACKUP_HASH" ]] || die 'Backup does not match the locked baseline'
printf '%s\n' "$BACKUP_HASH" >"$BACKUP_HASH_FILE"
printf '%s\n' "$BASE_DEFAULTS_HASH" >"$BASE_DEFAULTS_HASH_FILE"
printf '%s\n' "$OLD_RUNTIME" >"$OLD_RUNTIME_FILE"
printf '%s\n' "$NEW_LIMIT" >"$NEW_LIMIT_FILE"
chmod 400 "$BACKUP" "$BACKUP_HASH_FILE" "$BASE_DEFAULTS_HASH_FILE" "$OLD_RUNTIME_FILE" "$NEW_LIMIT_FILE"

[[ "$(hash_file "$CONFIG")" == "$BACKUP_HASH" ]] || die 'CONFIG diverged before candidate editing; stop'
CANDIDATE="$(mktemp --tmpdir="$(dirname "$CONFIG")" ".$(basename "$CONFIG").candidate.XXXXXXXX")"
cp --archive -- "$BACKUP" "$CANDIDATE"
"$EDITOR_BIN" "$CANDIDATE"
CANDIDATE_HASH="$(hash_file "$CANDIDATE")"
[[ "$CANDIDATE_HASH" != "$BACKUP_HASH" ]] || die 'The candidate did not change'
cp --archive -- "$CANDIDATE" "$APPLIED_COPY"
[[ "$(hash_file "$APPLIED_COPY")" == "$CANDIDATE_HASH" ]] || die 'Applied copy differs from candidate'
printf '%s\n' "$CANDIDATE_HASH" >"$APPLIED_HASH_FILE"
chmod 400 "$APPLIED_COPY" "$APPLIED_HASH_FILE"

restore_original() {
  local reason="$1" restore_tmp restored_defaults restored_runtime
  [[ "$(hash_file "$CONFIG")" == "$CANDIDATE_HASH" ]] || die "$reason; CONFIG diverged, so automatic restore stopped"
  restore_tmp="$(mktemp --tmpdir="$(dirname "$CONFIG")" ".$(basename "$CONFIG").restore.XXXXXXXX")"
  cp --archive -- "$BACKUP" "$restore_tmp"
  [[ "$(hash_file "$restore_tmp")" == "$BACKUP_HASH" ]] || die 'Prepared backup copy failed its hash'
  [[ "$(hash_file "$CONFIG")" == "$CANDIDATE_HASH" ]] || die 'CONFIG diverged immediately before backup replacement'
  mv --force -- "$restore_tmp" "$CONFIG"
  [[ "$(hash_file "$CONFIG")" == "$BACKUP_HASH" ]] || die 'Restored CONFIG does not match backup'
  validate_reader || die 'Restored backup does not parse; unit was not restarted'
  restored_defaults="$(get_defaults)" || die 'Cannot read restored defaults'
  [[ "$(printf '%s' "$restored_defaults" | hash_text)" == "$BASE_DEFAULTS_HASH" ]] || die 'Restored defaults differ from the baseline'
  if ! systemctl restart "$UNIT" || ! systemctl is-active --quiet "$UNIT"; then
    die 'Backup was restored but the unit did not return active; use the provider console'
  fi
  set_runtime "$OLD_RUNTIME" >/dev/null || die 'Backup is active but the previous runtime value could not be restored'
  restored_runtime="$(read_runtime)" || die 'Cannot verify runtime after recovery'
  [[ "$restored_runtime" == "$OLD_RUNTIME" ]] || die 'Runtime mismatch after recovery'
  die "$reason; backup and previous runtime were restored"
}

[[ "$(hash_file "$CONFIG")" == "$BACKUP_HASH" ]] || die 'CONFIG diverged immediately before candidate replacement'
mv --force -- "$CANDIDATE" "$CONFIG"
[[ "$(hash_file "$CONFIG")" == "$CANDIDATE_HASH" ]] || restore_original 'Candidate replacement hash mismatch'

if ! validate_reader; then
  restore_original 'Candidate option validation failed'
fi
CANDIDATE_DEFAULTS="$(get_defaults)" || restore_original 'Candidate defaults could not be read'
CANDIDATE_DEFAULTS_HASH="$(printf '%s' "$CANDIDATE_DEFAULTS" | hash_text)"
[[ "$CANDIDATE_DEFAULTS_HASH" != "$BASE_DEFAULTS_HASH" ]] || restore_original 'Effective defaults did not change'
[[ "$(printf '%s' "$CANDIDATE_DEFAULTS" | last_max)" == "$NEW_LIMIT" ]] || restore_original 'The last effective max_connections value is not NEW_LIMIT'
printf '%s\n' "$CANDIDATE_DEFAULTS_HASH" >"$CANDIDATE_DEFAULTS_HASH_FILE"
chmod 400 "$CANDIDATE_DEFAULTS_HASH_FILE"

[[ "$(hash_file "$CONFIG")" == "$CANDIDATE_HASH" ]] || restore_original 'CONFIG diverged before restart'
[[ "$(systemctl show "$UNIT" --property=ExecStart --value)" == "$EXEC_START" ]] || restore_original 'ExecStart changed before restart'
if ! systemctl restart "$UNIT" || ! systemctl is-active --quiet "$UNIT"; then
  restore_original 'Candidate restart or active check failed'
fi
[[ "$(hash_file "$CONFIG")" == "$CANDIDATE_HASH" ]] || restore_original 'CONFIG diverged after restart'
ACTIVE_LIMIT="$(read_runtime)" || restore_original 'SQL verification failed after restart'
[[ "$ACTIVE_LIMIT" == "$NEW_LIMIT" ]] || restore_original 'SQL value does not match NEW_LIMIT'

printf 'Applied and verified. Rollback state: %s\n' "$STATE_DIR"

Conserva el directorio de estado impreso. Este rollback exige los mismos binario, unidad, selectores y archivo cliente. Se detiene ante divergencia; si falla validación, reinicio, actividad o SQL después de restaurar el backup, repone los bytes aplicados solo cuando el hash aún coincide y vuelve a comprobar el servicio.

set -euo pipefail

[[ $EUID -eq 0 ]] || { echo 'Run from a dedicated root shell in the rollback window' >&2; exit 1; }

CONFIG='/REPLACE/WITH/SAME/EFFECTIVE/mariadb-server.cnf'
UNIT='REPLACE_WITH_SAME_MARIADB_UNIT'
SERVER_BIN='/REPLACE/WITH/SAME/EXECSTART/mariadbd'
CLIENT_BIN='/REPLACE/WITH/SAME/mariadb'
ADMIN_CNF='/REPLACE/WITH/SAME/PROTECTED/admin.cnf'
STATE_DIR='/root/mariadb-max-connections.REPLACE_WITH_EXACT_SUFFIX'
SELECTOR_MODE='REPLACE_WITH_none_OR_exact'
DEFAULTS_ARGS=()
# For exact mode, reproduce the same ExecStart selectors in the same order.

LOCK_DIR='/run/teramont-mariadb-guide'
LOCK_FILE="${LOCK_DIR}/max-connections.lock"

die() { echo "$*" >&2; exit 1; }
hash_file() { sha256sum -- "$1" | awk '{print $1}'; }
hash_text() { sha256sum | awk '{print $1}'; }
get_defaults() { "$SERVER_BIN" "${DEFAULTS_ARGS[@]}" --print-defaults; }
validate_reader() { "$SERVER_BIN" "${DEFAULTS_ARGS[@]}" --help --verbose >/dev/null; }
last_max() {
  tr ' ' '\n' | awk -F= '/^--max[-_]connections=/{value=$2} END{if(value!="") print value}'
}
read_runtime() {
  "$CLIENT_BIN" --defaults-extra-file="$ADMIN_CNF" --protocol=socket \
    --batch --skip-column-names -e 'SELECT @@GLOBAL.max_connections;'
}
set_runtime() {
  local value="$1"
  [[ "$value" =~ ^[1-9][0-9]*$ ]] || return 1
  "$CLIENT_BIN" --defaults-extra-file="$ADMIN_CNF" --protocol=socket \
    --batch --skip-column-names \
    -e "SET GLOBAL max_connections = ${value}; SELECT @@GLOBAL.max_connections;"
}

[[ "$CONFIG" = /* && "$CONFIG" != *REPLACE* && -f "$CONFIG" && ! -L "$CONFIG" ]] || die 'Replace CONFIG exactly'
[[ "$UNIT" != *REPLACE* && -n "$UNIT" ]] || die 'Replace UNIT exactly'
[[ "$SERVER_BIN" = /* && "$SERVER_BIN" != *REPLACE* && -x "$SERVER_BIN" ]] || die 'Replace SERVER_BIN exactly'
[[ "$CLIENT_BIN" = /* && "$CLIENT_BIN" != *REPLACE* && -x "$CLIENT_BIN" ]] || die 'Replace CLIENT_BIN exactly'
[[ "$ADMIN_CNF" = /* && "$ADMIN_CNF" != *REPLACE* && -f "$ADMIN_CNF" && ! -L "$ADMIN_CNF" ]] || die 'Replace ADMIN_CNF exactly'
[[ "$STATE_DIR" = /root/mariadb-max-connections.* && "$STATE_DIR" != *REPLACE* && -d "$STATE_DIR" ]] || die 'Replace STATE_DIR exactly'
ADMIN_MODE="$(stat -c '%a' -- "$ADMIN_CNF")" || die 'Cannot read ADMIN_CNF mode'
[[ "$(stat -c '%u' -- "$ADMIN_CNF")" == '0' ]] || die 'ADMIN_CNF must be owned by root'
(( (8#$ADMIN_MODE & 077) == 0 )) || die 'ADMIN_CNF must not be accessible by group or others'
for TRUSTED_BIN in "$SERVER_BIN" "$CLIENT_BIN"; do
  [[ "$(stat -Lc '%u' -- "$TRUSTED_BIN")" == '0' ]] || die 'A trusted binary is not owned by root'
  BIN_MODE="$(stat -Lc '%a' -- "$TRUSTED_BIN")" || die 'Cannot read trusted binary mode'
  (( (8#$BIN_MODE & 022) == 0 )) || die 'A trusted binary is writable by group or others'
done
command -v flock >/dev/null || die 'flock is required'

install -d -o root -g root -m 700 -- "$LOCK_DIR"
[[ -d "$LOCK_DIR" && ! -L "$LOCK_DIR" ]] || die 'Unsafe lock directory'
[[ "$(stat -c '%u:%g:%a' -- "$LOCK_DIR")" == '0:0:700' ]] || die 'Unsafe lock directory ownership or mode'
umask 077
exec {LOCK_FD}<>"$LOCK_FILE"
chmod 600 -- "$LOCK_FILE"
[[ -f "$LOCK_FILE" && ! -L "$LOCK_FILE" ]] || die 'Unsafe lock file'
[[ "$(stat -c '%u:%g:%a' -- "$LOCK_FILE")" == '0:0:600' ]] || die 'Unsafe lock ownership or mode'
flock -n "$LOCK_FD" || die 'Another cooperative MariaDB change holds the lock'

EXEC_START="$(systemctl show "$UNIT" --property=ExecStart --value)" || die 'Cannot read ExecStart'
[[ -n "$EXEC_START" && "$EXEC_START" != *'$'* ]] || die 'ExecStart is empty or contains unresolved variables; stop'
grep -F -- "path=$SERVER_BIN" <<<"$EXEC_START" >/dev/null || die 'SERVER_BIN does not match ExecStart'
grep -Eq -- '--max[-_]connections(=|[[:space:]])' <<<"$EXEC_START" && die 'ExecStart overrides max_connections; rollback needs manual review'
grep -Eq -- '--defaults-(file|extra-file|group-suffix)[[:space:]]' <<<"$EXEC_START" && die 'A defaults selector lacks = and cannot be reproduced safely'
mapfile -t EXEC_SELECTORS < <(grep -oE -- '--defaults-(file|extra-file|group-suffix)=[^ ;]+' <<<"$EXEC_START" || true)
case "$SELECTOR_MODE" in
  none)
    ((${#DEFAULTS_ARGS[@]} == 0 && ${#EXEC_SELECTORS[@]} == 0)) || die 'ExecStart has defaults selectors; use exact mode'
    ;;
  exact)
    ((${#DEFAULTS_ARGS[@]} > 0 && ${#DEFAULTS_ARGS[@]} == ${#EXEC_SELECTORS[@]})) || die 'Selectors are incomplete'
    for i in "${!DEFAULTS_ARGS[@]}"; do
      [[ "${DEFAULTS_ARGS[$i]}" == "${EXEC_SELECTORS[$i]}" ]] || die 'Selector order does not reproduce ExecStart'
    done
    ;;
  *) die 'Set SELECTOR_MODE to none or exact after inspecting ExecStart' ;;
esac

BACKUP="${STATE_DIR}/$(basename "$CONFIG").before"
BACKUP_HASH_FILE="${STATE_DIR}/backup.hash"
BASE_DEFAULTS_HASH_FILE="${STATE_DIR}/base-defaults.hash"
OLD_RUNTIME_FILE="${STATE_DIR}/old-runtime.value"
APPLIED_COPY="${STATE_DIR}/$(basename "$CONFIG").applied"
APPLIED_HASH_FILE="${STATE_DIR}/applied.hash"
CANDIDATE_DEFAULTS_HASH_FILE="${STATE_DIR}/candidate-defaults.hash"
NEW_LIMIT_FILE="${STATE_DIR}/new-limit.value"
for file in "$BACKUP" "$BACKUP_HASH_FILE" "$BASE_DEFAULTS_HASH_FILE" "$OLD_RUNTIME_FILE" "$APPLIED_COPY" "$APPLIED_HASH_FILE" "$CANDIDATE_DEFAULTS_HASH_FILE" "$NEW_LIMIT_FILE"; do
  [[ -f "$file" && ! -L "$file" ]] || die "Invalid state file: $file"
done

BACKUP_HASH="$(<"$BACKUP_HASH_FILE")"
BASE_DEFAULTS_HASH="$(<"$BASE_DEFAULTS_HASH_FILE")"
OLD_RUNTIME="$(<"$OLD_RUNTIME_FILE")"
APPLIED_HASH="$(<"$APPLIED_HASH_FILE")"
CANDIDATE_DEFAULTS_HASH="$(<"$CANDIDATE_DEFAULTS_HASH_FILE")"
NEW_LIMIT="$(<"$NEW_LIMIT_FILE")"
for value in "$BACKUP_HASH" "$BASE_DEFAULTS_HASH" "$APPLIED_HASH" "$CANDIDATE_DEFAULTS_HASH"; do
  [[ "$value" =~ ^[0-9a-f]{64}$ ]] || die 'A recorded hash is invalid'
done
[[ "$OLD_RUNTIME" =~ ^[1-9][0-9]*$ && "$NEW_LIMIT" =~ ^[1-9][0-9]*$ ]] || die 'A recorded SQL value is invalid'
[[ "$(hash_file "$BACKUP")" == "$BACKUP_HASH" ]] || die 'Backup hash mismatch'
[[ "$(hash_file "$APPLIED_COPY")" == "$APPLIED_HASH" ]] || die 'Applied-copy hash mismatch'
[[ "$(hash_file "$CONFIG")" == "$APPLIED_HASH" ]] || die 'CONFIG changed since apply; automatic rollback stopped'

validate_reader || die 'Current applied options do not parse'
CURRENT_DEFAULTS="$(get_defaults)" || die 'Cannot read current defaults'
[[ "$(printf '%s' "$CURRENT_DEFAULTS" | hash_text)" == "$CANDIDATE_DEFAULTS_HASH" ]] || die 'Current defaults differ from the applied record'
[[ "$(printf '%s' "$CURRENT_DEFAULTS" | last_max)" == "$NEW_LIMIT" ]] || die 'The last current max_connections value differs from the applied record'
PRE_ROLLBACK_RUNTIME="$(read_runtime)" || die 'Cannot read runtime before rollback'
[[ "$PRE_ROLLBACK_RUNTIME" =~ ^[1-9][0-9]*$ ]] || die 'Pre-rollback runtime is invalid'

restore_applied() {
  local reason="$1" applied_tmp applied_defaults active_runtime
  [[ "$(hash_file "$CONFIG")" == "$BACKUP_HASH" ]] || die "$reason; CONFIG diverged, so applied-byte recovery stopped"
  applied_tmp="$(mktemp --tmpdir="$(dirname "$CONFIG")" ".$(basename "$CONFIG").reapply.XXXXXXXX")"
  cp --archive -- "$APPLIED_COPY" "$applied_tmp"
  [[ "$(hash_file "$applied_tmp")" == "$APPLIED_HASH" ]] || die 'Prepared applied copy failed its hash'
  [[ "$(hash_file "$CONFIG")" == "$BACKUP_HASH" ]] || die 'CONFIG diverged immediately before applied replacement'
  mv --force -- "$applied_tmp" "$CONFIG"
  [[ "$(hash_file "$CONFIG")" == "$APPLIED_HASH" ]] || die 'Reapplied CONFIG hash mismatch'
  validate_reader || die 'Reapplied options do not parse'
  applied_defaults="$(get_defaults)" || die 'Cannot read reapplied defaults'
  [[ "$(printf '%s' "$applied_defaults" | hash_text)" == "$CANDIDATE_DEFAULTS_HASH" ]] || die 'Reapplied defaults mismatch'
  if ! systemctl restart "$UNIT" || ! systemctl is-active --quiet "$UNIT"; then
    die 'Applied bytes were restored but the unit did not return active; use the provider console'
  fi
  set_runtime "$PRE_ROLLBACK_RUNTIME" >/dev/null || die 'Applied config is active but its previous runtime could not be restored'
  active_runtime="$(read_runtime)" || die 'Cannot verify runtime after reapplying'
  [[ "$active_runtime" == "$PRE_ROLLBACK_RUNTIME" ]] || die 'Runtime mismatch after reapplying'
  die "$reason; applied bytes and pre-rollback runtime were restored"
}

RESTORE_TMP="$(mktemp --tmpdir="$(dirname "$CONFIG")" ".$(basename "$CONFIG").rollback.XXXXXXXX")"
cp --archive -- "$BACKUP" "$RESTORE_TMP"
[[ "$(hash_file "$RESTORE_TMP")" == "$BACKUP_HASH" ]] || die 'Prepared backup copy failed its hash'
[[ "$(hash_file "$CONFIG")" == "$APPLIED_HASH" ]] || die 'CONFIG diverged immediately before rollback replacement'
mv --force -- "$RESTORE_TMP" "$CONFIG"
[[ "$(hash_file "$CONFIG")" == "$BACKUP_HASH" ]] || restore_applied 'Rollback replacement hash mismatch'

if ! validate_reader; then
  restore_applied 'Restored backup option validation failed'
fi
RESTORED_DEFAULTS="$(get_defaults)" || restore_applied 'Restored defaults could not be read'
[[ "$(printf '%s' "$RESTORED_DEFAULTS" | hash_text)" == "$BASE_DEFAULTS_HASH" ]] || restore_applied 'Restored defaults differ from baseline'
[[ "$(systemctl show "$UNIT" --property=ExecStart --value)" == "$EXEC_START" ]] || restore_applied 'ExecStart changed before rollback restart'
if ! systemctl restart "$UNIT" || ! systemctl is-active --quiet "$UNIT"; then
  restore_applied 'Rollback restart or active check failed'
fi
set_runtime "$OLD_RUNTIME" >/dev/null || restore_applied 'Previous runtime could not be restored'
ACTIVE_LIMIT="$(read_runtime)" || restore_applied 'SQL verification failed after rollback'
[[ "$ACTIVE_LIMIT" == "$OLD_RUNTIME" ]] || restore_applied 'SQL runtime does not match the recorded previous value'

printf 'Rollback applied and verified. Previous runtime: %s\n' "$OLD_RUNTIME"

El lock vive en un directorio root-only de /run, se abre sin truncar y aplicación/rollback reutilizan el mismo archivo; solo coordina operadores que usan ese path. ADMIN_CNF debe pertenecer a root y los binarios absolutos no pueden ser escribibles por grupo u otros. Las comprobaciones de hash protegen frente a cambios externos detectables. Si una comprobación diverge o la unidad no vuelve activa, no sigas sobrescribiendo: usa consola y procedimiento del proveedor. En contenedores o bases gestionadas, usa la fuente persistente y el mecanismo de despliegue propios.

8. Verifica de extremo a extremo

Repite la misma operación autenticada desde la aplicación. Observa Threads_connected, Threads_running, rechazos, RSS, swap, latencia y errores de aplicación y servidor durante un intervalo comparable al incidente. Confirma que el valor permanece tras un reinicio solo cuando ese reinicio ya estaba autorizado; no reinicies únicamente para “probar”. Comprueba también jobs, réplicas y conexiones a través de proxy.

Si el error vuelve, revisa: privilegios parciales de PROCESSLIST; pools multiplicados por workers; timeouts de Sleep; consultas lentas cuando Threads_running es alto; archivo o grupo no efectivo; OOM/swap; límites del contenedor; y un proxy que mantenga su propio pool. El límite correcto es el que admite la concurrencia medida con margen y memoria estable, no el número más alto que el servidor acepta.

Evita que el error se repita

Alerta por porcentaje sostenido de Threads_connected / max_connections, por rechazos y por crecimiento de RSS o swap. Registra el presupuesto total de conexiones por servicio, proceso y réplica; aplica límites por usuario cuando aíslen cargas sin bloquear administración. Prueba backoff y recuperación en staging, conserva el rollback y reevalúa después de cada escalado o cambio del pool.

Si la demanda legítima ya no cabe con memoria estable, dimensiona RAM, CPU y almacenamiento a partir de métricas antes de comparar recursos de VPS. Aumentar capacidad es una decisión válida cuando la causa es carga real; no sustituye corregir una fuga.

Preguntas frecuentes

¿Reiniciar arregla Too many connections?

Puede vaciar conexiones y recuperar servicio brevemente, pero también elimina evidencia y no corrige el origen. Úsalo solo como cambio controlado con una hipótesis y rollback.

¿Cuántas conexiones son demasiadas?

Las que superan el presupuesto de la aplicación o hacen inestable la memoria y latencia. No hay un valor universal: mide concurrencia, buffers, RSS y margen operativo.

¿Sleep significa que hay una fuga?

No. Un pool mantiene sesiones inactivas para reutilizarlas. Investiga si su cantidad o antigüedad crecen sin regresar a la línea base.

¿Subir max_connections consume toda la RAM inmediatamente?

No necesariamente. Hay memoria global y asignaciones que dependen de conexiones y operaciones; el riesgo aparece bajo concurrencia real. Valídalo con carga y métricas, no con una multiplicación fija.

Error “Too many connections” en MySQL/MariaDB: diagnostica antes de subir el límite
GeneralAdministración de servidoresInfraestructuraLinux
¿Te gustó este artículo?Compártelo:

Sobre el autor

Mizael Segovia

Mizael Segovia

CEO & Desarrollador Full Stack y DevOps en Teramont Host

Continúa explorando guías, noticias y análisis relacionados.

CTA Pattern

¿Necesitas ayuda con tu servidor?

Nuestro equipo está listo para ayudarte con cualquier duda o problema que tengas.

Contáctenos