rate.Limiter仅适用于单机限流,需按路径/IP/用户隔离实例,避免全局共享;高并发下应优先用Allow()返回429而非Wait()阻塞;跨节点必须依赖Redis+Lua实现原子计数,固定窗口用INCR+EXPIRE,滑动窗口用ZSET或Lua脚本。

用 rate.Limiter 做单机限流,但别只靠它
Go 官方 golang.org/x/time/rate 提供的 rate.Limiter 是最常用、开箱即用的限流方案,适合保护单实例服务不被突发请求打垮。但它本质是内存态、无共享的——多个 Pod 或机器各自维护自己的桶,无法协同控流。
常见错误是直接在 HTTP handler 里全局初始化一个 limiter,然后对所有路径共用同一规则。这会导致:核心接口(如 /buy)和边缘接口(如 /health)被一视同仁,一旦限流触发,健康检查也可能失败,引发误判下线。
- 按路径/用户/IP 绑定独立 limiter:比如用
sync.Map存map[string]*rate.Limiter,key 可以是"user:123"或"ip:192.168.1.100" - 避免
limiter.Wait(ctx)长时间阻塞:它会挂起 goroutine 等令牌,高并发下可能堆积大量等待协程。改用limiter.Allow()+ 立即返回 429 更安全 - 注意参数含义:
rate.NewLimiter(rate.Limit(100), 10)表示「每秒最多 100 个请求,允许最多 10 个突发」,不是「10 秒内最多 100 个」
跨节点限流必须用 Redis + Lua,别信“本地缓存同步”
微服务部署多实例后,单机限流就失效了。有人试图用 sync.Map 加定时广播或 channel 同步计数,结果要么数据不一致,要么引入严重延迟和锁竞争——这是典型踩坑。
真正可靠的做法是把窗口计数逻辑下沉到 Redis,并用原子 Lua 脚本保证操作不可拆分。例如固定窗口(Fixed Window)只需一行 INCR + EXPIRE,但要注意临界问题;滑动窗口(Sliding Window)则需用 Redis 的 ZSET 记录时间戳,成本更高但更精准。
立即学习“go语言免费学习笔记(深入)”;
- 固定窗口适合低敏感场景(如登录尝试限制),实现简单:
INCR key+EXPIRE key 60 - 滑动窗口适合支付、下单等核心链路,需 Lua 脚本计算过去 60 秒内请求数,避免窗口切换时的流量尖峰
- 务必设置合理的 Redis 连接池大小(如
MaxActive: 20),否则限流中间件自己先成为瓶颈
goroutine 不是免费的,高流量下要主动限池
很多人以为 “Go 并发强 = 每个请求起一个 goroutine 就万事大吉”,结果压测时 P99 延迟飙升、GC 频繁、runtime.NumGoroutine() 突破万级——这不是并发强,是失控。
根本问题是:HTTP handler 默认为每个请求启动 goroutine,而网络 I/O、DB 查询、外部调用都可能阻塞,导致 goroutine 长期挂起,调度器不堪重负。这时候限流只是“堵上游”,goroutine 池才是“控下游”。
- 用
ants或goflow替代裸go handle(),池大小建议从CPU 核心数 × 3开始压测,而非盲目设 1000+ - 池提交任务前做快速校验:比如先查缓存命中、校验 token 有效性,失败直接返回,不占池位
- 配合
http.Server.ReadTimeout和WriteTimeout,防止慢连接长期占用 goroutine
限流只是最后一道防线,前置优化往往更有效
很多团队花大力气写复杂的滑动窗口限流,却忽略了一个事实:80% 的高流量压力来自重复请求、未压缩响应、长连接空耗、JSON 全量解析——这些全在限流之前就能解决。
比如一个 50KB 的 JSON 响应,开启 gzip 后可能只剩 8KB,带宽省了 84%,连接复用率提升后,同样机器能扛住 3 倍 QPS。这种优化不写一行限流代码,效果却远超调参。
- 静态资源走 CDN,API 层强制
gzip(用gzip.Handler包裹) - 用
json.Decoder流式解析大请求体,避免ioutil.ReadAll把整个 body 读进内存 - HTTP Server 启用
IdleTimeout(如 30s),及时释放空闲连接,防止连接数虚高 - 关键路径加
context.WithTimeout,防止下游依赖卡死拖垮整条链路
真正难的不是写限流算法,而是判断哪一层该限、哪一层该减、哪一层该丢——流量进来时,系统已经没有“完美方案”,只有取舍和优先级。










