Go中限流最轻量可控方式是time.Ticker配合channel;固定频率限流用Ticker实现QPS控制,需注意信号积压问题;令牌桶限流则用带缓冲chan模拟burst和rate。
Go 里实现爬虫限流,最轻量、最可控的方式就是用 time.Ticker 配合 channel 控制请求节奏,而不是依赖第三方库或复杂调度器。
time.Ticker 做固定频率限流这是最直观的限流方式:每 N 毫秒放行一个请求。适用于目标站点允许稳定 QPS(比如 10 QPS → 每 100ms 一个请求)。
Ticker 是持续发送时间信号的 channel,比反复 time.Sleep 更精确、更易管理Ticker.C,否则会阻塞;也别忘了 defer ticker.Stop()
Ticker 会积压信号,导致“脉冲式”并发 —— 这不是你想要的限流,得加缓冲或改用 time.AfterFunc 方式func main() {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop(
)
urls := []string{"https://example.com/1", "https://example.com/2", ...}
for _, url := range urls {
<-ticker.C // 等待下一个时间点
go func(u string) {
resp, err := http.Get(u)
if err != nil {
log.Printf("failed to fetch %s: %v", u, err)
return
}
defer resp.Body.Close()
}(url)
}
// 注意:这里没等 goroutine 结束,实际需用 sync.WaitGroup}
用带缓冲的 chan struct{} 实现令牌桶式限流
当需要支持突发流量(比如允许最多 5 个请求瞬间发出,之后限速为 10 QPS),纯 Ticker 不够用,得模拟令牌桶。核心是用带缓冲的 channel 当“令牌池”。
tokenCh 里塞令牌(struct{} 占 0 字节,最省)tokenCh 取一个令牌 —— 若缓冲已满,就阻塞等待;若取到就继续len(tokenCh) 判断剩余令牌,因为并发下不准确;channel 本身已提供线程安全的计数语义func newTokenBucket(burst int, rate time.Duration) <-chan struct{} {
ch := make(chan struct{}, burst)
go func() {
ticker := time.NewTicker(rate)
defer ticker.Stop()
for range ticker.C {
select {
case ch <- struct{}{}:
default:
}
}
}()
return ch
}
func main() {
tokenCh := newTokenBucket(5, 100*time.Millisecond) // 允许最多 5 个并发,平均 10 QPS
for _, url := range urls {
<-tokenCh // 拿令牌,阻塞直到有空位
go func(u string) {
defer func() { <-tokenCh }() // 请求结束归还?不,这里是单向发放,不回收
http.Get(u)
}(url)
}}
为什么不用 time.Sleep 直接控制?
看似简单,但容易出错:
time.Sleep(100 * time.Millisecond),如果某次 http.Get 耗时 500ms,那下一次请求就在 600ms 后才发 —— 实际 QPS 远低于预期Ticker + channel 是协作式控制,更利于组合(比如和 context.WithTimeout 一起用)限流只是爬虫健壮性的一环,真正上线时这几个细节常被跳过:
Timeout,否则一个卡住的请求会让整个限流 channel 堵死tokenCh,避免 A 站慢拖垮 B 站http.DefaultClient 的 Transport.MaxIdleConnsPerHost 默认是 2,高并发下会排队 —— 要调大,否则限流没意义