3.3 断点续爬:任务状态存哪里

场景:重启就回零

单机时代最让人崩溃的瞬间是重启。程序被 kill、机器重启、进程 panic,没有一处记录自己爬到哪了。重跑一次从队列头开始,上次的进度全丢,已经抓过的内容再抓一遍。量小的时候装作没看见,量大了就不能装:一次重启等于一天白干。

进入分布式之后,这个问题更严重——worker 挂掉的概率乘以机器数。断点续爬从可选功能变成了平台的生命线。

有意思的是,断点续爬做好之后,业务对该平台的态度变了。以前他们默认每天看板可能差几条,做好之后变成了信任:数据天天齐,偶尔系统问题也会自己标注。这份信任让数据在业务决策中的地位升了一档,算是工程投入带来的意外业务回报。

方案:状态存哪里,三个位置的比较

断点续爬的核心是任务状态持久化。我们比较了三个存放位置:

存储位置 优点 缺点 结论
worker 本地磁盘 读写最快 机器换了就丢 只做缓存
Redis 快、带过期 重启有窗口 做热状态
MySQL 持久可靠 写入慢 做唯一事实源

最终设计三层各司其职:MySQL 存任务状态与进度(事实源),Redis 存 worker 的实时偏移(热状态,方便快速恢复),worker 本地磁盘存断点缓存(重启时先用本地再找远端)。恢复的优先级是本地、Redis、MySQL,每一层都能独立重建,不存在单点依赖。

任务状态机也一并定下来,这个状态图后来贴在每个人的工位上:

状态 含义 谁写入 退出条件
pending 待分发 master 生成 worker 拉取
running 执行中 worker 认领 完成/失败/租约过期
failed 失败 worker 或 master 人工重试或放弃
done 完成 worker 终态
paused 暂停 控制台人工操作 恢复后回 pending

paused 状态是后期加的,对应控制台的暂停按钮。有的站点临时出问题,最稳的做法是把它整个停住,而不是指望它自己失败退出。这个功能在 5.2 节封禁危机里用得最多。

状态机的另一个设计要点是状态只进不退的路径限制:done 和 failed 是终态,普通流转不能回退,任何状态回退(比如人工重试)必须留下独立的操作记录。不加这条约束,很快就会有人尝试用状态机还原数据,留下一堆谁也看不懂的时序。

状态表还有一个实用建议:加一列 reason 记录每次状态变更的原因。最早没这列,排查时只能对着状态和时间猜。加上之后,failed 任务自带失败原因,paused 任务自带暂停人,审计与排障效率提升非常明显,成本只是一列 varchar。同样的思路也建议加在任务表上:每个字段都要问一句它能不能帮未来的自己少打一次电话,能的话就值得加。

配置与代码:进度检查点

断点续爬的第二个要素是细粒度进度。一个站点任务拆成很多分片任务,每个分片任务内部再按列表页的页码记进度。worker 每处理完一页就写一次检查点:

func (w *Worker) checkpoint(ctx context.Context, taskID int, page int) error {
    _, err := w.db.ExecContext(ctx,
        `UPDATE task_shard SET cur_page=?, updated_at=NOW()
         WHERE task_id=? AND shard_no=? AND worker=?`,
        page, taskID, w.shardNo, w.ID)
    return err
}

恢复时的逻辑是读出 cur_page,从下一页继续。 MySQL 的写入频率偏高?我们压测过,每隔一页写一次、每次一行 UPDATE,在基础版实例上毫无压力。写入不是瓶颈,瓶颈永远是网络抓取。

任务恢复的完整链路在 worker 启动时跑一次:

func (w *Worker) resume(ctx context.Context) error {
    rows, err := w.db.QueryContext(ctx,
        `SELECT task_id, shard_no, cur_page FROM task_shard
         WHERE worker=? AND status='running'`, w.ID)
    if err != nil {
        return err
    }
    defer rows.Close()
    for rows.Next() {
        var tid, page int
        var shard int
        if err := rows.Scan(&tid, &shard, &page); err != nil {
            continue
        }
        w.queue <- resumeJob{TaskID: tid, ShardNo: shard, FromPage: page + 1}
    }
    return nil
}

