PHP Magazin

PHP, JavaScript, Open Web Technologies
X

Schon abgestimmt? Quickvote: Ist TDD der Weisheit letzter Schluss?

Da ist mehr drin

PostgreSQL mit PL/Proxy skalieren

Hans-Jürgen Schönig

Die Skalierung relationaler Datenbanken ist einer der interessantesten aber auch definitiv auch einer der kompliziertesten Tasks. In den letzten Jahren gab es immer wieder verschiedenste Ansätze, die es ermöglichen sollten, den Durchsatz eines Datenbanksystems zu erhöhen. In diesem Artikel wollen wir einen vollkommen neuen und faszinzierenden Ansatz namens PL/Proxy vorstellen.

Seit ich 1999 begonnen habe, mich intensiv mit PostgreSQL zu beschäftigen, hat sich so Einiges verändert. Zu dieser Zeit zählten Datenbanken, die größer als vielleicht 5 GB waren, durchaus bereits zu den größeren ihrer Art. Ich kann mich noch gut erinnern, dass mein damaliger Chef noch groß mit unserem 10-GB-Datenbestand geprahlt hat. Im Jahre 1999 konnte man mit solchen Dingen noch durchaus ein wenig Eindruck schinden. Durch die rapide Weiterentwicklung von Hard- und Software haben sich die Dinge jedoch in vielen Bereichen massiv verändert, und heutzutage ist im professionellen Umfeld mit 5 oder 10 GB Daten wohl kaum noch jemand zu beeindrucken. In Zeiten von 1-TB-Festplatten haben sich die Grenzen verschoben.

Im Wandel der Zeit

Es gibt jedoch auch eine Reihe von Anwendungen denen auch mit modernen Rechnern nur schwer beizukommen ist. Das liegt zu einem Teil daran, dass der Speicherhunger moderner Anwendungen wesentlich größer ist als noch vor einigen Jahren - es werden schlichtweg mehr Daten gespeichert. Des Weiteren erreichen immer mehr Anwendungen immer mehr Menschen. Waren es früher noch statische Websites, die das Auge des Betrachters beglückten, so findet man heute Massen an dynamischem Content. Auch die zur Verfügung stehenden Technologien sind reifer geworden und fressen immer mehr Leistung.

Im Wandel der Zeit hat sich eine Tatsache jedoch kaum verändert: Irgendwo müssen Daten abgelegt werden. Und wo Daten sind, da wird auch abgefragt. Relationale Datenbanken bilden somit in vielen Fällen den Kern einer Anwendung - leider oft auch den Bottleneck.

Ist man jetzt in der unglücklichen Lage, dass die Datenbank einen Bottleneck bildet, gibt es einige verschiedene Wege, die man einschlagen kann:

Hardware

Der einfachste Weg, ist es zu versuchen, das Problem "mit Hardware zu erschlagen". Das Problem dabei ist, dass das nur begrenzt möglich ist und mitunter durchaus ins Geld gehen kann. Steigt die Last, wird man irgendwann nicht mehr in der Lage sein, noch größere Maschinen zu kaufen, um den Ansturm an Anfragen zu bewältigen.

Das Spielen der "Hardwarekarte", sofern das überhaupt möglich ist, sollte also bei wirklich großen Anwendungen die letzte Option sein.

Asynchrone Replikation

Wenn es um Skalierung geht, fällt im Zusammenhang mit PostgreSQL sehr oft der Begriff "Replikation". Leider ist für viele Menschen Replikation so etwas wie eine Wundertüte, die alle Probleme aus der Welt zaubert. Dem ist leider nicht so. Um den PL/Proxy-Ansatz vollständig zu verstehen, ist es wichtig, mit einigen falschen Vorannahmen im Umgang mit Replikation aufzuräumen.

Im Wesentlichen gibt es zwei Arten von Replikation: Im Fall von asynchroner Replikation werden Daten repliziert, nachdem sie erfolgreich geschrieben worden sind. Das kann mitunter zu einem etwas lästigen Problem führen. Stellen wir uns einen Cluster mit 2 Rechnern vor, dem ein Load Balancer vorgeschalten ist:

Tab. 1.: Cluster mit vorgeschaltetem Load Balancer
Server 1 Server 2
INSERT INTO user VALUES ('paul')
SELECT * FROM user WHERE username = 'paul'
-> Daten werden repliziert

Ein Benutzer registriert sich auf einer Website als paul. Hinter den Kulissen wird dieser Zugriff auf Server 1 ausgeführt. Der Benutzer klickt den Submit-Knopf und erwartet eigentlich, dass er jetzt angemeldet ist. Wenn es der Zufall will, wird paul aber vom Load Balancer auf den zweiten Server geschickt. Das führt in der Praxis dazu, dass sich der Benutzer zwar angemeldet hat, sich jedoch möglicherweise nicht wiederfindet, bis eine gewisse Zeit vergangen ist.

