<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>PostgreSQL - Matteo Cisilino</title>
	<atom:link href="https://www.cisilino.com/tag/postgresql/feed/" rel="self" type="application/rss+xml" />
	<link>https://www.cisilino.com</link>
	<description>Guide su WordPress, server e sicurezza</description>
	<lastBuildDate>Thu, 29 Oct 2026 18:58:17 +0000</lastBuildDate>
	<language>it-IT</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.4</generator>
	<item>
		<title>Quale database occupa spazio sul disco? Lo script per MySQL e PostgreSQL</title>
		<link>https://www.cisilino.com/2026/10/20/script-spazio-disco-database-mysql/</link>
					<comments>https://www.cisilino.com/2026/10/20/script-spazio-disco-database-mysql/#respond</comments>
		
		<dc:creator><![CDATA[matteocisilino]]></dc:creator>
		<pubDate>Tue, 20 Oct 2026 07:53:00 +0000</pubDate>
				<category><![CDATA[Server e database]]></category>
		<category><![CDATA[MySQL]]></category>
		<category><![CDATA[PostgreSQL]]></category>
		<category><![CDATA[script bash]]></category>
		<category><![CDATA[spazio disco]]></category>
		<guid isPermaLink="false">https://www.cisilino.com/?p=51</guid>

					<description><![CDATA[<p>Il disco del server si riempie e non sai quale database sia il responsabile? Uno script per misurare lo spazio di MySQL e PostgreSQL e confrontarlo nel tempo.</p>
<p>The post <a href="https://www.cisilino.com/2026/10/20/script-spazio-disco-database-mysql/">Quale database occupa spazio sul disco? Lo script per MySQL e PostgreSQL</a> first appeared on <a href="https://www.cisilino.com">Matteo Cisilino</a>.</p>]]></description>
										<content:encoded><![CDATA[<p class="wp-block-paragraph">Capita a tutti prima o poi: lo spazio su disco del server finisce, l’allarme scatta. La domanda è sempre la stessa. <strong>Quale database sta occupando tutto questo spazio?</strong></p>



<figure class="wp-block-image size-large"><img fetchpriority="high" decoding="async" src="https://www.cisilino.com/wp-content/uploads/2026/10/spazio-disco-database-mysql-postgresql.webp" alt="Grafico ad anello in quattro settori con tre barre di legenda: lo spazio su disco" width="1200" height="630"/></figure>



<p class="wp-block-paragraph">Io mi ci sono trovato di fronte dopo un’ottimizzazione. Avevo lanciato <code>OPTIMIZE TABLE</code> 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 e quale database fosse il vero responsabile. O se lo spazio mancante fosse addirittura da un’altra parte.</p>



<p class="wp-block-paragraph">Gli strumenti che avevo a disposizione rispondevano solo a metà del problema:</p>



<ul class="wp-block-list">
<li><code>du</code> mostra i file su disco, ma non dice a quale tabella appartengono.</li>



<li><code>information_schema</code> (o <code>pg_class</code> su PostgreSQL) dà le dimensioni logiche, ma non vede binlog, <code>ibdata1</code>, undo log o WAL.</li>



<li>entrambi fotografano un solo istante: il confronto nel tempo resta un lavoro manuale, fatto con copia e incolla e un foglio di calcolo.</li>
</ul>



<p class="wp-block-paragraph">Mi serviva una cosa semplice: <strong>fotografare lo stato di tutti i database in un dato momento, rifare la fotografia più tardi e farmi dire cosa è cambiato</strong>, ordinando le differenze dalla più grande alla più piccola. Così il colpevole salta subito all’occhio.</p>



<p class="wp-block-paragraph">Ne è uscito uno script Bash unico. Non ha dipendenze oltre al client del database e agli strumenti standard di Linux (<code>awk</code>, <code>du</code>, <code>df</code>). Funziona con <strong>MySQL/MariaDB</strong> e con <strong>PostgreSQL</strong>. Lo condivido qui nella speranza che faccia risparmiare a qualcuno lo stesso pomeriggio di frustrazione.</p>



<h2 class="wp-block-heading">Cosa fa</h2>



<p class="wp-block-paragraph">Lo script ha quattro comandi:</p>



<ul class="wp-block-list">
<li><code>snapshot</code> salva lo stato attuale (database, tabelle, file nella data directory, spazio del filesystem).</li>



<li><code>compare</code> confronta due fotografie e mostra le differenze, le più grandi in cima.</li>



<li><code>report</code> mostra i database e le tabelle più grandi di una singola fotografia.</li>



<li><code>list</code> elenca le fotografie salvate.</li>
</ul>



<p class="wp-block-paragraph">Il confronto è diviso in quattro sezioni, per non lasciare angoli bui:</p>



<ul class="wp-block-list">
<li><strong>dimensione di ogni database</strong> (dati + indici).</li>



<li><strong>spazio libero o recuperabile</strong> (<code>data_free</code> su MySQL, stima delle tuple morte su PostgreSQL).</li>



<li><strong>dimensione di ogni tabella</strong>.</li>



<li><strong>file reali nella data directory</strong>: cartelle dei database, <code>ibdata1</code>, binlog, undo, <code>pg_wal</code>.</li>
</ul>



<p class="wp-block-paragraph">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.</p>



<h2 class="wp-block-heading">Requisiti</h2>



<ul class="wp-block-list">
<li>Linux con Bash 4 o superiore, <code>awk</code>, <code>du</code>, <code>df</code> (tutto di serie).</li>



<li>il client <code>mysql</code> oppure <code>psql</code>, con un utente che possa leggere i metadati di tutti i database.</li>



<li>i permessi di root (o dell’utente <code>mysql</code>/<code>postgres</code>) se vuoi misurare anche i file su disco; senza, quella sezione viene saltata con un avviso.</li>
</ul>



<h2 class="wp-block-heading">Installazione</h2>



<p class="wp-block-paragraph">Salva lo script (lo trovi in fondo all’articolo) in un file e rendilo eseguibile.</p>



<pre class="wp-block-code"><code>nano db_space_compare.sh
chmod +x db_space_compare.sh</code></pre>



<h3 class="wp-block-heading">Credenziali</h3>



<p class="wp-block-paragraph">Per MySQL/MariaDB il modo più pulito è un file <code>~/.my.cnf</code> con le credenziali, così non compaiono nella cronologia dei comandi.</p>



<pre class="wp-block-code"><code>&#91;client]
user=root
password=LA_TUA_PASSWORD</code></pre>



<p class="wp-block-paragraph">In alternativa puoi passare le opzioni con una variabile d’ambiente.</p>



<pre class="wp-block-code"><code>export MYSQL_OPTS="-u root -pPASSWORD -h 127.0.0.1"</code></pre>



<p class="wp-block-paragraph">Per PostgreSQL funzionano <code>~/.pgpass</code>, le variabili <code>PG*</code> oppure <code>PG_OPTS</code>.</p>



<pre class="wp-block-code"><code>export PG_OPTS="-U postgres -h localhost"</code></pre>



<h2 class="wp-block-heading">Come si usa</h2>



<p class="wp-block-paragraph">Il flusso tipico è <em>prima, ottimizzazione, dopo, confronto</em>.</p>



<pre class="wp-block-code"><code># 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</code></pre>



<p class="wp-block-paragraph">Per PostgreSQL basta aggiungere l’opzione <code>-e postgres</code>.</p>



<pre class="wp-block-code"><code>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</code></pre>



<p class="wp-block-paragraph">Le opzioni disponibili sono:</p>



<ul class="wp-block-list">
<li><code>-e mysql|postgres</code> sceglie il motore (predefinito: <code>mysql</code>).</li>



<li><code>-d DIR</code> cambia la cartella in cui salvare le fotografie (predefinita: <code>./db_snapshots</code>).</li>



<li><code>-n N</code> imposta quante righe mostrare per sezione (predefinito: 20).</li>
</ul>



<p class="wp-block-paragraph">Se non indichi un’etichetta, la fotografia prende come nome la data e l’ora. Questo è comodo per monitorare la crescita nel tempo.</p>



<h2 class="wp-block-heading">Esempio di output</h2>



<p class="wp-block-paragraph">Ecco un confronto su dati di prova, costruiti apposta per mostrare un caso tipico. Il database <code>shop</code> si riduce dopo l’ottimizzazione, ma il database <code>log</code> cresce e i binlog raddoppiano.</p>



<pre class="wp-block-code"><code>Confronto 'prima' (2026-10-11 20:00:00)  -&gt;  'dopo' (2026-10-11 22:00:00)
Filesystem /var: usato 46.6 GiB -&gt; 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</code></pre>



<p class="wp-block-paragraph">Qui il colpevole è evidente: la tabella <code>log.eventi</code> è cresciuta di oltre 6 GiB. I file <code>mysql-bin.*</code> sono passati da 3,7 a 8,4 GiB. L’ottimizzazione su <code>shop</code> ha funzionato, ma il guadagno è stato mangiato dal resto.</p>



<h2 class="wp-block-heading">Come leggere i risultati e ritrovare lo spazio su disco</h2>



<ul class="wp-block-list">
<li>In cima a ogni sezione ci sono le voci cambiate di più. Un database o una tabella che <strong>non cala</strong> (o addirittura cresce) dopo l’ottimizzazione è il primo sospetto.</li>



<li>Se le tabelle calano ma il filesystem no, lo spazio su disco è altrove. Guarda la sezione sui file nella data directory (binlog, <code>ibdata1</code>, undo, <code>pg_wal</code>).</li>



<li><strong>MySQL/MariaDB:</strong> <code>ibdata1</code> non si restringe mai, nemmeno con <code>OPTIMIZE TABLE</code>. Con <code>innodb_file_per_table=OFF</code> il valore di <code>data_free</code> è condiviso e poco indicativo.</li>



<li><strong>PostgreSQL:</strong> un <code>VACUUM</code> semplice non restituisce spazio al sistema operativo, serve <code>VACUUM FULL</code> (o <code>pg_repack</code>). Per capire come PostgreSQL misura le dimensioni, la <a href="https://www.postgresql.org/docs/current/diskusage.html" target="_blank" rel="noopener">documentazione ufficiale sull’uso del disco</a> spiega come controllare lo spazio di tabelle e indici. Lo spazio “recuperabile” mostrato è una stima basata sulle tuple morte.</li>
</ul>



<h2 class="wp-block-heading">Monitorare la crescita nel tempo</h2>



<p class="wp-block-paragraph">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.</p>



<pre class="wp-block-code"><code># crontab -e (come root)
0 * * * * /usr/local/bin/db_space_compare.sh -d /var/lib/db_snapshots snapshot &gt;/dev/null 2&gt;&amp;1</code></pre>



<p class="wp-block-paragraph">Senza etichetta, ogni fotografia prende come nome la data e l’ora (per esempio <code>20261011-140000</code>). Con <code>list</code> vedi quelle disponibili e con <code>compare</code> ne confronti due. In pochi giorni emerge quale database cresce in modo anomalo.</p>



<pre class="wp-block-code"><code>./db_space_compare.sh -d /var/lib/db_snapshots list
./db_space_compare.sh -d /var/lib/db_snapshots compare 20261011-140000 20261012-140000</code></pre>



<h2 class="wp-block-heading">Lo script completo</h2>



<p class="wp-block-paragraph">Ecco il codice. Copialo in un file chiamato <code>db_space_compare.sh</code>, rendilo eseguibile e sei pronto.</p>



<pre class="wp-block-code"><code>#!/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 &#91;opzioni] snapshot &#91;etichetta]   (default: data-ora)
#   db_space_compare.sh &#91;opzioni] compare  &lt;prima&gt; &lt;dopo&gt;
#   db_space_compare.sh &#91;opzioni] report   &lt;etichetta&gt;
#   db_space_compare.sh &#91;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: $*" &gt;&amp;2; exit 1; }
warn() { echo "ATTENZIONE: $*" &gt;&amp;2; }
usage() { sed -n '2,/^$/{s/^# \{0,1\}//;p}' "$0"; exit "${1:-0}"; }

cleanup() {
  if &#91;&#91; -n $SNAP_IN_PROGRESS ]]; then
    rm -rf -- "$SNAP_IN_PROGRESS"
    echo "Fotografia incompleta rimossa: $SNAP_IN_PROGRESS" &gt;&amp;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))

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

read -r -a MYSQL_ARGS &lt;&lt;&lt; "${MYSQL_OPTS:-}"
read -r -a PG_ARGS    &lt;&lt;&lt; "${PG_OPTS:-}"

my() { mysql "${MYSQL_ARGS&#91;@]+"${MYSQL_ARGS&#91;@]}"}" --batch --skip-column-names "$@"; }
pg() { psql  "${PG_ARGS&#91;@]+"${PG_ARGS&#91;@]}"}" -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 &lt; 0) ? -b : b; i = 1
  while (a &gt;= 1024 &amp;&amp; i &lt; 6) { a /= 1024; i++ }
  if (i == 1) return sprintf("%s%d B", (b &lt; 0 ? "-" : ""), a)
  return sprintf("%s%.1f %s", (b &lt; 0 ? "-" : ""), a, u&#91;i])
}
function hs(b) { return (b &gt; 0 ? "+" : "") h(b) }
function key(   k, i) { k = $1; for (i = 2; i &lt;= nk; i++) k = k "." $i; return k }
function val(   n, c, i, s) { n = split(vc, c, ","); s = 0; for (i = 1; i &lt;= n; i++) s += $(c&#91;i]); return s }
'

meta_get() { awk -F= -v k="$2" '$1 == k { sub(/^&#91;^=]*=/, ""); 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
  : &gt; "$out"
  if &#91;&#91; -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&gt;/dev/null || true; } |
    awk -F'\t' -v p="$d/" '
      { n = $2
        if (index(n, p) == 1) n = substr(n, length(p) + 1)
        if (n ~ /\.&#91;0-9]&#91;0-9]&#91;0-9]&#91;0-9]&#91;0-9]&#91;0-9]$/) sub(/&#91;0-9]+$/, "*", n)
        s&#91;n] += $1 }
      END { for (n in s) printf "%s\t%.0f\n", n, s&#91;n] }' &gt; "$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 &#91;&#91; -n $datadir &amp;&amp; -e $datadir ]]; then
      { df -P -B1 -- "$datadir" 2&gt;/dev/null || true; } |
        awk 'NR == 2 { print "fs_mount=" $6; print "fs_used=" $3; print "fs_avail=" $4 }'
    fi
  } &gt; "$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 &gt;/dev/null 2&gt;&amp;1 || die "client 'mysql' non trovato"
  my -e "SELECT 1" &gt;/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 &#91;&#91; -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')" &gt; "$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" &gt; "$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 &gt;/dev/null 2&gt;&amp;1 || die "client 'psql' non trovato"
  pg -d "$maindb" -c "SELECT 1" &gt;/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" &gt; "$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).
  : &gt; "$dir/tables.tsv"
  while IFS= read -r db; do
    &#91;&#91; -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 &gt; 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 &lt;&gt; 'information_schema'
         AND s.schemaname NOT LIKE 'pg_toast%'" &lt; /dev/null &gt;&gt; "$dir/tables.tsv" \
      || warn "impossibile leggere le tabelle del database '$db'"
  done &lt; "$dir/.dblist"

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

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

  # Dettaglio di base/&lt;oid&gt; con il nome del database al posto dell'oid
  if &#91;&#91; -n $datadir &amp;&amp; -r $datadir/base &amp;&amp; -x $datadir/base ]]; then
    pg -d "$maindb" -c "SELECT oid, datname FROM pg_database" &gt; "$dir/.oidmap"
    { du -sb -- "$datadir"/base/* 2&gt;/dev/null || true; } |
      awk -F'\t' '
        FILENAME == ARGV&#91;1] { name&#91;$1] = $2; next }
        { o = $2; sub(/.*\//, "", o); printf "base/%s\t%s\n", ((o in name) ? name&#91;o] : o), $1 }' \
        "$dir/.oidmap" - &gt;&gt; "$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)
  &#91;&#91; -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 &#91;&#91; ! -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 &lt;= top &amp;&amp; $1 &gt; 0 { printf "  %-60s %12s\n", $2, $3 }'
}

compare_section() {
  local fa=$1 fb=$2 nk=$3 vc=$4 title=$5
  &#91;&#91; -f $fa ]] || fa=/dev/null
  &#91;&#91; -f $fb ]] || fb=/dev/null
  echo
  echo "=== $title ==="
  if &#91;&#91; ! -s $fa &amp;&amp; ! -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&#91;1] { k = key(); A&#91;k] = val(); S&#91;k] = 1; next }
                        { k = key(); B&#91;k] = val(); S&#91;k] = 1 }
    END {
      for (k in S) {
        inA = (k in A); inB = (k in B)
        a = inA ? A&#91;k] : 0; b = inB ? B&#91;k] : 0; d = b - a
        if (!inA)                 note = "NUOVO"
        else if (!inB)            note = "RIMOSSO"
        else if (a &gt; 0 &amp;&amp; d != 0) note = sprintf("%+.1f%%", 100 * d / a)
        else                      note = ""
        printf "%.0f\t%s\t%s\t%s\t%s\t%s\n", (d &lt; 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 &lt;= 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')}
  &#91;&#91; $label =~ ^&#91;A-Za-z0-9]&#91;A-Za-z0-9._-]*$ ]] || die "etichetta non valida: usa solo lettere, numeri, . _ -"
  local dir=$OUTDIR/$label
  &#91;&#91; ! -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 &lt; "$dir/tables.tsv") tabelle)"
  print_fs "$dir"
  top_section "$dir/databases.tsv" 1 5 "Database più grandi"
}

cmd_report() {
  local label=${1:-}
  &#91;&#91; -n $label ]] || die "uso: report &lt;etichetta&gt;"
  local d=$OUTDIR/$label
  &#91;&#91; -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:-}
  &#91;&#91; -n $a &amp;&amp; -n $b ]] || die "uso: compare &lt;etichetta_prima&gt; &lt;etichetta_dopo&gt;"
  &#91;&#91; $a != "$b" ]] || die "le due etichette sono uguali"
  local da=$OUTDIR/$a db=$OUTDIR/$b
  &#91;&#91; -f $da/meta.txt ]] || die "fotografia '$a' non trovata in $OUTDIR"
  &#91;&#91; -f $db/meta.txt ]] || die "fotografia '$b' non trovata in $OUTDIR"
  &#91;&#91; $(meta_get "$da" engine) == "$(meta_get "$db" engine)" ]] || warn "le due fotografie sono di motori diversi"

  echo "Confronto '$a' ($(meta_get "$da" timestamp))  -&gt;  '$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 &#91;&#91; -n $ua &amp;&amp; -n $ub ]]; then
    awk -v a="$ua" -v b="$ub" -v m="$m" "$AWK_LIB"'
      BEGIN { printf "Filesystem %s: usato %s -&gt; %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 &lt;&lt;'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 &#91;&#91; ! -d $OUTDIR ]]; then echo "Nessuna fotografia in $OUTDIR"; return 0; fi
  local d found=0
  for d in "$OUTDIR"/*/; do
    &#91;&#91; -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
  &#91;&#91; $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</code></pre>



<h2 class="wp-block-heading">Note finali</h2>



<ul class="wp-block-list">
<li>Lo script è in <strong>sola lettura</strong> sui database: esegue solo <code>SELECT</code> sui metadati. Scrive esclusivamente nella cartella delle fotografie.</li>



<li>Su MySQL 8 imposta <code>information_schema_stats_expiry = 0</code> nella sessione. Altrimenti le statistiche in cache (fino a 24 ore) potrebbero mostrare i valori “vecchi” anche dopo l’ottimizzazione.</li>



<li>Se una fotografia fallisce a metà, viene rimossa automaticamente per non lasciare dati incompleti.</li>



<li>Se invece il problema è un database che non parte dopo un aggiornamento, leggi <a href="https://www.cisilino.com/2026/10/13/mysql-non-si-avvia-debian-12/">MySQL non si avvia dopo l’aggiornamento a Debian 12</a>.</li>
<li>Prima di usarlo in produzione, provalo su un ambiente di test e verifica i permessi dell’utente del database.</li>
</ul>



<p class="wp-block-paragraph">Se lo usi per tenere d’occhio lo spazio su disco e trovi un caso che non copre, o hai idee per estenderlo (altri motori, esportazione in CSV, avvisi via email), scrivimi nei commenti.</p><p>The post <a href="https://www.cisilino.com/2026/10/20/script-spazio-disco-database-mysql/">Quale database occupa spazio sul disco? Lo script per MySQL e PostgreSQL</a> first appeared on <a href="https://www.cisilino.com">Matteo Cisilino</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://www.cisilino.com/2026/10/20/script-spazio-disco-database-mysql/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>