有人问过为什么恢复逻辑要放在 worker 启动时而不是 master 主动下发。原因是 worker 最清楚自己上一次跑到了哪里,也让恢复的责任落在挂掉的那一方,职责单一。master 只负责回收僵尸任务,不负责替 worker 记进度,两边互不越界。

恢复链路里还有一个容易漏的细节:恢复读取到的分片要重新检查站点状态。如果站点此刻正处于封禁降级期,恢复了任务也只会疯狂失败。我们的做法是恢复前先查一遍站点健康表,不健康的站点把恢复任务挂起等待,健康的才放行。

踩坑:状态一致性的四个坑

第一个坑是检查点写得太粗。最初一个分片任务只在结束时写一次状态,崩溃就整片重抓。改成每页一写之后浪费从十分钟级降到一页级。粒度原则:检查点的间隔应该约等于你能接受的重做工作量。

第二个坑是 running 状态的僵尸任务。 worker 挂了没来得及置为 failed,任务永远停在 running,master 以为有人在干。解决靠 3.1 节的租约机制:master 定期把过期租约的任务回收到 pending。状态系统必须有清扫器,否则状态表会被僵尸任务污染。

第三个坑是重复恢复。某次发布流程里 worker 重启了两遍,两遍都读到了自己的 running 任务,结果同一批分片跑了两次。加了一层分布式锁:恢复前先在 Redis 抢任务恢复锁,抢到才执行。小成本大收益。

第四个坑是分布式时间不同步。租约过期判断依赖时间,某台 worker 的系统时钟快了两分钟,master 以为它租约过期,任务被重发。所有机器上一个 ntpd,这是一切时间敏感设计的前提。事情很小,但排查花了半天,因为我们一直以为是自己代码的问题。

第五个坑是恢复任务的优先级。重启后的第一批恢复任务一股脑塞进本地队列,可能插在正在运行的紧急任务前头。后来给恢复任务一个单独的低优先级入口,保证不影响当天主窗口的节奏。恢复是补课,不能抢占正课的位置。

另外提一句幂等:断点续爬天然意味着同一段内容可能被抓两次(崩溃前的最后一次结果与恢复后的结果),所以落库必须幂等——2.3 节的 ON DUPLICATE KEY UPDATE 就是这里的安全网。两个机制是同一套设计的两面。

恢复时的链路最好再加一层人工确认。我们给高危操作(整站恢复、跨周回补)配了一个二次确认,执行人必须在控制台输入任务编号确认。分布式系统里,一键全自动的按钮往往是最可怕的按钮。

最后分享一个上线仪式感:每次发布新版本前,我们会先在测试环境杀掉一个 worker 再重启,专门验证恢复链路确实有效。断点续爬是那种平时不出事就显得多余的机制,不定期演练就会在真正需要时掉链子。演练记录保留在 5.1 节的运维清单里。

小结

断点续爬的本质是把惊叹号变成逗号:任何故障之后都能从中断处继续。三层存储各司其职、状态机清晰、检查点细粒度、租约清扫僵尸任务。这四样做齐,分布式采集平台的下限就稳了。下一节讲多机日志怎么统一收进 CLS,让排障不再靠 SSH 到每台机器上翻文件,继续看 3.4 节。

检查点写入还有一个真实优化值得记。每页一写的 UPDATE 在分片数破万之后开始拖慢恢复:worker 重启时上万条 SELECT 串行执行,光读检查点就要两分钟。改成批量查询加分页游标之后降到几秒。之后我们又给检查点加了一层本地写缓冲:页面完成后先落本地磁盘再异步同步 MySQL,worker 崩溃时本地兜底,MySQL 抖动时本地还能续。持久化的层级多了,每一层都比上一层更快也更不持久。恢复链路验证脚本也一并写了:每次发布前在测试环境杀掉一个 worker,看它能否在预期秒数内续跑。

延伸阅读