Asynchrone Replikation eignet sich also kaum für Systeme, die zu jedem Zeitpunkt konsistent sein müssen. Des Weiteren ist es nicht möglich, Schreibvorgänge zu skalieren, wie das fälschlicherweise oft vermutet wird. Der Grund dafür ist relativ einfach: Sofern Daten verändert werden, muss auf beiden Maschinen geschrieben werden. Das Schreiben auf vielen Maschinen kann unter keinen Umständen schneller als das Schreiben auf einem einzelnen Knoten sein.

Mit asynchroner Replikation kann man also die Verfügbarkeit und unter gewissen Fällen die Lesegeschwindigkeit erhöhen - es ist aber keinesfalls möglich, die Schreibgeschwindigkeit zu skalieren.
Für viele Anwendungen reicht dieses Replikationsverfahren jedoch völlig aus. Denken wir beispielsweise an einen Wetterbericht. Es ist völlig irrelevant, ob der Wetterbericht für Tokyo 2 Sekunden oder 2 Minuten nach seiner Erstellung in Frankfurt eintrifft, da dir vorhergesagte Gewitterfront ohnehin erst in 3 Tagen eintreffen wird.

Synchone Replikation

Um die Probleme mit asynchroner Replikation aus dem Weg zu räumen, kann man synchrone Replikation verwenden. Für PostgreSQL gibt es in diesem Zusammenhang einige Open-Source-Implementierungen wie den Cybercluster oder PGCluster.

Bei synchroner Replikation müssen alle Knoten beim Abschluß einer Transaktion konsistent sein. Schematisch sieht das Ganze dann wie in Tabelle 2 aus:

Tab.2.: Synchrone Replikation
Server 1 Server 2
BEGIN; BEGIN;
INSERT INTO user VALUES ('paul'); INSERT INTO user VALUES ('paul');
COMMIT; COMMIT;

Alle schreibenden Transaktionen müssen sich "On-Commit" darüber unterhalten, ob auch wirklich auf allen Maschinen committet werden kann. Das sorgt zwar dafür, dass die Dinge in sich konsistent bleiben, führt aber eine weitere Unbekannte ein, die das ganze Unterfangen wesentlich erschwert: Network Latency.

Sehen wir uns ein kleines Listing an:

mac:~ hs$ ping www.linux-magazin.de
PING www.linux-magazin.de (80.237.227.140): 56 data bytes
64 bytes from 80.237.227.140: icmp_seq=0 ttl=53 time=26.226 ms
64 bytes from 80.237.227.140: icmp_seq=1 ttl=53 time=26.588 ms
64 bytes from 80.237.227.140: icmp_seq=2 ttl=53 time=26.179 ms
64 bytes from 80.237.227.140: icmp_seq=3 ttl=53 time=27.453 ms

Wenn ich in Wien einen Ping absetze, dauert es etwa 26 ms, bis ich vom entsprechenden Webserver eine Antwort bekomme. Theoretisch kann ich pro Sekunde also 40 Anfragen schaffen. Die Zeit, die für den reinen Netzwerktransfer verloren geht, ist also signifikant. Im Vergleich:

test=# INSERT INTO t_user VALUES ('paul');
INSERT 0 1
Time: 1.293 ms

Die Zeit, die für das Netzwerk benötigt wird, ist in diesem Beispiel bereits 20 mal höher. Klarerweise handelt es sich hier um ein besonders abschreckendes Beispiel - in der Praxis wird man jedoch sehen, dass es durch den Synchronisationsprozess durchaus zu signifikanten Einbrüchen bei der Schreibperformance kommen kann. Im Gegenzug wird die Leseperformance jedoch linear mit der Anzahl der Knoten skalieren.

Synchrone Multimaster-Replikation wird also besonders dann gut geeignet sein, wenn sehr viel gelesen wird (99 % oder mehr) und Konsistenz von essenzieller Bedeutung ist. Des Weiteren ist es wichtig, dass die Distanzen zwischen den einzelnen Knoten möglichst klein sind, um die Schreibgeschwindigkeit entsprechend halten zu können. Synchrone Replikation über große Distanzen ist denkbar sinnlos (dafür nehme man asynchrone Replikation).

PL/Proxy - ein innovatives Konzept

Wie man sich vorstellen kann, kann ein Multimaster-Cluster nicht unendlich skalieren, weil der Kommunikations-Overhead im Fall einer Schreiboperation irgendwann schlichtweg viel zu groß ist. Wie kann man also skalieren, wenn Konsistenzüberlegungen relevant sind, der Cluster eine gewisse Größe überschreitet und die Datenmenge ins Astronomische abgleitet?

