<?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>Bash-Skript - Matteo Cisilino</title>
	<atom:link href="https://www.cisilino.com/de/tag/bash-skript/feed/" rel="self" type="application/rss+xml" />
	<link>https://www.cisilino.com/de/</link>
	<description>Guide su WordPress, server e sicurezza</description>
	<lastBuildDate>Sat, 31 Oct 2026 10:03:58 +0000</lastBuildDate>
	<language>de</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.4</generator>
	<item>
		<title>Datenbank-Zugangsdaten-Rotation nach einem ISPConfig-Sicherheitsvorfall</title>
		<link>https://www.cisilino.com/de/2026/10/31/ispconfig-zugangsdaten-rotieren-nach-angriff/</link>
					<comments>https://www.cisilino.com/de/2026/10/31/ispconfig-zugangsdaten-rotieren-nach-angriff/#respond</comments>
		
		<dc:creator><![CDATA[matteocisilino]]></dc:creator>
		<pubDate>Sat, 31 Oct 2026 10:03:56 +0000</pubDate>
				<category><![CDATA[Server und Datenbanken]]></category>
		<category><![CDATA[Bash-Skript]]></category>
		<category><![CDATA[Incident Response]]></category>
		<category><![CDATA[ISPConfig]]></category>
		<category><![CDATA[MySQL]]></category>
		<category><![CDATA[Server-Management]]></category>
		<category><![CDATA[Zugangsdaten-Rotation]]></category>
		<guid isPermaLink="false">https://www.cisilino.com/?p=1415</guid>

					<description><![CDATA[<p>Wie ich ISPConfig-Zugangsdaten rotieren lasse, sobald ich einen Einbruch vermute: mit reset_DB_password.sh, Backups und Dry-Run, Phase für Phase für Standalone-, Master- und Slave-Knoten.</p>
<p>The post <a href="https://www.cisilino.com/de/2026/10/31/ispconfig-zugangsdaten-rotieren-nach-angriff/">Datenbank-Zugangsdaten-Rotation nach einem ISPConfig-Sicherheitsvorfall</a> first appeared on <a href="https://www.cisilino.com/de/">Matteo Cisilino</a>.</p>]]></description>
										<content:encoded><![CDATA[<p class="wp-block-paragraph">ISPConfig-Zugangsdaten rotieren bedeutet für mich: handeln, sobald ich einen Einbruch vermute. Nicht erst, nachdem ich einen bestätigt habe. Datenbank-Zugangsdaten auf einem ISPConfig-Server zu rotieren klingt nach Routinewartung. Routine bleibt es nicht mehr, sobald man eine Webshell auf einer gehosteten Website findet. Oder eine Konfigurationsdatei, die man sich nicht erklären kann, in einem öffentlichen Backup. Ab diesem Punkt muss jedes Passwort, das zu diesem Datenbank-Stack gehört, neu sein. Überprüft. Dokumentiert. Nicht einfach als unbedenklich angenommen.</p>



<figure class="wp-block-image size-large"><img fetchpriority="high" decoding="async" src="https://www.cisilino.com/wp-content/uploads/2026/10/database-credential-rotation-ispconfig.webp" alt="ISPConfig-Zugangsdaten rotieren: Schloss und Schlüssel über einer ISPConfig-Datenbank" width="1200" height="630"/></figure>



<h2 class="wp-block-heading">Wann hört Rotation auf, optional zu sein?</h2>



<p class="wp-block-paragraph">Ein paar Szenarien bringen das Thema regelmäßig genug zur Sprache, dass ich einen Rotationsplan dafür bereithalte. Ich improvisiere lieber keinen unter Druck.</p>



<p class="wp-block-paragraph">Ein Plugin oder CMS auf einer gehosteten Website wird kompromittiert. Über einen echten Fall auf dieser Art von Server habe ich hier geschrieben: <a href="https://www.cisilino.com/de/2026/10/29/php2shell-cve-2026-109618-server-absichern/">PHP2Shell: So habe ich meine Server bis zum Patch geschützt</a>, eine PHP-Schwachstelle, die einem Angreifer erlaubte, eine Webshell abzulegen, bevor es einen Patch gab. Sobald eine Webshell unter einem Hosting-Benutzer läuft, kann sie oft die eigene config.inc.php dieser Website lesen. Auf einer gemeinsam genutzten ISPConfig-Box liegt diese Datei direkt neben den Zugangsdaten für die eigene Datenbank des Kontrollpanels. Nicht nur das eine Zugangsdatum, hinter dem der Angreifer eigentlich her war.</p>



<p class="wp-block-paragraph">Eine Konfigurationsdatei landet irgendwo, wo sie nicht hingehört. Ein Backup-Archiv, das in einem öffentlichen Ordner liegen bleibt. Ein Repository, das mit einer versehentlichen Datei gepusht wird. Ein Log, das lesbarer ist, als es sein sollte. config.inc.php und mysql_clientdb.conf haben an keinem dieser Orte etwas zu suchen. Fehler passieren trotzdem, auch mir.</p>



<p class="wp-block-paragraph">Ein Systemadministrator verlässt das Team. Wie einvernehmlich der Abschied auch war, gemeinsam genutzte Datenbank-Zugangsdaten, die diese Person einmal hatte, sind Zugangsdaten, denen ich nicht mehr vollständig vertraue. Vertrauen wird nicht automatisch übertragen, wenn jemand zur Tür hinausgeht.</p>



<p class="wp-block-paragraph">Ein vermuteter Einbruch, selbst ein unbestätigter. Dieser Fall lässt sich am schwersten jemandem erklären, der noch nie einen Server unter Angriffsverdacht betrieben hat. Man wartet nicht auf einen Beweis, bevor man handelt. Man geht von einer Kompromittierung aus und rotiert. Eine unnötige Rotation kostet einen Nachmittag. Eine ausgelassene, nach einem echten Einbruch, kann jede Website kosten, die der Server hostet.</p>



<h2 class="wp-block-heading">Warum ISPConfig-Zugangsdaten rotieren von Hand so fehleranfällig ist</h2>



<p class="wp-block-paragraph">Als ich die Datenbank-Zugangsdaten von ISPConfig zum ersten Mal von Hand rotiert habe, habe ich unterschätzt, wie viele Dateien betroffen sind. Sie müssen alle zusammen geändert werden, sonst geht ein paar Stunden später irgendwo leise etwas kaputt.</p>



<p class="wp-block-paragraph">Es gibt config.inc.php auf der Server-Seite und eine zweite Kopie auf der Interface-Seite. Beide müssen dieselben neuen Werte bekommen. Es gibt mysql_clientdb.conf, das ISPConfig ausliest, um die eigene Datenbank jeder gehosteten Website zu verwalten, nicht nur seine eigene Haushaltsführung. Dovecot, Pure-FTPd und Postfix führen jeweils eine eigene Kopie einiger dieser Zugangsdaten. Mail und FTP können still aufhören, sich zu authentifizieren, wenn man den Datenbank-Benutzer aktualisiert und dabei nur eine dieser drei Dienste vergisst.</p>



<p class="wp-block-paragraph">Dann gibt es die Master/Slave-Topologie, die einige meiner Setups verwenden. Ein Slave-Knoten authentifiziert sich über das Netzwerk an der Datenbank seines Masters, mit einem separaten Passwort. Dieses Passwort muss auf dem Master selbst rotiert werden, nicht nur lokal. Bringt man die Reihenfolge durcheinander, kann sich ein Slave vom eigenen Master aussperren, von dem er eigentlich replizieren soll.</p>



<p class="wp-block-paragraph">Konzeptionell ist nichts davon schwierig. Es sind nur viele Dateien. Mehrere Dienste, die in sinnvoller Reihenfolge neu gestartet werden müssen. Fast keine Toleranz für einen Tippfehler, besonders wenn man das unter Druck macht, gleich nachdem man etwas gefunden hat, das man nicht finden wollte. Genau diese Kombination ist der Grund, warum sich ISPConfig-Zugangsdaten rotieren von Hand immer riskanter anfühlte, als die Aufgabe selbst sein sollte. Deshalb habe ich reset_DB_password.sh geschrieben, nachdem ich das Problem mehr als einmal selbst durchgemacht hatte.</p>



<h2 class="wp-block-heading">Was reset_DB_password.sh tatsächlich macht</h2>



<p class="wp-block-paragraph">reset_DB_password.sh erkennt, ob der Knoten Standalone, Master oder Slave ist. Dann führt es nur die Phasen aus, die diese Rolle benötigt. Genau so, wie man ISPConfig-Zugangsdaten rotieren sollte: in der richtigen Reihenfolge, für die richtige Rolle.</p>



<p class="wp-block-paragraph">Phase A rotiert das lokale MySQL-Root-Passwort. Das ist das Passwort, mit dem ISPConfig selbst die Datenbank jeder gehosteten Website verwaltet. Das Skript aktualisiert mysql_clientdb.conf entsprechend dem neuen Wert.</p>



<p class="wp-block-paragraph">Phase B rotiert das Passwort des ispconfig-Kontrollbenutzers. Sie aktualisiert config.inc.php sowohl auf der Server- als auch auf der Interface-Seite. Danach aktualisiert sie die passenden Zugangsdaten in Dovecot, Pure-FTPd und Postfix, damit Mail und FTP weiter authentifizieren, ohne einen separaten manuellen Durchgang durch drei weitere Konfigurationsdateien.</p>



<p class="wp-block-paragraph">Phase C läuft nur auf einem Slave. Sie verbindet sich remote mit der Datenbank des Masters. Dort rotiert sie das Passwort des Slave-Benutzers und aktualisiert anschließend lokal dbmaster_password, damit die Replikation weiter korrekt gegen den neuen Wert authentifiziert.</p>



<p class="wp-block-paragraph">Nach Phase B bietet das Skript an, Dovecot, Pure-FTPd und Postfix für einen neu zu starten. Ich sage fast immer Ja. Wenn man ISPConfig-Zugangsdaten rotiert und dabei einen Mail-Dienst mit dem alten Passwort weiterlaufen lässt, ist man nicht fertig, man ist nur still kaputt. Der Neustart im gleichen Durchlauf, direkt nachdem sich die Konfigurationsdateien geändert haben, hält das Ganze in einem kurzen Wartungsfenster statt in drei separaten, die man später entdeckt.</p>



<h2 class="wp-block-heading">Backups, bevor sich irgendetwas ändert</h2>



<p class="wp-block-paragraph">Bevor es auch nur eine Datei anfasst, erstellt das Skript eine vollständige rsync-Kopie von /usr/local/ispconfig/ in einem zeitgestempelten Ordner. Es versucht außerdem einen nicht blockierenden mysqldump der Datenbanken mysql und dbispconfig. Nicht blockierend mit Absicht: Ich schließe lieber eine Rotation der Datenbank-Zugangsdaten ab, als auf einen Dump zu warten, der aus irgendeinem unzusammenhängenden Grund hängen bleibt. Jede Konfigurationsdatei, die bearbeitet wird, bekommt erst ihre eigene .bak-Kopie, bevor auch nur eine Zeichenkette darin ersetzt wird.</p>



<p class="wp-block-paragraph">Nichts davon ersetzt eine unabhängige Backup-Strategie. Ich mache weiterhin ein separates Snapshot oder ein vollständiges Backup, bevor ich das auf einem Server ausführe, der wirklich wichtig ist. Der eigene Disclaimer des Skripts sagt dasselbe, nur direkter, als ich es gerade getan habe.</p>



<h2 class="wp-block-heading">Erst der Trockenlauf, jedes Mal</h2>



<p class="wp-block-paragraph">Jede Phase fragt nach Bestätigung, bevor irgendetwas geändert wird. &#8211;dry-run simuliert einen kompletten Durchlauf, ohne eine einzige Datei zu berühren. Ich benutze dry-run zuerst auf jedem Server, jedes Mal. Auch auf solchen, auf denen ich schon einmal Zugangsdaten rotiert habe und die ich zu kennen glaube. &#8211;skip-local-root überspringt Phase A, für den Fall, dass nur das Passwort des Kontrollbenutzers rotiert werden muss. &#8211;role=master oder &#8211;role=slave überschreibt die automatische Erkennung, für den seltenen Fall, dass sie die Topologie eines Servers nicht selbst korrekt erkennt.</p>



<p class="wp-block-paragraph">Die erzeugten Passwörter sind 32 Zeichen lang, nur Buchstaben und Ziffern. Lang genug, um Raten zu widerstehen. Einfach genug, dass die Parser von MySQL und PHP niemals über ein vereinzeltes Sonderzeichen in einer Verbindungszeichenfolge stolpern. Ich habe das Skript auf ISPConfig 3.3.1p1 getestet. Gegen ältere oder neuere Zweige habe ich es nicht laufen lassen. Ich würde dort kein identisches Verhalten annehmen, ohne es selbst vorher zu prüfen.</p>



<p class="wp-block-paragraph">Die Rolle eines Knotens ist nicht immer auf den ersten Blick offensichtlich, besonders auf einem Server, den man geerbt und nicht selbst aufgebaut hat. Deshalb gibt es &#8211;role, und deshalb lohnt es sich, die Ausgabe des Trockenlaufs beim ersten Mal Zeile für Zeile zu lesen, nicht nur zu überfliegen. Ich bestätige lieber zwei Minuten lang, dass ein Slave wirklich ein Slave ist, als es mitten in der Rotation auf die harte Tour herauszufinden.</p>



<h2 class="wp-block-heading">Was ich dir sagen würde, bevor du es ausführst</h2>



<p class="wp-block-paragraph"><strong>Hinweis</strong>: Das Skript wird wie es ist, zu Informationszwecken bereitgestellt. Die Nutzung erfolgt ausschließlich auf eigenes Risiko: Ich übernehme keine Verantwortung für Datenverlust, Ausfallzeiten oder Fehler, die durch falsche Anwendung, Veränderung des Codes oder einen anderen Kontext als den, in dem ich es selbst einsetze, entstehen. Erstelle immer ein unabhängiges Backup oder einen VM-Snapshot, bevor du es in Produktion ausführst, unabhängig davon, was der eigene Backup-Schritt des Skripts bereits für dich erfasst.</p>



<p class="wp-block-paragraph">Es ist freie Software. Du darfst sie weiterverbreiten und verändern, mit Nennung meines Namens als ursprünglichen Autor. Der Quellcode von reset_DB_password.sh, und der Rest der ispconfig-fast-tools-Sammlung, liegt auf GitHub unter <a href="https://github.com/matteocisilino/ispconfig-fast-tools" target="_blank" rel="noopener">github.com/matteocisilino/ispconfig-fast-tools</a>.</p>



<h2 class="wp-block-heading">ISPConfig-Zugangsdaten rotieren ist eine Gewohnheit, kein Aufräumjob</h2>



<p class="wp-block-paragraph">Datenbank-Zugangsdaten nach einem bestätigten Einbruch zu rotieren, ist einfach kompetente Reaktion auf einen Sicherheitsvorfall. Die meisten Administratoren würden es tun, ohne dass man es ihnen zweimal sagen muss. Die schwerere Gewohnheit ist, es auch nach einem bloß vermuteten Einbruch zu tun, bevor jemand Beweise hat. Das ist die einzige Version des Plans, die den Schaden tatsächlich begrenzt, solange er noch begrenzbar ist. Ein Skript, das die richtigen Phasen für die Rolle eines Servers ausführt, zuerst eigene Backups erstellt und mich den ganzen Vorgang per Trockenlauf prüfen lässt, bevor irgendetwas Reales berührt wird. Das macht die Gewohnheit praktisch an einem Dienstagabend, statt theoretisch in einem Blogartikel.</p>



<p class="wp-block-paragraph">ISPConfig, MySQL, PHP, Dovecot, Pure-FTPd, Postfix und die anderen genannten Namen sind Marken ihrer jeweiligen Inhaber. Sie werden hier nur zur Identifikation der Produkte verwendet.</p><p>The post <a href="https://www.cisilino.com/de/2026/10/31/ispconfig-zugangsdaten-rotieren-nach-angriff/">Datenbank-Zugangsdaten-Rotation nach einem ISPConfig-Sicherheitsvorfall</a> first appeared on <a href="https://www.cisilino.com/de/">Matteo Cisilino</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://www.cisilino.com/de/2026/10/31/ispconfig-zugangsdaten-rotieren-nach-angriff/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Welche Datenbank belegt den Speicherplatz? Ein Skript für MySQL und PostgreSQL</title>
		<link>https://www.cisilino.com/de/2026/10/20/skript-speicherplatz-datenbank-mysql/</link>
					<comments>https://www.cisilino.com/de/2026/10/20/skript-speicherplatz-datenbank-mysql/#respond</comments>
		
		<dc:creator><![CDATA[matteocisilino]]></dc:creator>
		<pubDate>Tue, 20 Oct 2026 07:53:00 +0000</pubDate>
				<category><![CDATA[Server und Datenbanken]]></category>
		<category><![CDATA[Bash-Skript]]></category>
		<category><![CDATA[MySQL]]></category>
		<category><![CDATA[PostgreSQL]]></category>
		<category><![CDATA[Speicherplatz]]></category>
		<guid isPermaLink="false">https://www.cisilino.com/?p=268</guid>

					<description><![CDATA[<p>Die Festplatte des Servers läuft voll und Sie wissen nicht, welche Datenbank schuld ist? Ein Skript, das den Platzbedarf von MySQL und PostgreSQL misst und im Zeitverlauf vergleicht.</p>
<p>The post <a href="https://www.cisilino.com/de/2026/10/20/skript-speicherplatz-datenbank-mysql/">Welche Datenbank belegt den Speicherplatz? Ein Skript für MySQL und PostgreSQL</a> first appeared on <a href="https://www.cisilino.com/de/">Matteo Cisilino</a>.</p>]]></description>
										<content:encoded><![CDATA[<p>Das passiert früher oder später jedem: Die Festplatte des Servers läuft voll, der Alarm geht los und die Frage ist immer dieselbe. <strong>Welche Datenbank belegt den ganzen Platz?</strong> Beim Speicherplatz von MySQL gibt es dafür selten eine schnelle Antwort.</p>
<figure class="wp-block-image size-large"><img decoding="async" src="https://www.cisilino.com/wp-content/uploads/2026/10/speicherplatz-datenbanken-mysql-postgresql.webp" alt="Speicherplatz von MySQL als Ringdiagramm in vier farbige Abschnitte geteilt, rechts drei Legendenbalken" width="1200" height="630"/></figure>
<p>Ich stand davor nach einer Optimierung. Ich hatte <code>OPTIMIZE TABLE</code> ausgeführt in der Hoffnung, Platz zurückzugewinnen, aber das Dateisystem schrumpfte nicht wie erwartet. Ohne ein „Vorher“, mit dem man das „Nachher“ vergleichen konnte, ließ sich nicht sagen, ob die Optimierung gewirkt hatte, welche Datenbank wirklich verantwortlich war und ob der fehlende Platz vielleicht ganz woanders steckte.</p>
<p>Die Werkzeuge, die ich hatte, beantworteten nur die Hälfte des Problems.</p>
<ul class="wp-block-list">
<li><code>du</code> zeigt die Dateien auf der Platte, sagt aber nicht, zu welcher Tabelle sie gehören.</li>
<li><code>information_schema</code> (oder <code>pg_class</code> bei PostgreSQL) liefert die logischen Größen, sieht aber weder Binlogs noch <code>ibdata1</code>, Undo-Logs oder WAL.</li>
<li>beide zeigen nur einen einzigen Augenblick: Der Vergleich über die Zeit bleibt Handarbeit mit Copy-and-paste und Tabellenkalkulation.</li>
</ul>
<p>Ich brauchte etwas Einfaches: <strong>den Zustand aller Datenbanken zu einem bestimmten Zeitpunkt festhalten, später erneut festhalten und mir sagen lassen, was sich geändert hat</strong>, die Unterschiede sortiert vom größten zum kleinsten. So fällt der Übeltäter sofort auf.</p>
<p>Herausgekommen ist ein einzelnes Bash-Skript ohne Abhängigkeiten außer dem Datenbank-Client und den üblichen Linux-Werkzeugen (<code>awk</code>, <code>du</code>, <code>df</code>). Es funktioniert mit <strong>MySQL/MariaDB</strong> und mit <strong>PostgreSQL</strong>. Ich teile es hier in der Hoffnung, jemandem denselben frustrierenden Nachmittag zu ersparen.</p>
<h2 class="wp-block-heading">Was es tut</h2>
<p>Das Skript hat vier Befehle.</p>
<ul class="wp-block-list">
<li><code>snapshot</code> speichert den aktuellen Zustand (Datenbanken, Tabellen, Dateien im Datenverzeichnis, Platz des Dateisystems).</li>
<li><code>compare</code> vergleicht zwei Momentaufnahmen und zeigt die Unterschiede, die größten zuerst.</li>
<li><code>report</code> zeigt die größten Datenbanken und Tabellen einer einzelnen Momentaufnahme.</li>
<li><code>list</code> listet die gespeicherten Momentaufnahmen auf.</li>
</ul>
<p>Der Vergleich ist in vier Abschnitte unterteilt, damit keine blinden Flecken bleiben.</p>
<ul class="wp-block-list">
<li><strong>Größe jeder Datenbank</strong> (Daten + Indizes).</li>
<li><strong>freier oder rückgewinnbarer Platz</strong> (<code>data_free</code> bei MySQL, Schätzung der toten Tupel bei PostgreSQL).</li>
<li><strong>Größe jeder Tabelle</strong>.</li>
<li><strong>tatsächliche Dateien im Datenverzeichnis</strong>: Datenbankordner, <code>ibdata1</code>, Binlogs, Undo, <code>pg_wal</code>.</li>
</ul>
<p>Der letzte Abschnitt löst das Rätsel oft. Viele Male gehört der „auffällige“ Platz zu keiner Datenbank, sondern zu den Binärlogs oder zu den gemeinsamen InnoDB-Dateien.</p>
<h2 class="wp-block-heading">Voraussetzungen</h2>
<ul class="wp-block-list">
<li>Linux mit Bash 4 oder höher, <code>awk</code>, <code>du</code>, <code>df</code> (alles Standard).</li>
<li>der Client <code>mysql</code> oder <code>psql</code>, mit einem Benutzer, der die Metadaten aller Datenbanken lesen darf.</li>
<li>Root-Rechte (oder die des Benutzers <code>mysql</code>/<code>postgres</code>), wenn Sie auch die Dateien auf der Platte messen wollen; ohne sie wird dieser Abschnitt mit einer Warnung übersprungen.</li>
</ul>
<h2 class="wp-block-heading">Installation</h2>
<p>Speichern Sie das Skript (am Ende des Artikels) in einer Datei und machen Sie es ausführbar.</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">Zugangsdaten</h3>
<p>Für MySQL/MariaDB ist der sauberste Weg eine Datei <code>~/.my.cnf</code> mit den Zugangsdaten, damit sie nicht im Befehlsverlauf auftauchen.</p>
<pre class="wp-block-code"><code>&#91;client]
user=root
password=LA_TUA_PASSWORD</code></pre>
<p>Alternativ können Sie die Optionen über eine Umgebungsvariable übergeben.</p>
<pre class="wp-block-code"><code>export MYSQL_OPTS="-u root -pPASSWORD -h 127.0.0.1"</code></pre>
<p>Für PostgreSQL funktionieren <code>~/.pgpass</code>, die Variablen <code>PG*</code> oder <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">So verwenden Sie es</h2>
<p>Der typische Ablauf ist <em>vorher, Optimierung, nachher, Vergleich</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>Für PostgreSQL genügt es, die Option <code>-e postgres</code> hinzuzufügen.</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>Die verfügbaren Optionen sind.</p>
<ul class="wp-block-list">
<li><code>-e mysql|postgres</code> wählt die Engine (Standard: <code>mysql</code>).</li>
<li><code>-d DIR</code> ändert den Ordner, in dem die Momentaufnahmen gespeichert werden (Standard: <code>./db_snapshots</code>).</li>
<li><code>-n N</code> legt fest, wie viele Zeilen pro Abschnitt angezeigt werden (Standard: 20).</li>
</ul>
<p>Geben Sie keine Bezeichnung an, erhält die Momentaufnahme Datum und Uhrzeit als Namen. Das ist praktisch, um das Wachstum über die Zeit zu überwachen.</p>
<h2 class="wp-block-heading">Beispielausgabe</h2>
<p>Hier ein Vergleich mit Testdaten, extra gebaut, um einen typischen Fall zu zeigen: Die Datenbank <code>shop</code> schrumpft nach der Optimierung, aber die Datenbank <code>log</code> wächst und die Binlogs verdoppeln sich.</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>Hier ist der Übeltäter offensichtlich: Die Tabelle <code>log.eventi</code> ist um mehr als 6 GiB gewachsen, und die Dateien <code>mysql-bin.*</code> sind von 3,7 auf 8,4 GiB gestiegen. Die Optimierung von <code>shop</code> hat funktioniert, aber der Gewinn wurde vom Rest aufgefressen.</p>
<h2 class="wp-block-heading">Wie man die Ergebnisse zum Speicherplatz von MySQL liest</h2>
<ul class="wp-block-list">
<li>Am Anfang jedes Abschnitts stehen die Einträge mit der größten Änderung. Eine Datenbank oder Tabelle, die nach der Optimierung <strong>nicht schrumpft</strong> (oder sogar wächst), ist der erste Verdächtige.</li>
<li>Schrumpfen die Tabellen, das Dateisystem aber nicht, liegt der Platz woanders: Sehen Sie sich den Abschnitt über die Dateien im Datenverzeichnis an (Binlogs, <code>ibdata1</code>, Undo, <code>pg_wal</code>).</li>
<li><strong>MySQL/MariaDB:</strong> <code>ibdata1</code> schrumpft nie, auch nicht mit <code>OPTIMIZE TABLE</code>. Mit <code>innodb_file_per_table=OFF</code> ist der Wert von <code>data_free</code> gemeinsam genutzt und wenig aussagekräftig.</li>
<li><strong>PostgreSQL:</strong> Ein einfaches <code>VACUUM</code> gibt keinen Platz an das Betriebssystem zurück, dafür braucht man <code>VACUUM FULL</code> (oder <code>pg_repack</code>). Der angezeigte „rückgewinnbare“ Platz ist eine Schätzung auf Basis der toten Tupel. Wie PostgreSQL den Platz selbst berechnet, erklärt die <a href="https://www.postgresql.org/docs/current/diskusage.html" target="_blank" rel="noopener">Dokumentation zur Plattenbelegung</a>.</li>
</ul>
<h2 class="wp-block-heading">Wachstum über die Zeit überwachen</h2>
<p>Hängt das Problem nicht an einer einzelnen Optimierung, sondern an schrittweisem Wachstum, lohnt es sich, die Momentaufnahmen mit cron zu automatisieren, eine pro Stunde, und dann zwei beliebige Zeitpunkte zu vergleichen.</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>Ohne Bezeichnung heißt jede Momentaufnahme nach Datum und Uhrzeit (zum Beispiel <code>20261011-140000</code>). Mit <code>list</code> sehen Sie die vorhandenen, mit <code>compare</code> vergleichen Sie zwei. In wenigen Tagen zeigt sich, welche Datenbank ungewöhnlich wächst.</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">Das vollständige Skript</h2>
<p>Hier ist der Code. Kopieren Sie ihn in eine Datei namens <code>db_space_compare.sh</code>, machen Sie sie ausführbar, und Sie sind startklar.</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">Abschließende Hinweise</h2>
<ul class="wp-block-list">
<li>Das Skript liest den Speicherplatz von MySQL nur aus und ist auf den Datenbanken <strong>schreibgeschützt</strong>: Es führt nur <code>SELECT</code> auf den Metadaten aus. Es schreibt ausschließlich in den Ordner der Momentaufnahmen.</li>
<li>Unter MySQL 8 setzt es <code>information_schema_stats_expiry = 0</code> in der Sitzung, weil sonst die zwischengespeicherten Statistiken (bis zu 24 Stunden) auch nach der Optimierung „alte“ Werte zeigen könnten.</li>
<li>Schlägt eine Momentaufnahme auf halbem Weg fehl, wird sie automatisch entfernt, um keine unvollständigen Daten zu hinterlassen.</li>
<li>Bevor Sie es in der Produktion einsetzen, probieren Sie es in einer Testumgebung aus und prüfen die Berechtigungen des Datenbankbenutzers.</li>
</ul>
<p>Wenn Sie es nutzen und einen Fall finden, den es nicht abdeckt, oder Ideen zur Erweiterung haben (andere Engines, CSV-Export, E-Mail-Benachrichtigungen), schreiben Sie mir in die Kommentare. Läuft MySQL nach einem Upgrade gar nicht mehr, hilft dieser Artikel: <a href="https://www.cisilino.com/de/2026/10/13/mysql-startet-nicht-debian-12/">MySQL startet nach dem Debian-12-Upgrade nicht mehr</a>.</p><p>The post <a href="https://www.cisilino.com/de/2026/10/20/skript-speicherplatz-datenbank-mysql/">Welche Datenbank belegt den Speicherplatz? Ein Skript für MySQL und PostgreSQL</a> first appeared on <a href="https://www.cisilino.com/de/">Matteo Cisilino</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://www.cisilino.com/de/2026/10/20/skript-speicherplatz-datenbank-mysql/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>
