Ratenbegrenzung in Go: Implementierung des Token Bucket Algorithmus
Takashi Yamamoto
Infrastructure Engineer · Leapcell

Implementierung des Token Bucket Algorithmus in Go
Einführung in die Ratenbegrenzung
Im Bereich der Informatik und des Netzwerk-Engineerings ist die Ratenbegrenzung ein entscheidender Mechanismus zur Aufrechterhaltung der Systemstabilität, zur Verhinderung von Missbrauch und zur Gewährleistung einer fairen Ressourcenzuteilung. Im Kern steuert die Ratenbegrenzung die Anzahl der Anfragen oder Operationen, die innerhalb eines bestimmten Zeitfensters verarbeitet werden können. Dies ist in Szenarien wie API-Gateways, verteilten Systemen und jeder Anwendung, in der der Ressourcenverbrauch reguliert werden muss, unerlässlich.
Unter den verschiedenen Algorithmen, die zur Implementierung der Ratenbegrenzung entwickelt wurden, hat sich der Token Bucket Algorithmus als eine der beliebtesten und vielseitigsten Optionen herauskristallisiert. Seine Attraktivität liegt in seiner Fähigkeit, sowohl stationären Datenverkehr als auch gelegentliche Verkehrsspitzen zu bewältigen, was ihn für eine Vielzahl von realen Anwendungen geeignet macht. In diesem umfassenden Leitfaden werden wir uns mit der Funktionsweise des Token Bucket Algorithmus befassen, seine Implementierung in der Programmiersprache Go untersuchen und seine praktischen Anwendungen und Überlegungen beleuchten.
Den Token Bucket Algorithmus verstehen
Kernkonzepte
Der Token Bucket Algorithmus arbeitet mit einer einfachen, aber eleganten Metapher: einem Eimer, der Token enthält. Jedes Token repräsentiert das Recht, eine Operation durchzuführen oder eine Anfrage zu bearbeiten. Der Algorithmus funktioniert durch die folgenden Schlüsselmechanismen:
- Token-Generierung: In festen Intervallen werden dem Eimer neue Token hinzugefügt. Die Rate, mit der Token generiert werden, bestimmt die langfristige durchschnittliche Anforderungsrate, die das System verarbeiten kann.
- Token-Verbrauch: Um eine Anfrage zu bearbeiten, muss ein Token aus dem Eimer entfernt werden. Wenn ein Token verfügbar ist, wird die Anfrage zugelassen; wenn nicht, wird die Anfrage entweder verzögert oder abgelehnt.
- Eimerkapazität: Der Eimer hat eine maximale Kapazität, die die Anzahl der Token begrenzt, die er zu einem bestimmten Zeitpunkt enthalten kann. Diese Kapazität bestimmt die Fähigkeit des Systems, Verkehrsspitzen zu bewältigen.
Visuelle Darstellung
+------------------------+
| |
| [Token] [Token] |
| [Token] [Token] | <- Eimer mit Kapazität = 5
| [Token] | Enthält derzeit 5 Token
| |
+------------------------+
^ |
| v
+------------------------+
| |
| Token-Generator | <- Fügt 2 Token pro Sekunde hinzu
| |
+------------------------+
^
|
+------------------------+
| |
| Eingehende Anfragen |
| |
+------------------------+
In diesem Diagramm kann der Eimer maximal 5 Token aufnehmen. Der Token-Generator fügt dem Eimer jede Sekunde 2 Token hinzu, aber der Eimer wird seine Kapazität von 5 Token nie überschreiten. Wenn eingehende Anfragen eintreffen, entnimmt jede Anfrage ein Token aus dem Eimer. Wenn keine Token verfügbar sind, wird die Anfrage entweder zur späteren Verarbeitung in die Warteschlange gestellt oder direkt abgelehnt.
Dynamisches Verhalten
Die wahre Stärke des Token Bucket Algorithmus liegt in seinem dynamischen Verhalten:
- In Zeiten geringen Anfragevolumens sammeln sich Token im Eimer an, bis zu seiner maximalen Kapazität. Dies baut eine "Reserve" auf, die verwendet werden kann, um plötzliche Verkehrsspitzen zu bewältigen.
- Während Verkehrsspitzen kann das System Anfragen mit einer höheren Rate als der durchschnittlichen Token-Generierungsrate verarbeiten, solange sich Token im Eimer befinden.
- Sobald der Eimer leer ist, kann das System Anfragen nur noch mit der Token-Generierungsrate verarbeiten, wodurch der Datenverkehr effektiv auf den langfristigen Durchschnitt gedrosselt wird.
Dieses Verhalten macht den Token Bucket Algorithmus besonders gut geeignet für Anwendungen, die variable Verkehrsmuster aufweisen, wie z. B. Webserver, API-Endpunkte und Netzwerk-Router.
Implementierung des Token Buckets in Go
Go's Concurrency Primitives, insbesondere Goroutinen und Kanäle, machen es zu einer ausgezeichneten Sprache für die Implementierung des Token Bucket Algorithmus. Gehen wir eine Schritt-für-Schritt-Implementierung durch.
Grundlegende Token-Bucket-Struktur
Definieren wir zunächst die Struktur unseres Token-Buckets:
package tokenbucket import ( "sync" "time" ) // TokenBucket repräsentiert einen Token-Bucket-Rate-Limiter type TokenBucket struct { capacity int // Maximale Anzahl von Token, die der Bucket aufnehmen kann rate int // Anzahl der Token, die pro Sekunde hinzugefügt werden sollen tokens int // Aktuelle Anzahl von Token im Bucket lastRefill time.Time // Zeitstempel der letzten Token-Nachfüllung mutex sync.Mutex // Mutex zum Schutz des gleichzeitigen Zugriffs }
Diese Struktur enthält:
capacity
: Die maximale Anzahl von Token, die der Bucket aufnehmen kannrate
: Die Anzahl der Token, die pro Sekunde hinzugefügt werden sollentokens
: Die aktuelle Anzahl von Token im BucketlastRefill
: Der Zeitstempel, wann Token das letzte Mal dem Bucket hinzugefügt wurdenmutex
: Ein Synchronisations-Primitive, um Thread-Sicherheit zu gewährleisten.
Erstellen eines neuen Token Buckets
Als nächstes implementieren wir eine Funktion, um einen neuen Token Bucket zu erstellen:
// NewTokenBucket erstellt einen neuen Token Bucket mit der angegebenen Kapazität und Rate func NewTokenBucket(capacity, rate int) *TokenBucket { return &TokenBucket{ capacity: capacity, rate: rate, tokens: capacity, // Beginne mit einem vollen Bucket lastRefill: time.Now(), } }
Diese Funktion initialisiert einen neuen Token Bucket mit der angegebenen Kapazität und Token-Generierungsrate. Indem wir mit einem vollen Bucket beginnen, ermöglichen wir einen sofortigen Verkehrs-Burst bis zur Kapazität des Buckets.
Nachfüllen von Token
Das Herzstück des Token Bucket Algorithmus ist der Token-Nachfüllmechanismus. Wir müssen berechnen, wie viele Token dem Bucket basierend auf der seit der letzten Nachfüllung verstrichenen Zeit hinzugefügt werden sollen:
// refill fügt dem Bucket Token basierend auf der verstrichenen Zeit seit der letzten Nachfüllung hinzu func (tb *TokenBucket) refill() { now := time.Now() elapsed := now.Sub(tb.lastRefill) // Berechne, wie viele Token hinzugefügt werden sollen tokensToAdd := int(elapsed.Seconds() * float64(tb.rate)) if tokensToAdd > 0 { tb.lastRefill = now // Füge Token hinzu, überschreite aber nicht die Kapazität des Buckets tb.tokens = min(tb.tokens+tokensToAdd, tb.capacity) } } // min ist eine Hilfsfunktion, um das Minimum von zwei ganzen Zahlen zu finden func min(a, b int) int { if a < b { return a } return b }
Abrufen von Token
Implementieren wir nun die Methode, die es Clients ermöglicht, Token aus dem Bucket abzurufen:
// Take versucht, eine bestimmte Anzahl von Token aus dem Bucket zu entnehmen // Gibt true zurück, wenn erfolgreich, andernfalls false func (tb *TokenBucket) Take(tokens int) bool { tb.mutex.Lock() defer tb.mutex.Unlock() // Zuerst füllen wir den Bucket mit Token basierend auf der verstrichenen Zeit nach tb.refill() // Überprüfen, ob wir genug Token haben if tb.tokens >= tokens { tb.tokens -= tokens return true } // Nicht genug Token verfügbar return false }
Die Take
-Methode folgt diesen Schritten:
- Sie erwirbt eine Sperre, um Thread-Sicherheit zu gewährleisten, da mehrere Goroutinen möglicherweise gleichzeitig auf den Bucket zugreifen möchten.
- Sie füllt den Bucket mit allen Token nach, die seit der letzten Operation generiert wurden.
- Sie prüft, ob genügend Token verfügbar sind, um die Anfrage zu erfüllen.
- Wenn genügend Token verfügbar sind, entfernt sie diese aus dem Bucket und gibt
true
zurück; andernfalls gibt siefalse
zurück.
Blockierendes Abrufen
In einigen Fällen möchten wir möglicherweise blockieren, bis Token verfügbar werden, anstatt sofort false zurückzugeben. Implementieren wir eine TakeWithTimeout
-Methode, die für eine angegebene Dauer wartet, bis Token verfügbar werden:
// TakeWithTimeout versucht, Token aus dem Bucket zu entnehmen und wartet bis zum angegebenen Timeout // Gibt true zurück, wenn erfolgreich, false, wenn das Timeout erreicht wird func (tb *TokenBucket) TakeWithTimeout(tokens int, timeout time.Duration) bool { // Berechnen Sie den frühesten Zeitpunkt, zu dem wir aufhören können zu warten deadline := time.Now().Add(timeout) for { tb.mutex.Lock() // Füllen Sie den Bucket nach tb.refill() // Überprüfen Sie, ob wir jetzt genügend Token haben if tb.tokens >= tokens { tb.tokens -= tokens tb.mutex.Unlock() return true } // Berechnen, wie lange wir auf weitere Token warten müssen tokensNeeded := tokens - tb.tokens timeNeeded := time.Duration(tokensNeeded) * time.Second / time.Duration(tb.rate) // Wenn wir die Token vor der Deadline bekommen können, warten wir und versuchen es erneut if time.Now().Add(timeNeeded).Before(deadline) { // Entsperren Sie vor dem Warten, um andere Operationen zu ermöglichen tb.mutex.Unlock() // Warten Sie die erforderliche Zeit, aber nicht länger als das verbleibende Timeout waitTime := minDuration(timeNeeded, deadline.Sub(time.Now())) time.Sleep(waitTime) } else { // Nicht genug Zeit, um die erforderlichen Token zu bekommen tb.mutex.Unlock() return false } } } // minDuration ist eine Hilfsfunktion, um das Minimum von zwei Dauern zu finden func minDuration(a, b time.Duration) time.Duration { if a < b { return a } return b }
Diese Methode verwendet eine Schleife, um wiederholt nach verfügbaren Token zu suchen, bis entweder die erforderlichen Token abgerufen wurden oder das Timeout erreicht ist. Sie berechnet die Mindestzeit, die benötigt wird, um die erforderlichen Token zu generieren, und wartet diese Zeitdauer (oder bis zum Timeout, je nachdem, was zuerst eintritt), bevor sie erneut prüft.
Erweiterte Funktionen und Optimierungen
Burst-Kontrolle
Während die grundlegende Implementierung Bursts durch die Akkumulation von Token behandelt, möchten wir möglicherweise eine ausgefeiltere Burst-Kontrolle hinzufügen. Beispielsweise könnten wir die maximale Anzahl von Token begrenzen, die in einer einzelnen Anfrage entnommen werden können:
// TakeWithBurstLimit ist ähnlich wie Take, begrenzt aber die maximal entnommenen Token in einer Anfrage func (tb *TokenBucket) TakeWithBurstLimit(tokens, maxBurst int) bool { // Stellen Sie sicher, dass wir nicht mehr als die maximale Burst-Größe nehmen if tokens > maxBurst { tokens = maxBurst } return tb.Take(tokens) }
Diese Änderung verhindert, dass eine einzelne Anfrage alle verfügbaren Token verbraucht, wodurch sichergestellt wird, dass einige Token für andere Anfragen verbleiben.
Überwachung und Metriken
Für Produktionssysteme ist es oft nützlich, den Zustand des Token-Buckets zu überwachen:
// Metrics gibt den aktuellen Zustand des Token-Buckets zurück func (tb *TokenBucket) Metrics() (capacity, rate, currentTokens int) { tb.mutex.Lock() defer tb.mutex.Unlock() // Nachfüllen, um genaue aktuelle Token-Anzahl zu erhalten tb.refill() return tb.capacity, tb.rate, tb.tokens }
Diese Methode bietet Einblicke in die Kapazität des Buckets, die Token-Generierungsrate und die aktuelle Token-Anzahl, die für die Leistungsüberwachung und -optimierung von unschätzbarem Wert sein können.
Dynamische Ratenanpassung
In einigen Szenarien möchten wir möglicherweise die Token-Generierungsrate basierend auf den Systembedingungen dynamisch anpassen:
// SetRate aktualisiert die Token-Generierungsrate func (tb *TokenBucket) SetRate(newRate int) { tb.mutex.Lock() defer tb.mutex.Unlock() // Zuerst nachfüllen, um die Zeit mit der alten Rate zu berücksichtigen tb.refill() tb.rate = newRate }
Dies ermöglicht es dem System, sich an veränderte Bedingungen anzupassen, z. B. die Erhöhung der Rate außerhalb der Spitzenzeiten oder die Verringerung der Rate während Zeiten hoher Auslastung.
Praktische Anwendungen
API-Ratenbegrenzung
Eine der häufigsten Anwendungen des Token Bucket Algorithmus ist die API-Ratenbegrenzung:
// Beispiel: Verwenden eines Token-Buckets zur Ratenbegrenzung eines API-Endpunkts func handleAPIRequest(w http.ResponseWriter, r *http.Request, bucket *TokenBucket) { // Versuchen Sie, ein Token für diese Anfrage zu entnehmen if !bucket.Take(1) { http.Error(w, "Zu viele Anfragen", http.StatusTooManyRequests) return } // Verarbeiten Sie die Anfrage... w.WriteHeader(http.StatusOK) w.Write([]byte("Anfrage erfolgreich verarbeitet")) }
In diesem Beispiel verbraucht jede API-Anfrage ein Token aus dem Bucket. Wenn keine Token verfügbar sind, gibt der Server eine 429 Too Many Requests-Antwort zurück.
Traffic Shaping
Der Token Bucket Algorithmus kann auch für Traffic Shaping in Netzwerkanwendungen verwendet werden:
// Beispiel: Verwenden eines Token-Buckets für Traffic Shaping func sendPackets(packets [][]byte, bucket *TokenBucket) { for _, packet := range packets { // Jedes Paket verbraucht Token basierend auf seiner Größe packetSize := len(packet) // Warten Sie, bis wir genug Token für dieses Paket haben if !bucket.TakeWithTimeout(packetSize, 5*time.Second) { fmt.Println("Timeout beim Warten auf Token") return } // Senden Sie das Paket... fmt.Printf("Senden von Paket der Größe %d\n", packetSize) } }
Hier ist die Anzahl der benötigten Token proportional zur Größe des zu sendenden Pakets, was eine genauere Kontrolle über die Netzwerkbandbreite ermöglicht.
Ressourcenzuteilung
Token Buckets können auch verwendet werden, um die Ressourcenzuteilung innerhalb eines Systems zu verwalten:
// Beispiel: Verwenden von Token Buckets, um verschiedene Arten von Operationen zu priorisieren type ResourceManager struct { criticalOperationsBucket *TokenBucket normalOperationsBucket *TokenBucket lowPriorityBucket *TokenBucket } func NewResourceManager() *ResourceManager { return &ResourceManager{ // Kritische Operationen erhalten mehr Token und eine größere Burst-Kapazität criticalOperationsBucket: NewTokenBucket(100, 50), // Normale Operationen erhalten eine moderate Token-Zuteilung normalOperationsBucket: NewTokenBucket(50, 20), // Operationen mit niedriger Priorität erhalten weniger Token lowPriorityBucket: NewTokenBucket(20, 5), } } func (rm *ResourceManager) performCriticalOperation() bool { return rm.criticalOperationsBucket.Take(1) } func (rm *ResourceManager) performNormalOperation() bool { return rm.normalOperationsBucket.Take(1) } func (rm *ResourceManager) performLowPriorityOperation() bool { return rm.lowPriorityBucket.Take(1) }
In diesem Beispiel haben verschiedene Arten von Operationen unterschiedliche Token Buckets mit unterschiedlichen Kapazitäten und Raten, wodurch sichergestellt wird, dass kritische Operationen vorrangigen Zugriff auf Systemressourcen erhalten.
Leistungsüberlegungen
Gleichzeitigkeit
Bei der Implementierung des Token Bucket Algorithmus in Go ist es wichtig, die Leistungsimplikationen des gleichzeitigen Zugriffs zu berücksichtigen. Die Verwendung eines Mutex gewährleistet die Thread-Sicherheit, kann aber in stark parallelen Systemen zu einem Engpass werden.
Eine Möglichkeit, dies zu entschärfen, ist die Verwendung einer feiner abgestimmten Sperrstrategie oder das Sharding der Token Buckets über mehrere Instanzen:
// Beispiel: Sharding von Token Buckets für bessere Gleichzeitigkeit type ShardedTokenBucket struct { buckets []*TokenBucket numShards int } func NewShardedTokenBucket(capacity, rate, numShards int) *ShardedTokenBucket { buckets := make([]*TokenBucket, numShards) for i := 0; i < numShards; i++ { // Jeder Shard erhält einen gleichen Anteil der Gesamtkapazität und -rate buckets[i] = NewTokenBucket(capacity/numShards, rate/numShards) } return &ShardedTokenBucket{ buckets: buckets, numShards: numShards, } } func (stb *ShardedTokenBucket) Take(tokens int) bool { // Wählen Sie einen Shard basierend auf einem Hash (z. B. der Anfrage-ID) shard := 0 // Verwenden Sie in der Praxis eine geeignete Hash-Funktion return stb.buckets[shard].Take(tokens) }
Indem wir den Token Bucket in mehrere Shards aufteilen, reduzieren wir die Konkurrenz um den Mutex, was eine bessere Leistung in parallelen Umgebungen ermöglicht.
Präzision vs. Effizienz
Die in diesem Artikel vorgestellte Implementierung berechnet die Token-Anzahl bei jeder Operation neu, was eine hohe Präzision bietet, aber in Systemen mit sehr hohem Durchsatz Leistungseinbußen verursachen kann.
Ein alternativer Ansatz ist die Verwendung einer Hintergrund-Goroutine, um dem Bucket in regelmäßigen Abständen Token hinzuzufügen:
// Beispiel: Verwenden einer Hintergrund-Goroutine zum Hinzufügen von Token func startRefillGoroutine(bucket *TokenBucket, interval time.Duration) { go func() { ticker := time.NewTicker(interval) defer ticker.Stop() for range ticker.C { bucket.mutex.Lock() // Fügen Sie Token basierend auf dem Intervall und der Rate hinzu tokensToAdd := int(interval.Seconds() * float64(bucket.rate)) bucket.tokens = min(bucket.tokens+tokensToAdd, bucket.capacity) bucket.mutex.Unlock() } }() }
Dieser Ansatz kann effizienter sein, führt aber zu einer gewissen Ungenauigkeit, da Token in Batches und nicht kontinuierlich hinzugefügt werden.
Fazit
Der Token Bucket Algorithmus bietet eine flexible und effiziente Möglichkeit, die Ratenbegrenzung und das Traffic Shaping in einer Vielzahl von Anwendungen zu implementieren. Seine Fähigkeit, sowohl stationären Datenverkehr als auch plötzliche Bursts zu verarbeiten, macht ihn besonders wertvoll in realen Systemen, in denen Verkehrsmuster oft unvorhersehbar sind.
In diesem Artikel haben wir die Kernkonzepte des Token Bucket Algorithmus untersucht, ihn in Go implementiert und verschiedene Erweiterungen und Optimierungen untersucht. Wir haben uns auch praktische Anwendungen angesehen, von der API-Ratenbegrenzung bis zum Network Traffic Shaping.
Wenn Sie einen Token Bucket in Ihren eigenen Anwendungen implementieren, denken Sie daran, Folgendes zu berücksichtigen:
- Die geeignete Kapazität und Token-Generierungsrate für Ihren Anwendungsfall
- Wie Anfragen behandelt werden, wenn keine Token verfügbar sind (ablehnen, in die Warteschlange stellen oder verzögern)
- Thread-Sicherheit und Leistung in parallelen Umgebungen
- Überwachung und dynamische Anpassung basierend auf den Systembedingungen
Leapcell: Das Beste von Serverless Webhosting
Abschließend empfehlen wir eine hervorragende Plattform für die Bereitstellung von Go-Diensten: Leapcell
🚀 Mit Ihrer Lieblingssprache entwickeln
Problemlos in JavaScript, Python, Go oder Rust entwickeln.
🌍 Unbegrenzte Projekte kostenlos bereitstellen
Sie zahlen nur für das, was Sie nutzen – keine Anfragen, keine Gebühren.
⚡ Pay-as-You-Go, ohne versteckte Kosten
Keine Leerlaufgebühren, nur nahtlose Skalierbarkeit.
📖 Entdecken Sie unsere Dokumentation
🔹 Folgen Sie uns auf Twitter: @LeapcellHQ