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 上跑十分钟,指标曲线无异常再全量推送。限速本身就是敏感操作,参数调错一夜就可能把采集量砍半,灰度这一步至今救过我们两次。

延伸阅读