Category Archives: Datenbanken (PostgreSQL, MySQL)

Muss man MySQL denn alles mehrmals sagen?

Unser täglich MySQL-Rant gib uns heute…

Ein geschätzter Kollege meldete mir heute erstmalig, dass sich seine Datenbank auf unserem MySQL-Server (in einer separaten Instanz, in der nur diese Datenbank läuft) über zu viele Verbindungen beschwert – soll heißen, sie verweigert die Annahme weiterer Datenbankverbindungen, da die maximale Anzahl an Verbindungen bereits ausgereizt wurde.

Nun, ein schneller Blick in das Manual – der entsprechende Schalter heißt max_connections. Diesen also ebenso schnell in der my.cnf auf 1000 gesetzt:

max_connections = 1000

MySQL neu gestartet und MySQL vermeldet stolz:

mysql> show variables like 'max_connections';
+-----------------+-------+
| Variable_name | Value |
+-----------------+-------+
| max_connections | 214 |
+-----------------+-------+
1 row in set (0,00 sec)

Grmph. Natürlich gibt es keinen Hinweis in den Logfiles, was MySQL gestört haben könnte.

Probieren wir doch mal, im Live-System die Anzahl hochzusetzen:

mysql> set global max_connections = 1000;
Query OK, 0 rows affected (0,00 sec)

mysql> show variables like 'max_connections';
+-----------------+-------+
| Variable_name | Value |
+-----------------+-------+
| max_connections | 1000 |
+-----------------+-------+
1 row in set (0,00 sec)

Okay, das funktioniert – wirft aber eher neue Fragen auf…

Nur eine etwas längere Websuche später erfahre ich endlich hier, dass die Anzahl gleichzeitig offener Dateien hochgesetzt werden muss.

Dies kann man wahlweise umständlich über sysctl machen oder aber sehr elegant in der my.cnf selbst:

open_files_limit = 8192
max_connections = 1000

Kurzer Check:

mysql> show variables like 'max_connections';
+-----------------+-------+
| Variable_name | Value |
+-----------------+-------+
| max_connections | 1000 |
+-----------------+-------+
1 row in set (0,01 sec)

Das sieht doch schon mal super aus – die Ursache war also open_files_limit, das wir ja auf 8192 gesetzt haben:

mysql> show variables like 'open_files_limit';
+------------------+-------+
| Variable_name | Value |
+------------------+-------+
| open_files_limit | 1024 |
+------------------+-------+
1 row in set (0,00 sec)

Aehm, moment … 1024? Wo kommt denn jetzt 1024 her?

Kurzer Blick ins Manual: Default-Wert ist 0.

Hmm, was zeigt er denn an, wenn man keinen Wert in der my.cnf setzt?

mysql> show variables like 'open_files_limit';
+------------------+-------+
| Variable_name | Value |
+------------------+-------+
| open_files_limit | 1024 |
+------------------+-------+
1 row in set (0,00 sec)

Okay … wir lernen also: bei open_files_limit wird der Default-Wert von 0 beim MySQL-Start auf 1024 aufgerundet während ein expliziter Wert von 8192 auf 1024 heruntergesetzt wird.

