Представьте: ваш API — это как популярный ночной клуб, и без надлежащего контроля толпы всё может быстро стать хаотичным. Именно здесь на помощь приходит троттлинг — это, по сути, вышибала вашего сервера, решающий, кто и когда может войти. Сегодня мы подробно рассмотрим реализацию надёжных механизмов троттлинга в Go, которые обеспечат бесперебойную работу вашего API, даже когда интернет решит устроить вечеринку на ваших конечных точках.

Великая путаница: ограничение скорости против троттлинга

Прежде чем мы закатаем рукава и начнём программировать, давайте проясним распространённое заблуждение, в которое иногда впадают даже опытные разработчики. Хотя эти термины часто используются взаимозаменяемо (и я сам был виновен в этом), на самом деле они обозначают совершенно разные вещи.

Ограничение скорости — это как наличие личного помощника у каждого пользователя. Если вы установите лимит в 100 запросов в час на пользователя, каждый человек сможет сделать свои 100 запросов независимо. Представьте, что каждому дают свою собственную пиццу — делиться не нужно.

Троттлинг, с другой стороны, больше похож на одну пиццу для всей вечеринки. Когда вы устанавливаете троттлинг на 100 запросов в час, это общая пропускная способность вашего сервера, независимо от того, сколько пользователей стучится в вашу дверь. Один прожорливый пользователь теоретически может съесть всю пиццу, оставив всех остальных голодными.

Для нашего сегодняшнего пути реализации мы сосредоточимся на троттлинге — защите вашего сервера от перегрузки совокупным спросом всех пользователей.

Выбор стратегии троттлинга: buffet алгоритмов

Как и в случае с разными способами приготовления кофе (и поверьте мне, я пробовал их все во время ночных сессий кодирования), существует несколько алгоритмов для реализации троттлинга. Давайте рассмотрим самые популярные из них:

Алгоритм «ведро токенов»

Ведро токенов — это как банка печенья (токенов), которая пополняется с постоянной скоростью. Каждому запросу нужно взять печенье, чтобы продолжить. Печенья нет? Извините, придётся подождать, пока банка снова не наполнится.

package main
import (
    "fmt"
    "sync"
    "time"
)
type TokenBucket struct {
    capacity   int
    tokens     int
    refillRate int
    lastRefill time.Time
    mutex      sync.Mutex
}
func NewTokenBucket(capacity, refillRate int) *TokenBucket {
    return &TokenBucket{
        capacity:   capacity,
        tokens:     capacity,
        refillRate: refillRate,
        lastRefill: time.Now(),
    }
}
func (tb *TokenBucket) Allow() bool {
    tb.mutex.Lock()
    defer tb.mutex.Unlock()
    now := time.Now()
    elapsed := now.Sub(tb.lastRefill).Seconds()
    // Add tokens based on elapsed time
    tokensToAdd := int(elapsed * float64(tb.refillRate))
    tb.tokens += tokensToAdd
    if tb.tokens > tb.capacity {
        tb.tokens = tb.capacity
    }
    tb.lastRefill = now
    if tb.tokens > 0 {
        tb.tokens--
        return true
    }
    return false
}

Алгоритм фиксированного окна

Этот подход похож на часовые временные интервалы в кабинете врача. Каждый час получает свежую порцию доступных приёмов, но как только они заканчиваются, вам придётся ждать следующего часа.

type FixedWindow struct {
    limit       int
    window      time.Duration
    counter     int
    windowStart time.Time
    mutex       sync.Mutex
}
func NewFixedWindow(limit int, window time.Duration) *FixedWindow {
    return &FixedWindow{
        limit:       limit,
        window:      window,
        windowStart: time.Now(),
    }
}
func (fw *FixedWindow) Allow() bool {
    fw.mutex.Lock()
    defer fw.mutex.Unlock()
    now := time.Now()
    // Check if we need to start a new window
    if now.Sub(fw.windowStart) >= fw.window {
        fw.counter = 0
        fw.windowStart = now
    }
    if fw.counter < fw.limit {
        fw.counter++
        return true
    }
    return false
}

Создание готового к использованию middleware для троттлинга

Теперь, когда мы рассмотрели теорию, давайте создадим нечто, что вы сможете использовать в продакшене. Мы создадим гибкое middleware, которое работает с популярными фреймворками Go, такими как Gin.

package throttle
import (
    "fmt"
    "net/http"
    "sync"
    "time"
    "github.com/gin-gonic/gin"
)
type Throttler struct {
    bucket *TokenBucket
    mutex  sync.RWMutex
}
type TokenBucket struct {
    capacity     int
    tokens       int
    refillRate   int
    lastRefill   time.Time
    tickerStop   chan bool
    mutex        sync.Mutex
}
func NewThrottler(capacity, refillRate int) *Throttler {
    bucket := &TokenBucket{
        capacity:   capacity,
        tokens:     capacity,
        refillRate: refillRate,
        lastRefill: time.Now(),
        tickerStop: make(chan bool),
    }
    // Start the refill goroutine
    go bucket.startRefillTicker()
    return &Throttler{bucket: bucket}
}
func (tb *TokenBucket) startRefillTicker() {
    ticker := time.NewTicker(time.Second)
    defer ticker.Stop()
    for {
        select {
        case <-ticker.C:
            tb.refill()
        case <-tb.tickerStop:
            return
        }
    }
}
func (tb *TokenBucket) refill() {
    tb.mutex.Lock()
    defer tb.mutex.Unlock()
    tokensToAdd := tb.refillRate
    tb.tokens += tokensToAdd
    if tb.tokens > tb.capacity {
        tb.tokens = tb.capacity
    }
}
func (tb *TokenBucket) takeToken() bool {
    tb.mutex.Lock()
    defer tb.mutex.Unlock()
    if tb.tokens > 0 {
        tb.tokens--
        return true
    }
    return false
}
func (t *Throttler) Middleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        if !t.bucket.takeToken() {
            c.JSON(http.StatusTooManyRequests, gin.H{
                "error":   "Rate limit exceeded",
                "message": "Please try again later",
                "code":    "THROTTLED",
            })
            c.Abort()
            return
        }
        c.Next()
    }
}
// Cleanup stops the refill ticker
func (t *Throttler) Cleanup() {
    close(t.bucket.tickerStop)
}

Собираем всё вместе: полный пример

Давайте создадим полноценный HTTP-сервер, который демонстрирует наше middleware для троттлинга в действии:

package main
import (
    "log"
    "net/http"
    "github.com/gin-gonic/gin"
)
func main() {
    // Create a throttler: 10 requests capacity, refill 2 tokens per second
    throttler := NewThrottler(10, 2)
    defer throttler.Cleanup()
    router := gin.Default()
    // Apply throttling middleware globally
    router.Use(throttler.Middleware())
    // Health check endpoint (also throttled)
    router.GET("/health", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{
            "status":  "healthy",
            "message": "API работает без сбоев!",
        })
    })
    // Data endpoint
    router.GET("/api/data", func(c *gin.Context) {
        // Simulate some processing time
        time.Sleep(100 * time.Millisecond)
        c.JSON(http.StatusOK, gin.H{
            "data":      []string{"item1", "item2", "item3"},
            "timestamp": time.Now().Unix(),
        })
    })
    // Status endpoint to check throttling state
    router.GET("/throttle/status", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{
            "available_tokens": throttler.bucket.tokens,
            "capacity":        throttler.bucket.capacity,
            "refill_rate":     throttler.bucket.refillRate,
        })
    })