Diese Frage hat sich wohl auch Hannu Krosing, der Database Chief Architect von Skype gestellt. Sein Ansatz ist so innovativ wie genial und fügt sich nahtlos in PostgreSQL ein. In PostgreSQL sind Stored Procedures im Wesentlichen Plug-ins. Wenn man Programmiersprachen serverseitig verwenden will, bedarf es lediglich eines "kleinen" Plug-ins, das es PostgreSQL ermöglicht, eine Sprache entsprechend anzusprechen. Will man eine Sprache in eine Datenbank einhängen, kann man CREATE LANGUAGE verwenden:

test=# \h CREATE LANGUAGE
Command:     CREATE LANGUAGE
Description: define a new procedural language
Syntax:
CREATE [ PROCEDURAL ] LANGUAGE name
CREATE [ TRUSTED ] [ PROCEDURAL ] LANGUAGE name
    HANDLER call_handler [ VALIDATOR valfunction ]

Hinter den Kulissen funktioniert das Ganze so: PostgreSQL läd ein C-Modul (Shared Object), das Anfragen entsprechend an die "externe" Programmiersprache weitergibt. Im Prinzip kann man so jede Programmiersprache für Stored Procedures missbrauchen, sofern man nur einen entsprechenden Call Handler schreibt, der die Anfrage verarbeitet und an die Programmiersprache "weiterleitet". Die Implementierung solcher Call Handler ist nicht sonderlich schwierig und kann von einem geübten C-Programmierer in endlicher Zeit bewerkstelligt werden.

Wieso also nicht einfach eine Miniprogrammiersprache definieren, die nichts Anderes tut als Aufrufe zu verteilen und Daten entsprechend zu lokalisieren? Und genau diesen Ansatz verfolgt PL/Proxy.

Anfragen abarbeiten

PL/Proxy ist lediglich eine Programmiersprache, die Daten partitioniert. Das Interessante an diesem Ansatz ist, dass PL/Proxy nur vier Befehle umfasst:

  • CONNECT definiert eine Partition auf der eine Abfrage ausgeführt werden soll.
  • CLUSTER gibt an, auf welchem Cluster eine Abfrage verteilt werden soll.
  • RUN ON ALL/ANY/ gibt an, ob eine Abfrage auf jeder, auf irgendeiner oder auf einer bestimmten Partition gestartet werden soll.
  • SELECT ermöglicht das Ausführen einer Query.

SELECT ist nur selten nötig, da PL/Proxy auf der zu ermittelnden Partition eine Prozedur ausführt, die dieselbe Signatur hat. Rufe man also die Funktion nummer_suchen(text, text) auf, wird auch auf der entsprechenden Partition, die die Daten letztendlich enthält, eine Funktion mit demselben Namen und derselben Parameterliste ausgeführt - aber dazu später mehr.

Sehen wir uns die schematische Abarbeitung eines Aufrufs an. Im folgenden Beispiel wollen wir schlichtweg ein Telefonbuch abfragen, das über mehrere Rechner verteilt ist:

SELECT * FROM nummer_suchen('josef maierling', 'berlin');

Ziel ist es, Herrn Josef Maierlin in Berlin zu finden. Die Clientanwendung schickt diese Anfrage an die Datenbank. PL/Proxy ermittelt die Partition mit den Daten und ruft dann eine entsprechende Stored Procedure auf. PL/Proxy selbst dient also lediglich der Verteilung und benötigt selbst keine I/O, sondern lediglich CPU. Da die Programmiersprache extrem schlank ist, wird kaum RAM verwendet. Durch die einfache Syntax wird auch mit der CPU sehr sparsam umgegangen - PL/Proxy eignet sich somit hervorragend für die Verwendung innerhalb von Connection Pools. Ein weiterer Vorteil ist, dass der Client im Prinzip nur PL/Proxy sieht und nicht verstehen muss, wo die Daten wirklich zu finden sind.

Wie wird partitioniert?

Nachdem wir gesehen haben, wie PL/Proxy prinzipiell arbeitet, wollen wir uns ansehen, wie PL/Proxy Daten innerhalb des Clusters verteilt. Das Konzept ist relativ einfach: In PostgreSQL gibt es eine Funktion namens hashtext, die wie folgt verwendet werden kann:

test=# SELECT hashtext('josef mailering');
 hashtext  
-----------
 613233415
(1 row)

test=# SELECT hashtext('www.postgresql.at');
  hashtext   
-------------
 -1421050721
(1 row)