Was passiert denn, wenn man einfach die 1024 selbst setzt (denn ohne ein explizites Setzen wird ja max_connections ignoriert?

open_files_limit = 1024
max_connections = 1000

Nunja, ausnahmsweise mal das Erwartete:

mysql> show variables like 'max_connections';
+-----------------+-------+
| Variable_name | Value |
+-----------------+-------+
| max_connections | 1000 |
+-----------------+-------+
1 row in set (0,01 sec)

mysql> show variables like 'open_files_limit';
+------------------+-------+
| Variable_name | Value |
+------------------+-------+
| open_files_limit | 1024 |
+------------------+-------+
1 row in set (0,00 sec)

Keine Ahnung was der Blödsinn soll, aber bis hierher könnte man das Gelernte folgendermaßen zusammenfassen:

Möchte man bei MySQL die maximale Anzahl gleichzeitiger Verbindungen (max_connections) erhöhen, muss man zusätzlich explizit die Anzahl gleichzeitig offener Verbindungen (open_files_limit) auf den Wert setzen, den sie so eh schon haben.

Wenn man jetzt noch ein gutes Gefühl hätte, dass die 1024 Filedeskriptoren wirklich für 1000 Verbindungen ausreichen werden…

So, genug geranted. Das eigentliche Problem liegt in der im Betriebssystem vorgegebenen Einstellung, wie hoch ein Nutzer die Anzahl offener Files setzen darf. Bei Ubuntu liegt diese standardmäßig bei 1024 und MySQL hat das lustige Verhalten, diesen Wert blind zu übernehmen (egal, was in der my.cnf steht – ja, auch wenn dort ein niedrigerer Wert steht).

Um also MySQL zu mehr Filedeskriptoren zu verhelfen, müssen wir doch beim Betriebssystem vorbeigucken (der vollständige Weg (OS+MySQL) ist u.a. hier beschrieben).

Ich habe also in /etc/security/limits.conf die beiden folgenden Zeilen hinzugefügt

mysql soft nofile 8192
mysql hard nofile 8192

und boshafterweise in my.cnf open_files_limit auf 4096 gesetzt und das Ergebnis lautet:

mysql> show variables like 'open%';
+------------------+-------+
| Variable_name | Value |
+------------------+-------+
| open_files_limit | 8192 |
+------------------+-------+
1 row in set (0,00 sec)

Das entspricht zwar immer noch nicht dem, was laut Handbuch passieren sollte, aber wer will schon kleinlich sein…

Zur eventuellen Einschränkung sei gesagt, dass wir hier von MySQL 5.5 sprechen – keine Ahnung, ob sich spätere Versionen anders verhalten.

SQL-Namenskonventionen

Es gibt wohl kaum ein Thema, dass im Datenbankumfeld so umstritten ist, wie ein konkretes Schema für die Benennung von Objekten in Datenbanken.

Es gibt nicht nur keinen Standard, selbst so etwas wie eine einfache Konvention scheint ob der völlig konträren Positionen der Diskussionsteilnehmer völlig unmöglich zu sein.

Aus diesem Grund soll die folgende Liste von Stichpunkten auch keinerlei Anspruch auf die objektive Wahrheit erheben, sondern lediglich das wiedergeben, was sich in zahlreichen Projekten von mir als sinnvoll erwiesen hat.

  • kein Abweichen von der ersten Normalform – d.h. niemals strukturierte Daten in einzelnen Datenbankfeldern
  • so normalisiert wie möglich (insbesondere niemals Daten redundant speichern) – nur in Ausnahmefällen denormalisiert lassen (z.B. Anrede “Herr/Frau” nicht in separate Tabelle auslagern)
  • grundsätzlich alle Bezeichner in Kleinbuchstaben; Unterstriche sind erlaubt (“person” statt “Person”)
  • grundsätzlich alle Bezeichner in der gleichen Sprache
  • Tabellen und Attributnamen im Singular (“person” statt “personen”)
  • Attributnamen (außer Fremdschlüssel) mit Präfix, der Kurzform des Tabellennamens wiederspiegelt (“pers_name” statt “name”)
  • Primärschlüssel ist immer eine separate Spalte namens Präfix + “_id” (“pers_id”) vom Typ Serial (PostgreSQL) bzw. Integer Auto Increment (MySQL)
  • Fremdschlüsselnamen werden inklusive Präfix von der referenzierten Tabelle übernommen (“pers_id” bleibt in jeder referenzierenden Tabelle “pers_id”)
  • keine Abkürzungen (“pers_geburtsjahr” statt “pers_geb”)
  • keine “Anreicherung” von Attributnamen (“pers_geburtsjahr” statt “pers_geburtsjahr_int”)
  • Spalten mit Booleschen Werte so benennen, dass sofort klar ist, wofür true/false stehen (“maennlich” statt “geschlecht”)

Diese Liste ist zwar offensichtlich unvollständig, aber selbst diese minimale Konvention hat mir schon sehr gute Dienste, vor allem bei der Erklärung der Datenbankschemata gegenüber Dritten, geleistet.

Der Monat fängt ja gut an

Unser täglich MySQL-Rant gib uns heute (ok, der erste Teil ist zugegebenermaßen nur ein Verständnisproblem)…

Ich wurde heute von der Frage eines Studenten überrascht, warum man, wenn man vom 19. April 19 Tage abzieht und dann noch einen weiteren Tag abzieht, als Ergebnis beim 1. April landet.

Genaugenommen ging es ihm um die folgende MySQL-Anweisung aus einem älteren Software-Projekt:

mysql> select CURRENT_DATE - INTERVAL DAYOFMONTH(CURRENT_DATE) - 1 DAY;
+----------------------------------------------------------+
| CURRENT_DATE - INTERVAL DAYOFMONTH(CURRENT_DATE) - 1 DAY |
+----------------------------------------------------------+
| 2016-04-01                                               |
+----------------------------------------------------------+
1 row in set (0.00 sec)

Hmm, was ist denn, wenn wir den Tag am Ende nicht abziehen?

mysql> select CURRENT_DATE - INTERVAL DAYOFMONTH(CURRENT_DATE) - 0 DAY;
+----------------------------------------------------------+
| CURRENT_DATE - INTERVAL DAYOFMONTH(CURRENT_DATE) - 0 DAY |
+----------------------------------------------------------+
| 2016-03-31                                               |
+----------------------------------------------------------+
1 row in set (0.00 sec)

Ok, …, auch interessant. Bei der Suche nach einer Erklärung irritierte mich erstmal, was das INTERVAL in der Anweisung sollte. Ist das ein Cast, oder was?

Die (nach wie vor grottige) MySQL-Doku kennt INTERVAL in der Übersicht der Datums- und Zeitfunktionen nicht als separate Anweisung oder Funktion, sondern lediglich als Schlüsselwort innerhalb von Datumsfunktionen wie z.B. DATE_ADD.

Erst bei deren Beschreibung findet man die nette Information, dass man mittels INTERVAL Datumsberechnungen durchführen kann und zwar mit der Syntax:

date + INTERVAL expr unit

Und damit erkennt man dann auch, wie die originale Anweisung interpretiert wird, nämlich so:

mysql> select CURRENT_DATE - INTERVAL (DAYOFMONTH(CURRENT_DATE) - 1) DAY;
+------------------------------------------------------------+
| CURRENT_DATE - INTERVAL (DAYOFMONTH(CURRENT_DATE) - 1) DAY |
+------------------------------------------------------------+
| 2016-04-01                                                 |
+------------------------------------------------------------+
1 row in set (0,00 sec)

Ok, Rätsel gelöst, ein paar Nebenerkenntnisse gibt es dann doch noch…

Spaßig wird die Sache z.B., wenn man mal ohne INTERVAL arbeitet:

mysql> select CURRENT_DATE - DAYOFMONTH(CURRENT_DATE);
+-----------------------------------------+
| CURRENT_DATE - DAYOFMONTH(CURRENT_DATE) |
+-----------------------------------------+
|                                20160400 |
+-----------------------------------------+
1 row in set (0.00 sec)

Und ja, derartig ungültige Daten darf man sogar in Tabellen abspeichern! Die Doku dazu liest sich MySQL-typisch unbesorgt – man darf halt einfach nicht zuviel “erwarten”, dann geht alles gut 🙂

MySQL permits you to store dates where the day or month and day are zero in a DATE or DATETIME column. […] In this case, you simply store the date as ‘2009-00-00’ or ‘2009-01-00’.

If you store dates such as these, you should not expect to get correct results for functions such as DATE_SUB() or DATE_ADD() that require complete dates.[…]

Die Behandlung des nichtexistenten Jahres 0 kann dann auch niemanden mehr überraschen:

MySQL permits you to store a “zero” value of ‘0000-00-00’ as a “dummy date.” This is in some cases more convenient than using NULL values, and uses less data and index space.

Dies bringt mich zu PostgreSQL, wo selbstverständlich jedwede ungültigen Daten verboten sind. Der zugehörige Abschnitt aus dem Manual gehört zu meinen Lieblingstellen:

The first century starts at 0001-01-01 00:00:00 AD, although they did not know it at the time. This definition applies to all Gregorian calendar countries.

There is no century number 0, you go from -1 century to 1 century. If you disagree with this, please write your complaint to: Pope, Cathedral Saint-Peter of Roma, Vatican.

Nur der Vollständig halber: in PostgreSQL kommt die Berechnung des Monatsersten ohne unschöne Subtraktionen aus – das Äquivalent zu ersten SQL-Anweisung in diesem Blogeintrag lautet:

#select date_trunc('month', current_date)::date;

 date_trunc 
------------
 2016-04-01
(1 row)

Systemtabellen werden eh überbewertet

Unser täglich MySQL-Rant gib uns heute…

Momentan bin ich beruflich leider gezwungen, mich näher mit dem Galera-Cluster (eine Multi-Master-Synchronisationslösung auf MySQL/MariaDB-Basis) zu beschäftigen. Prinzipiell hätte ich natürlich eine ernsthafte Datenbankreplikationslösung auf PostgreSQL-Basis bevorzugt, aber leider benötigen nun gleich mehrere zukünftig in unserem Rechenzentrum eingesetzte Softwareprodukte eine hochverfügbare MySQL-Lösung.

Kleiner Einschub: wenn man die konkreten Hersteller eben dieser Softwareprodukte fragt, warum sie unbedingt MySQL genommen haben, kommt man gerne Antworten, die mit

“Wir sind zwar keine Datenbankexperten, aber…”

beginnen und irgend einem Gestammel der Bauart

“…hat halt funktioniert und wir bleiben dabei.”

endet. Warum wir überhaupt derartige Softwareprodukte auswählen, soll jetzt hier nicht das Thema sein…

Anyway, ich habe mich nun einige Zeit mit der offiziellen Galera-Doku und zahlreichen weiteren Dokumenten beschäftigt und sehe mein Bauchgefühl bestätigt: Der Cluster ist eine wirklich beeindruckende Implementationsleistung – basierend auf einem dafür völlig ungeeigneten Datenbanksystem.

Der Grund für diese böse Feststellung liegt vor allem in einer Altlast, die MySQL (und auch dessen Abkömmling MariaDB) immer noch mit sich herumschleppen: die MyISAM-Storage-Engine. Diese ehemalige Standardengine stammt aus einer Zeit, als MySQL noch keine Ambitionen hatte, ein echtes Datenbanksystem zu sein und lässt daher so ziemlich alle datenbankrelevanten Eigenschaften vermissen (Transaktionen, Fremdschlüssel, …). Schon vor Jahren wurde daher eine andere Engine namens InnoDB zur neuen Standardengine ernannt. Diese weist so ziemlich alle bei MyISAM fehlenden Eigenschaften auf und machte MySQL erst zu einer (nunja zumindest etwas) ernstzunehmenden Datenbank.

Und folgerichtig (genaugenommen, weil alles andere technisch deutlich aufwendiger wäre) unterstützt Galera-Cluster ausschließlich das Replizieren von InnoDB-Datenbanken.

So weit, so gut – gäbe es da nicht ein klitzekleines Detail am Rande: in jedem MySQL-Datenbanksystem gibt es immer noch mindestens eine MyISAM-Datenbank und zwar die MySQL-Systemdatenbank! Also die Datenbank, in der ein MySQL selbst speichert, welche Datenbanken mit welchen Tabellen und welchen Spalten es gibt, welche Nutzer mit welchen Passwörtern von wo zugreifen können, usw.

Hmm, das würde ja heißen, dass die Systemdatenbank bei Galera-Cluster nicht mitrepliziert werden würde, oder? Das kann doch nicht wirklich sein?

Nunja, doch!:

Currently replication works only with the InnoDB storage engine. Any writes to tables of other types, including system (mysql.*) tables are not replicated (this limitation excludes DDL statements such as CREATE USER, which implicitly modify the mysql.* tables — those are replicated).

Beispielszenario gefällig? Gegeben sei ein Cluster aus zwei Knoten; die Datenbanknutzer werden bei jeder neuen Verbindung per Load-Balancer auf einen der beiden geleitet. Der Nutzer ändert mittels UPDATE in der Tabelle mysql.user sein Passwort. Folge: sein Passwort wurde auf dem aktuellen Knoten geändert, aber leider wird der zweite Knoten davon nicht unterrichtet. Ab sofort hat der Nutzer also eine 50%-Chance, dass seine nächste Anmeldung am Server funktionieren wird.

Nun klingt es vielleicht abenteuerlich, sein Passwort direkt in den Systemtabellen zu ändern statt die (im Cluster auch replizierten) Higher-Level-SQL-Befehle (in diesem Fall ALTER USER) zu verwenden, aber etliche Datenbankadministrationswerkzeuge (auch die von uns selbst geschriebenen) gehen direkt über die Systemdatenbank. In unserem Fall werden wir uns wohl einige Workarounds ausdenken müssen…

Die oben verlinkte “Mängelliste” weist übrigens noch weitere Highlights auf. Der folgende Hack klingt noch halbwegs sympathisch:

If you are using mysqldump for state transfer, and it failed for whatever reason […], you will see an SQL SYNTAX error in the server error log. Don’t let it fool you, this is just a fancy way to deliver a message (the pseudo-statement inside of the bogus SQL will actually contain the error message).

Spannender finde ich die unglaublich konkreten Angaben, wie groß Transaktionen werden dürfen (man beachte die literarisch wertvolle Verwendung von “might”, “can” und “less fortunate scenario”):

Do not use transactions of any essential size. Just to insert 100K rows, the server might require additional 200-300 Mb. In a less fortunate scenario it can be 1.5 Gb for 500K rows, or 3.5 Gb for 1M rows…

Aber dieser Punkt endet noch besser:

…See MDEV-466 for some numbers (you’ll see that it’s closed, but it’s not closed because it was fixed).

Mein persönliches Highlight ist aber das Folgende:

Locking is lax when DDL is involved. For example, if your DML transaction uses a table, and a parallel DDL statement is started, in the normal MySQL setup it would have waited for the metadata lock, but in Galera context it will be executed right away. It happens even if you are running a single node, as long as you configured it as a cluster node…

Im Prinzip steht da nicht weniger als: Wenn du echtes ACID willst (ja, DML und DDL gleichzeitig sollte deterministisch möglich sein), kümmer dich selbst drum! Aber wenigstens werden die Konsequenzen bzw. die Unkenntnis selbiger nicht beschönigt:

…See also MDEV-468. This behavior might cause various side-effects, the consequences have not been investigated yet. Try to avoid such parallelism.

Hmm, eigentlich wollte ich diesen Artikel noch mit einem schmissigen Satz ala “ich werde dennoch hochmotiviert an die neue Herausforderung herantreten” beenden, aber der Freitagnachmittag ist schon zu weit fortgeschritten…

Read-Only-Nutzer in PostgreSQL

Nur damit ich nicht immer wieder nachschlagen muss (oder, was wahrscheinlicher ist, einen Teil vergesse)

So wird in PostgreSQL ein universeller Read-Only-Nutzer richtig angelegt:

CREATE ROLE nutzer LOGIN PASSWORD 'passwort' NOINHERIT;
GRANT CONNECT ON DATABASE datenbank TO nutzer;
\c datenbank;

-- Zugriffe auf existierende Objekte
GRANT USAGE ON SCHEMA public TO nutzer;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO nutzer;
GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO nutzer;
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO nutzer;

-- Zugriffe auf zukünftige Objekte
ALTER DEFAULT PRIVILEGES FOR ROLE dbowner IN SCHEMA public GRANT SELECT ON TABLES TO nutzer;
ALTER DEFAULT PRIVILEGES FOR ROLE dbowner IN SCHEMA public GRANT SELECT ON SEQUENCES TO nutzer;
ALTER DEFAULT PRIVILEGES FOR ROLE dbowner IN SCHEMA public GRANT EXECUTE ON FUNCTIONS TO nutzer;

Spaß im Alltag bei der Datenbankwiederherstellung

tl;dr
Wenn man bei PostgreSQL einzelne gelöschte Datenbanken, deren Nutzer sowie alle damit zusammenhängenden Berechtigungen wiederherstellen möchte, reichen pg_dump und pg_dumpall --globals-only nicht aus. Zusätzlich muss man kreativ im vollständigen Dump von pg_dumpall rumgreppen.

Wie ich schon in mehreren Blogartikeln schrub, ist die Betätigung im Umfeld von PostgreSQL lehrreich und bisweilen überraschend. Das Folgende wird ein etwas längeres Posting, da der Kontext des beschriebenen Phänomens/Problems etwas umfangreicher ist.

Daneben sollte ich für einige kritische Leser evtl. noch vorher erwähnen, dass ich den ganzen Kram hier auch schreibe, damit ich mich später erinnere, warum ich es so gemacht habe…

Hintergrund

Wie sicherlich die meisten meiner Leser wissen, betreut meine Wenigkeit hauptberuflich (und das natürlich nur neben unzähligen anderen Dingen 😉 ) die zentralen PostgreSQL-Datenbankserver der Humboldt-Universität, auf denen Wissenschaftler, Projektmitarbeiter, usw. ihre Datenbanken ablegen können.

Auf PostgreSQL-Ebene wird das Ganze so umgesetzt, dass jeder Nutzer nach Beantragung ein oder mehrere Datenbanken (in den folgenden Beispielen “datenbank” genannt) mit je einem eigenen Datenbankaccount (im Folgenden “nutzer”) erhält. Zusätzlich kann es je nach Bedarf weitere Datenbankaccounts (im Folgenden “unternutzer”) mit optional eingeschränkten Zugriffsrechten geben.

Standardrechte in PostgreSQL

Bereits beim Anlegen der Datenbanken muss man tierisch aufpassen, da das Standardverhalten von PostgreSQL offensichtlich für andere Einsatzszenarien vorgesehen ist: auf eine neu angelegte Datenbank hat automatisch jeder, dem Server bekannte Nutzer Zugriff!

Um dies zu verhindern, lautet die erste Anweisung, die bei uns automatisch bei jeder neuen Datenbank ausgeführt wird:

REVOKE ALL ON DATABASE datenbank FROM PUBLIC;

Damit werden der magischen Rolle PUBLIC alle Rechte entzogen. Dass der Nutzer dennoch an seine Datenbank kommt, liegt daran, dass sein Account als Owner der Datenbank weiterhin alle Rechte besitzt.

Da dies jedoch nicht für die weiteren Accounts des Nutzers gilt, müssen für diese die Zugriffsberechtigungen (bei uns ebenfalls automatisch) nachgetragen werden:

GRANT CONNECT ON DATABASE datenbank TO unternutzer;

Die zweite Falle lauert eine Ebene tiefer: Auf das Standardschema namens public (nicht zu verwechseln mit der bereits genannten, gleichnamigen Rolle) darf jeder mit der Datenbank verbundene Nutzer (egal welchen Einschränkungen er sonst unterliegt) per default eigene Objekte (also Tabellen, Views, usw.) anlegen.

Also folgen bei uns den bereits genannten Befehlen beim Anlegen einer Datenbank noch:

\c datenbank;
REVOKE ALL ON SCHEMA public FROM PUBLIC;
GRANT ALL ON SCHEMA public TO nutzer;

…sowie für jeden einzelnen Unteraccount:

\c datenbank;
GRANT USAGE ON SCHEMA public TO unternutzer;

Soweit so gut – kommen wir zum Backup:

Logisches Backup/Restore im Allgemeinen

Bei PostgreSQL hat man, wenn man nur logische Backups betrachtet, die Wahl zwischen 2,5 Werkzeugen: pg_dump, pg_dumpall und pg_dumpall mit dem Schalter -g.

Ohne zu tief in die Details einzusteigen (die ich teilweise schon an anderer Stelle beschrieben habe), leisten die Werkzeuge folgendes:

  • pg_dump zieht eine einzelne Datenbank ab
  • pg_dumpall zieht den gesamten Cluster ab (Datenbanken und sonstige Objekte, z.B. Rollen)
  • pg_dumpall -g zieht nur die sonstigen Objekte (globals) ab

Wir lassen täglich alle genannten Werkzeuge laufen: je einmal pg_dumpall und pg_dumpall -g auf jedem unserer Cluster und dann noch einmal pg_dump für jede einzelne Datenbank.

Interessant ist nun die Frage, welches Backup wir zur Wiederherstellung verwenden und die wenig überraschende Antwort lautet: Kommt drauf an…

Wenn man den gesamten Cluster wiederherstellen muss, ist die Antwort leicht: man legt einen neuen Cluster an und spielt den pg_dumpall-Dump ein (kam bisher erfreulicherweise nur geplant bei großen Datenbankupdates vor).

Hat ein Nutzer nur eine Tabelle gelöscht, kann man mit dem pg_dump-Dump sehr selektiv nur diese wiederherstellen (kommt sehr selten vor).

Der mit Abstand häufigste Fall ist jedoch das komplette Wiederherstellen einer einzelnen Datenbank, gerne nachdem wir(!) sie gelöscht haben – nein, nur weil ein Nutzer sagt, dass er eine Datenbank nicht mehr braucht, heißt nicht, dass er sie nicht mehr braucht – und bisweilen merkt er das erst deutlich später. In diesem Fall existiert nicht nur die Datenbank nicht mehr; auch die Datenbankaccounts haben durch uns bereits das Zeitliche gesegnet.

Aus diesem Grund hilft dann auch der reine pg_dump-Dump nicht mehr weiter, da dieser die Existenz aller Datenbankaccounts voraussetzt.

Logisches Backup/Restore einzelner Datenbanken inkl. Nutzer

Naiver Ansatz: man greppt aus dem pg_dumpall -g (geht deutlich schneller als den pg_dumpall-Dump zu nehmen) alle CREATE- und ALTER-ROLE-Statements raus und führt diese vor dem Einspielen des Dumps aus. Dieser Ansatz hat aber leider zwei Nachteile: erstens muss man alle betroffenen Datenbankaccounts kennen und, viel schlimmer, einige Berechtigungen werden nicht wiederhergestellt. pg_dump speichert nämlich nur die Informationen inklusive Berechtigungen die sich innerhalb einer Datenbank befinden. Das CREATE-DATABASE-Statement legt pg_dump nur auf besonderen Wunsch und dann aber auch nur mit dem ganz oben beschriebenen Standardverhalten (alle haben Zugriff) an.

Der pg_dumpall-Dump enthält alle notwendigen Informationen; leider kann man aus diesem aber keine einzelnen Datenbanken wiederherstellen (es sei denn, man editiert von Hand in dem riesigen Dumpfile, bei uns im GB-Bereich, rum).

Als Lösung verwenden wir folgenden Ansatz: wir greppen im pg_dumpall-Dump nach allen GRANTS auf die wiederherzustellende Datenbank und greppen die CREATE-ROLE-Anweisungen der dabei gefundenen Datenbankaccounts dann aus dem pg_dumpall -g-Dump. Dann müssen wir nur das CREATE-DATABASE- sowie die GRANT- und REVOKE-Statements aus dem pg_dumpall-Dump holen.

Sobald wir PostgreSQL mit den gesammelten SQL-Anweisungen gefüttert haben, erhalten wir eine leere Datenbank samt aller Nutzer und deren Berechtigungen, die nur noch darauf wartet, mittels des pg_dump-Dumps befüllt zu werden.

Das beschriebene Zusammenklauben aller SQL-Anweisungen leistet das folgende Shellskript:

#!/bin/bash

# Nach allen Nutzern suchen, die Rechte
# auf der Datenbank haben...
for i in `\
	grep "^GRANT .* ON DATABASE $DATABASE TO" $PGDUMPALLFILE \
	| sed "s/^GRANT .* ON DATABASE $DATABASE TO \(.*\);/\1/" \
	`;
do
	# ... und zu diesen Nutzern die CREATE- und
	# ALTER-ROLE-Anweisungen holen
	fgrep -w "ROLE $i" $PGDUMPALLGLOBALFILE ;
done

# Die CREATE- und ALTER-DATABASE- sowie die zugehörigen
# REVOKE- und GRANT-Anweisungen holen
fgrep -w "DATABASE $DATABASE" $PGDUMPALLFILE;

Nur der Vollständigkeit halber – der anschließende Aufruf von pg_restore sieht folgendermaßen aus:

pg_restore -1 -d $DATABASE $PGDUMPFILE

Yep, PostgreSQL macht Spaß…

Das andere Beste aus zwei Welten

Wie ich schon irgendwie erwartet hatte, gefiel Uwe meine Regex-SQL-Mischung nicht.

Mit seiner Erlaubnis veröffentliche ich im Folgenden seinen (von mir reparierten 🙂 ) Gegenvorschlag:

select pers_name, sp_id, max(strikesequence) as strikesinarow from
( select *,
        length(
            unnest(
                string_to_array(
                    regexp_replace(
                        regexp_replace(
                            concat(
                                spiel.sp_w01_1, ',',
                                spiel.sp_w02_1 , ',',
                                spiel.sp_w03_1 , ',',
                                spiel.sp_w04_1 , ',',
                                spiel.sp_w05_1 , ',',
                                spiel.sp_w06_1 , ',',
                                spiel.sp_w07_1 , ',',
                                spiel.sp_w08_1 , ',',
                                spiel.sp_w09_1 , ',',
                                spiel.sp_w10_1 , ',',
                                spiel.sp_w10_2 , ',',
                                spiel.sp_w10_3 , ','
                            ), '10,', 'X', 'g'
                        ), '[^X]+', '-', 'g'
                    ), '-'
                )
            )
        ) as strikesequence
    from spiel natural join person
) as sub
group by pers_name, sp_id
order by pers_name, strikesinarow desc; 

Sein Ansatz ist definitiv kürzer und beim tippen deutlich weniger fehleranfällig, dafür aber IMHO nicht ganz so leicht nachvollziehbar.

Unbestritten sollte jedoch sein, dass weder seine noch meine Lösung in Produktivsystemen eingesetzt werden sollten…

Das Beste aus zwei Welten

SQL und Regex – zwei Garanten für genialen und im Nachhinein völlig unverständlichen Code. Was liegt also näher, als beides zu kombinieren…

Im konkreten, unglaublich wichtigen Problemfall ging es darum, aus einer Tabelle mit Bowlingergebnissen die Anzahl der unmittelbar nacheinandergeworfenen Strikes (strikes in a row) zu berechnen.

Ja, mit einer PROCEDURE wäre das Ganze trivial, aber es bestand der Wille, das Ganze in Plain-SQL hinzubekommen, also insbesondere ohne Schleifen. Dank regulären Ausdrücken klappt das nicht nur, sondern sieht darüber hinaus auch noch unglaublich ästhetisch aus.

Die zugrundeliegende Tabelle enthält in den Spalten sp_w01_1, sp_w01_2, sp_w02_1 usw. die im jeweiligen Wurf gefallenen Pins (w01_1 == erster Frame, erster Wurf; w01_2 == erster Frame, zweiter Wurf usw.). Für das Aufsummieren der Strikes sind nur die jeweils ersten Würfe interessant, lediglich im zehnten Frame müssen auch der zweite und dritte Wurf berücksichtigt werden.

Die folgende SQL-Abfrage liefert das Gewünschte – eine Erklärung für Denkfaule folgt darunter

select pers_name, sp_id,

regexp_replace(
regexp_replace(
regexp_replace(
regexp_replace(
regexp_replace(
regexp_replace(
regexp_replace(
regexp_replace(
regexp_replace(
regexp_replace(
regexp_replace(
regexp_replace(
regexp_replace(
regexp_replace(

CASE WHEN spiel.sp_w01_1 = 10 THEN 'X'::text ELSE '-'::text END ||
CASE WHEN spiel.sp_w02_1 = 10 THEN 'X'::text ELSE '-'::text END ||
CASE WHEN spiel.sp_w03_1 = 10 THEN 'X'::text ELSE '-'::text END ||
CASE WHEN spiel.sp_w04_1 = 10 THEN 'X'::text ELSE '-'::text END ||
CASE WHEN spiel.sp_w05_1 = 10 THEN 'X'::text ELSE '-'::text END ||
CASE WHEN spiel.sp_w06_1 = 10 THEN 'X'::text ELSE '-'::text END ||
CASE WHEN spiel.sp_w07_1 = 10 THEN 'X'::text ELSE '-'::text END ||
CASE WHEN spiel.sp_w08_1 = 10 THEN 'X'::text ELSE '-'::text END ||
CASE WHEN spiel.sp_w09_1 = 10 THEN 'X'::text ELSE '-'::text END ||
CASE WHEN spiel.sp_w10_1 = 10 THEN 'X'::text ELSE '-'::text END ||
CASE WHEN spiel.sp_w10_2 = 10 THEN 'X'::text ELSE '-'::text END ||
CASE WHEN spiel.sp_w10_3 = 10 THEN 'X'::text ELSE '-'::text END,

'.*XXXXXXXXXXXXX.*'::text, '13'::text, 'g'::text),
'.*XXXXXXXXXXXX.*'::text, '12'::text, 'g'::text),
'.*XXXXXXXXXXX.*'::text, '11'::text, 'g'::text),
'.*XXXXXXXXXX.*'::text, '10'::text, 'g'::text),
'.*XXXXXXXXX.*'::text, '9'::text, 'g'::text),
'.*XXXXXXXX.*'::text, '8'::text, 'g'::text),
'.*XXXXXXX.*'::text, '7'::text, 'g'::text),
'.*XXXXXX.*'::text, '6'::text, 'g'::text),
'.*XXXXX.*'::text, '5'::text, 'g'::text),
'.*XXXX.*'::text, '4'::text, 'g'::text),
'.*XXX.*'::text, '3'::text, 'g'::text),
'.*XX.*'::text, '2'::text, 'g'::text),
'.*X.*'::text, '1'::text, 'g'::text),
'.*-.*'::text, '0'::text, 'g'::text)

::integer AS strikesinarow

from spiel natural join person
order by pers_name, strikesinarow desc;

Zuerst wird ein String zusammengesetzt, in dem die gesamte Abfolge Strikes/Nichtstrikes des gesamten Spiels codiert ist ('X' == Strike, '-' == kein Strike).

Dann geht die Reg(h)exerei los: Sollte der String 13 mal hintereinander 'X' enthalten, so wird er (als Ganzes) durch den String "13" ersetzt. Sollte der String 12 mal hintereinander 'X' enthalten, so wird er (wieder als Ganzes) durch den String "12" ersetzt, usw. Nur bei einem Strike-losen Spiel wird der String am Ende durch den String "0" ersetzt.

Nunja, und anschließend muss der String (der ja nun die gesuchte Zahl enthält) nur noch in einen Integer umgewandelt werden…

Die Datenbank im Schatten der anderen

Unser täglich MySQL- … ähm, moment … PostgreSQL-Rant gib uns heute? Tja, auch PostgreSQL enthält für den DB-Admin die eine oder andere Überraschung bereit… Wobei Rant übertrieben ist, schließlich ist alles Folgende nachvollziehbar und letztendlich auch sauber dokumentiert (ja, ich bleibe PostgreSQL-Fan).

Im Konkreten geht es um das Backup-Werkzeuggespann pg_dump/pg_restore und vor allem einigen Schaltern bzw. Schalterkombination von letztgenanntem Programm.

Angenommen, man möchte einen Datenbankdump der Datenbank “kochbuch” im Binärformat ziehen (was für ein abwegiger Beispieldatenbankname), dann sollte der Aufruf von pg_dump in etwa so aussehen:

pg_dump -Fc -w kochbuch > kochbuch.dump
# -Fc == Binärdumpformat
# -w == keine Passwortabfrage

So weit, so uninteressant. Wie bei jedem Backup ist die viel spannendere Frage, wie das Backup wieder zurück in die Datenbank kommt. An dieser Stelle kommt pg_restore ins Spiel, und zwar bei uns im Idealfall so:

pg_restore kochbuch.dump -v -1 -c -d kochbuch
# -v == verbose
# -1 == alles in einer Transaktion
# -c == löscht vorher alle anzulegenden Objekte (clean restore)
# -d == die Datenbank, mit der sich pg_restore zur Wiederherstellung verbindet

Die zu sehende Schalterkombi stellt eigentlich das Optimum dar, wenn man die Datenbank vollständig in den Zustand des damaligen Backups zurückversetzen möchte, was bei uns das Standardszenario ist.

Das -c sorgt insbesondere dafür, dass alle neueren Tabelleninhalte eliminiert werden; das Einspielen in einer Transaktion mittels -1 sorgt dafür, dass schon beim ersten Fehler das gesamte Einspielen vollständig revertiert wird, d.h. am Ende des Befehls hat man entweder eine sauber zurückgespielte Datenbank oder aber den unveränderten Zustand vor dem Einspielversuch.

Dummerweise kann man die Schalterkombi im realen Leben nicht verwenden. Zwei Negativbeispiele (es gibt sicher weitere) ergeben sich schon dann, wenn seit dem Backup Tabellen gelöscht oder hinzugefügt wurden. In ersterem Fall schlägt das Löschen der gelöschten Tabelle fehl, in zweiterem das Löschen des Schemas, da die neuen Tabellen noch drinliegen. Beide Fälle sorgen dafür, dass die Transaktion, in diesem Fall nur halbgewollt, abbricht.

Man muss also die -1 weglassen und die Warnungen ob der nicht löschbaren Objekte ignorieren (das Weglassen nur des -c unter Beibehaltung von -1 ist i.d.R. gar nicht möglich, da das Einfügen der bereits enthaltenen Elemente unweigerlich zum Abbruch der Transaktion führt) – unschön.

Also härtere Bandagen: vollständiges Löschen der existierenden Datenbank und Einspielen des Dumps in eine jungfräuliche, da neuangelegte Datenbank.

Erfreulicherweise kennt pg_restore den Schalter -C, der laut --help die zweite Hälfte leisten soll (“-C, create the target database“). Also frisch ans Werk – erst die Datenbank mittels dropdb löschen und dann neu Erstellen/Einspielen:

$ dropdb kochbuch
$ pg_restore /tmp/test.dump -v -C -d kochbuch
pg_restore: connecting to database for restore
pg_restore: [archiver (db)] connection to database "kochbuch" failed: FATAL: database "kochbuch" does not exist
pg_restore: *** aborted because of error

Dat war dann wohl nix! Die Datenbank kann nicht angelegt werden, da er sich mit der neu anzulegenden Datenbank nicht verbinden kann? Hmm, --help reicht offensichtlich nicht aus, also rein in die Doku (in meinem Fall von Version 9.1) und dort steht dann auch die zumindest für mich überraschende Zeile:

(When this option [-C] is used, the database named with -d is used only to issue the initial CREATE DATABASE command. All data is restored into the database name that appears in the archive.)

Ich muss mich also mit irgendeiner wildfremden Datenbank verbinden, damit PostgreSQL dann von dieser Datenbank aus die eigentlich wiederherzustellende Datenbank erzeugen kann – irgendwie nachvollziehbar, aber intuitiv geht anders.

Anyway, die Lösung lautet also (nur ein Beispiel, ich habe viele Datenbanken, die als Verbindungsdatenbank herhalten können)

$ dropdb kochbuch
$ pg_restore /tmp/test.dump -v -C -d postgres

…und schon macht pg_restore genau das Gewollte:

pg_restore: connecting to database for restore
pg_restore: creating DATABASE kochbuch
pg_restore: connecting to new database "kochbuch"
usw.

Noch ein Hinweis: ab Postgres 9.2 kann man (endlich) -c und -C auch kombinieren (vorher ergibt das ein “-C and -c are incompatible options“), so dass das Löschen und Neuanlegen der Datenbank in einem Befehl erfolgt. Aber auch hier gilt dann, dass man eine unbeteiligte Datenbank angeben muss, von der aus das DROP DATABASE und das CREATE DATABASE aus durchgeführt wird.

Wieder was gelernt, womit man vor Anfängern als PostgreSQL-Guru dastehen kann 🙂

SSD – HD – LAN – SAN – DB?

Zur Zeit besuche ich eine Lehrveranstaltung zum Thema Implementation von Datenbanksystemen (was berufliche Fortbildungen angeht, ist es durchaus nützlich, an einer Uni zu arbeiten).

In einer der ersten Vorlesungen ging es um das Thema, auf welchen Medien man Datenbanken prinzipiell ablegen kann/sollte und welche Vor-/Nachteile insbesondere bzgl. Geschwindigkeit mit einer konkreten Wahl verbunden sind.

Auch wenn die Wahl auf den von mir betreuten Uni-Datenbankservern längst getroffen wurde – alle unsere Datenbanken liegen aus verschiedenen Gründen im SAN – und Geschwindigkeit bei unseren DB-Servern eine eher untergeordnete Rolle spielt, war dennoch meine Neugierde geweckt, die prinzipiell zur Verfügung stehenden Optionen mal grob quantitativ zu untersuchen.

Eines vorneweg: die Testszenarien sind zugegebenermaßen alles andere als perfekt – mir ging es primär darum, grobe Größenordnungen zu ermitteln.

Als Testrechner fungierte mein Arbeitsplatzrechner, über den ich folgende Medien testen konnte:

  1. eine Solid-State-Disk, angeschlossen per SATA (3.0 Gbps)
  2. eine normale Festplatte, angeschlossen per SATA (3.0 Gbps)
  3. eine NFS-Platte, angebunden per Ethernet (1.0 Gbps)
  4. eine SAN-Platte, angebunden per zweier Fibre-Channel-Karten (je 8.0 Gbps)

Man sieht sofort, dass sich die ersten beiden Testszenarien nur schwer auf einen echten Server übertragen lassen. Bei Servern sind die lokale Platten üblicherweise per RAID-Controller angebunden, der wiederum via PCI-Express angeschlossen ist und so, mehrere parallel arbeitende Platten vorausgesetzt, den SATA-Engpass überwinden kann. Dafür kann man dank dieser beiden Szenarien schön SSD und HD miteinander vergleichen.

Das dritte und das vierte Szenario sind hingegen durchaus realistisch. In unseren Servern stecken exakt die gleichen Fibre-Channel-Karten und die (virtuelle) SAN-Platte liegt auf den gleichen Platten wie die unserer “echten” Server (und konkurrierte auch bei Zugriffen mit diesen). Auch an das LAN sind unsere Server nicht schneller als 1Gbps angebunden. Allerdings muss man erwähnen, dass der verwendete NFS-Fileserver die Daten selbst auch wieder auf einer SAN-Platte speichert. Beim LAN-SAN-Vergleich kann man also lediglich den zusätzlichen Overhead des LANs ermitteln.

Zusammenfassend kann man bis hierher also festhalten, dass man nur die Testergebnisse der ersten und der letzten beiden Szenarien (und letztere mit der genannten Einschränkung) untereinander vergleichen sollte.

Als Testsystem kam PostgreSQL 9.2.1 zum Einsatz – zum einen, da ich nach wie vor ein unverbesserlicher Postgres-Fan bin und zum anderen, weil PostgreSQL standardmäßig ein Benchmark-Programm, pgbench, mitbringt. Alle Parameter des Datenbanksystems wurden in der Default-Stellung belassen.

Die von pgbench verwendete Test-Datenbank, mit deren Hilfe das Programm eine Art TPC-B-Test durchführt, wurde einmal mit folgenden Parametern initialisiert:

pgbench -i -F 100 -s 250 pgbench

Durch diese Parameterwahl wurde die Testinstanz ca. 3.9GB groß (insbesondere für die Vergleichbarkeit der SAN-Messungen wichtig, da bei kleineren Testinstanzen die Gefahr besteht, dass sich der gesamte DB-Benchmark im Cache des SAN-Virtualisierungsservers abspielt). Danach wurde die Instanz per rsync auf die verschiedenen Medien verteilt, so dass jedes Postgres initial auf einer identischen Instanz arbeitete.

Zum Testen wurde pgbench mit folgenden drei Parameter-Mengen gestartet:

pgbench -v -c 1 -T 3600 pgbench
pgbench -v -c 10 -T 3600 pgbench
pgbench -v -c 100 -T 3600 pgbench

d.h. die Tests liefen jeweils eine Stunde und es wurden nacheinander Benchmarks mit einem, zehn und hundert pseudoparallel arbeitenden DB-Clients durchgeführt.

Im folgenden sind die gemessenen Ergebnisse aufgelistet (alle Angaben in tps):

SSD HD LAN SAN
1 Client 143 45 72 125
10 Clients 211 135 160 1758
100 Clients 228 188 267 2888

Die einzelnen Schlußfolgerungen überlasse ich dem geneigten Leser selbst. Wie mehrfach oben beschrieben, lassen sich die Testszenarien nur mit Einschränkungen untereinander vergleichen.

Ich muss allerdings zugeben, dass mich die SAN-Performance wirklich überrascht hat (und das, obwohl ich bereits vorher Verfechter von SAN-Lösungen – auch im DB-Bereich – war) und zwar insbesondere im (nicht ganz sauberen – siehe oben) Vergleich zur SSD.

Insofern kann ich diesen Blogpost eigentlich nur mit einem herzlichen Dank an die SAN-Gruppe des CMS abschließen: Macht weiter so! 🙂