第23篇-Go限流与计量-Token维度的精细化管控

【AIaaS 全栈架构师】第 23 篇:Go 限流与计量——Token 维度的精细化管控

系列定位:AIaaS 全栈架构师教程,技术栈以 Go 为主。本篇深入 LLM 网关最独特的部分——Token 维度限流与计量。


本篇你将学到

  • 理解 LLM 限流与普通 API 限流的关键差异,掌握多维限流(QPS/TPM/并发)的设计
  • 用 Go 从零实现令牌桶与滑动窗口两种经典限流算法
  • 在 SSE 流式响应中实时统计 output token,实现边推理边计量
  • 使用 Redis + Lua 脚本实现分布式限流

限流和计量是 LLM API 网关区别于普通 API 网关最显著的两个能力。普通网关限流按请求数(QPS)就够了;LLM 网关必须按 Token 数(TPM)限流,因为一个"写一篇万字长文"的请求和一个"说 hi"的请求,消耗的算力差了成百上千倍。


一、LLM 限流的独特维度

1.1 为什么 QPS 不够用

先看一个具体场景。假设你的平台限额是"每秒 10 个请求"(10 QPS),看似合理。但如果:

  • 用户 A 每次请求都让模型输出 8000 token(长文生成)
  • 用户 B 每次请求只输出 50 token(简单问答)

两者消耗的 GPU 算力相差 160 倍,但在 QPS 维度上他们"平等"。结果就是:用户 A 用 10 QPS 就能跑满整张 GPU,用户 B 被挤到排队。QPS 无法反映真实资源消耗,必须引入 Token 维度。

1.2 三维限流模型

一个成熟的 LLM 限流系统通常是三维叠加的:

维度全称含义保护目标
QPSQueries Per Second每秒请求数防止连接/调度层过载
TPMTokens Per Minute每分钟 Token 数防止 GPU 算力被占满
并发数Concurrent Requests同时在执行的请求数防止 KV Cache 显存溢出

超出

通过

超出

通过

超出

通过

请求到达

QPS
检查

429 Too Many Requests

并发数
检查

429
建议排队等待

预估输入
Token + TPM
检查

429
Token quota exceeded

放行执行

推理完成后
统计实际 output token
扣减 TPM 额度

注意第三个检查点——输入 Token 可以在请求前预估(因为请求体里有 messages),但输出 Token 只能在推理完成后才知道。所以 TPM 限流分两阶段:

  1. 请求前:检查历史已消耗 TPM + 本次预估 input token
  2. 请求后:根据实际 output token 补扣额度

1.3 限流 vs 计量 vs 计费

这三个概念容易混淆,先澄清边界:

概念时机目的数据去向
限流请求前 + 请求后保护系统不被打挂内存/Redis 计数器
计量请求后记录实际消耗日志/消息队列
计费离线/准实时生成账单数据库/账单系统

本篇聚焦限流和计量。计费留到第 36 篇专题讲解。


二、令牌桶算法 Go 实现

令牌桶(Token Bucket)是最常用的限流算法。核心思想:以固定速率往桶里放令牌,桶满了就丢弃;每个请求消耗一个令牌;没令牌就拒绝。

2.1 算法原理

令牌桶

取 1 令牌

有令牌

无令牌

生成器
每 1/rate 秒
加 1 个令牌


容量 = burst
当前令牌 = tokens

请求

放行

拒绝 429

令牌桶的两个关键参数:

  • rate:令牌生成速率(个/秒),决定稳态吞吐
  • burst:桶容量,决定突发流量上限

令牌桶的优势是允许突发——桶里攒了 100 个令牌,突然来 50 个请求可以瞬间放行,之后才按 rate 补充。这比"严格每秒 N 个"的计数器更贴合真实流量模式。

2.2 惰性计算实现

不需要真的起一个 goroutine 每秒往桶里加令牌。用一个时间戳记录"上次补充时间",每次取令牌时根据时间差一次性补上:

package main

import (
	"sync"
	"time"
)

// TokenBucket 令牌桶限流器(惰性计算版)
type TokenBucket struct {
	mu         sync.Mutex
	rate       float64   // 令牌生成速率(个/秒)
	burst      float64   // 桶容量
	tokens     float64   // 当前令牌数
	lastUpdate time.Time // 上次补充时间
}

// NewTokenBucket 创建令牌桶
// rate: 每秒生成多少令牌;burst: 桶最大容量
func NewTokenBucket(rate, burst float64) *TokenBucket {
	return &TokenBucket{
		rate:       rate,
		burst:      burst,
		tokens:     burst, // 初始满桶
		lastUpdate: time.Now(),
	}
}

// Allow 尝试消耗 n 个令牌,返回是否允许
func (tb *TokenBucket) Allow(n float64) bool {
	tb.mu.Lock()
	defer tb.mu.Unlock()

	now := time.Now()
	// 计算自上次以来应补充的令牌
	elapsed := now.Sub(tb.lastUpdate).Seconds()
	tb.lastUpdate = now
	tb.tokens += elapsed * tb.rate
	// 不能超过桶容量
	if tb.tokens > tb.burst {
		tb.tokens = tb.burst
	}

	if tb.tokens >= n {
		tb.tokens -= n
		return true
	}
	return false
}

// Tokens 返回当前可用令牌数(仅用于观察/调试)
func (tb *TokenBucket) Tokens() float64 {
	tb.mu.Lock()
	defer tb.mu.Unlock()
	return tb.tokens
}

2.3 用于 QPS 限流

把令牌桶包装成 HTTP 中间件,针对每个租户单独限流:

package main

import (
	"net/http"
	"strconv"
	"sync"
)

// RateLimiterMap 按租户维度管理限流器
type RateLimiterMap struct {
	mu       sync.RWMutex
	limiters map[string]*TokenBucket // key: tenantID
	rate     float64                  // 默认速率
	burst    float64                  // 默认桶容量
}

func NewRateLimiterMap(rate, burst float64) *RateLimiterMap {
	return &RateLimiterMap{
		limiters: make(map[string]*TokenBucket),
		rate:     rate,
		burst:    burst,
	}
}

// Get 获取或创建租户的限流器
func (m *RateLimiterMap) Get(tenantID string) *TokenBucket {
	m.mu.RLock()
	tb, ok := m.limiters[tenantID]
	m.mu.RUnlock()
	if ok {
		return tb
	}

	m.mu.Lock()
	defer m.mu.Unlock()
	// 双检
	if tb, ok := m.limiters[tenantID]; ok {
		return tb
	}
	tb = NewTokenBucket(m.rate, m.burst)
	m.limiters[tenantID] = tb
	return tb
}

// RateLimitMiddleware QPS 限流中间件
func RateLimitMiddleware(limiterMap *RateLimiterMap, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		tenant := GetTenant(r.Context())
		if tenant == nil {
			next.ServeHTTP(w, r) // 未认证请求放行(认证中间件应已拦截)
			return
		}
		tb := limiterMap.Get(tenant.ID)
		if !tb.Allow(1) {
			w.Header().Set("Retry-After", "1")
			writeRateLimitError(w, "rate_limit_exceeded",
				"QPS limit exceeded, please retry later")
			return
		}
		next.ServeHTTP(w, r)
	})
}

func writeRateLimitError(w http.ResponseWriter, code, message string) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusTooManyRequests)
	resp := map[string]interface{}{
		"error": map[string]interface{}{
			"message": message,
			"type":    "rate_limit_error",
			"code":    code,
		},
	}
	_ = json.NewEncoder(w).Encode(resp)
}

2.4 单元测试验证

用 Go 标准 testing 验证令牌桶行为:

package main

import (
	"testing"
	"time"
)

func TestTokenBucket_Basic(t *testing.T) {
	// rate=10/s, burst=10
	tb := NewTokenBucket(10, 10)

	// 初始满桶,前 10 个请求应全部通过
	for i := 0; i < 10; i++ {
		if !tb.Allow(1) {
			t.Fatalf("request %d should be allowed", i+1)
		}
	}

	// 第 11 个应该被拒绝
	if tb.Allow(1) {
		t.Fatal("request 11 should be rejected")
	}
}

