Perché ho scritto questo script

Capita a tutti prima o poi: il disco del server si riempie, l’allarme scatta e la domanda è sempre la stessa. Quale database sta occupando tutto questo spazio?

Io mi ci sono trovato di fronte dopo un’ottimizzazione. Avevo lanciato OPTIMIZE TABLE sperando di recuperare spazio, ma il filesystem non scendeva come previsto. Senza un “prima” da confrontare con il “dopo” era impossibile capire se l’ottimizzazione avesse funzionato, quale database fosse il vero responsabile e se lo spazio mancante fosse addirittura da un’altra parte.

Gli strumenti che avevo a disposizione rispondevano solo a metà del problema:

  • du mostra i file su disco, ma non dice a quale tabella appartengono;
  • information_schema (o pg_class su PostgreSQL) dà le dimensioni logiche, ma non vede binlog, ibdata1, undo log o WAL;
  • entrambi fotografano un solo istante: il confronto nel tempo resta un lavoro manuale, fatto con copia e incolla e un foglio di calcolo.

Mi serviva una cosa semplice: fotografare lo stato di tutti i database in un dato momento, rifare la fotografia più tardi e farmi dire cosa è cambiato, ordinando le differenze dalla più grande alla più piccola. Così il colpevole salta subito all’occhio.

Ne è uscito uno script Bash unico, senza dipendenze oltre al client del database e agli strumenti standard di Linux (awk, du, df). Funziona con MySQL/MariaDB e con PostgreSQL. Lo condivido qui nella speranza che faccia risparmiare a qualcuno lo stesso pomeriggio di frustrazione.

Cosa fa

Lo script ha quattro comandi:

  • snapshot salva lo stato attuale (database, tabelle, file nella data directory, spazio del filesystem);
  • compare confronta due fotografie e mostra le differenze, le più grandi in cima;
  • report mostra i database e le tabelle più grandi di una singola fotografia;
  • list elenca le fotografie salvate.

Il confronto è diviso in quattro sezioni, per non lasciare angoli bui:

  • dimensione di ogni database (dati + indici);
  • spazio libero o recuperabile (data_free su MySQL, stima delle tuple morte su PostgreSQL);
  • dimensione di ogni tabella;
  • file reali nella data directory: cartelle dei database, ibdata1, binlog, undo, pg_wal.

L’ultima sezione è spesso quella che risolve il mistero. Molte volte lo spazio “anomalo” non appartiene a nessun database, ma ai log binari o ai file condivisi di InnoDB.

Requisiti

  • Linux con Bash 4 o superiore, awk, du, df (tutto di serie);
  • il client mysql oppure psql, con un utente che possa leggere i metadati di tutti i database;
  • i permessi di root (o dell’utente mysql/postgres) se vuoi misurare anche i file su disco; senza, quella sezione viene saltata con un avviso.

Installazione

Salva lo script (lo trovi in fondo all’articolo) in un file e rendilo eseguibile:

nano db_space_compare.sh
chmod +x db_space_compare.sh

Credenziali

Per MySQL/MariaDB il modo più pulito è un file ~/.my.cnf con le credenziali, così non compaiono nella cronologia dei comandi:

[client]
user=root
password=LA_TUA_PASSWORD

In alternativa puoi passare le opzioni con una variabile d’ambiente:

export MYSQL_OPTS="-u root -pPASSWORD -h 127.0.0.1"

Per PostgreSQL funzionano ~/.pgpass, le variabili PG* oppure PG_OPTS:

export PG_OPTS="-U postgres -h localhost"

Come si usa

Il flusso tipico è prima, ottimizzazione, dopo, confronto:

# 1. fotografia prima dell'ottimizzazione
sudo ./db_space_compare.sh snapshot prima

# 2. esegui l'ottimizzazione (OPTIMIZE TABLE, VACUUM FULL, ...)

# 3. fotografia dopo
sudo ./db_space_compare.sh snapshot dopo

# 4. confronto
sudo ./db_space_compare.sh compare prima dopo

Per PostgreSQL basta aggiungere l’opzione -e postgres:

sudo ./db_space_compare.sh -e postgres snapshot prima
sudo ./db_space_compare.sh -e postgres snapshot dopo
sudo ./db_space_compare.sh -e postgres compare prima dopo

