3.1 master 与 worker:任务分发设计

场景:单机扛不住的第一个信号

单机原型跑了六周之后,第一个扛不住的信号出现了。一个重点目标站临时放量,单窗口的抓取时间从两小时拖到五个小时,把后续任务全挤到早高峰,业务九点打开看板时数据还没跑完。更糟的是一次内存抖动导致进程重启,积压的队列和进度全部遗失,那天晚上全员回公司重跑。

这两件事指向同一个结论:调度与抓取必须拆开,抓取部分必须能水平扩展。于是我们迎来了全书最大的架构变动:master 负责任务调度与去重指纹库,worker 部署在轻量应用服务器上分片抓取。

改造前我们做了一次可行性评估,结论是改动量比想象中小:master 的调度逻辑早在单机阶段就独立成一个包了(2.0 节说过,代码按可拆的方式写),URL 仓库在 Redis 里天然是中心化的,worker 的抓取主循环一行没改。真正的工作量在通信层和状态表,各一节课就能讲完。这让我们再次确认单机阶段那条原则的价值:架构是长出来的,但长的时候你心里得有图纸。

方案:分发模式的选择

master 与 worker 之间怎么传任务,我们比较了三种模式。 这里多说一句为什么没选消息队列:我们的任务分发是典型的拉模型场景,任务状态需要记账、需要租约、需要人工暂停,这些都是工作流语义而不是消息语义。消息队列擅长的是无人值守的高速投递,它没有任务的状态、重试策略、人工干预这些概念。为了这些能力去引 Kafka 再自己补一套工作流更重。选组件要先看语义匹配度,再看性能。

模式 优点 缺点 我们的判断
推模式 master 主动控制节奏 worker 空闲时被动等 控制力强但吞吐受限
拉模式 worker 按能力自取 master 需要并发处理拉取 扩容最自然
消息广播 解耦彻底 语义偏消息而非任务 引入新组件不值

最后选了拉模式:worker 空了就找 master 要活,master 记账。这个选择直接决定了横向扩容的体验——新机器到货、装好镜像、启动进程,三分钟后它就开始拉活,master 不需要任何感知。反过来推模式下,master 要维护每台 worker 的负载状态,机器一多调度逻辑就开始复杂。

任务分片也有一张决策表:

分片维度 适用场景 我们的取舍
按站点 站点间量级差异大 采用,粒度清晰
按域名 一个站多个域名 站点内统一处理,不分
按哈希取模 均匀打散 不灵活,站点故障难隔离
按优先级 紧急任务插队 采用,P0 独占一个队列

最终的分片是站点维度加优先级维度:每个站点是一组任务,P0 站点独占最高优先级。worker 拉取时声明要哪个优先级的任务,master 从对应队列里按站点分组下发。这样某个站被封或限速时,受影响的队列排在后面,不会拖垮整体。

master 与 worker 之间的通信协议我们选了最普通的 HTTP 加 JSON 而不是 gRPC。原因有三:调试时 curl 就能复现,语言无关(以后可能掺 Python 脚本任务),而且我们的调用频率极低(每个 worker 几秒一次拉取),性能根本不是考量因素。 gRPC 的流式和类型安全在这里没有兑现场景,简单协议的维护成本低到几乎为零。

配置与代码:心跳与拉取

worker 上报心跳,master 据此做健康判断;worker 拉取任务,master 用乐观锁防止双发:

type Heartbeat struct {
    WorkerID string
    Host     string
    Version  string
    Capacity int
    Load     int
    At       time.Time
}

func (w *Worker) beat(ctx context.Context) {
    t := time.NewTicker(10 * time.Second)
    for {
        select {
        case <-ctx.Done():
            return
        case <-t.C:
            hb := Heartbeat{
                WorkerID: w.ID, Host: w.Host,
                Capacity: w.Cap, Load: w.QueueLen(),
                At: time.Now(),
            }
            _ = w.master.Heartbeat(ctx, hb)
        }
    }
}