hashtext liefert als Ergebnis einen Integer-Wert, der sich besonders gut für die Partitionierung eignet, da die Funktion eine stetige Gleichverteilung im Wertebereich liefert (die Partitionen werden also annähernd gleich groß). Nehmen wir an, wir verfügen über vier Partitionen - um die Partition zu ermitteln, brauchen wir lediglich die letzten beiden Bits des Werts zu verwenden:

test=# SELECT hashtext('www.postgresql-support.de') & 2;
 ?column? 
----------
        0
(1 row)

test=# SELECT hashtext('hans-juergen schoenig') & 2;
 ?column? 
----------
        2
(1 row)

& 2 gibt uns die letzten beiden Bits als Zahl. 0 sagt uns, dass der Wert in der ersten Partition zu finden sein wird. 2 gibt an, dass es sich um die dritte Partition handelt. Das Verfahren ist also denkbar einfach.

An die Tasten

Nach diesem kleinen Überblick wollen wir uns ansehen, wie PL/Proxy installiert werden kann. Es sollte Ihnen nach dieser kleinen Einführung leicht fallen, selbst einfache Anwendungen zu implementieren.

PL/Proxy kann bequem und kostenfrei heruntergeladen werden. Nach dem Entpacken, kann man das Package mit make und make install leicht und schnell installieren (PostgreSQL muss logischerweise bereits installiert sein).

Im nächsten Schritt können wir PL/Proxy bereits aktivieren:

hs@quad:/tmp/plproxy-2.0.4$ createdb test 
hs@quad:/tmp/plproxy-2.0.4$ psql test 

Wir haben eine Datenbank angelegt und PL/Proxy eingefügt. Somit verfügen wir bereits über eine Datenbank, die für die Applikation als Gateway zu den Backend-Datenbanken fungiert - aus Sicht der Applikation kommen alle Daten in unserem Beispiel aus test.

Um PL/Proxy zu testen, legen wir im ersten Schritt Partitionen an. Um die Demo nicht unnötig zu verkomplizieren, machen wir das auf derselben Maschine:

hs@quad:/tmp/plproxy-2.0.4/doc$ createdb part0 
hs@quad:/tmp/plproxy-2.0.4/doc$ createdb part1 
hs@quad:/tmp/plproxy-2.0.4/doc$ createdb part2 
hs@quad:/tmp/plproxy-2.0.4/doc$ createdb part3

Um effizient im Cluster zu partitionieren, benötigen wir eine Funktion, die weiß, welche Partitionen es prinzipiell gibt. Um eine derartige Funktion zu implementieren, verwenden wir in diesem Beispiel PL/pgSQL:

hs@quad:/tmp/plproxy-2.0.4$ createlang plpgsql test

Im nächsten Schritt implementieren wir die Clusterinfrastruktur:

CREATE SCHEMA plproxy; 

CREATE OR REPLACE FUNCTION plproxy.get_cluster_partitions(cluster_name text) 
RETURNS SETOF text AS $$ 
 BEGIN 
      IF cluster_name = 'usercluster' THEN 
          RETURN NEXT 'dbname=part0 host=127.0.0.1'; 
          RETURN NEXT 'dbname=part1 host=127.0.0.1'; 
          RETURN NEXT 'dbname=part2 host=127.0.0.1'; 
          RETURN NEXT 'dbname=part3 host=127.0.0.1'; 
          RETURN; 
      END IF; 
      RAISE EXCEPTION 'Unknown cluster'; 
END; 
$$ LANGUAGE plpgsql;

Diese Funktion ist in die Datenbank test einzufügen. Sofern eine Anfrage an test kommt, kann get_cluster_partitions verwendet werden, um die Partition zu ermitteln. In unserem Beispiel sind wie man oben erkennt, vier Partitionen.

Mit dieser einfachen Konfiguration ist es bereits möglich, ein SQL-Statement auf einen beliebigen Knoten zu verschieben:

test=# INSERT INTO t_user VALUES ('paul');
INSERT 0 1
Time: 1.293 ms

In unserem Beispiel führt PL/Proxy das SELECT-Statement auf Partition 0 aus.

Zusammenfassend

Wie Sie sehen können, ist PL/Proxy sehr flexibel und ermöglicht dem Benutzer, eine einfache Handhabe des Clusters. Alle wesentlichen Parameter werden in der Regel unter der Zuhilfenahme von Stored Procedures konfiguriert und können somit leicht und vor allem transaktionssicher konfiguriert werden.

Vor allem für große Anwendungen, die viele Daten verwalten müssen, bietet PL/Proxy gegenüber In-Memory-Datenbanken einige Vorteile, da im Fehlerfall keine Daten verloren gehen können.

 
Verwandte Themen: 

Kommentare

Ihr Kommentar zum Thema

Als Gast kommentieren:

Gastkommentare werden nach redaktioneller Prüfung freigegeben (bitte Policy beachten).