Le opzioni disponibili sono:

  • -e mysql|postgres sceglie il motore (predefinito: mysql);
  • -d DIR cambia la cartella in cui salvare le fotografie (predefinita: ./db_snapshots);
  • -n N imposta quante righe mostrare per sezione (predefinito: 20).

Se non indichi un’etichetta, la fotografia prende come nome la data e l’ora. Questo è comodo per monitorare la crescita nel tempo.

Esempio di output

Ecco un confronto su dati di prova, costruiti apposta per mostrare un caso tipico: il database shop si riduce dopo l’ottimizzazione, ma il database log cresce e i binlog raddoppiano.

Confronto 'prima' (2026-10-11 20:00:00)  ->  'dopo' (2026-10-11 22:00:00)
Filesystem /var: usato 46.6 GiB -> 48.4 GiB (+1.9 GiB)

=== Database - dimensione totale ===
  NOME                                                      PRIMA         DOPO    DIFFERENZA  NOTE
  log                                                    21.4 GiB     27.5 GiB      +6.1 GiB  +28.3%
  shop                                                    5.6 GiB      3.6 GiB      -2.0 GiB  -35.0%

=== Tabelle - dimensione (dati + indici) ===
  NOME                                                      PRIMA         DOPO    DIFFERENZA  NOTE
  log.eventi                                             21.4 GiB     27.5 GiB      +6.1 GiB  +28.3%
  shop.ordini                                             5.6 GiB      3.6 GiB      -2.0 GiB  -35.0%
  log.nuova                                                   0 B      1.0 KiB      +1.0 KiB  NUOVO

=== File nella data directory (cartelle DB, ibdata1, binlog, undo, WAL) ===
  NOME                                                      PRIMA         DOPO    DIFFERENZA  NOTE
  log                                                    21.4 GiB     27.5 GiB      +6.1 GiB  +28.3%
  mysql-bin.*                                             3.7 GiB      8.4 GiB      +4.7 GiB  +125.0%
  shop                                                    5.7 GiB      3.6 GiB      -2.0 GiB  -36.1%
  ibdata1                                                11.2 GiB     11.2 GiB           0 B

Qui il colpevole è evidente: la tabella log.eventi è cresciuta di oltre 6 GiB, e i file mysql-bin.* sono passati da 3,7 a 8,4 GiB. L’ottimizzazione su shop ha funzionato, ma il guadagno è stato mangiato dal resto.

Come leggere i risultati

  • In cima a ogni sezione ci sono le voci cambiate di più. Un database o una tabella che non cala (o addirittura cresce) dopo l’ottimizzazione è il primo sospetto.
  • Se le tabelle calano ma il filesystem no, lo spazio è altrove: guarda la sezione sui file nella data directory (binlog, ibdata1, undo, pg_wal).
  • MySQL/MariaDB: ibdata1 non si restringe mai, nemmeno con OPTIMIZE TABLE. Con innodb_file_per_table=OFF il valore di data_free è condiviso e poco indicativo.
  • PostgreSQL: un VACUUM semplice non restituisce spazio al sistema operativo, serve VACUUM FULL (o pg_repack). Lo spazio “recuperabile” mostrato è una stima basata sulle tuple morte.

Monitorare la crescita nel tempo

Se il problema non è legato a una singola ottimizzazione ma a una crescita graduale, conviene automatizzare le fotografie con cron, una all’ora, e poi confrontare due istanti qualsiasi:

# crontab -e (come root)
0 * * * * /usr/local/bin/db_space_compare.sh -d /var/lib/db_snapshots snapshot >/dev/null 2>&1

Senza etichetta, ogni fotografia prende come nome la data e l’ora (per esempio 20261011-140000). Con list vedi quelle disponibili e con compare ne confronti due. In pochi giorni emerge quale database cresce in modo anomalo.

./db_space_compare.sh -d /var/lib/db_snapshots list
./db_space_compare.sh -d /var/lib/db_snapshots compare 20261011-140000 20261012-140000

Lo script completo

Ecco il codice. Copialo in un file chiamato db_space_compare.sh, rendilo eseguibile e sei pronto.

#!/usr/bin/env bash
#
# db_space_compare.sh
# Fotografa l'occupazione di spazio di tutti i database (MySQL/MariaDB o
# PostgreSQL) e confronta due fotografie (es. prima/dopo l'ottimizzazione)
# per capire quale database, tabella o file occupa spazio in modo anomalo.
#
# Uso:
#   db_space_compare.sh [opzioni] snapshot [etichetta]   (default: data-ora)
#   db_space_compare.sh [opzioni] compare  <prima> <dopo>
#   db_space_compare.sh [opzioni] report   <etichetta>
#   db_space_compare.sh [opzioni] list
#
# Opzioni:
#   -e mysql|postgres   motore del database (default: mysql)
#   -d DIR              cartella delle fotografie (default: ./db_snapshots)
#   -n N                righe mostrate per sezione (default: 20)
#
# Credenziali:
#   MySQL/MariaDB: ~/.my.cnf oppure MYSQL_OPTS="-u root -pPASSWORD -h 127.0.0.1"
#   PostgreSQL:    ~/.pgpass, variabili PG* oppure PG_OPTS="-U postgres -h localhost"
#                  (non mettere -d in PG_OPTS: lo script si collega a ogni database)
#
# Per misurare anche i file su disco (cartelle dei DB, ibdata1, binlog, undo,
# pg_wal) va eseguito come root o come utente mysql/postgres; altrimenti
# quella parte viene saltata con un avviso.
#
# Esempio:
#   sudo ./db_space_compare.sh snapshot prima
#   ... ottimizzazione (OPTIMIZE TABLE / VACUUM FULL / ...) ...
#   sudo ./db_space_compare.sh snapshot dopo
#   sudo ./db_space_compare.sh compare prima dopo

set -euo pipefail
export LC_ALL=C

ENGINE=mysql
OUTDIR=./db_snapshots
TOPN=20
SNAP_IN_PROGRESS=""

die()  { echo "ERRORE: $*" >&2; exit 1; }
warn() { echo "ATTENZIONE: $*" >&2; }
usage() { sed -n '2,/^$/{s/^# \{0,1\}//;p}' "$0"; exit "${1:-0}"; }

cleanup() {
  if [[ -n $SNAP_IN_PROGRESS ]]; then
    rm -rf -- "$SNAP_IN_PROGRESS"
    echo "Fotografia incompleta rimossa: $SNAP_IN_PROGRESS" >&2
  fi
}
trap cleanup EXIT

while getopts ":e:d:n:h" opt; do
  case $opt in
    e) ENGINE=$OPTARG ;;
    d) OUTDIR=$OPTARG ;;
    n) TOPN=$OPTARG ;;
    h) usage 0 ;;
    *) usage 1 ;;
  esac
done
shift $((OPTIND - 1))

[[ $ENGINE == mysql || $ENGINE == postgres ]] || die "motore non valido: '$ENGINE' (usa mysql o postgres)"
[[ $TOPN =~ ^[0-9]+$ ]] || die "-n deve essere un numero"

read -r -a MYSQL_ARGS <<< "${MYSQL_OPTS:-}"
read -r -a PG_ARGS    <<< "${PG_OPTS:-}"

my() { mysql "${MYSQL_ARGS[@]+"${MYSQL_ARGS[@]}"}" --batch --skip-column-names "$@"; }
pg() { psql  "${PG_ARGS[@]+"${PG_ARGS[@]}"}" -X -q -A -t -F $'\t' -v ON_ERROR_STOP=1 "$@"; }

# Funzioni awk comuni: formattazione in KiB/MiB/GiB, chiave e valore di riga.
#   nk = numero di colonne che formano il nome (1 = database, 2 = db.tabella)
#   vc = colonne da sommare per il valore (es. "4,5" = dati + indici)
AWK_LIB='
function h(b,   a, i, u) {
  split("B KiB MiB GiB TiB PiB", u, " ")
  a = (b < 0) ? -b : b; i = 1
  while (a >= 1024 && i < 6) { a /= 1024; i++ }
  if (i == 1) return sprintf("%s%d B", (b < 0 ? "-" : ""), a)
  return sprintf("%s%.1f %s", (b < 0 ? "-" : ""), a, u[i])
}
function hs(b) { return (b > 0 ? "+" : "") h(b) }
function key(   k, i) { k = $1; for (i = 2; i <= nk; i++) k = k "." $i; return k }
function val(   n, c, i, s) { n = split(vc, c, ","); s = 0; for (i = 1; i <= n; i++) s += $(c[i]); return s }
'

meta_get() { awk -F= -v k="$2" '$1 == k { sub(/^[^=]*=/, ""); print; exit }' "$1/meta.txt"; }

# ---------------------------------------------------------------- raccolta dati

# Occupazione reale su disco delle voci di primo livello della data directory:
# cartelle dei database, ibdata1, binlog, undo, redo log, pg_wal, base, ...
# I file numerati (mysql-bin.000123, relay-log.000045, ...) sono sommati in una voce.
collect_disk() {
  local d=$1 out=$2
  : > "$out"
  if [[ -z $d || ! -r $d || ! -x $d ]]; then
    warn "data directory '${d:-?}' non leggibile: salto i file su disco (esegui come root per includerli)"
    return 0
  fi
  { du -sb -- "$d"/* 2>/dev/null || true; } |
    awk -F'\t' -v p="$d/" '
      { n = $2
        if (index(n, p) == 1) n = substr(n, length(p) + 1)
        if (n ~ /\.[0-9][0-9][0-9][0-9][0-9][0-9]$/) sub(/[0-9]+$/, "*", n)
        s[n] += $1 }
      END { for (n in s) printf "%s\t%.0f\n", n, s[n] }' > "$out"
}

write_meta() {
  local dir=$1 datadir=$2 version=$3
  {
    echo "timestamp=$(date '+%Y-%m-%d %H:%M:%S')"
    echo "engine=$ENGINE"
    echo "host=$(hostname)"
    echo "version=$version"
    echo "datadir=$datadir"
    if [[ -n $datadir && -e $datadir ]]; then
      { df -P -B1 -- "$datadir" 2>/dev/null || true; } |
        awk 'NR == 2 { print "fs_mount=" $6; print "fs_used=" $3; print "fs_avail=" $4 }'
    fi
  } > "$dir/meta.txt"
}

# File prodotti da ogni fotografia (separati da TAB):
#   tables.tsv     db, tabella, engine, dati, indici, libero/recuperabile, righe
#   databases.tsv  db, dati, indici, libero/recuperabile, totale
#   disk.tsv       voce della data directory, byte su disco
#   meta.txt       data, host, versione, filesystem

snapshot_mysql() {
  local dir=$1 pre="" datadir version
  command -v mysql >/dev/null 2>&1 || die "client 'mysql' non trovato"
  my -e "SELECT 1" >/dev/null || die "connessione a MySQL/MariaDB fallita (controlla ~/.my.cnf o MYSQL_OPTS)"

  # MySQL 8 tiene in cache le statistiche di information_schema fino a 24 ore:
  # senza questo, i valori "dopo" l'ottimizzazione potrebbero essere quelli vecchi.
  if [[ -n $(my -e "SHOW VARIABLES LIKE 'information_schema_stats_expiry'") ]]; then
    pre="SET SESSION information_schema_stats_expiry = 0;"
  fi

  my -e "$pre
    SELECT table_schema, table_name, IFNULL(engine, '-'),
           IFNULL(data_length, 0), IFNULL(index_length, 0), IFNULL(data_free, 0), IFNULL(table_rows, 0)
      FROM information_schema.TABLES
     WHERE table_type IN ('BASE TABLE', 'SYSTEM VERSIONED')
       AND table_schema NOT IN ('information_schema', 'performance_schema', 'sys')" > "$dir/tables.tsv"

  my -e "$pre
    SELECT s.schema_name,
           IFNULL(SUM(t.data_length), 0), IFNULL(SUM(t.index_length), 0), IFNULL(SUM(t.data_free), 0),
           IFNULL(SUM(t.data_length), 0) + IFNULL(SUM(t.index_length), 0)
      FROM information_schema.SCHEMATA s
      LEFT JOIN information_schema.TABLES t
             ON t.table_schema = s.schema_name
            AND t.table_type IN ('BASE TABLE', 'SYSTEM VERSIONED')
     WHERE s.schema_name NOT IN ('information_schema', 'performance_schema', 'sys')
     GROUP BY s.schema_name" > "$dir/databases.tsv"

  datadir=$(my -e "SELECT @@datadir"); datadir=${datadir%/}
  version=$(my -e "SELECT VERSION()")
  collect_disk "$datadir" "$dir/disk.tsv"
  write_meta "$dir" "$datadir" "$version"
}

snapshot_postgres() {
  local dir=$1 maindb=${PGDATABASE:-postgres} datadir version db
  command -v psql >/dev/null 2>&1 || die "client 'psql' non trovato"
  pg -d "$maindb" -c "SELECT 1" >/dev/null || die "connessione a PostgreSQL fallita (controlla PG_OPTS, PGUSER, ~/.pgpass)"

  pg -d "$maindb" -c "SELECT datname FROM pg_database WHERE datallowconn AND NOT datistemplate ORDER BY 1" > "$dir/.dblist"

  # Per ogni database: dimensione di ogni tabella (catalogo incluso, es. pg_largeobject)
  # e stima dello spazio occupato da tuple morte (bloat recuperabile con VACUUM FULL).
  : > "$dir/tables.tsv"
  while IFS= read -r db; do
    [[ -n $db ]] || continue
    pg -d "$db" -c "
      SELECT current_database(), s.schemaname || '.' || s.relname, 'table',
             pg_table_size(s.relid), pg_indexes_size(s.relid),
             CASE WHEN s.n_live_tup + s.n_dead_tup > 0
                  THEN (pg_table_size(s.relid)::numeric * s.n_dead_tup / (s.n_live_tup + s.n_dead_tup))::bigint
                  ELSE 0 END,
             s.n_live_tup
        FROM pg_stat_all_tables s
       WHERE s.schemaname <> 'information_schema'
         AND s.schemaname NOT LIKE 'pg_toast%'" < /dev/null >> "$dir/tables.tsv" \
      || warn "impossibile leggere le tabelle del database '$db'"
  done < "$dir/.dblist"

  pg -d "$maindb" -c "SELECT datname, pg_database_size(oid) FROM pg_database WHERE datallowconn AND NOT datistemplate" > "$dir/.dbsize"
  awk -F'\t' '
    FILENAME == ARGV[1] { d[$1] += $4; i[$1] += $5; f[$1] += $6; next }
    { printf "%s\t%.0f\t%.0f\t%.0f\t%s\n", $1, d[$1], i[$1], f[$1], $2 }' \
    "$dir/tables.tsv" "$dir/.dbsize" > "$dir/databases.tsv"

  datadir=$(pg -d "$maindb" -c "SHOW data_directory" 2>/dev/null || true)
  version=$(pg -d "$maindb" -c "SHOW server_version")
  collect_disk "$datadir" "$dir/disk.tsv"

  # Dettaglio di base/<oid> con il nome del database al posto dell'oid
  if [[ -n $datadir && -r $datadir/base && -x $datadir/base ]]; then
    pg -d "$maindb" -c "SELECT oid, datname FROM pg_database" > "$dir/.oidmap"
    { du -sb -- "$datadir"/base/* 2>/dev/null || true; } |
      awk -F'\t' '
        FILENAME == ARGV[1] { name[$1] = $2; next }
        { o = $2; sub(/.*\//, "", o); printf "base/%s\t%s\n", ((o in name) ? name[o] : o), $1 }' \
        "$dir/.oidmap" - >> "$dir/disk.tsv"
  fi
  write_meta "$dir" "$datadir" "$version"
}

# --------------------------------------------------------------- presentazione

print_fs() {
  local d=$1 used avail mount
  used=$(meta_get "$d" fs_used); avail=$(meta_get "$d" fs_avail); mount=$(meta_get "$d" fs_mount)
  [[ -n $used ]] || return 0
  awk -v u="$used" -v a="$avail" -v m="$mount" "$AWK_LIB"'
    BEGIN { printf "Filesystem %s: usato %s, libero %s\n", m, h(u), h(a) }'
}

top_section() {
  local f=$1 nk=$2 vc=$3 title=$4
  echo
  echo "=== $title ==="
  if [[ ! -s $f ]]; then echo "  (nessun dato)"; return 0; fi
  awk -F'\t' -v nk="$nk" -v vc="$vc" "$AWK_LIB"'
    { v = val(); printf "%.0f\t%s\t%s\n", v, key(), h(v) }' "$f" |
    sort -t $'\t' -k1,1nr -k2,2 |
    awk -F'\t' -v top="$TOPN" 'NR <= top && $1 > 0 { printf "  %-60s %12s\n", $2, $3 }'
}

compare_section() {
  local fa=$1 fb=$2 nk=$3 vc=$4 title=$5
  [[ -f $fa ]] || fa=/dev/null
  [[ -f $fb ]] || fb=/dev/null
  echo
  echo "=== $title ==="
  if [[ ! -s $fa && ! -s $fb ]]; then echo "  (nessun dato)"; return 0; fi
  printf '  %-50s %12s %12s %13s  %s\n' "NOME" "PRIMA" "DOPO" "DIFFERENZA" "NOTE"
  awk -F'\t' -v nk="$nk" -v vc="$vc" "$AWK_LIB"'
    FILENAME == ARGV[1] { k = key(); A[k] = val(); S[k] = 1; next }
                        { k = key(); B[k] = val(); S[k] = 1 }
    END {
      for (k in S) {
        inA = (k in A); inB = (k in B)
        a = inA ? A[k] : 0; b = inB ? B[k] : 0; d = b - a
        if (!inA)                 note = "NUOVO"
        else if (!inB)            note = "RIMOSSO"
        else if (a > 0 && d != 0) note = sprintf("%+.1f%%", 100 * d / a)
        else                      note = ""
        printf "%.0f\t%s\t%s\t%s\t%s\t%s\n", (d < 0 ? -d : d), k, h(a), h(b), hs(d), note
      }
    }' "$fa" "$fb" |
    sort -t $'\t' -k1,1nr -k2,2 |
    awk -F'\t' -v top="$TOPN" 'NR <= top { printf "  %-50s %12s %12s %13s  %s\n", $2, $3, $4, $5, $6 }'
}

# -------------------------------------------------------------------- comandi

cmd_snapshot() {
  local label=${1:-$(date '+%Y%m%d-%H%M%S')}
  [[ $label =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || die "etichetta non valida: usa solo lettere, numeri, . _ -"
  local dir=$OUTDIR/$label
  [[ ! -e $dir ]] || die "la fotografia '$label' esiste già ($dir)"
  mkdir -p -- "$dir"
  SNAP_IN_PROGRESS=$dir
  echo "Fotografia '$label' ($ENGINE) in corso..."
  "snapshot_$ENGINE" "$dir"
  rm -f -- "$dir/.dblist" "$dir/.dbsize" "$dir/.oidmap"
  SNAP_IN_PROGRESS=""
  echo "Salvata in $dir ($(wc -l < "$dir/tables.tsv") tabelle)"
  print_fs "$dir"
  top_section "$dir/databases.tsv" 1 5 "Database più grandi"
}

cmd_report() {
  local label=${1:-}
  [[ -n $label ]] || die "uso: report <etichetta>"
  local d=$OUTDIR/$label
  [[ -f $d/meta.txt ]] || die "fotografia '$label' non trovata in $OUTDIR"
  echo "Fotografia '$label' del $(meta_get "$d" timestamp) - $(meta_get "$d" engine) $(meta_get "$d" version) su $(meta_get "$d" host)"
  print_fs "$d"
  top_section "$d/databases.tsv" 1 5   "Database più grandi (dati + indici)"
  top_section "$d/tables.tsv"    2 4,5 "Tabelle più grandi (dati + indici)"
  top_section "$d/tables.tsv"    2 6   "Tabelle con più spazio recuperabile (data_free / tuple morte stimate)"
  top_section "$d/disk.tsv"      1 2   "Voci più grandi nella data directory"
}

cmd_compare() {
  local a=${1:-} b=${2:-}
  [[ -n $a && -n $b ]] || die "uso: compare <etichetta_prima> <etichetta_dopo>"
  [[ $a != "$b" ]] || die "le due etichette sono uguali"
  local da=$OUTDIR/$a db=$OUTDIR/$b
  [[ -f $da/meta.txt ]] || die "fotografia '$a' non trovata in $OUTDIR"
  [[ -f $db/meta.txt ]] || die "fotografia '$b' non trovata in $OUTDIR"
  [[ $(meta_get "$da" engine) == "$(meta_get "$db" engine)" ]] || warn "le due fotografie sono di motori diversi"

  echo "Confronto '$a' ($(meta_get "$da" timestamp))  ->  '$b' ($(meta_get "$db" timestamp))"
  local ua ub m
  ua=$(meta_get "$da" fs_used); ub=$(meta_get "$db" fs_used); m=$(meta_get "$db" fs_mount)
  if [[ -n $ua && -n $ub ]]; then
    awk -v a="$ua" -v b="$ub" -v m="$m" "$AWK_LIB"'
      BEGIN { printf "Filesystem %s: usato %s -> %s (%s)\n", m, h(a), h(b), hs(b - a) }'
  fi

  compare_section "$da/databases.tsv" "$db/databases.tsv" 1 5   "Database - dimensione totale"
  compare_section "$da/databases.tsv" "$db/databases.tsv" 1 4   "Database - spazio libero/recuperabile"
  compare_section "$da/tables.tsv"    "$db/tables.tsv"    2 4,5 "Tabelle - dimensione (dati + indici)"
  compare_section "$da/disk.tsv"      "$db/disk.tsv"      1 2   "File nella data directory (cartelle DB, ibdata1, binlog, undo, WAL)"

  cat <<'EOF'

Come leggere il risultato:
  - In cima a ogni sezione ci sono le voci cambiate di più: un database o una
    tabella che dopo l'ottimizzazione non cala, o addirittura cresce, è il
    primo sospetto.
  - Se le tabelle calano ma il filesystem no, lo spazio sta altrove: guarda la
    sezione "File nella data directory" (binlog, ibdata1, undo, pg_wal).
  - MySQL/MariaDB: ibdata1 non si restringe mai, nemmeno con OPTIMIZE; con
    innodb_file_per_table=OFF il data_free è condiviso e poco indicativo.
  - PostgreSQL: VACUUM semplice non restituisce spazio al disco, serve
    VACUUM FULL (o pg_repack); lo spazio "recuperabile" è una stima.
EOF
}

cmd_list() {
  if [[ ! -d $OUTDIR ]]; then echo "Nessuna fotografia in $OUTDIR"; return 0; fi
  local d found=0
  for d in "$OUTDIR"/*/; do
    [[ -f $d/meta.txt ]] || continue
    found=1
    printf '%-24s %s  %-8s  %s\n' "$(basename "$d")" "$(meta_get "$d" timestamp)" \
      "$(meta_get "$d" engine)" "$(meta_get "$d" host)"
  done
  [[ $found -eq 1 ]] || echo "Nessuna fotografia in $OUTDIR"
}

cmd=${1:-}
shift || true
case $cmd in
  snapshot) cmd_snapshot "$@" ;;
  compare)  cmd_compare "$@" ;;
  report)   cmd_report "$@" ;;
  list)     cmd_list ;;
  ""|help)  usage 0 ;;
  *)        die "comando sconosciuto: '$cmd' (usa snapshot, compare, report, list)" ;;
esac

Note finali

  • Lo script è in sola lettura sui database: esegue solo SELECT sui metadati. Scrive esclusivamente nella cartella delle fotografie.
  • Su MySQL 8 imposta information_schema_stats_expiry = 0 nella sessione, perché altrimenti le statistiche in cache (fino a 24 ore) potrebbero mostrare i valori “vecchi” anche dopo l’ottimizzazione.
  • Se una fotografia fallisce a metà, viene rimossa automaticamente per non lasciare dati incompleti.
  • Prima di usarlo in produzione, provalo su un ambiente di test e verifica i permessi dell’utente del database.

Se lo usi e trovi un caso che non copre, o hai idee per estenderlo (altri motori, esportazione in CSV, avvisi via email), scrivimi nei commenti.

Commenti

Lascia un commento

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *