Teil 1: Just add Hardware – mehr hilft mehr stimmt nicht immer

Prinzipien skalierbarer Architektur

Stefan von Brauk, Christian Neudert
©Shutterstock/mindscanner

Skalierbarkeit ist seit jeher eines der zentralen Architekturziele. Seitdem aber die zu verarbeitenden Datenmengen exponentiell wachsen und Two- und Three-Tier-Architekturen durch Multi-Tier-Architekturen abgelöst werden, kommt dem Architekturziel „Skalierbarkeit“ eine immer größere Bedeutung zu. Und da Skalierbarkeit zudem mit anderen Architekturzielen kollidieren kann, stellt sich die Frage, welche technologieneutralen Prinzipien existieren, um eine skalierbare Architektur zu entwickeln und zu steuern.

Skalierbarkeit beschreibt als Qualitätsattribut die Fähigkeit einer IT-Architektur, an eine steigende Last mit möglichst gering-invasiven Methoden anpassbar zu sein. Hierbei sind insbesondere zwei Dimensionen von Interesse: Zum einen müssen steigende Datenvolumen betrachtet werden, die beispielsweise durch Datenbankmanagementsysteme verarbeitet werden müssen; zum anderen ist die Zugriffszeit relevant, die unter steigenden Zugriffszahlen leidet. Eine ideale skalierbare Architektur zeichnet sich vor dem Hintergrund dieser Dimensionen dadurch aus, dass einer steigenden Last durch das bloße Hinzufügen einer (proportionalen) Menge neuer Hardware begegnet werden kann und im Ergebnis die Performance der Anwendung (z. B. messbar an den Leistungsgrößen Latenz und Durchsatz) konstant bleibt.

Die Bedeutung dieser Fähigkeit nimmt aufgrund der Entwicklungen der letzten Jahre stetig zu: die von IT-Systemen erzeugten und zu verarbeitenden Datenmengen steigen exponentiell, da immer mehr Lebensbereiche von IT-Anwendungen durchdrungen werden. Gleichzeitig steigt die Vernetzung der IT-Anwendungen untereinander kontinuierlich. Hierbei existieren eine Vielzahl unterschiedlicher Endgeräte und Plattformen (Smartphone, Tablets, Embedded-Anwendungen vom Kühlschrank über das Auto bis hin zu Wearables), die integriert werden müssen. Schließlich eröffnen neue Möglichkeiten der Allokation von Rechnerkapazitäten (Virtualisierung, Cloud Computing) auch neue architektonische Lösungsmöglichkeiten.

Die Herausforderung des Architekten besteht entsprechend darin, die Skalierbarkeit einer IT-Lösung mit konkurrierenden Qualitätsattributen in Einklang zu bringen: Skalierbarkeit, Verfügbarkeit und Performance sind allerdings einander widersprechende Architekturziele. Last but not least ist auch der Kostenaspekt für das jeweilige Anwendungsszenario zu berücksichtigen. Die im Folgenden dokumentierten Prinzipien sollen helfen, eine Architektur zu entwerfen, die eine größtmögliche Skalierbarkeit unterstützt und gleichzeitig andere Architekturziele angemessen berücksichtigt.

Prinzip 1: die richtige Skalierungsstrategie wählen

In vielen Einsatzszenarien ist Skalierung gleichbedeutend mit dem Hinzufügen von Speichermodulen, Bestückung freier Prozessorsockel oder dem Einbau neuer oder größerer Festplatten. Dieser als vertikale Skalierung bzw. Scale up bezeichnete Ansatz hat die Vorteile, dass keine neuen Serversysteme in das Netzwerk integriert werden müssen, keine neue Software installiert werden muss, die Anpassung von Konfigurationen sich auf ein kleines Maß beschränkt und die IT-Architektur als solche unberührt bleibt. Solange keine Spezialhardware eingekauft werden muss, ist dieses Vorgehen zudem noch recht günstig. Schließlich ist auf Anwendungsebene diese Strategie oft auch die einzig mögliche, da nicht alle (Legacy-)Anwendungen darauf ausgelegt sind, auf mehrere Server verteilt oder parallel betrieben zu werden – die Parallelisierung von Aktivitäten ist aber ein wesentliches Merkmal der Skalierbarkeit. Naturgemäß stößt die Ausbaufähigkeit einzelner Knoten zum einen an physische Grenzen, sodass der nächste Skalierungsschritt dann nur noch durch den Ersatz der entsprechenden Hardware mit leistungsfähigeren – und oftmals überproportional teuren – Systemen erfolgen kann. Zu berücksichtigen ist im Hinblick auf Performanceaspekte aber auch, dass der Ansatz oftmals nicht linear skaliert. Nach Amdahls Gesetz bewirkt beispielsweise die Verdoppelung der Anzahl von Prozessoren einen Performancegewinn von lediglich 20 Prozent, wenn der Anteil des parallelisierbaren Codes der betriebenen Anwendung 70 Prozent beträgt. Diese Skalierungsstrategie macht es also nicht nur erforderlich, Hardware hinzuzufügen, sondern auf der Ebene der Softwarearchitektur einen möglichst hohen Parallelisierungsgrad zu ermöglichen (siehe unten):

Die alternative Skalierungsstrategie der horizontalen Skalierung (Scale out) setzt auf einer anderen Granularitätsebene an: Erhöht sich die Last auf einem IT-System, soll es durch einfaches Hinzufügen von neuen Knoten aus billiger x86-Standardhardware oder virtuellen Maschinen möglich sein, der erhöhten Last zu entsprechen. Neben dem Kostenaspekt hat diese Elastizität die Vorteile, dass die Leistungserweiterung im laufenden Betrieb und ohne aufwändige Migrationsprojekte implementiert werden kann. Allerdings stellt sich die Frage, unter welchen Bedingungen eine solche Skalierungsstrategie angewandt werden kann, denn es ist offensichtlich, dass durch den Parallelbetrieb identischer Server und Anwendungen erhebliche architektonische Herausforderungen beispielsweise hinsichtlich Datenkonsistenz, Verfügbarkeit und Redundanz generiert werden.

Aufmacherbild: Signpost Scalability von Shutterstock / Urheberrecht: mindscanner

[ header = Seite 2: Prinzip 2 – Last richtig verteilen ]

Prinzip 2: Last richtig Verteilen

Kommt es zu dem Szenario, dass aufgrund von steigender Last mehrere Knoten eingesetzt werden sollen, stellt sich die Frage, wie die auflaufende Last verteilt werden kann. Abbildung 1 zeigt exemplarisch drei Alternativen zum horizontalen Skalieren von Datenbanksystemen auf – diese Alternativen können zudem miteinander kombiniert werden. Partitionierung ist eine weit verbreitete Methode, um z. B. den Datenbestand eines Datenbanksystems über mehrere Speichermedien zu verteilen. Das Vorgehen hat den Vorteil, dass sehr große Speicherbereiche genutzt werden können, wobei gleichzeitig die referenzielle Integrität des verwendeten Datenbankschemas erhalten bleibt. Allerdings bleibt ein großer Teil der Rechenlast auf einer zentralen Instanz, die zudem nicht inhärent redundant ausgelegt ist.

Abb. 1: Beispiele horizontaler Skalierung

Demgegenüber sind Cluster-Lösungen grundsätzlich redundant ausgeführt. Zudem kann in einer Cluster-Lösung auch die Rechenleistung der einzelnen Knoten des Clusters zur Bewältigung der anfallenden Last genutzt werden. Dies wird in der Regel dadurch erkauft, dass die Knoten ihren jeweiligen Zustand replizieren und oft durch administrative Knoten überwacht werden. Hierdurch fällt der Grad der Skalierbarkeit immer weiter ab, d. h. mit jedem neu zum Cluster hinzutretenden Knoten steigt die Leistungsfähigkeit des Clusters unterproportional an. Weiterhin sind Administration und Optimierung eines Clusters aufwändig und wenig fehlertolerant.

Sharding setzt als weitere Alternative auf abgeschlossene Datenmengen, d. h. jeder Knoten ist für ein in sich konsistentes Datenvolumen verantwortlich. Durch das Hinzufügen neuer Shards ist bei geeignetem Sharding-Algorithmus die Skalierung nahezu grenzenlos; allerding ist ohne weitere konzeptionelle Unterstützung keine Ausfallsicherheit gegeben. Da zudem eine referenzielle Integrität über die einzelnen Shards hinweg nicht gegeben ist, stellt diese Form der Skalierung große Herausforderungen an konsumierende Applikationen und Dienste: Data Warehouses und OLAP-Werkzeuge sind beispielsweise nicht in der Lage, ohne Anpassungen oder Adapter mit Shards integriert zu werden. Auch kann der Ansatz redundante Datenhaltung fördern und schließlich dazu führen, dass eine echte Transaktionssicherheit (Kasten: „Das ABC des Architekten: ACID, BASE, CAP“) nicht mehr gegeben ist.

Ein weiterer Aspekt des horizontalen Skalierungsprinzips ist es, Ressourcen und Konsumenten zusammenzubringen. Werden alle Ressourcen an einem Ort geografisch gebündelt, wird sich die Netzwerktopologie als der nächste Flaschenhals herausstellen. Content Delivery Networks (CDN) sind ein Beispiel dafür, wie die Knoten einer horizontalen Skalierung auch geografisch verteilt werden können (Abb. 2), um so eine optimale Latenz und hohen Durchsatz zu verbinden. CDNs stellen in ihrer Struktur somit nichts anderes als komplexe, verteilte Caches dar, die eine singuläre zentrale Instanz entlasten. CDNs zeigen auch auf, unter welchen Umständen ein verteiltes Caching sehr gut einsatzbar ist:

  • Es wird vorwiegend lesend auf Daten zugegriffen – durch Vorproduktion von statischem Content wird dieser Umstand zudem noch aktiv gefördert.
  • Es wird häufig auf gleiche Daten zugegriffen.
  • Die cachenden Knoten gehören zum Applikationskontext.

Gerade der letzte Punkt zeigt auf, welche Probleme mit Caching (und analogen Techniken wie verteilten Key-Value Stores usw.) verbunden sein können: Caching setzt voraus, dass der Lebenszyklus der verwendeten Daten wohlbekannt ist und durch das cachende System kontrolliert wird. Besitzt der Konsument der Daten diese Detailkenntnisse nicht bzw. ist die Detailkenntnis per kontraktbasierter Schnittstelle ausgeschlossen, ist Caching i.d.R. ein sehr problembehaftetes Vorgehen. Beispielsweise kann Caching dazu führen, dass ACID-Prinzipien nicht eingehalten werden können und Probleme bei der Replikation der Daten bei schreibendem Zugriff entstehen (Kasten: „Das ABC des Architekten: ACID, BASE, CAP“).

Abb. 2: Struktur eines Content Delivery Networks

Das ABC des Architekten: ACID, BASE, CAP

Das CAP-Theorem von Eric Brewer behauptet, dass in einem Netzwerksystem zu einem gegebenen Zeitpunkt nur zwei der drei Eigenschaften Konsistenz (C), Verfügbarkeit (A) und Partitionstoleranz (P) gleichzeitig implementierbar sind. Wird beispielsweise eine Datenbank auf einem einzelnen Knoten des Netzwerks betrieben, sind Konsistenz und Verfügbarkeit gegeben, aber es existiert keine alternative Partition. Ist die Datenbank hingegen auf zwei Netzwerkpartitionen verteilt, ist durch notwendige Replikationen der Knoten entweder die Verfügbarkeit der Daten oder die Konsistenz der Daten eingeschränkt. Der Architekt muss entsprechend den Anforderungen an die Transaktionalität der verarbeiteten Daten die richtige Auswahl treffen, welche Merkmale bei einer horizontalen Skalierung implementiert werden müssen. Daten, mit strikten Anforderungen an Transaktionalität benötigen die ACID-Prinzipien, welche durch transaktionale Datenbanksysteme implementiert werden:

Atomic
Consistent
Isolated
Durable

Daten, die mit diesen Anforderungen verarbeitet werden müssen, unterliegen somit gem. dem CAP-Theorem einer eingeschränkten Verfügbarkeit zugunsten einer vollständigen Konsistenz. Sind die Anforderungen jedoch weicher hinsichtlich der notwendigen Transaktionalität, können die Daten nach den BASE-Prinzipien verarbeitet werden:

Basically Available
Soft State
Eventually Consistent

Hier wird die Konsistenz der Daten (zumindest temporär) zugunsten einer konstanten Verfügbarkeit der Daten eingeschränkt. Natürlich ist die richtige Vorgehensweise direkt aus den Businessanforderungen abzuleiten. Beispielsweise machen Transaktionen, die mit hohem Risiko für Vermögenswerte oder gesundheitlicher Unversehrtheit verbunden sind, oftmals eine strikte Konsistenz in der Fachdomäne erforderlich, wohingegen bei Anwendungen aus dem Bereich der sozialen Medien die Anforderungen an hohe Verfügbarkeit überwiegen.

[ header = Seite 3: Prinzip 3 – Schichten- und Servicebildung ]

Prinzip 3: Schichten- und Servicebildung

Ein klassisches Verfahren, um Skalierbarkeit zu gewährleisten, ist die horizontale Schichtenbildung. Schichten können unabhängig voneinander entwickelt und verteilt betrieben werden. Diese Verteilung erlaubt es, eine Schicht unabhängig von den darüber liegenden Schichten zu skalieren. Ab einer gewissen Größe des Systems kann sich die Dekomposition allerdings als schwierig hinsichtlich der Skalierbarkeit herausstellen, da dann innerhalb einer Schicht oft die funktionale Kohäsion sinkt und die Komplexität steigt – mit negativem Einfluss auf die Skalierbarkeit. Bündelt sich zum Beispiel die Geschäftslogik in einer Schicht, wobei nur ein Teil der Geschäftsprozesse viele Ressourcen verbraucht, muss trotzdem die gesamte Schicht skaliert werden. Ist aufgrund der Komplexität der Schicht der Knoten bereits groß dimensioniert, kann in der Folge nur relativ teuer vertikal skaliert werden. Wird dem Problem durch eine neue Schicht begegnet (vertical Scale out), sinkt durch die weitere Indirektion die Performance. Beide Lösungsansätze sind nicht zufriedenstellend. Grundsätzlicher lässt sich das Problem nur durch eine stärker hierarchische Zerlegung der Fachlichkeit lösen.

Ein möglicher Lösungsansatz dafür ist das Paradigma der serviceorientierten Architektur (SOA). In einer SOA ist durch die Grundannahme, dass es sich um ein verteiltes System von Diensten handelt, eine klarere Trennung von Geschäftsprozessen und Ressourcen gewährleistet. Wichtige Skalierungsmerkmale einer SOA sind funktional kohärente Dienste, die über Vermittler lose miteinander gekoppelt sind. Bestmöglich skaliert die asynchrone Kommunikation über einen Vermittler (Broker). Kann beispielsweise ein Dienst seine festgelegten Antwortzeiten nicht einhalten, ist kein aufrufender Dienst direkt betroffen, sondern eingehende Anfragen können vom Vermittler zwischengespeichert und später verarbeitet werden (Store and Forward). Bei konstant hoher Last kann horizontal skaliert werden, indem eine weitere Instanz des Diensts bereitgestellt wird. Diese Dekomposition einer Applikation in Services wird ähnlich in der Staged Event-driven Architecture (SEDA) vorgenommen, in der Applikationen als Netzwerk von Verarbeitungsstufen designt werden.

Im Unterschied zur Schichtenarchitektur wird ein Geschäftsprozess in einer SOA bereits zu Beginn als Summe autonomer, funktional kohärenter Dienste gestaltet. Jeder Dienst hat eine klare fachliche Zuständigkeit und die Datenhoheit über diese Domäne. Der Zugriff auf die Daten ist über eine Schnittstelle beschränkt und die interne Struktur ist anderen Diensten nicht bekannt. Durch die klare Domänenseparierung entstehen vertikale Systemschnitte, die – wenn sie auch technisch autonom aufgebaut („shared nothing Architektur“) sind – vollkommen losgelöst voneinander skaliert werden können.

Autonome Dienste, die asynchron kommunizieren, erlauben zudem die parallele Ausführung einer Aktion. Lässt sich eine Aktion in mehrere Dienstaufrufe zerlegen, die parallel ausgeführt werden können, verringert sich idealerweise die gesamte Ausführungsdauer. Je näher dabei die Zerlegung der Aktion an der Systemgrenze erfolgt, desto größer ist der parallelisierbare Anteil (Abb. 3). Durch die Verteilung der Anfrage auf die Dienste kann der Ressourcenbedarf gleichmäßig verteilt werden. Das Gesamtsystem kann mehr Aktionen verarbeiten und wird im Idealfall nur durch die Anzahl offener Verbindungen begrenzt. Bei Webanwendungen lässt sich dies zum Beispiel durch Konzepte wie Edge Side Includes und Server Side Includes umsetzten. Diese Strategie lässt sich noch weiter fortschreiben: Da die (mobilen) Endgeräte von Nutzern immer leistungsfähiger werden, kann es eine sinnvolle Alternative sein, diese Endgeräte in die Gesamtbetrachtung der Architektur einzubeziehen und so zentrale Aufgaben von der zentralen Dienststruktur auf die Endgeräte zu verlagern.

Abb. 3: Beispiel einer SOA-Referenzarchitektur

 

[ header = Seite 4: Prinzip 4 – Zustandslosigkeit fördern ]

Prinzip 4: Zustandslosigkeit fördern

Häufig ergibt sich in der Architektur das Problem, das Daten an Ressourcen gebunden werden wie z. B. Sitzungs- oder Verbindungsdaten in einem Webserver. Dadurch entsteht u. a. das Problem, dass die Sitzung oder Verbindung nur durch die verarbeitende Instanz bedient werden kann. Im Falle einer synchronen Kommunikation, in der eine offene Transaktion auf die Antwort des schreibenden Diensts wartet, führt dies dazu, dass Ressourcen wie Threads, Speicher und Netzwerkverbindungen belegt werden. Besser ist es, dass der aufrufende Dienst seine Aktion abschickt und erst nach Abschluss der Aktion benachrichtigt wird. Die Daten werden so von Ressourcen entkoppelt und fließen durch das System. Die Dienste können so gestaltet sein, dass sie den Kontext der Aktion nicht kennen, aber das Ergebnis an interessierte Dienste verteilen. Diese Zustandslosigkeit lässt sich aus zwei Richtungen fördern: Bottom-up durch die idempotente Gestaltung von Diensten und Top-down durch Terminierung zustandsbehafteter Kommunikation an den Grenzen des Systems. Idempotente Dienste liefern selbst bei wiederholter Ausführung einer Aktion dasselbe Ergebnis. Alle Leseoperationen sind idempotent; bei Schreiboperation erfolgt beispielsweise das Hinzufügen zu einer Liste so, dass kein Wert doppelt vorhanden ist (der alte Wert wird überschrieben) und Zahlenwerte nur gesetzt aber nicht inkrementell erhöht werden können.

Idempotente Dienste können Schreiboperationen sehr stabil horizontal skalieren. Sie verringern in Verbindung mit einem Messaging-System die Komplexität, da sie bei auftretender Nachrichtenduplikation stets das gleiche Ergebnis liefern (at-least-once Delivery). Zustandslosigkeit lässt sich auch fördern, indem möglichst viele zustandsbehaftete Kommunikationsbeziehungen außerhalb oder an den Grenzen des Systems terminiert werden. Dazu gehört beispielsweise, TLS-Verbindungen bereits auf dem Load Balancer zu terminieren und sämtliche Anfragen innerhalb des Systems nur noch unverschlüsselt umzusetzen. Auch applikationsspezifische Sitzungsdaten sollten außerhalb des Systems oder an seinen Grenzen gespeichert werden. Für kleinere Datenmengen bietet sich die Speicherung clientseitig an. Größere Datenmengen können serverseitig in einem Cache nah der Systemgrenze – beispielsweise in Key-Value Stores – gehalten werden. Auch die Verwendung von Load Balancern wird bei der Verwendung von zustandslosen Diensten einfacher, da die Verbindung von Sitzungsdaten und Dienstinstanzen („sticky Connections“) vermieden werden kann. Dasselbe gilt für den Einsatz von Caches in Systemen mit vielen Lesezugriffen. Zustandslose Dienste reagieren vorhersehbar und können in Kombination mit Caches die Leseoperationen signifikant beschleunigen. Auch hier gilt, dass der Vorteil an den Systemgrenzen am größten ist.

[ header = Seite 5: Prinzip 5 – Code und Ressourcen trennen ]

Prinzip 5: Code und Ressourcen trennen

Um auf Applikationsebene die Parallelisierung von Aktivitäten zu fördern, ist die Trennung von Code und den verwendeten Ressourcen in dem Sinne voranzutreiben, dass bei der Erstellung des Codes keine Limitierungen hinsichtlich der verwendeten Ressourcen implizit oder explizit erfolgt – nur so ist es möglich, das Applikationen alle zu Verfügung gestellten Ressourcen (z. B. im Cloud Computing oder in virtualisierten Umgebungen) nutzen können. Diese Überlegung betrifft natürlich zuallererst Ressourcen wie Speicher, Plattenplatz usw. ebenso wie Transaktionskontexte, Datenbankverbindungen und alle anderen Komponenten der Laufzeitumgebung. Bei all diesen Ressourcen darf die Bindung von Code und Ressource im Laufzeitkontext nur minimal und sollte auch dynamisch zur Laufzeit änderbar sein.

Die Überlegung lässt sich aber auf die Thread-Ebene und letztendlich auf das grundsätzliche Programmierparadigma ausweiten: Aus Sicht der Parallelisierbarkeit ist auch die Trennung des Applikationscodes von dem verwendeten Thread-Modell anzustreben, sodass es möglich sein sollte, den Applikationscode entsprechend der anfallenden Last auf möglichst viele Threads zu verteilen. In diesem Sinne ist ein asynchrones, eventgetriebenes Programmiermodell einer direkt auf Threads, Semaphoren usw. aufsetzenden Vorgehensweise unbedingt vorzuziehen. Entsprechend sind auf Softwareebene Architekturmuster wie Events und Messaging, Publish/Subscribe, Futures, Aktoren/Agenten sowie Elemente funktionaler Programmierung geeignet, die Skalierung auf Codeebene zu fördern. Dieses „Reactive Programming“ genannte Programmierparadigma geht idealerweise mit der Nutzung non-blocking APIs bei der Ressourcennutzung einher.

Die Trennung von Code und Ressourcen bedeutet natürlich auch, dass das eigentliche Ressourcenmanagement nach Möglichkeit dem umgebenden Container (Applikationsserver) bzw. dedizierter Managementsoftware (z. B. Cloud-Management-Software) überlassen wird. Entsprechend sind für den Zugriff auf Ressourcen die durch die Container bereitgestellten Factories zu nutzen, die Mechanismen wie Connection Pooling, Failover usw. implementieren. Techniken wie Ressource Injection sollten hierzu unbedingt berücksichtigt werden.

Fazit

Das durch moderne Anwendungsszenarien motivierte Revival des Distributed Computing macht es notwendig, in vielen Kontexten neu über die Architektur geplanter und etablierter IT-Anwendungen nachzudenken. Da wie gezeigt Skalierbarkeit und Performance oftmals nicht Hand in Hand gehen, sondern sich widersprechende Ziele sind, ist zuallererst eine klare Priorisierung der Architekturziele notwendig. Mithilfe der gezeigten Prinzipien lassen sich dann strukturelle Bottlenecks im Bereich der System- und Softwarearchitektur vermeiden oder gezielt Kompromisse eingehen. Hierbei sind Scale-out und Scale-up Strategien, die sich je nach betrachtetem Architekturkontext ergänzend oder alternativ einsetzen lassen. Die sich aus den vorgestellten Ansätzen ergebenden Konsequenzen für Betrieb und Entwicklung werden im zweiten Teil des Beitrags vorgestellt.

Geschrieben von
Stefan von Brauk
Stefan von Brauk
Dr. Stefan von Brauk ist Management Consultant und Team Manager bei Cassini Consulting und berät u. a. Blue Chips in den Bereichen IT- und Architekturmanagement sowie Methoden der Softwareentwicklung.
Christian Neudert
Christian Neudert
Christian Neudert ist Consultant bei Cassini Consulting und berät u. a. Blue Chips in den Bereichen Softwarearchitektur und agile Softwareentwicklung.
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu: