1. 从一次批量抓取崩溃说起:为什么单线程下载在抖音场景下必然翻车
做过内容归档的人大概都有过这种体验:脚本跑得好好的,前几十条视频顺利落盘,突然某一条卡住不动,整个队列跟着僵死;或者跑到一半网络抖了一下,重试逻辑没写好,直接把已经下载好的文件覆盖成半截损坏的碎片。我最早写抖音内容抓取工具的时候,就是用一个最朴素的循环加requests.get,结果在一次三百多条的目标清单里,成功率只有六成出头,剩下的要么超时、要么拿到的是被限流后的空响应。
这个问题的根源不在于代码写得丑,而在于抖音的内容获取链路本身就是一个高不确定性环境。它涉及签名参数动态生成、请求头校验、CDN 分片、跳转重定向、以及服务端对异常频率的软性限制。任何一环出问题,单线程串行流程都会把整条链路拖死。所以后来我彻底重构了这套工作流,核心思路就是标题里说的"3层容错架构"——把容错从"事后补救"变成"分层内建"。
这篇内容适合两类人看:一类是正在用 douyin-downloader 或者类似工具做内容归档、素材收集的运营和技术同学;另一类是想理解"一个高可用抓取工作流到底该怎么设计"的开发者。我会把三层容错的设计动机、每一层的具体实现逻辑、参数怎么调、踩过哪些坑,全部摊开讲清楚。你不需要有很深的分布式背景,只要写过基本的 Python 请求代码,就能跟着复现。
先说结论:三层容错分别解决的是单请求级别的失败、单任务级别的失败、以及整批工作流级别的失败。这三层的边界如果划不清楚,就会出现"重试把限流触发得更狠"或者"任务级失败被当成请求级失败反复重试"这类典型问题。下面逐层拆。
2. 第一层容错:单请求的重试、退避与请求指纹管理
2.1 为什么简单的 retry 装饰器在这里不够用
很多人第一反应是套一个tenacity或者自己写个for i in range(3)的重试。这在普通 API 场景下没问题,但在抖音内容获取里会踩两个坑。
第一个坑是重试的请求长得一模一样。如果你的请求头、签名参数、时间戳在重试时完全不变,服务端很容易识别出这是同一个失败请求的重复投递,直接给你更严格的限制。正确的做法是每次重试都重新生成请求指纹——包括更新签名、刷新时间戳、必要时轮换 User-Agent 池。
第二个坑是固定间隔重试。失败后立刻重试,等于在服务端已经"注意到你"的时候继续加压。我实测下来,指数退避配合抖动(jitter)的效果最好,基础间隔从 1 秒起步,每次乘以 1.8 左右,再加一个 ±30% 的随机抖动,避免多个任务在同一时刻集体重试形成脉冲。
import time import random def backoff_delay(attempt, base=1.0, factor=1.8, max_delay=30.0): delay = min(base * (factor ** attempt), max_delay) jitter = delay * random.uniform(-0.3, 0.3) return max(0.5, delay + jitter)这段逻辑看起来简单,但max_delay和factor这两个参数是要根据你的并发规模调的。并发越高,factor 应该越大,否则退避还没生效,请求已经又堆上去了。
2.2 请求指纹的三个可变维度
我把请求指纹拆成三个可以独立轮换的维度,这样重试时不会整体变化导致签名失效,也不会完全不变导致被识别。
| 维度 | 是否每次重试都变 | 说明 |
|---|---|---|
| 时间戳与签名 | 必须变 | 签名依赖时间戳,时间戳不变签名就是废的 |
| User-Agent | 按轮换池随机 | 准备 5-8 个主流移动端 UA,重试时换一个 |
| 请求间隔 | 指数退避 | 不改变请求内容,只改变节奏 |
这里有个细节:签名参数里的时间戳和请求头里的时间戳必须一致,否则服务端校验会直接拒绝。我早期就犯过这个错,重试时只更新了 URL 里的签名时间戳,忘了同步 header,结果所有重试全部 403,排查了半天才定位到。
2.3 超时设置:连接超时和读取超时要分开
requests的timeout参数如果只给一个值,会同时作用于连接和读取。但在抖音场景下,连接通常很快,慢的是读取(尤其是视频流分片)。我建议分开设置:
timeout = (5, 20) # 连接5秒,读取20秒连接超时给短一点,快速失败快速重试;读取超时给长一点,避免大文件下载中途被误判为失败。这个参数我调过好几轮,连接 5 秒、读取 20 秒是成功率比较稳的组合。如果你的网络环境波动大,读取可以放宽到 30 秒,但不要无限等,否则任务级容错就没法及时介入了。
提示:重试次数不要设太高。单请求层面我一般设 3 次,超过 3 次还失败,说明问题不在这一层,应该交给任务级容错去处理,而不是在这里死磕。
3. 第二层容错:任务级的状态机与断点续传
3.1 把每个内容抓取当成一个独立任务
第一层解决的是"一次请求失败怎么办",但如果某个内容本身已经不可访问(比如被删除、被设为私密),你重试一百次也没用。这时候就需要第二层:任务级容错。
我的做法是把每个目标内容抽象成一个任务对象,带一个明确的状态机:
PENDING -> FETCHING -> DOWNLOADING -> VERIFYING -> DONE \-> FAILED_RETRYABLE \-> FAILED_PERMANENT关键区别在于FAILED_RETRYABLE和FAILED_PERMANENT。前者是网络抖动、临时限流这类可以再试的;后者是内容不存在、权限不足这类重试无意义的。如果不做这个区分,整批任务会被少数永久失败的任务拖住,反复消耗重试配额。
判断逻辑我总结了几条经验规则:
- HTTP 404 / 内容已删除标记:直接判永久失败,不重试
- HTTP 403 / 429:判可重试,但退避时间要拉长
- 连接超时 / 读取超时:判可重试
- 返回内容为空但状态码 200:需要进一步校验,可能是被软性拦截
最后这条特别容易被忽略。有些时候服务端返回 200,但 body 是空的或者是一个提示页,如果你不校验内容就直接落盘,会得到一堆垃圾文件。
3.2 断点续传:分片下载与临时文件管理
视频文件通常不小,下载到一半失败如果从头再来,既浪费时间又增加请求压力。所以任务级容错必须包含断点续传。
实现上我用的是分片下载加临时文件记录:
import os def download_with_resume(url, filepath, chunk_size=1024*1024): tmp_path = filepath + ".part" downloaded = os.path.getsize(tmp_path) if os.path.exists(tmp_path) else 0 headers = {"Range": f"bytes={downloaded}-"} if downloaded else {} # 发起请求,追加写入 tmp_path # 完成后 os.rename(tmp_path, filepath)这里有几个实操要点。第一,.part临时文件在任务成功后才重命名为正式文件,这样即使中途崩溃,也不会污染已完成的内容库。第二,Range 请求不是所有 CDN 都支持,如果服务端返回 200 而不是 206,说明不支持断点续传,这时候要清空临时文件重新完整下载,否则会拼接出错。第三,临时文件要定期清理,我一般设置 24 小时未更新的.part文件自动删除,避免磁盘被僵尸文件占满。
3.3 任务队列的并发控制
任务级容错还涉及并发数的问题。并发太低效率上不去,并发太高容易触发限流。我的经验值是同时活跃的下载任务控制在 4-8 个,具体取决于你的网络出口和目标内容的分布。
更重要的是,并发控制要和第一层的退避联动。如果某个时间段内失败率突然升高,应该动态降低并发,而不是维持原并发继续硬冲。我加了一个简单的自适应逻辑:连续 5 个任务失败,并发数减半;连续 20 个任务成功,并发数逐步恢复。
class AdaptiveConcurrency: def __init__(self, initial=6, min_conc=1, max_conc=10): self.current = initial self.min_conc = min_conc self.max_conc = max_conc self.consecutive_fail = 0 self.consecutive_success = 0 def on_failure(self): self.consecutive_fail += 1 self.consecutive_success = 0 if self.consecutive_fail >= 5: self.current = max(self.min_conc, self.current // 2) self.consecutive_fail = 0 def on_success(self): self.consecutive_success += 1 self.consecutive_fail = 0 if self.consecutive_success >= 20: self.current = min(self.max_conc, self.current + 1) self.consecutive_success = 0这套逻辑不复杂,但效果很明显。我实测下来,加了自适应并发之后,整批任务的成功率从 78% 提升到了 94% 左右,而且没有出现被明显限流的情况。
4. 第三层容错:工作流级别的检查点与幂等恢复
4.1 为什么需要工作流级别的容错
前两层解决的是单个请求和单个任务的问题。但如果你要处理的是几百上千条内容,跑一次可能要几个小时,中间可能因为各种原因中断——进程被杀、机器重启、网络整体故障。这时候如果没有工作流级别的容错,你只能从头再来,前面下载好的内容要么重复下载,要么被覆盖。
第三层容错的核心是检查点(checkpoint)加幂等恢复。简单说,就是定期把"哪些任务已完成、哪些还在进行、哪些失败了"这个状态持久化下来,重启后从检查点继续,而不是从头开始。
4.2 检查点的存储选型
检查点存哪里,这个选择有讲究。我对比过几种方案:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| JSON 文件 | 简单、无依赖 | 并发写入有风险、大清单性能差 | 小批量、单进程 |
| SQLite | 单文件、支持事务、查询方便 | 高并发写入需要加锁 | 中小批量、推荐 |
| Redis | 高性能、支持原子操作 | 需要额外服务、持久化配置复杂 | 大批量、多进程 |
我最终选了 SQLite,因为它在"单机、中等批量、需要事务保证"这个场景下是最平衡的。每条任务一行记录,状态更新用UPDATE ... WHERE status = 'PENDING'这种带条件的更新,天然保证幂等——即使同一任务被处理两次,第二次的条件更新不会生效。
CREATE TABLE tasks ( id TEXT PRIMARY KEY, url TEXT NOT NULL, status TEXT DEFAULT 'PENDING', retry_count INTEGER DEFAULT 0, filepath TEXT, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- 幂等领取任务 UPDATE tasks SET status = 'FETCHING', updated_at = CURRENT_TIMESTAMP WHERE id = ? AND status IN ('PENDING', 'FAILED_RETRYABLE');这个WHERE status IN (...)就是幂等的关键。不管多少个进程同时来领这个任务,只有一个能成功把状态改掉,其他的影响行数为 0,直接跳过。
4.3 崩溃恢复时的状态清理
重启后有一类特殊状态需要处理:FETCHING和DOWNLOADING。这些是"进行中"的状态,但进程已经死了,实际上没有人在处理它们。如果不清理,这些任务会永远卡住。
我的做法是启动时扫描所有进行中状态的任务,把它们重置为可重试状态,同时检查对应的.part临时文件是否存在,存在就保留用于断点续传,不存在就从头开始。
def recover_interrupted_tasks(conn): conn.execute(""" UPDATE tasks SET status = 'FAILED_RETRYABLE' WHERE status IN ('FETCHING', 'DOWNLOADING') """) conn.commit()这一步看起来简单,但非常关键。我见过不少工具就是因为没有这个恢复逻辑,一旦崩溃就有一批任务永远卡在中间状态,既不算完成也不算失败,整批任务的统计都对不上。
4.4 完成校验:怎么确认一个任务真的完成了
第三层容错还有一个容易被忽视的点:完成校验。任务状态标成 DONE 不代表文件真的没问题。我加了三重校验:
- 文件存在且大小大于一个最小阈值(比如 100KB,排除空文件)
- 文件头字节符合视频格式特征(MP4 的 ftyp box)
- 文件大小与响应头里的 Content-Length 一致(如果服务端提供了的话)
只有三重都通过,才把状态改成 DONE。任何一重失败,降级为可重试状态并删除损坏文件。这个校验逻辑帮我拦下了不少"看起来下载成功实际是坏文件"的情况,尤其是网络不稳定的时候。
5. 三层容错如何协同:一次完整工作流的执行链路
5.1 从任务领取到落盘的全过程
把三层串起来看,一次完整的内容获取是这样的:
工作流启动,从 SQLite 里领取一批 PENDING 任务,交给任务调度器。调度器根据当前自适应并发数,把任务分发给工作线程。每个工作线程执行任务时,内部的请求走第一层容错——失败重试、指数退避、指纹轮换。如果单请求重试 3 次仍失败,任务标记为可重试或永久失败,交回任务层。任务层根据失败类型决定是否重新入队。整个过程中,检查点定期写入 SQLite,崩溃后可恢复。
这个链路里,三层的职责边界非常清晰:
- 第一层只管"这一次请求",不关心任务整体
- 第二层只管"这一个任务",不关心整批进度
- 第三层只管"整批工作流",不关心单个请求细节
边界清晰的好处是,任何一层出问题都不会污染其他层。比如第一层重试次数用完了,它不会自己去改任务状态,而是抛给第二层处理;第二层判定永久失败后,也不会去动检查点,交给第三层统一记录。
5.2 参数联动:三层之间的配置怎么配合
三层容错的参数不是孤立的,需要联动调整。我整理了一张对照表:
| 参数 | 所属层 | 推荐值 | 联动关系 |
|---|---|---|---|
| 单请求重试次数 | 第一层 | 3 | 越高,第二层压力越小,但单任务耗时越长 |
| 退避基础间隔 | 第一层 | 1s | 与并发数反相关,并发高则间隔大 |
| 任务最大重试次数 | 第二层 | 5 | 应大于单请求重试次数,否则任务层没意义 |
| 自适应并发上限 | 第二层 | 8-10 | 与网络出口质量相关 |
| 检查点写入间隔 | 第三层 | 每 10 个任务 | 太频繁影响性能,太稀疏丢失进度多 |
这张表是我调了很多轮之后稳定下来的配置。新手最容易犯的错是把任务最大重试次数设得比单请求重试次数还小,导致任务层根本没机会介入,所有失败都在第一层被消耗掉了。
5.3 日志与可观测性:出问题时怎么定位是哪一层
三层架构如果没有好的日志,出问题时会很难定位。我的做法是给每层打不同前缀的日志:
[REQ]开头的是第一层请求日志,记录每次请求的 URL、状态码、耗时、重试次数[TASK]开头的是第二层任务日志,记录任务 ID、状态流转、失败原因分类[FLOW]开头的是第三层工作流日志,记录检查点写入、恢复、整批统计
这样出问题时,先看[FLOW]确认整体进度,再看[TASK]找到具体失败的任务,最后看[REQ]定位到具体是哪次请求出的问题。排查链路非常清晰。
注意:日志里不要记录完整的签名参数和敏感 header,只记录必要的诊断信息。这既是安全考虑,也能避免日志文件膨胀过快。
6. 实测数据与踩坑复盘:这套架构到底值不值
6.1 重构前后的效率对比
我用同一批 500 条目标内容做了对比测试,环境是普通家用宽带,单机运行。
| 指标 | 单线程朴素版 | 三层容错版 |
|---|---|---|
| 总耗时 | 约 4 小时 20 分 | 约 1 小时 10 分 |
| 成功率 | 61% | 96% |
| 需人工介入 | 是 | 否 |
| 崩溃后可恢复 | 否 | 是 |
| 重复下载量 | 高 | 接近零 |
耗时降低主要来自并发和断点续传,成功率提升主要来自分层重试和永久失败识别。最让我满意的是"崩溃后可恢复"这一项——以前跑长任务必须守着,现在可以放心让它自己跑。
6.2 踩过的三个典型坑
坑一:重试时没更新签名,导致重试全部失败。这个前面提过,根源是没理解签名和时间戳的绑定关系。修复方法是在每次重试前重新走一遍签名生成逻辑,而不是复用第一次的请求对象。
坑二:断点续传时服务端返回 200 而非 206,导致文件拼接损坏。有些 CDN 对 Range 请求的响应不规范,返回完整内容而不是分片。如果不检查状态码就直接追加写入,文件会变成"前半段 + 完整内容"的畸形结构。修复方法是严格检查响应状态码,非 206 就清空临时文件重新下载。
坑三:检查点写入太频繁,SQLite 被写锁拖慢。我一开始每完成一个任务就写一次检查点,结果在并发 8 的情况下,SQLite 的写锁成了瓶颈。后来改成批量写入,每 10 个任务或每 30 秒写一次,性能立刻恢复正常。
6.3 什么情况下这套架构可能过度设计
说实话,如果你只是偶尔下载十几条内容,这套三层架构确实有点重。杀鸡用牛刀。但如果你的场景符合以下任意一条,这套架构就是值得的:
- 单批任务超过 100 条
- 需要长时间无人值守运行
- 对成功率有要求,不能接受大量人工补漏
- 需要定期重复执行,形成稳定的内容归档流程
我个人的判断标准是:只要你会因为一次失败而需要手动重跑,就应该考虑上容错架构。因为手动重跑的时间成本,远高于前期多写的那几百行代码。
7. 把这套思路迁移到其他抓取场景
三层容错的本质不是抖音特有的,它是一套通用的高可用抓取工作流设计模式。任何面对"高不确定性外部服务 + 大批量任务 + 需要无人值守"的场景,都可以套用。
迁移的时候,需要替换的主要是第一层的请求指纹逻辑和第二层的失败分类规则。比如换成其他内容平台,签名算法不同、限流特征不同,但"重试要换指纹""失败要分类"这两个原则是不变的。第三层的检查点和幂等恢复几乎是通用的,直接复用即可。
我在另一个电商订单抓取的项目里就复用了这套架构,只改了第一层的请求构造和第二层的失败判定,第三层原封不动,两天就搭起来了。这也是我为什么建议把三层边界划清楚——边界清晰,复用成本才低。
最后分享一个我在实际使用中的小习惯:每次跑大批量任务之前,先用 10 条内容做一次冒烟测试,确认三层容错都正常工作,再放开全量。这个习惯帮我避免了好几次"跑了三小时才发现配置写错"的尴尬。冒烟测试的成本是几分钟,但省下的可能是几个小时。