← [ BACK_TO_NOTES ]

Go 限流

Go 服务限流:令牌桶与漏桶、x/time/rate、HTTP 中间件、按键限流与分布式限流边界。

#concept #growing #backend #go

[!info] related notes

Go 限流

这个概念为什么会出现

服务容量有限。突发流量、爬虫、误循环客户端会把 goroutine、连接池、下游 API 配额与延迟 SLA 全部打穿。限流在入口按速率拒绝或排队,把过载变成可预期的 429,而不是雪崩 500。

Go 官方扩展库 golang.org/x/time/rate 实现令牌桶,是进程内限流的默认选择。

[!abstract] 一句话理解 令牌桶以速率 r 产令牌、容量 b 允许突发;请求取令牌失败则等待或直接 429。HTTP 中间件按全局限流或按 IP/用户键限流。

最小可运行示例

先把示例放进业务场景,再看代码:

场景:公开 API /work 全局限流,过载直接 429

对外提供 GET /work 做轻量计算。
爬虫或误循环客户端会把进程打满;入口用令牌桶限流:

  • 稳定速率 5 req/s
  • 桶容量 10 允许短突发
  • 没令牌 → 立即 429 + Retry-After,不排队拖垮延迟

需要 Go 1.22+ 的方法路由语法,并在自己的示例模块中添加 golang.org/x/time/rate;依赖版本也有最低 Go 版本要求。本次示例使用 Go 1.26.8 与 golang.org/x/time v0.15.0 验证。

package main

import (
	"encoding/json"
	"log"
	"net/http"
	"time"

	"golang.org/x/time/rate"
)

// rateLimitMiddleware:HTTP 入口全局限流中间件。
func rateLimitMiddleware(lim *rate.Limiter) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			// Allow:立刻判断;失败不阻塞,快速失败
			if !lim.Allow() {
				// 固定 1 秒是退避建议,不是令牌预留,也不保证重试成功
				w.Header().Set("Retry-After", "1")
				w.Header().Set("Content-Type", "application/json")
				w.WriteHeader(http.StatusTooManyRequests)
				_ = json.NewEncoder(w).Encode(map[string]string{"error": "rate limit exceeded"})
				return
			}
			next.ServeHTTP(w, r)
		})
	}
}

func main() {
	// rate.Limit(5) = 每秒 5 个令牌;burst=10 允许瞬时尖峰
	lim := rate.NewLimiter(rate.Limit(5), 10)

	mux := http.NewServeMux()
	// 仅包装业务接口;所有调用者共享本进程的一个桶。
	work := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte("ok"))
	})
	mux.Handle("GET /work", rateLimitMiddleware(lim)(work))
	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	})

	srv := &http.Server{
		Addr:              ":8080",
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
	}
	log.Fatal(srv.ListenAndServe())
}

建议运行:

go run .
# 终端 2:快速打 20 次;客户端足够快时可观察到 429
for i in $(seq 1 20); do curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/work; done

桶初始满载,足够快的请求会耗尽突发额度并出现 429;串行 curl 的耗时允许补充令牌,因此不能断言恰好前 10 个成功。

结合场景再看三个关注点

  1. rate.Limit(5) 是每秒 5 个。
  2. Allow 不阻塞;Wait 可能等待,也可能因已取消或预计等待超过 deadline 直接失败。WaitN 还会检查 n 是否超过 burst(rate.Inf 例外)。
  3. 全局限流保护进程;多租户要按键多个 Limiter。

核心概念与准确模型

令牌桶 vs 漏桶

令牌桶漏桶
突发允许至桶容量输出更平滑
空闲积攒令牌队列以恒定速率漏
典型用途API 配额流量整形

x/time/rate = 令牌桶。rate.Every(100*time.Millisecond) 约 10/s。

API 选择

方法行为
Allow / AllowN立即是/否
Wait / WaitN等待许可;取消、预计超过 deadline 或 n 超 burst 时返回错误
Reserve / ReserveN预留未来令牌;先检查 OK,再按 Delay 等待;放弃时 Cancel

HTTP 入口可用 Allow → 429 避免排队,后台消费可用 Wait。Reservation 会影响之后的请求,不是只读的「多久后有令牌」查询;Cancel 也不保证恢复所有额度。Limiter 可并发使用,零值合法但拒绝所有事件。

按键限流

type keyedLimiter struct {
	mu    sync.Mutex
	m     map[string]*rate.Limiter
	r     rate.Limit
	burst int
}

// 初始化 map;r、burst 的合法范围由配置校验负责。
func newKeyedLimiter(r rate.Limit, burst int) *keyedLimiter {
	return &keyedLimiter{m: make(map[string]*rate.Limiter), r: r, burst: burst}
}

func (k *keyedLimiter) get(key string) *rate.Limiter {
	k.mu.Lock()
	defer k.mu.Unlock()
	if lim, ok := k.m[key]; ok {
		return lim
	}
	lim := rate.NewLimiter(k.r, k.burst)
	k.m[key] = lim
	return lim
}

这个片段需导入 sync,并通过 newKeyedLimiter 构造。生产还需限制 key 总量和清理闲置项,否则高基数 key 会持续占内存。淘汰会重置新桶的突发额度;旧请求若仍持有被淘汰的 limiter,还可能与同 key 的新桶并存,必须评估配额语义。

用户键应来自已验证身份;IP 键需解析 RemoteAddr 的 host,并只信任受控代理提供的转发头,不能直接相信任意 X-Forwarded-For。

并发信号量 vs 速率

  • 限流:单位时间事件数
  • 信号量(有界 channel):同时进行中数量

二者常组合:既限 QPS 又限 in-flight。

分布式限流

单进程 limiter 不跨实例共享状态。严格共享配额需要网关或共享存储中的原子算法;仅把计数放进 Redis、分开 GET/SET 并不保证原子性。还需明确窗口/桶模型、过期、故障时放行或拒绝,以及多地域一致性取舍。

设计动机

  1. 保护尾部延迟:过载时快速失败优于全体变慢。
  2. 公平性:按用户/IP 隔离吵闹邻居。
  3. 合同配额:对外 API 的计费与 SLA。

边界情况与反直觉行为

1. 突发耗尽后的行为

burst=10, rate=5/s:可瞬间 10 个,然后约每 200ms 一个。客户端重试风暴会持续 429——配合指数退避。

2. Wait 与超时

请求内等待传 r.Context() 或更短的派生预算;后台任务传自身生命周期 ctx。预计等待超出 deadline 的错误不一定是 context.DeadlineExceeded,也可能在 ctx.Err() 仍为 nil 时返回;不能把所有 Wait 错误替换成 ctx.Err()。Wait 不限制等待者数量,排队规模仍需单独控制。

3. 限流位置

业务配额应豁免必要探针,避免正常实例被判为不就绪或触发重启。示例仅包装 /work;GET pattern 也匹配 HEAD,因此 HEAD /work 同样消耗令牌。

4. 与鉴权顺序

先限流可挡无 token 洪水;先鉴权可按用户精确配额。常见:边缘粗限流 + 鉴权后细限流。

常见误区

[!warning] 常见误区:把 rate.Every 与 Limit 搞混
二者都可表达每秒速率,先算清单位。

[!warning] 常见误区:无限增长的 per-IP map
限制 key 基数并清理闲置项,同时评估淘汰重置额度、并存旧桶和攻击者轮换 key 的影响。

[!warning] 常见误区:把 Retry-After 当成功保证 RFC 6585 允许但不强制 429 携带 Retry-After。没有该头也可指数退避;有该头仍应限制重试次数,并考虑抖动、请求可重放性和取消。

[!warning] 常见误区:只限入不限出
仍可能打爆依赖。

与相邻概念对比

概念差异
熔断看错误率/延迟开路;限流看速率
负载均衡分散流量;不替代入口配额
信号量限制并发数;限流限制速率
缓存减负载;限流在过载时丢弃

工程实践

  1. 分层限额:IP 粗、用户细、全局兜底。
  2. 可观测:429 计数、按路由限额命中。
  3. 配置化:速率来自配置。
  4. 测试:算法用 AllowN 的显式时间验证补充速率;中间件可用零补充速率和固定 burst 测拒绝分支,避免依赖真实 sleep 或 curl 的精确次数。
  5. 中间件链:限流 → 认证 → 校验 → handler(按威胁模型调整)。

可验证实验

实验 1:突发

burst=2, rate=1,连发 5 次,观察前 2 成功后 429。

实验 2:Wait

用 Wait 写小客户端,确认总耗时符合速率。

实验 3:健康检查豁免

对 /healthz 不套中间件,压测业务路径时探针仍 200。

实验 4:按键隔离

用户 A 打满不影响用户 B。

本节总结

  • 本质:用算法把无限到达变成有界处理速率。
  • 关键规则:令牌桶参数、Allow vs Wait、按键与分布式边界。
  • 最易错:限死探针、map 泄漏、多实例误以为全局限额。
  • 下一步:中间件 组装;缓存 降低真实负载。

自测题

概念题

  1. rate.NewLimiter(10, 20) 的长期速率与突发上限?
  2. HTTP API 为何常用 Allow 而非 Wait?
  3. 三副本服务每实例 100 rps,理论集群上限?

代码推理题

中间件里 lim.Wait(r.Context()) 成功后 handler 很慢——限流保护的是到达速率还是并发?

工程思考题

登录接口如何防撞库?仅全局限流够吗?

参考答案

展开
  1. 长期 10/s,突发 20。
  2. 连接与 goroutine 有限,与其占着等令牌不如 429 让客户端退避。
  3. 约 300 rps(若无共享计数)。
    代码题:主要是到达取令牌的速率;并发仍可能高,需信号量/超时另控。
    工程题:结合 IP/账号与全局额度、风险验证和监控;低于全局阈值的攻击仍可能持续。账号锁定策略需避免被攻击者用于锁死他人,IP 也不等于用户身份。

延伸阅读与资料来源

资料类型支撑
golang.org/x/time/rate库文档Limiter API
net/http标准库中间件与 429
RFC 6585 — 429标准Too Many Requests
go-http-middleware · go-context本库组合与取消

创建于 2026/6/25 更新于 2026/10/6
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本