func TestTokenBucket_Refill(t *testing.T) {
	tb := NewTokenBucket(10, 10)
	// 消耗全部令牌
	for i := 0; i < 10; i++ {
		tb.Allow(1)
	}
	// 立刻请求应该被拒
	if tb.Allow(1) {
		t.Fatal("should be rejected immediately after drain")
	}
	// 等待 0.2 秒应补充约 2 个令牌
	time.Sleep(200 * time.Millisecond)
	if !tb.Allow(1) {
		t.Fatal("should be allowed after refill")
	}
}

func TestTokenBucket_Burst(t *testing.T) {
	// rate=1/s, burst=5 —— 低速率高突发
	tb := NewTokenBucket(1, 5)
	// 突发 5 个应全部通过
	for i := 0; i < 5; i++ {
		if !tb.Allow(1) {
			t.Fatalf("burst request %d should pass", i+1)
		}
	}
	// 第 6 个拒绝
	if tb.Allow(1) {
		t.Fatal("6th should be rejected")
	}
}

// BenchmarkTokenBucket 并发基准测试
func BenchmarkTokenBucket_Concurrent(b *testing.B) {
	tb := NewTokenBucket(1000000, 1000000) // 高速率避免成为瓶颈
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			tb.Allow(1)
		}
	})
}

在终端运行:

go test -v -run TestTokenBucket
go test -bench BenchmarkTokenBucket

三、滑动窗口限流

令牌桶适合"平滑限流",但有时你需要精确控制"每分钟不超过 N 个"。固定窗口算法(整点清零)有临界突发问题——59 秒和 1 秒内各来 N 个请求,1 秒窗口内有 2N 个。滑动窗口解决这个问题。

3.1 算法原理

滑动窗口记录每个请求的时间戳,实时淘汰窗口外的旧请求:

package main

import (
	"sync"
	"time"
)

// SlidingWindow 滑动窗口限流器
type SlidingWindow struct {
	mu       sync.Mutex
	window   time.Duration // 窗口大小(如 1 分钟)
	limit    int           // 窗口内最大请求数
	requests []time.Time   // 窗口内请求时间戳列表
}

func NewSlidingWindow(window time.Duration, limit int) *SlidingWindow {
	return &SlidingWindow{
		window: window,
		limit:  limit,
	}
}

// Allow 尝试获取许可
func (sw *SlidingWindow) Allow() bool {
	sw.mu.Lock()
	defer sw.mu.Unlock()

	now := time.Now()
	cutoff := now.Add(-sw.window)

	// 淘汰窗口外的旧请求(从头部移除)
	idx := 0
	for i, t := range sw.requests {
		if t.After(cutoff) {
			idx = i
			break
		}
		if i == len(sw.requests)-1 {
			idx = len(sw.requests) // 全部过期
		}
	}
	sw.requests = sw.requests[idx:]

	if len(sw.requests) >= sw.limit {
		return false
	}
	sw.requests = append(sw.requests, now)
	return true
}

滑动窗口用 slice 存时间戳,在超高并发下 slice 移除头部元素开销大(O(n) 内存拷贝)。生产环境可用环形缓冲(ring buffer)或 Redis ZSET 优化。本段聚焦算法原理。

3.2 令牌桶 vs 滑动窗口选型

维度令牌桶滑动窗口
突发流量允许(受 burst 限制)严格拒绝
内存O(1)O(窗口内请求数)
精度平滑近似精确
适用场景QPS/TPM 限流精确配额控制
分布式友好需改造Redis ZSET 天然适配

经验法则:QPS 用令牌桶,TPM 配额用滑动窗口


四、Token 计数器:流式响应中实时统计

这是 LLM 限流/计量最技术密集的部分。难点在于:SSE 流式响应的 output token 是逐个 chunk 产生的,我们必须边接收边计数。

4.1 input token 预估

input token 在请求前就知道(messages 内容已知)。最简单的预估方法是用空格分词:

package main

import (
	"strings"
	"unicode/utf8"
)

// EstimateTokens 粗略估算文本的 token 数
// 经验值:英文约 1 token ≈ 4 字符;中文约 1 token ≈ 1.5 字符
func EstimateTokens(text string) int {
	charCount := utf8.RuneCountInString(text)
	// 检测中文字符比例
	chineseChars := 0
	for _, r := range text {
		if r >= 0x4E00 && r <= 0x9FFF {
			chineseChars++
		}
	}
	if charCount == 0 {
		return 0
	}
	chineseRatio := float64(chineseChars) / float64(charCount)
	// 按中英文比例加权
	return int(float64(charCount) * (0.25 + 0.4*chineseRatio))
}

