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,看它能否在预期秒数内续跑。
