Go 限流入门:用令牌桶保护一个 HTTP 接口

从一个简单 HTTP 接口开始,讲 Go 中令牌桶限流的基本思想、按 IP 限流、清理状态和测试方式。

限流不是大公司才需要的功能。一个公开表单、一个登录接口、一个 AI 调用入口,如果没有任何限制,很容易被误用、刷爆或拖垮下游依赖。对 Go 初学者来说,限流最值得先理解的是“保护边界”:限制请求进入系统的速度,而不是等数据库或外部服务扛不住后再处理错误。

本文用令牌桶思路写一个 HTTP 中间件。示例不依赖第三方库,便于理解原理。真实项目可以使用成熟库或网关限流,但理解基本模型后,你会更知道配置该怎么设。

令牌桶的直觉

令牌桶可以想象成一个小桶:系统按固定速度往桶里放令牌,桶满了就不再增加。每个请求进来要拿一个令牌,拿到就通过,拿不到就返回 429。桶容量决定允许的突发量,补充速度决定长期平均速率。

比如每秒补 5 个令牌,桶容量 10。平时没请求时桶会积到 10 个,突然来 10 个请求可以马上通过;之后如果继续高频请求,就只能按每秒 5 个的速度通过。

一个简单 Limiter

先写结构:

type TokenBucket struct {
	mu       sync.Mutex
	tokens   float64
	capacity float64
	rate     float64
	last     time.Time
}

func NewTokenBucket(rate float64, capacity int) *TokenBucket {
	return &TokenBucket{
		tokens:   float64(capacity),
		capacity: float64(capacity),
		rate:     rate,
		last:     time.Now(),
	}
}

允许请求:

func (b *TokenBucket) Allow() bool {
	b.mu.Lock()
	defer b.mu.Unlock()

	now := time.Now()
	elapsed := now.Sub(b.last).Seconds()
	b.last = now

	b.tokens += elapsed * b.rate
	if b.tokens > b.capacity {
		b.tokens = b.capacity
	}
	if b.tokens < 1 {
		return false
	}
	b.tokens--
	return true
}

这里用 float64 是为了表达“半个令牌”这类时间累计。初学者不用纠结细节,先理解它每次请求都会按经过时间补充令牌,然后尝试消耗一个。

做成 HTTP 中间件

全局限流:

func RateLimit(bucket *TokenBucket, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !bucket.Allow() {
			w.Header().Set("Retry-After", "1")
			http.Error(w, "too many requests", http.StatusTooManyRequests)
			return
		}
		next.ServeHTTP(w, r)
	})
}

使用:

bucket := NewTokenBucket(5, 10)
mux.Handle("/api/search", RateLimit(bucket, searchHandler))

这会限制整个接口总流量。对于内部小服务,全局限流已经有价值。但如果多个用户共享一个桶,一个用户刷接口会影响其他用户。公开接口更常见的是按 IP 或按用户限流。

按 IP 限流

维护一个 map:

type IPLimiter struct {
	mu      sync.Mutex
	rate    float64
	burst   int
	buckets map[string]*TokenBucket
}

func NewIPLimiter(rate float64, burst int) *IPLimiter {
	return &IPLimiter{
		rate:    rate,
		burst:   burst,
		buckets: make(map[string]*TokenBucket),
	}
}

获取 bucket:

func (l *IPLimiter) bucket(ip string) *TokenBucket {
	l.mu.Lock()
	defer l.mu.Unlock()

	b, ok := l.buckets[ip]
	if !ok {
		b = NewTokenBucket(l.rate, l.burst)
		l.buckets[ip] = b
	}
	return b
}

中间件:

func (l *IPLimiter) Middleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ip, _, err := net.SplitHostPort(r.RemoteAddr)
		if err != nil {
			http.Error(w, "bad remote address", http.StatusBadRequest)
			return
		}
		if !l.bucket(ip).Allow() {
			http.Error(w, "too many requests", http.StatusTooManyRequests)
			return
		}
		next.ServeHTTP(w, r)
	})
}

如果服务在反向代理后面,RemoteAddr 可能是代理地址。这时要由可信代理设置 X-Forwarded-ForX-Real-IP,应用只在确认来源可信时读取这些头。不要无条件相信客户端传来的 IP 头。

清理长期不用的 bucket

按 IP 限流会让 map 增长。可以记录最后访问时间:

type visitor struct {
	bucket   *TokenBucket
	lastSeen time.Time
}

定期清理:

func (l *IPLimiter) Cleanup(maxIdle time.Duration) {
	l.mu.Lock()
	defer l.mu.Unlock()

	now := time.Now()
	for ip, v := range l.visitors {
		if now.Sub(v.lastSeen) > maxIdle {
			delete(l.visitors, ip)
		}
	}
}

示例为了简短前面用了 map[string]*TokenBucket,实际项目可以改成 map[string]visitor。限流状态本身也需要管理,否则防护代码会变成内存增长来源。

测试限流

测试全局桶比较容易:

func TestTokenBucketAllowsBurst(t *testing.T) {
	b := NewTokenBucket(1, 2)
	if !b.Allow() || !b.Allow() {
		t.Fatal("expected first two requests to pass")
	}
	if b.Allow() {
		t.Fatal("expected third request to be limited")
	}
}

涉及时间的测试容易不稳定。更严谨的设计是把时钟注入 limiter,但入门阶段先测不依赖等待的 burst 行为。不要在测试里随便 time.Sleep(2 * time.Second),测试会变慢,还可能在 CI 上抖动。

小结

限流的基本目标是保护系统边界。令牌桶通过“固定速度补充令牌、请求消耗令牌”来同时支持平均速率和短暂突发。Go 里可以把它封装成 HTTP 中间件,再根据需要做全局限流、按 IP 限流或按用户限流。

实际落地时要注意代理 IP、状态清理、429 响应和测试稳定性。限流不是越严格越好,而是要匹配业务容量和用户体验。先保护关键接口,再逐步观察和调整。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页