// EstimateInputTokens 估算整个请求的 input token
func EstimateInputTokens(messages []Message) int {
	total := 0
	// 每条消息有约 4 token 的格式开销(role 标记等)
	for _, msg := range messages {
		total += EstimateTokens(msg.Content) + 4
	}
	return total + 2 // 结尾标记
}

生产环境应使用真实的 tokenizer(如 tiktoken-go 库)精确计数。这里给出经验估算法用于演示原理。

4.2 流式 Token 计数器

在 SSE 流式转发过程中,每个 chunk 的 delta.content 是一段文本。我们逐 chunk 调用 EstimateTokens 累加:

package main

import (
	"bufio"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"strings"
	"sync/atomic"
)

// TokenCounter 流式 Token 计数器
type TokenCounter struct {
	InputTokens  int          // input token(请求前确定)
	OutputTokens atomic.Int64 // output token(流式中累加)
}

func NewTokenCounter(inputTokens int) *TokenCounter {
	return &TokenCounter{InputTokens: inputTokens}
}

// CountChunk 处理一个 SSE chunk,提取 content 并计数
func (tc *TokenCounter) CountChunk(line string) {
	if !strings.HasPrefix(line, "data: ") {
		return
	}
	data := strings.TrimPrefix(line, "data: ")
	if data == "[DONE]" {
		return
	}
	var chunk struct {
		Choices []struct {
			Delta struct {
				Content string `json:"content"`
			} `json:"delta"`
		} `json:"choices"`
	}
	if err := json.Unmarshal([]byte(data), &chunk); err != nil {
		return
	}
	for _, choice := range chunk.Choices {
		if choice.Delta.Content != "" {
			tc.OutputTokens.Add(int64(EstimateTokens(choice.Delta.Content)))
		}
	}
}

// Total 返回总 token 数
func (tc *TokenCounter) Total() int {
	return tc.InputTokens + int(tc.OutputTokens.Load())
}

4.3 集成到流式代理

把 TokenCounter 嵌入到第 22 篇的 SSE 转发逻辑中。核心是在转发每个 chunk 时顺便计数:

// StreamProxyWithCount 带计数的流式代理
func StreamProxyWithCount(w http.ResponseWriter, r *http.Request,
	backendURL string, bodyBytes []byte, counter *TokenCounter) error {

	// 构造后端请求
	req, err := http.NewRequestWithContext(r.Context(), "POST",
		backendURL+"/v1/chat/completions", bytes.NewReader(bodyBytes))
	if err != nil {
		return err
	}
	req.Header.Set("Content-Type", "application/json")

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	// 设置 SSE 响应头
	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-cache")
	w.Header().Set("X-Accel-Buffering", "no")
	w.WriteHeader(http.StatusOK)

	flusher, _ := w.(http.Flusher)
	scanner := bufio.NewScanner(resp.Body)
	scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)

	for scanner.Scan() {
		line := scanner.Text()

		// 关键:边转发边计数
		counter.CountChunk(line)

		// 转发给客户端
		_, _ = fmt.Fprintf(w, "%s\n", line)
		if line == "" {
			if flusher != nil {
				flusher.Flush()
			}
		}
	}
	return scanner.Err()
}

4.4 计量中间件完整实现

把限流和计量组装成中间件链:

// MeteringMiddleware 计量中间件:请求前预检 + 请求后扣减
func MeteringMiddleware(tpmLimiter *RateLimiterMap, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		tenant := GetTenant(r.Context())
		if tenant == nil {
			next.ServeHTTP(w, r)
			return
		}

		// 读取请求体
		bodyBytes, err := io.ReadAll(r.Body)
		if err != nil {
			writeError(w, http.StatusBadRequest, "read_error", "Failed to read body")
			return
		}
		r.Body.Close()

		// 解析请求,预估 input token
		var req ChatRequest
		if err := json.Unmarshal(bodyBytes, &req); err != nil {
			writeError(w, http.StatusBadRequest, "invalid_json", "Invalid JSON")
			return
		}
		inputTokens := EstimateInputTokens(req.Messages)

		// TPM 预检:本次预估消耗 = input + max_tokens
		estimatedCost := float64(inputTokens + req.MaxTokens)
		tb := tpmLimiter.Get(tenant.ID)
		if !tb.Allow(estimatedCost) {
			w.Header().Set("Retry-After", "60")
			writeRateLimitError(w, "token_limit_exceeded",
				"Token quota exceeded for this minute")
			return
		}

		// 用包装的 ResponseWriter 捕获流式 token
		counter := NewTokenCounter(inputTokens)

		// 如果是流式,需要特殊的 handler 处理
		if req.Stream {
			// 重新填充 body 给后续 handler
			r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
			// 将 counter 放入 context
			ctx := context.WithValue(r.Context(), counterKey, counter)
			next.ServeHTTP(w, r.WithContext(ctx))

			// 流式结束后,记录实际 output token 到计量系统
			log.Printf("tenant=%s model=%s input=%d output=%d total=%d",
				tenant.ID, req.Model, counter.InputTokens,
				counter.OutputTokens.Load(), counter.Total())
			return
		}

		// 非流式:包装 ResponseWriter 捕获响应体
		rw := &capturingResponseWriter{ResponseWriter: w}
		r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
		ctx := context.WithValue(r.Context(), counterKey, counter)
		next.ServeHTTP(rw, r.WithContext(ctx))

		// 非流式响应中后端通常返回 usage,可从中提取精确 token 数
		actualOutput := extractOutputTokens(rw.body)
		if actualOutput > 0 {
			counter.OutputTokens.Store(int64(actualOutput))
		}
		log.Printf("tenant=%s model=%s input=%d output=%d",
			tenant.ID, req.Model, counter.InputTokens, counter.OutputTokens.Load())
	})
}

// capturingResponseWriter 捕获非流式响应体(用于提取 usage)
type capturingResponseWriter struct {
	http.ResponseWriter
	body []byte
}

func (crw *capturingResponseWriter) Write(b []byte) (int, error) {
	crw.body = append(crw.body, b...)
	return crw.ResponseWriter.Write(b)
}

func extractOutputTokens(body []byte) int {
	var resp ChatResponse
	if err := json.Unmarshal(body, &resp); err != nil {
		return 0
	}
	return resp.Usage.CompletionTokens
}

4.5 计量数据流全景

计量日志 后端推理引擎 推理 Handler TPM 限流器 计量中间件 客户端 计量日志 后端推理引擎 推理 Handler TPM 限流器 计量中间件 客户端 边转发边调用 counter.CountChunk 异步写入 Kafka 供计费系统消费 alt [额度不足] [额度充足] POST /v1/chat/completions 解析 body,预估 input token Allow(input + max_tokens)? false 429 Token quota exceeded true(预扣额度) 转发请求(附带 counter) 调用推理引擎 SSE 流式返回 逐 chunk 转发 流结束 记录 input/output token

五、Redis 分布式限流

单机限流器(上面的 TokenBucketSlidingWindow)只在单个网关实例内有效。生产环境网关通常多实例部署,必须用集中式存储协调。

5.1 架构

网关集群

INCR + EXPIRE

INCR + EXPIRE

INCR + EXPIRE

实例 1

实例 2

实例 3

Redis
集中计数器

5.2 用 Redis 原子命令实现固定窗口

最简单的 Redis 限流用 INCR + EXPIRE

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
)

// RedisRateLimiter 基于 Redis 的分布式限流器(固定窗口版)
type RedisRateLimiter struct {
	client *redis.Client
}

func NewRedisRateLimiter(addr string) *RedisRateLimiter {
	return &RedisRateLimiter{
		client: redis.NewClient(&redis.Options{Addr: addr}),
	}
}

// Allow 检查是否允许(固定窗口:按分钟计数)
func (rl *RedisRateLimiter) Allow(ctx context.Context,
	key string, limit int64, window time.Duration) (bool, error) {

	// 用窗口起始时间作为 key 后缀,保证同一窗口内计数
	windowKey := fmt.Sprintf("%s:%d", key, time.Now().UnixNano()/int64(window))

	pipe := rl.client.Pipeline()
	incr := pipe.Incr(ctx, windowKey)
	pipe.Expire(ctx, windowKey, window+time.Second) // 多加 1 秒防止边界竞争

	_, err := pipe.Exec(ctx)
	if err != nil {
		return false, err
	}
	return incr.Val() <= limit, nil
}

5.3 Lua 脚本实现令牌桶

Redis 的 INCR 方案有竞态问题(INCR 和 EXPIRE 之间可能中断)。用 Lua 脚本保证原子性,同时实现令牌桶:

// 令牌桶 Lua 脚本
// KEYS[1] = bucket key
// ARGV[1] = capacity(桶容量)
// ARGV[2] = rate(每秒生成令牌数)
// ARGV[3] = now(当前时间戳,秒)
// ARGV[4] = requested(请求消耗令牌数)
const tokenBucketLua = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

-- 读取当前状态
local data = redis.call('HMGET', key, 'tokens', 'timestamp')
local tokens = tonumber(data[1]) or capacity
local last_time = tonumber(data[2]) or now

-- 补充令牌
local delta = math.max(0, now - last_time)
tokens = math.min(capacity, tokens + delta * rate)

local allowed = 0
if tokens >= requested then
    tokens = tokens - requested
    allowed = 1
end

-- 写回状态,设置过期时间防止冷数据
redis.call('HMSET', key, 'tokens', tokens, 'timestamp', now)
redis.call('EXPIRE', key, 3600)

