最轻量可控的Go限流方式是time.Ticker配合chan struct{}实现令牌桶,复用单个Ticker、用缓冲channel作令牌池,select非阻塞取令牌,超时即拒绝;切忌混用context.WithTimeout或sync.WaitGroup于限流路径,禁用len(ch)做原子计数。

用 time.Ticker 配合 select 做简单令牌桶限流
Go 里最轻量、最可控的限流方式不是靠第三方库,而是用内置的 time.Ticker + chan struct{} 手动“发令牌”。它不依赖外部状态,也不需要锁,天然适合高并发场景。
常见错误是直接用 time.Sleep 控制间隔——这会阻塞 goroutine,无法应对突发流量;或者把 ticker 放在循环里反复新建,导致内存泄漏和时间漂移。
- 每个限流器只启一个
time.Ticker,复用到底 - 用带缓冲的
chan struct{}当令牌池,容量即 QPS 上限(比如make(chan struct{}, 10)表示最多并发 10 个请求) - 每次请求走
select尝试取令牌,超时就拒绝,不阻塞
// 每秒放 5 个令牌
ticker := time.NewTicker(200 * time.Millisecond)
tokens := make(chan struct{}, 5)
<p>go func() {
for range ticker.C {
select {
case tokens <- struct{}{}:
default:
}
}
}()</p><p>// 请求入口
select {
case <-tokens:
// 允许执行
case <-time.After(10 * time.Millisecond):
// 超时,拒绝
}
sync.WaitGroup 和 context.WithTimeout 别混着用限流
限流是控制“进来多少”,不是控制“执行多久”。很多人误以为用 context.WithTimeout 包裹 handler 就算限流了——其实那只是防超时,对并发数完全没约束。更危险的是,在限流逻辑里又套一层 sync.WaitGroup 等待,容易造成 goroutine 泄漏或死锁。
典型症状:服务压测时 CPU 不高但连接堆积,pprof 显示大量 goroutine 卡在 runtime.gopark。
立即学习“go语言免费学习笔记(深入)”;
- 限流判断必须在真正干活前完成,且不依赖任何可能阻塞的操作
-
context.WithTimeout只用于单次请求生命周期管理,和限流逻辑正交 -
sync.WaitGroup仅用于收尾等待,绝不能出现在限流路径中
用 chan int 做计数型限流时,记得关 channel 和清空
有人图省事用 chan int 记当前请求数,len(ch) 判断是否超限。这看似简单,但隐患极大:channel 本身不提供原子计数,len 返回的是近似值;而且如果没显式关闭或消费完,残留数据会让后续判断失真。
尤其在 HTTP handler 中,goroutine 退出后 channel 若未被清空,下一次调用 len 还会看到旧值。
- 不要依赖
len(ch)做精确判断,它不是线程安全的计数器 - 若真要用 channel 存状态,务必在每次使用前用
for select { case 清空 - 更稳妥的做法是用
atomic.Int64或sync.Mutex+ 普通变量
HTTP 中间件里嵌 channel 限流,注意 http.ResponseWriter 已写入就不能撤回
在中间件里做限流,最容易踩的坑是:判断超限时,已经写了部分响应头(比如 w.WriteHeader(429)),但后面又想改状态码或加 header——晚了,Go 的 http.ResponseWriter 一旦开始写 body,header 就冻结了。
现象是日志里一堆 http: superfluous response.WriteHeader,客户端收到的却是 200 或其他错误码。
- 限流检查必须放在所有
w.WriteHeader和w.Write之前 - 不要在限流失败后还继续调用
next.ServeHTTP - 建议统一用
http.Error(w, "...", http.StatusTooManyRequests)一次性处理
实际用下来,最难的不是写对逻辑,而是想清楚“限流点”该卡在哪一层:是网关层按 IP?是 RPC 客户端按目标服务?还是某个关键函数内部?位置错了,再准的 channel 也白搭。


















