3.2 并发与限速:礼貌策略怎么定
场景:被封的那一天
分布式改造的第一个周末,我们就被上了一课。两台 worker 同时全速跑,对同一批目标站的总请求量翻了四倍。周一早上发现三个重点站把我们的 IP 段全部 403,业务数据直接断流两天。道歉信、降级、紧急减速,搞了一整周才慢慢恢复。
被封之后我们复盘出一个朴素的结论:分布式扩容最大的风险不是机器不够,是自己人把自己搞死。单机时代机器能力封顶、自然限速,多机之后总并发是乘积,礼貌策略必须显式设计、全局统一。
被封锁造成的损失远不止带宽。最伤的是信誉:三个重点站里有一个的合作方知道是我们这边机器失控,专门拉了一次会。从那以后我们内部立了个规矩,任何涉及并发参数和站点策略的修改都要团队 Review,不能一个人夜里手滑改全局配置。规矩虽小,但后来真的挡住过一次误操作。
方案:三层限速体系
我们最后定的是三层限速,自上而下各管各的:
| 层级 | 限什么 | 实现位置 | 默认值 |
|---|---|---|---|
| 全局层 | 对外总并发与总 QPS | master 下发配额 | 每站点每 worker 5 QPS |
| 站点层 | 每个目标站的请求间隔 | worker 本地令牌桶 | 间隔 200 毫秒 |
| 任务层 | 单任务的重试退避 | worker 任务循环 | 指数退避上限 5 分钟 |
为什么要三层:全局层防止平台整体太过张扬,站点层给每个目标站礼貌,任务层防止失败任务疯了一样重试。三层叠加之后,最坏情况是某站点慢一点,绝不可能是请求洪峰。
礼貌策略的参数从哪里来?一部分是经验值,一部分是和目标站沟通后确认的。有几个站点的运维愿意明确告诉我们可接受频率,这种站点我们严格按约定来,当成服务协议执行。 robots.txt 里有 Crawl-delay 字段的,一律遵守上限,这部分在 5.4 节的合规护栏里还会严肃展开。
关于默认值还有一个经验:限速参数的第一版永远按保守填。五千一个 QPS 跑总比五百被骂好,跑稳了再往上调比出事后往下调容易得多。我们目前的默认间隔是每站点每 worker 每秒五个请求,配合后面的退避机制,一整年没有一个站因为速度问题投诉过。
新站点接入时还有一道手续:先把站点按重要度分档。重点合作站单独一档,限速值按双方约定来;普通站一档,用默认保守值;低优先级站一档,空闲窗口才跑。分档之后限速参数的管理从一个大锅饭变成几张小表,谁升谁降都有据可依,不用每次出事都全员讨论。分档表还有一个副作用:它顺带成了新人入职的培训材料,看一遍表就能读懂这个平台最在意什么。
配置与代码:每站点令牌桶
Go 标准库的 time/rate 正好匹配需求。每个站点一个 limiter,由一个集中管理器统一创建与查询:
type LimiterHub struct {
mu sync.Mutex
caps map[string]*rate.Limiter // key: site_id
}
func (h *LimiterHub) Wait(ctx context.Context, siteID string) error {
h.mu.Lock()
lim, ok := h.caps[siteID]
if !ok {
lim = rate.NewLimiter(rate.Every(200*time.Millisecond), 1)
h.caps[siteID] = lim
}
h.mu.Unlock()
return lim.Wait(ctx)
}
抓取主循环里每个请求前等一次令牌,失败时按退避策略重试:
for attempt := 0; attempt < maxRetry; attempt++ {
if err := hub.Wait(ctx, it.SiteID); err != nil {
return err
}
doc, err := fetch(it.URL)
if err == nil {
return doc, nil
}
if isLimited(err) {
backoff := time.Duration(1<<attempt) * time.Minute
if backoff > 5*time.Minute {
backoff = 5 * time.Minute
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(backoff):
}
continue
}
return nil, err
}
遇到 429 或 503 时指数退避,上限五分钟。这套退避在 5.2 节反爬应对里会扩展成更完整的策略:根据响应头 Retry-After 调整、根据封禁等级降级到备用 IP。
令牌桶的 Wait 还有一个细节值得注意:它会占用 goroutine 等待,大量并发任务在等待时 goroutine 数会涨上去。我们简单试过一次五千并发,直接内存报警。控制并发上限本身才是重点,令牌桶只控制速率。当前我们每个 worker 的并发 worker 池固定在 20,多了不加,这在 3.1 节的心跳字段 Capacity 里能看到。
踩坑:限速本身的四个坑
第一个坑是限速参数只针对 HTTP 请求计数,漏掉了重定向。一次 302 跳转会触发两次真实请求,对目标站来说就是两倍流量。后来在 Transport 里加 Redirect 钩子,重定向也过令牌桶。
第二个坑是 limiter 在 worker 重启后重建,瞬时全速。 worker 启动的前三十秒是最凶猛的,因为所有令牌桶都满令牌。解法是启动时先从 master 拉各站点的最近请求时间,恢复桶的状态再开跑;启动时也给一个预热期,前几分钟并发减半。
第三个坑是不同任务对同一站点的限速互相覆盖。一个站点的回补任务跑在 worker A、常规任务跑在 worker B,两个 worker 各自的令牌桶都允许 5 QPS,合起来十倍。 这也就是为什么全局配额必须由 master 下发而不是各 worker 自治——分布式系统里,任何每个节点自己说了算的限制都是假的限制。
第四个坑是深夜窗口放宽限速引发的反爬。有阵子觉得凌晨目标站没人,把 QPS 放宽三倍。某天对方升级了风控,凌晨策略照样封。从那以后限速参数的调整一律保守:宁可慢,不可断。
第五个坑更隐蔽:限速模块的指标没有暴露。限速生效与否、等待时长多少、哪个站点退避最频繁,这些信息一开始都没埋点,出问题只能猜。后来在 limiter 上加了等待时长分布与触发统计,直接成为 5.1 节监控面板的核心曲线之一。任何策略模块上线时,先想好它本身的指标怎么看。
礼貌策略说到底是让别人舒服你也舒服的策略。被封锁那次之后,我们形成了一个习惯:任何新增站点上线前,先用单 worker、每秒一个请求的最保守参数跑一天,确认风控无异常再逐步恢复到默认值。这套上线流程在 5.2 节有完整列表。
新站点上线第一天还有个观察项专门盯 HTTP 状态码分布:429 或 403 占比超过百分之一就自动降速,而不是等大规模封禁出现。提前减速永远比事后道歉便宜。三层限速、令牌桶加退避、配额集中下发,这四样做齐,多机扩展才是安全的。机器人协议的事见 5.4 节。我们还把限速参数做成了控制台可调,顺手也暴露了审计记录,谁在什么时候改了哪个站的限速都有日志,这属于 5.1 节监控告警的一部分。
小结
限速这件事,技术上线只需要一个令牌桶,难的是把它放进整个协作流程里。三层限速解决的是流量形状,退避策略解决的是失败恢复,配额集中下发解决的是多机一致性,审计记录解决的是人际信任。四样看起来都不复杂,合在一起才撑住了后面六台 worker 的平稳扩展。
回头看被封那一周,最值得记的不是技术方案,而是一个认知:爬虫平台的对手从来不是目标站的风控,而是自己手里的并发能力。礼貌策略写得再保守都不为过,因为出事的代价永远比减速的代价高。下一节讲任务状态如何持久化,让重启不再丢进度,继续看 3.3。
补一个限速模块自身可观测性的细节。令牌桶暴露的指标我们最终留了三个:等待时长分布、拿令牌失败次数、每站点当前生效的 QPS 上限。第三个看起来多余其实最关键——限速参数被人为改动时生效值曲线会立刻跳动,等于给配置变更画了一条审计线。有一次同事半夜调整某站限速忘同步群里,正是这条曲线在次日复盘里说明了一切。策略模块上线,先想好它自己的指标怎么看。没有指标的策略等于不存在的策略,出问题时既无法复盘也无法自证清白。顺带一个部署层面的经验:限流配置我们做了灰度生效,新参数先在某一个 worker 上跑十分钟,指标曲线无异常再全量推送。限速本身就是敏感操作,参数调错一夜就可能把采集量砍半,灰度这一步至今救过我们两次。
