Load Balancer spielen eine Schlüsselrolle in der Webarchitektur. Mit ihnen können Sie die Last auf mehrere Backends verteilen und so die Skalierbarkeit verbessern. Und da wir mehrere Backends konfiguriert haben, wird der Service hoch verfügbar, da der Balancer im Falle eines Ausfalls auf einem Server einen anderen funktionierenden Server auswählen kann.
Nachdem ich mit professionellen Balancern wie NGINX gespielt hatte, versuchte ich, aus Spaß einen einfachen Balancer zu erstellen. Ich habe es auf Go geschrieben, es ist eine moderne Sprache, die volle Parallelität unterstützt. Die Standardbibliothek in Go bietet viele Funktionen und ermöglicht das Schreiben von Hochleistungsanwendungen mit weniger Code. Darüber hinaus wird zur Vereinfachung der Verteilung eine einzelne statisch verknüpfte Binärdatei generiert.
Wie unser Balancer funktioniert
Es werden verschiedene Algorithmen verwendet, um die Last auf die Backends zu verteilen. Zum Beispiel:
- Round Robin - Die Last wird unter Berücksichtigung der gleichen Rechenleistung der Server gleichmäßig verteilt.
- Weighted Round Robin - Je nach Rechenleistung können Servern unterschiedliche Gewichte zugewiesen werden.
- Geringste Verbindungen - Die Last wird auf Server mit der geringsten Anzahl aktiver Verbindungen verteilt.
In unserem Balancer implementieren wir den einfachsten Algorithmus - Round Robin.
Auswahl bei Round Robin
Der Round Robin-Algorithmus ist einfach. Es gibt allen Darstellern die gleiche Möglichkeit, Aufgaben zu erledigen.
Wählen Sie Server in Round Robin aus, um eingehende Anforderungen zu verarbeiten.Wie in der Abbildung gezeigt, wählt der Algorithmus die Server zyklisch in einem Kreis aus. Aber wir können sie nicht
direkt auswählen, oder?
Und wenn der Server lügt? Wir müssen wahrscheinlich keinen Datenverkehr dorthin senden. Das heißt, der Server kann nicht direkt verwendet werden, bis wir ihn in den gewünschten Zustand versetzt haben. Es ist erforderlich, den Datenverkehr nur an die Server zu leiten, die in Betrieb sind.
Definieren Sie die Struktur
Wir müssen alle Details im Zusammenhang mit dem Backend nachverfolgen. Sie müssen wissen, ob er lebt, und die URL verfolgen. Dazu können wir folgende Struktur definieren:
type Backend struct { URL *url.URL Alive bool mux sync.RWMutex ReverseProxy *httputil.ReverseProxy }
Keine Sorge, ich erkläre die Bedeutung der Felder im Backend.
Jetzt müssen Sie im Balancer irgendwie alle Backends verfolgen. Dazu können Sie Slice und einen variablen Zähler verwenden. Definiere es in ServerPool:
type ServerPool struct { backends []*Backend current uint64 }
Verwenden von ReverseProxy
Wie wir bereits festgestellt haben, besteht das Wesen des Balancers darin, den Datenverkehr auf verschiedene Server zu verteilen und die Ergebnisse an den Client zurückzugeben. In der Go-Dokumentation heißt es:
ReverseProxy ist ein HTTP-Handler, der eingehende Anforderungen entgegennimmt und an einen anderen Server sendet und die Antworten an den Client zurücksendet.Genau das, was wir brauchen. Das Rad muss nicht neu erfunden werden. Sie können unsere Anfragen einfach über
ReverseProxy
.
u, _ := url.Parse("http://localhost:8080") rp := httputil.NewSingleHostReverseProxy(u)
Mit
httputil.NewSingleHostReverseProxy(url)
Sie
ReverseProxy
initialisieren, das Anforderungen an die übergebene
url
sendet. Im obigen Beispiel wurden alle Anforderungen an localhost: 8080 gesendet und die Ergebnisse an den Client gesendet.
Wenn Sie sich die Signatur der ServeHTTP-Methode ansehen, finden Sie die Signatur des HTTP-Handlers darin. Daher können Sie es in
http
an
HandlerFunc
.
Weitere Beispiele finden Sie in der
Dokumentation .
Für unseren Balancer können Sie
ReverseProxy
mit der zugehörigen
URL
im
Backend
initiieren, sodass ReverseProxy Anforderungen an die
URL
weiterleitet.
Serverauswahlprozess
Bei der nächsten Serverauswahl müssen wir die zugrunde liegenden Server überspringen. Aber Sie müssen die Zählung organisieren.
Zahlreiche Clients stellen eine Verbindung zum Balancer her, und wenn jeder von ihnen den nächsten Knoten auffordert, Datenverkehr zu übertragen, kann ein Race Condition auftreten. Um dies zu verhindern, können wir
ServerPool
mit
mutex
blockieren. Aber es wird redundant sein, außerdem wollen wir
ServerPool
nicht blockieren. Wir müssen nur den Zähler um eins erhöhen.
Die beste Lösung, um diese Anforderungen zu erfüllen, wäre die atomare Inkrementierung. Go unterstützt es mit dem
atomic
Package.
func (s *ServerPool) NextIndex() int { return int(atomic.AddUint64(&s.current, uint64(1)) % uint64(len(s.backends))) }
Wir erhöhen den aktuellen Wert atomar um eins und geben den Index zurück, indem wir die Länge des Arrays ändern. Dies bedeutet, dass der Wert immer im Bereich von 0 bis zur Länge des Arrays liegen sollte. Letztendlich interessieren wir uns für einen bestimmten Index, nicht für den gesamten Zähler.
Einen Live-Server auswählen
Wir wissen bereits, dass unsere Anfragen zyklisch über alle Server hinweg rotiert werden. Und wir müssen nur den Leerlauf überspringen.
GetNext()
immer einen Wert zwischen 0 und der Länge des Arrays zurück. Wir können jederzeit den nächsten Knoten abrufen, und wenn dieser inaktiv ist, müssen wir das Array als Teil der Schleife weiter durchsuchen.
Wir durchlaufen das Array.Wie in der Abbildung gezeigt, möchten wir vom nächsten Knoten zum Ende der Liste gehen. Dies kann mit
next + length
. Um jedoch einen Index auszuwählen, müssen Sie ihn auf die Länge des Arrays beschränken. Dies kann einfach mit der Änderungsoperation durchgeführt werden.
Nachdem wir während der Suche einen funktionierenden Server gefunden haben, sollte dieser als aktuell markiert sein:
Vermeidung der Racebedingung in der Backend-Struktur
Hier müssen Sie sich an ein wichtiges Thema erinnern. Die
Backend
Struktur enthält eine Variable, die von mehreren Goroutinen gleichzeitig geändert oder abgefragt werden kann.
Wir wissen, dass Goroutinen die Variable mehr lesen als in sie schreiben. Aus diesem
RWMutex
wir uns für
RWMutex
entschieden, um den Zugriff auf
Alive
zu serialisieren.
Ausgleichsanforderungen
Nun können wir eine einfache Methode formulieren, um unsere Anforderungen auszugleichen. Es wird nur fehlschlagen, wenn alle Server ausfallen.
Diese Methode kann einfach als
HandlerFunc
an den HTTP-Server
HandlerFunc
.
server := http.Server{ Addr: fmt.Sprintf(":%d", port), Handler: http.HandlerFunc(lb), }
Wir leiten den Datenverkehr nur an aktive Server weiter
Unser Balancer hat ein ernstes Problem. Wir wissen nicht, ob der Server läuft. Um dies herauszufinden, müssen Sie den Server überprüfen. Hierfür gibt es zwei Möglichkeiten:
- Aktiv: Beim Ausführen der aktuellen Anforderung stellen wir fest, dass der ausgewählte Server nicht antwortet, und markieren ihn als inaktiv.
- Passiv: Sie können Server in bestimmten Abständen anpingen und den Status überprüfen.
Aktive Überprüfung laufender Server
Wenn ein Fehler
ReverseProxy
initiiert
ErrorHandler
die
ErrorHandler
Rückruffunktion. Dies kann verwendet werden, um Fehler zu erkennen:
proxy.ErrorHandler = func(writer http.ResponseWriter, request *http.Request, e error) { log.Printf("[%s] %s\n", serverUrl.Host, e.Error()) retries := GetRetryFromContext(request) if retries < 3 { select { case <-time.After(10 * time.Millisecond): ctx := context.WithValue(request.Context(), Retry, retries+1) proxy.ServeHTTP(writer, request.WithContext(ctx)) } return }
Bei der Entwicklung dieses Fehlerhandlers haben wir die Funktionen von Closures verwendet. Dies ermöglicht es uns, externe Variablen wie Server-URLs in unserer Methode zu erfassen. Der Handler überprüft den Wiederholungszähler. Wenn er kleiner als 3 ist, senden wir dieselbe Anforderung erneut an denselben Server. Dies liegt daran, dass der Server aufgrund vorübergehender Fehler möglicherweise unsere Anforderungen verwirft, diese jedoch bald verfügbar sind (der Server verfügt möglicherweise nicht über freie Sockets für neue Clients). Sie müssen also den Verzögerungstimer für einen neuen Versuch nach ca. 10 ms einstellen. Mit jeder Anfrage erhöhen wir die Anzahl der Versuche.
Nach jedem fehlgeschlagenen Versuch markieren wir den Server als inaktiv.
Jetzt müssen Sie einen neuen Server für dieselbe Anforderung zuweisen. Wir werden dies mit dem Versuchszähler unter Verwendung des
context
tun. Nachdem wir die Anzahl der Versuche erhöht haben, übergeben wir sie an
lb
, um einen neuen Server für die Verarbeitung der Anforderung auszuwählen.
Wir können dies nicht auf unbestimmte Zeit tun, daher werden wir in
lb
prüfen, ob die maximale Anzahl von Versuchen erreicht wurde, bevor wir mit der Verarbeitung der Anforderung fortfahren.
Sie können einfach den Versuchszähler aus der Anfrage abrufen. Wenn er das Maximum erreicht, unterbrechen wir die Anfrage.
Dies ist eine rekursive Implementierung.
Verwenden des Kontextpakets
Mit dem
context
können Sie nützliche Daten in HTTP-Anforderungen speichern. Wir werden dies aktiv nutzen, um Daten zu verfolgen, die sich auf Anfragen beziehen -
Attempt
und
Retry
.
Zunächst müssen Sie die Schlüssel für den Kontext festlegen. Es wird empfohlen, keine Zeichenfolgen, sondern eindeutige numerische Werte zu verwenden. Go verfügt über ein
iota
Schlüsselwort für die inkrementelle Implementierung von Konstanten, von denen jede einen eindeutigen Wert enthält. Dies ist eine großartige Lösung zum Definieren von Zifferntasten.
const ( Attempts int = iota Retry )
Sie können den Wert dann extrahieren, wie wir es normalerweise mit der
HashMap
tun. Der Standardwert kann von der aktuellen Situation abhängen.
Passive Serverüberprüfung
Passive Überprüfungen identifizieren und beheben Serverausfälle. Wir rufen sie in einem bestimmten Intervall an, um ihren Status zu bestimmen.
Versuchen Sie zum Pingen, eine TCP-Verbindung herzustellen. Wenn der Server antwortet, wird markiert, dass er funktioniert. Diese Methode kann angepasst werden, um bestimmte Endpunkte wie
/status
aufzurufen. Stellen Sie sicher, dass die Verbindung nach dem Erstellen geschlossen wird, um die zusätzliche Belastung des Servers zu verringern. Andernfalls wird er versuchen, diese Verbindung aufrechtzuerhalten und schließlich seine Ressourcen zu erschöpfen.
Jetzt können Sie die Server durchlaufen und deren Status markieren:
Um diesen Code regelmäßig auszuführen, können Sie den Timer in Go ausführen. Hiermit können Sie Ereignisse im Kanal anhören.
In diesem Code gibt der
<-tC
Kanal alle 20 Sekunden einen Wert zurück.
select
können Sie dieses Ereignis definieren. Wenn keine
default
vorliegt, wird gewartet, bis mindestens ein Fall ausgeführt werden kann.
Führen Sie nun den Code in einer separaten Goroutine aus:
go healthCheck()
Fazit
In diesem Artikel haben wir viele Fragen untersucht:
- Round Robin Algorithmus
- ReverseProxy aus der Standardbibliothek
- Mutexe
- Atomare Operationen
- Kurzschlüsse
- Rückrufe
- Auswahloperation
Es gibt viele weitere Möglichkeiten, unseren Balancer zu verbessern. Zum Beispiel:
- Verwenden Sie Heap, um Live-Server zu sortieren und den Suchbereich zu verringern.
- Statistiken sammeln.
- Implementieren Sie den gewichteten Round-Robin-Algorithmus mit der geringsten Anzahl von Verbindungen.
- Unterstützung für Konfigurationsdateien hinzufügen.
Usw.
Der Quellcode ist
hier .