master 下发任务时,先在任务表里把任务状态从 pending 改成 running 并记下 worker_id,再返回任务内容。状态转移用 UPDATE ... WHERE status = pending 实现,影响行数为零说明被别的 worker 抢走了,这行代码是整个分布式一致性的关键:

res, err := db.ExecContext(ctx,
    `UPDATE task SET status='running', worker=?, lease_until=?
     WHERE id=? AND status='pending'`, wID, time.Now().Add(lease), taskID)
if err == nil && res.RowsAffected() == 1 {
    return task, nil // 抢到了
}

租约 lease_until 是给 worker 强制续约用的:worker 每分钟续一次租约,master 定期扫描过期租约的任务回收。这套机制就是 3.3 节断点续爬的地基。

踩坑:分发型 distributed 系统的四个坑

第一个坑是心跳间隔与租约时长不匹配。心跳十秒一次,租约设了三十秒,有次 worker 网络抖动错过两个心跳,租约过期但进程其实还活着,任务被回收重发,结果同一批页面被抓了两遍。改法:租约时长设成心跳间隔的五倍以上,且回收前先做一次探活,而不是机械地按时间判断。

第二个坑是任务粒度太大。一个任务对应一整个站一天的量,worker 拉一个要跑四十分钟,master 那边看哪台都忙,实际上负载极不均衡。我们把任务拆小到站点乘列表页分片的粒度,单任务三五分钟,调度立刻顺滑。任务粒度的黄金区间是几分钟级别:太大不均衡,太小调度开销大。

第三个坑是 worker 版本不一致。灰度新 worker 时发现新版改了解析接口,旧版 worker 上报的数据字段对不上,master 那边静默解析失败。后来所有跨进程通信都带版本号,master 对低版本直接拒绝,强制统一。灰度可以,但语义必须向前兼容或明确拒绝,不能静默。

第四个坑是 master 单点。单机 master 挂一次全平台停半小时。短期方案是双 master 加 KeepAlived 风格的外部探活切换,长期方案把队列状态都落在 Redis 和 MySQL 上,master 本身无状态,换一台机器重启就能恢复。现在我们的 master 故障恢复时间是三分钟,因为它几乎没有自己的状态。

顺带说一下 worker 数量的扩展过程:从两台加到四台时一切顺滑,加到第六台时 master 的 MySQL 开始偶发慢查询——每次拉任务都是一次状态更新,拉取频率乘以 worker 数打到了数据库连接上限。解决办法出人意料地简单:worker 批量拉任务,一次拿一批而不是一个,master 的写入压力直接降一个数量级。批量是分布式系统里最万能的解药。

小结

任务分发的核心是把控制权与执行力分开:master 用数据库做唯一事实源,worker 用拉模式自取任务,粒度控制在几分钟。租约加乐观锁解决重复分发,版本号解决协议演进。这一套搭好,横向扩容就变成了纯粹的机器采购问题。下一节讲并发与限速,让这些 worker 在多机环境下对目标站保持礼貌,这是 3.2 节。

分发设计中还有一个数字值得较真:心跳间隔。最早设成十秒,master 看到的 worker 状态始终滞后,一次 worker 宕机之后任务过了两分钟才被重新分发,业务方已经有了感知。改成五秒心跳加三次丢失判定之后,恢复时延降到二十秒内。但也不能无脑往小调——心跳太频繁会放大网络抖动造成的误判。五秒这个值是我们观察了两周误报率之后定下的,参数调优的终点永远是数据而不是直觉。分发粒度我们也走过同样的路:从整站点一路细化到分片,粒度越小恢复越快、重做越少。粒度细化还有个隐性收益:worker 之间负载天然均衡。整站点粒度时,大站所在的 worker 要跑三小时,分到小站的早就闲着了;改成按分片之后任务像扑克牌一样被打散分发,最长任务与最短任务的耗时长差从十倍缩到两倍以内。

延伸阅读