return allowed
`

// RedisTokenBucket 分布式令牌桶
type RedisTokenBucket struct {
	client *redis.Client
	script *redis.Script
}

func NewRedisTokenBucket(addr string) *RedisTokenBucket {
	return &RedisTokenBucket{
		client: redis.NewClient(&redis.Options{Addr: addr}),
		script: redis.NewScript(tokenBucketLua),
	}
}

func (rtb *RedisTokenBucket) Allow(ctx context.Context, key string,
	capacity, rate float64, requested int) (bool, error) {

	now := float64(time.Now().UnixNano()) / 1e9
	result, err := rtb.script.Run(ctx, rtb.client,
		[]string{key},
		capacity, rate, now, requested,
	).Int()
	if err != nil {
		return false, err
	}
	return result == 1, nil
}

5.4 Redis 限流注意事项

问题解决方案
Redis 故障导致限流失效本地兜底限流(令牌桶降级),fail-open 策略
网关实例间时钟不同步从 NTP 同步,或用 Redis TIME 命令取时间
单 key 成为热点按 tenant 分片,或用 Redis Cluster
Lua 脚本阻塞脚本保持简短,避免循环

六、多维限流组装

把 QPS + 并发 + TPM 三个维度组装到一起,形成完整的限流中间件链:

package main

import (
	"sync"
	"sync/atomic"
)

// ConcurrencyTracker 并发请求计数器(用于并发数限流)
type ConcurrencyTracker struct {
	mu      sync.Mutex
	current map[string]*atomic.Int64 // tenantID -> 当前并发数
	limit   int64
}

func NewConcurrencyTracker(limit int64) *ConcurrencyTracker {
	return &ConcurrencyTracker{
		current: make(map[string]*atomic.Int64),
		limit:   limit,
	}
}

func (ct *ConcurrencyTracker) TryAcquire(tenantID string) bool {
	ct.mu.Lock()
	c, ok := ct.current[tenantID]
	if !ok {
		c = &atomic.Int64{}
		ct.current[tenantID] = c
	}
	ct.mu.Unlock()

	current := c.Add(1)
	if current > ct.limit {
		c.Add(-1)
		return false
	}
	return true
}

func (ct *ConcurrencyTracker) Release(tenantID string) {
	ct.mu.Lock()
	c, ok := ct.current[tenantID]
	ct.mu.Unlock()
	if ok {
		c.Add(-1)
	}
}

// MultiDimensionLimit 三维限流中间件
func MultiDimensionLimit(qpsMap, tpmMap *RateLimiterMap,
	concurrency *ConcurrencyTracker, next http.Handler) http.Handler {

	qpsLimiter := qpsMap    // 如 10 QPS
	tpmLimiter := tpmMap    // 如 100000 TPM
	// concurrency         // 如 5 并发

	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		tenant := GetTenant(r.Context())
		if tenant == nil {
			next.ServeHTTP(w, r)
			return
		}

		// 1. QPS 检查
		if !qpsLimiter.Get(tenant.ID).Allow(1) {
			writeRateLimitError(w, "qps_exceeded", "Too many requests per second")
			return
		}

		// 2. 并发检查
		if !concurrency.TryAcquire(tenant.ID) {
			writeRateLimitError(w, "concurrency_exceeded",
				"Too many concurrent requests")
			return
		}
		defer concurrency.Release(tenant.ID) // 确保请求结束释放

		// 3. TPM 检查在 MeteringMiddleware 中处理(因为要解析 body)
		next.ServeHTTP(w, r)
	})
}

6.1 中间件执行顺序

请求

认证中间件

QPS 限流
令牌桶

并发限流
计数器

计量中间件
TPM 检查 +
Token 计数

路由 + Failover

返回响应

顺序设计原则:便宜的检查在前,昂贵的检查在后。认证和 QPS 只需读 header/计数器,开销极小;TPM 需要解析 body 预估 token,开销略大,放最后。


七、性能基准与生产经验

7.1 限流器性能对比

用 Go benchmark 测试不同限流器的吞吐:

func BenchmarkTokenBucket(b *testing.B) {
	tb := NewTokenBucket(1000000, 1000000)
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			tb.Allow(1)
		}
	})
}

func BenchmarkSlidingWindow(b *testing.B) {
	sw := NewSlidingWindow(time.Minute, 1000000)
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			sw.Allow()
		}
	})
}

func BenchmarkConcurrencyTracker(b *testing.B) {
	ct := NewConcurrencyTracker(1000000)
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			ct.TryAcquire("bench")
			ct.Release("bench")
		}
	})
}

典型结果(8 核机器):

限流器单次操作吞吐(ops/s)
TokenBucket~50ns~20M
SlidingWindow~200ns(窗口满时)~5M
ConcurrencyTracker~80ns~12M

可见单机限流开销极小,真正的瓶颈永远是 Redis 网络往返(~0.3ms/次)。

7.2 生产经验清单

  1. Redis 故障降级策略:Redis 不可用时,fail-open(放行 + 本地限流兜底)还是 fail-close(拒绝全部),取决于业务。多数平台选择 fail-open + 告警。
  2. Token 预估偏差:max_tokens 是上限,实际消耗通常远小于此。TPM 预扣太多会导致额度浪费。实践中的做法是预扣 input + max_tokens * 0.5
  3. 计量数据异步化:Token 计数日志不要同步写数据库,用 channel + batch 异步写 Kafka,避免影响请求延迟。
  4. 冷启动保护:新租户首次请求时,令牌桶从满桶开始,可能瞬间突发。生产环境应让桶从 1/3 容量起步。
  5. 限流维度组合:除了 tenant 维度,还可以按 model 维度限流(保护热门模型)、按 IP 维度(防爬虫)。

本篇小结

知识点核心内容
LLM 限流三维模型QPS(连接保护)+ TPM(算力保护)+ 并发数(显存保护)
令牌桶算法惰性计算补充令牌,允许突发,适合 QPS 限流
滑动窗口精确控制窗口内请求数,适合配额管理
流式 Token 计数在 SSE 转发中逐 chunk 解析 delta.content 并累加
Redis 分布式限流Lua 脚本保证原子性,INCR 固定窗口 / HMSET 令牌桶
中间件顺序认证 → QPS → 并发 → TPM,便宜的在前

下篇预告

第 24 篇:Go gRPC 推理服务——高性能跨语言通信

网关和限流讲完了,现在深入通信层。下一篇我们用 Protocol Buffers 定义推理服务接口,实现 Go gRPC 服务端和客户端,包括双向流式推理和拦截器(认证/日志/指标),并对比 gRPC 与 HTTP 在推理场景下的性能差异。


如果本篇内容对你有帮助,欢迎点赞收藏!有任何疑问,欢迎在评论区交流。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值