# 每天15分钟玩转Scrapy - 性能调优


## 开场

性能这件事，我的经验是可以量出来的部分比想象中多。

很多人调优的顺序是反的。先凭感觉把并发开大，发现没变快，再加缓存，还是没变快，最后开始怀疑框架。真正该做的第一步是量出瓶颈在哪，而这件事比想象的简单，因为 Scrapy 已经把每个环节的耗时和计数都记下来了，你只要知道去看哪几个数。

这一篇分成三块。先把旋钮认全，说清楚哪个真的有用。然后是我自己做的一组并发对照，结果有点出乎意料，因为这个坑我花了不少时间才定位到。最后说一件容易被忽略的事，瓶颈经常不在网络那一侧。

所有数字都来自同一台机器上的一次运行，横向比较有效，绝对值别当成硬指标。这类测量对机器状态很敏感，我建议你在自己的环境里重跑一遍。

## 速度先量再调

聊调优之前先把变量数清楚。延迟类的旋钮只有三个。

| 旋钮 | 作用 |
|------|------|
| `DOWNLOAD_DELAY` | 同一个域两次请求之间的基准间隔 |
| `DOWNLOAD_DELAY_JITTER` | 在基准上抖动的幅度，0.5 表示上下 50% |
| `CONCURRENT_REQUESTS_PER_DOMAIN` | 同一个域同时在飞的请求数 |

第三个旋钮有个 2.18 起的新玩法，`CONCURRENT_REQUESTS` 设成 0 表示不限。别急着用，这个开关的意思是「把对方的承受能力当成无限」。

第二个旋钮是新面孔。老教程会让你设 `RANDOMIZE_DOWNLOAD_DELAY = True`，**这个设置现在弃用了**，2.19 上显式设置它还会打 `ScrapyDeprecationWarning`。新写法是 `DOWNLOAD_DELAY_JITTER = 0.5`，语义从「开不开抖动」变成「抖多大」。

而 0.5 恰好就是 `DOWNLOAD_DELAY_JITTER` 的默认值，抖动公式是 `delay * (1 + uniform(-jitter, jitter))`。拿默认值算，设了 1 秒延迟，实际间隔在 0.5 到 1.5 秒之间随机。

### 1. 两组蜘蛛的对比

我做了两个蜘蛛。一个是链式翻页，解析完第 1 页才知道第 2 页在哪。另一个把 10 个页面 URL 一次性全抛出去。

同一个站点、同样 100 条数据，三套参数各跑一次。

链式翻页那一组。

| 配置 | 耗时 |
| 模板默认，1 秒延迟加单并发 | 11.72 秒 |
| 关掉延迟，并发开到 8 | 3.08 秒 |
| AutoThrottle 目标并发 2.0 | 3.43 秒 |

并行起点那一组。

| 配置 | 耗时 |
|------|------|
| 模板默认，1 秒延迟加单并发 | 10.38 秒 |
| 关掉延迟，并发开到 8 | 0.98 秒 |
| AutoThrottle 目标并发 2.0 | 2.02 秒 |

**并行起点那组快了 10.6 倍，链式那组只快了 3.8 倍。** 差距不在参数上，在蜘蛛的形状上。

链式翻页每一页的 URL 都藏在上一个响应里，同一时刻永远只有一个请求在飞。并发数调到 8 也好调到 80 也好，没有第二个请求可以并发。它那 3.8 倍全是关掉延迟挣来的。

![蜘蛛形状决定并发上限](https://static.xiongneng.me/scrapy-08-spider-shape-20260913062827.png)

这件事我当初没想明白，看着 `CONCURRENT_REQUESTS_PER_DOMAIN` 一路加，耗时纹丝不动，还以为是 Scrapy 没读配置。**并发数只对天生并行的起点有用。** 列表页上能一次拿到全部详情页链接的那种结构，是该花力气优化的形状。我自己的感受是，蜘蛛的形状定下来之前，调参基本是在给自己找事。

另一个数字也很说明问题。同一个草稿模板跑出来的 `scrapy crawl` 项目，默认就带着 `CONCURRENT_REQUESTS_PER_DOMAIN = 1` 和 `DOWNLOAD_DELAY = 1`。写代码练手没问题，真要抓点东西，这两个值得自己动。

⚠️ 顺带提一个测量口径的陷阱。我一开始从 `process_request` 开始掐表，算出来的平均耗时 2994 毫秒。改用 `request.meta["download_latency"]`（这个值由 HTTP 下载器写在纯网络往返那一步）之后，平均只有 266 毫秒。

差了 11 倍，多出来的全是**调度器排队的时间**。你设了 1 秒延迟，请求就得在队列里等着，这段等待跟站点快慢毫无关系。只看一个平均数就判断站点变慢了，很容易得出反向结论。

### 2. AutoThrottle 自己找节奏

手工调延迟的问题在于，你不知道对方能承受多少。设保守了浪费时间，设激进了被封。

`AutoThrottle` 的思路是把这件事交给反馈。它盯着每次请求的响应延迟，延迟高就放慢、降并发，延迟低就加快。你要告诉它的是「我大概想同时发几个请求」，而不是「延迟设几秒」。

```python
AUTOTHROTTLE_ENABLED = True
AUTOTHROTTLE_START_DELAY = 0.5
AUTOTHROTTLE_MAX_DELAY = 10.0
AUTOTHROTTLE_TARGET_CONCURRENCY = 2.0
AUTOTHROTTLE_DEBUG = True
```

打开调试之后能看到它在线调参。

```
slot: quotes.toscrape.com | conc: 7 | delay:  361 ms (+261) | latency:  722 ms | size:  1962 bytes
slot: quotes.toscrape.com | conc: 6 | delay:  356 ms (-5)   | latency:  702 ms | size:  2875 bytes
slot: quotes.toscrape.com | conc: 5 | delay:  354 ms (-1)   | latency:  705 ms | size:  1940 bytes
slot: quotes.toscrape.com | conc: 4 | delay:  353 ms (+0)   | latency:  707 ms | size:  1802 bytes
slot: quotes.toscrape.com | conc: 3 | delay:  352 ms (+0)   | latency:  704 ms | size:  1937 bytes
```

`conc` 是当前并发，后面括号里是这次相对上次调整了多少。第一次它按站点延迟把延迟从 100 毫秒一口气顶到 361 毫秒，之后稳定在 352 毫秒附近不再摆动。

实测耗时 2.02 秒对不限速的 0.98 秒。慢了一倍，但这一倍买来的是「不把对方打挂」。

⚠️ 开了 AutoThrottle 就别再定死 `DOWNLOAD_DELAY`，两个旋钮抢同一个位置，结果不好预期。规范做法是把 `DOWNLOAD_DELAY` 留着默认，让扩展自己接管。

## 并发到底能开多大

这个问题我原本以为有标准答案，量完之后发现答案有点意外。说实话我一开始是照着文档的语义去理解的，以为有这个旋钮就等于有了这道闸门。

先搭一个可控的环境。我写了个服务端，`/index?n=&ms=` 返回 n 条指向详情页的链接，`/item/<i>?ms=` 每条固定延迟 ms 毫秒。要测并发的效果，这样最干净，服务器本身不成为变量。

然后一个蜘蛛，从 `/index` 一次拿到 50 个详情页链接。50 页，每页延迟 200 毫秒，串行跑完的理论值是 10.2 秒。

改 `CONCURRENT_REQUESTS`，跑四次。

| 页级并发 | 耗时 | 相对串行的加速 |
|---------|------|--------------|
| 1 | 10.23 秒 | 1.0 倍 |
| 4 | 2.73 秒 | 3.7 倍 |
| 16 | 1.09 秒 | 9.4 倍 |
| 32 | 0.83 秒 | 12.4 倍 |

收益递减看得很清楚。从 1 到 4 拿了 3.7 倍，从 16 到 32 只多拿了 0.3 倍多一点。原因是这组数据里服务器本身没有压力，唯一的开销就是那个 200 毫秒的固定延迟。真实站点上加并发会先撞到对方的能力上限，再撞到你自己机器的连接数，收益曲线比这个更早变平。

到这里都还正常。真正让我意外的是下一个实验。你想想看，一个按域限流的旋钮，如果它不生效，那前面那些「安全爬取」的建议就全落空了。

### 域级并发形同虚设

我一直以为限制爬速的安全做法是按住 `CONCURRENT_REQUESTS_PER_DOMAIN`，把页级并发放开。这个直觉在 2.19 上不成立。

同一组 5 个页面，把页面延迟调到 1000 毫秒，这样串行和并行的差别会拉到 5 秒左右，看得更清楚。三套配置各跑一遍。

| 页级并发 | 域级并发 | 耗时 |
|---------|---------|------|
| 1 | 8 | 5.09 秒 |
| 32 | 1 | 1.07 秒 |
| 1 | 1 | 5.09 秒 |

第一行和第三行的耗时几乎一样，5.09 对 5.09。**域级并发设成 8 和设成 1 没有任何区别，决定耗时的只有页级并发那个值。**

中间那一行更说明问题。页级并发 32，域级并发 1，按定义「同一个域同时只允许 1 个请求在飞」，它应该跑出 5 秒。实际是 1.07 秒，全部并行。

我不信，去读了运行时状态。下载器上挂着一个 `slots` 字典，按域分槽，每个槽有自己的并发值。我把槽位的值打出来，确实读到了 1。

```
槽位并发=1，共 7 次「队列非空且有空闲槽」的出队事件：
相对时刻       正在传输           队列剩余           空闲槽
--------------------------------------------------
0.000s     0              1              1
0.008s     0              1              1
0.008s     0              1              1
0.008s     0              1              1
0.008s     0              1              1
0.008s     0              1              1
0.009s     0              1              1

出队时看到的「正在传输」峰值 = 0
```

这组数字就是答案。**槽位声明了并发 1，7 次出队全挤在 9 毫秒之内完成，而每次出队的时候「正在传输」都是 0。**

![域级并发闸门为什么不合上](https://static.xiongneng.me/scrapy-08-domain-slot-20260913062827.png)

「正在传输」这个计数是用来判断槽位有没有空的依据。它一直是 0，说明每个请求都在被计入这个计数之前就已经离开队列了。限流判断发生在这一步之后，于是这道闸门根本没有机会合上。

⚠️ 我去读了 2.19 的下载器源码，机制是清楚的。出队那段是个同步的 `while` 循环，条件里有一句 `slot.free_transfer_slots() > 0`，而它的实现是「槽位并发数减去正在传输的个数」。

```python
while slot.queue and slot.free_transfer_slots() > 0:
    slot.lastseen = now
    request, queue_dfd = slot.queue.popleft()
    _schedule_coro(self._wait_for_download(slot, request, queue_dfd))
```

问题在于 `_schedule_coro` 只是把协程排进调度，不会立刻执行。而「把自己加进正在传输」这一句，是在那个协程真正跑起来之后才执行的。

```python
async def _download(self, slot, request):
    slot.transferring.add(request)
```

于是整个 `while` 循环在同一个同步块里把队列抽空了，那期间 `transferring` 一直是空的，`free_transfer_slots()` 就一直返回完整的并发数。闸门全程没合上。

页级并发走的是另一条路，它在下载器自己的 `active` 集合上判断，跟这个循环无关，所以它是真的生效。

⚠️ 说清楚这个机制不是为了让你去改源码。**对你的实际价值是，在 2.19 上想控制爬速，别指望 `CONCURRENT_REQUESTS_PER_DOMAIN`。** 它的值会被读进槽位，看起来配置生效了，实际不起作用。

那控速该用什么。三个层次按优先级。

1. `CONCURRENT_REQUESTS` 是唯一真正生效的并发旋钮，先调它
2. `DOWNLOAD_DELAY` 加 `DOWNLOAD_DELAY_JITTER` 控制请求间隔，这个也生效
3. 要按域精细控制，用 `AutoThrottle` 扩展，它按每个域自己的响应时间动态调

至于 `CONCURRENT_REQUESTS = 0` 这个「不限」的写法，我建议别用。它的含义是把对方的承受能力当成无限的，而这在真实站点上从来不是真的。

## 瓶颈常常不在网络

前面几节全在调网络那一侧的参数。但爬虫有两个半场，下载是一半，处理 item 是另一半。第二半被我忽略了很久。说到底，一只爬虫跑得快不快，取决于两个半场里慢的那个。

管道是串行执行的。引擎把每个 item 依次交给管道链上的每一个组件，前一个处理完才轮到下一个。这条链是串的，**管道里的耗时是累加的，而且它跟下载不是并行关系，是一条独立的串行链路。**

我拿一个故意变慢的管道做对照。蜘蛛抓 100 页，每页服务端延迟 50 毫秒，页级并发开到 16。管道里加一个人为延迟，从 0 调到 20 毫秒每条。

| 管道延迟 | 耗时 |
|---------|------|
| 0 | 0.70 秒 |
| 20 毫秒/条 | 2.42 秒 |

一百条乘以 20 毫秒是 2.0 秒，跟实测的 2.42 秒基本对得上。而下载那一半的计算值是 100 乘 50 毫秒除以 16，大约 0.31 秒。

**管道的串行耗时已经比下载的并行耗时大好几倍，这时候并发调多大都没有用。** 你把页级并发放到 64，整个爬虫的耗时还是 2 秒出头，因为上游在等下游。

管道长这样。

```python
class SlowPipeline:
    def __init__(self, sleep_ms, crawler):
        self.sleep_ms = sleep_ms
        self.crawler = crawler
        self.stats = crawler.stats

    @classmethod
    def from_crawler(cls, crawler):
        return cls(crawler.settings.getint("PIPELINE_SLEEP_MS", 0), crawler)

    def process_item(self, item):
        if self.sleep_ms:
            time.sleep(self.sleep_ms / 1000.0)
            self.stats.inc_value("pipeline/slept")
        return item
```

⚠️ 这段代码里用的是 `time.sleep`，它会把整个事件循环按住。这是教学用的极端例子，但它对应的真实写法一点不夸张，**同步的数据库客户端、同步的 HTTP 请求、同步的文件写入，全都是同一个效果。**

判断自己的管道有没有成为瓶颈，有个很简单的办法。跑完之后对比这两个数。

![下载半场与处理半场](https://static.xiongneng.me/scrapy-08-pipeline-bottleneck-20260913062827.png)

| 统计项 | 看什么 |
|-------|--------|
| `downloader/response_count` 除以总耗时 | 下载的实际吞吐 |
| `item_scraped_count` 除以总耗时 | 整条链路的实际吞吐 |

两个数差得越多，说明管道那一段吃掉了越多时间。还有一个更直接的信号，`item_scraped_count` 明显小于 `response_received_count`，而这个差距又不是因为解析没命中，那大概率就是管道在拖。

解法有两条。一条是把管道改成异步的，`process_item` 写成 `async def`，Scrapy 会 await 它，用 `asyncpg`、`aiomysql` 这类异步驱动把阻塞去掉。另一条是把管道里那些重活挪出主流程，比如先进一个内存队列，由单独的协程批量写。

第一条更彻底，代价是你的数据库驱动得换。第二条改动小，但引入了队列，进程被强杀的时候队列里的数据会丢，得自己权衡。

顺带说一个观察。这事儿我在跑这些对照的时候看得很清楚，同一份蜘蛛在管道没压力的情况下，从并发 16 加到 32 只快了 0.3 秒左右。**在你还没量出瓶颈在哪之前，动并发这个旋钮的收益上限其实很低。** 先把两个吞吐数字比一比，比一直试参数效率高得多。

## 把请求省下来，把数据看清楚

调优这件事有两个配套动作，一个帮你少发请求，一个帮你把已经发生的事看清楚。

调试选择器的时候反复抓同一个页面是常态，每次都真发请求既慢又对不起对方。把响应缓存到本地能省掉这部分。而跑完之后的那一大坨统计，是排查问题的第一手材料，比翻日志快得多。

这两件事都属于「调优之前的准备工作」，但它们的回报很直接。

### 1. 缓存与统计

调试选择器的时候反复抓同一个页面很常见。`HTTPCACHE_ENABLED` 就是为这个准备的，它把响应原样存到磁盘，下次同样的请求直接读盘。

```python
HTTPCACHE_ENABLED = True
HTTPCACHE_EXPIRATION_SECS = 600
HTTPCACHE_DIR = "httpcache"
```

同一个十页站点连跑两次，差距是这样的。

```
第一次  'httpcache/firsthand': 10, 'httpcache/miss': 10, 'httpcache/store': 10
        'elapsed_time_seconds': 10.958

第二次  'httpcache/hit': 10
        'elapsed_time_seconds': 0.041
```

10.96 秒掉到 0.041 秒，两百多倍。`firsthand` 是「第一次见到、真的发了请求」，`hit` 是「直接读盘」。

⚠️ 缓存有个副作用很容易咬人。你改了选择器、改了 parse 逻辑，跑起来结果没变，原因就是它还在吃旧缓存。这时候去清 `HTTPCACHE_DIR`，**千万别先怀疑代码**。缓存这东西在调试期挺好用，忘了它的存在就挺烦人。

缓存之外，还有一类信息只有统计里有。跑完那一刻打印的那一大坨字典，不是日志噪音，是排查问题的第一手材料。

我在完整案例里把关键的几项挑出来，让蜘蛛在关闭时自己写成报表。

```python
    def write_report(self, spider, reason=None):
        st = self.crawler.stats.get_stats()

        def g(key, default=0):
            return st.get(key, default)

        start = st.get("start_time")
        elapsed = (
            (datetime.now(timezone.utc) - start).total_seconds() if start else 0.0
        )
        net_n, net_ms = g("latency/net_count"), g("latency/net_total_ms")
        wall_n, wall_ms = g("latency/wall_count"), g("latency/wall_total_ms")
```

⚠️ 这里踩了两个时序坑。`elapsed_time_seconds` 和 `finish_reason` 这两个值由 `CoreStats` 扩展在 `spider_closed` 里写，而我们的回调可能排在它前面，读出来就是 0 和空。耗时自己从 `start_time` 算，结束原因直接用信号传进来的 `reason`。

报表长这样。

```
======== 本次爬取报表 ========
结束原因        : finished
请求总数        : 64
响应 200        : 63
抓到的 item     : 60
下载的图片      : 60
图片失败        : 0
重试次数        : 0
重试放弃        : 0
UA 轮换次数     : 64
去重过滤        : 0
平均网络往返    : 266 ms（64 次）
平均端到端耗时  : 2994 ms（64 次，含排队）
总耗时          : 10.75 秒
```

最后聊两句日志。`LOG_LEVEL` 默认 `INFO`，`LOG_FILE` 能把它落到文件。2.19 加了 `LOG_COLOR` 和 `LOG_INSTALL_ROOT_HANDLER`，终端上的颜色和根 logger 的接管都能配。

⚠️ 别图省事把 `LOG_LEVEL` 设成 `DEBUG` 然后跑一整天。媒体管道在 DEBUG 下会给每张图打三到四行，抓十万张图就是几十万行日志，磁盘写满只是时间问题。要看细节就开一次短跑，看完关掉。

## 容易栽的跟头

**坑 1：把 `CONCURRENT_REQUESTS_PER_DOMAIN` 当成限速的主要手段。** 这是这一篇里最贵的一个坑，因为我花了很长时间才定位到。实测数据是页级并发 1 加域级并发 8 跑了 5.09 秒，页级并发 32 加域级并发 1 跑了 1.07 秒。域级那个值在运行时的槽位里确实读到了，但它拦不住请求。**在 2.19 上想控速就用 `CONCURRENT_REQUESTS` 加 `DOWNLOAD_DELAY`，要按域精细控制就上 `AutoThrottle`。**

**坑 2：以为并发开得越大越快。** 同一组 50 页的对照里，从并发 1 到 4 拿了 3.7 倍，从 16 到 32 只多拿了 0.3 倍多一点。并发这个旋钮的收益是有上限的，撞到上限之后再往上加，多出来的只有你自己机器的连接数和对方的判断。跑一次矩阵，找到拐点，比一直往上试有效。

**坑 3：管道里做同步 IO。** 这是第二个隐形的瓶颈。管道是在 item 处理链上串行执行的，里面阻塞多久，整条链路就等多久。实测里管道加 20 毫秒每条，100 条就让总耗时从 0.70 秒涨到 2.42 秒，这时候下载并发调到多少都不影响结果。**判据很简单，`item_scraped_count` 除以总耗时，跟 `downloader/response_count` 除以总耗时比一比，差得多就说明管道在拖。**

**坑 4：在 `spider_closed` 回调里读 `elapsed_time_seconds` 和 `finish_reason`。** 这两个值由 `CoreStats` 扩展在它的 `spider_closed` 里写，你的回调排在它前面就会读到 0 和空字符串。耗时自己从 `start_time` 算，结束原因直接用信号传进来的 `reason` 参数。

**坑 5：改了选择器，结果没变，去怀疑代码。** 大概率是 `HTTPCACHE_ENABLED` 还开着。缓存按请求指纹命中，你改的是解析逻辑不是请求地址，它照样给你旧响应。先去清 `HTTPCACHE_DIR` 再排查，能省下不少时间。

**坑 6：图省事把 `LOG_LEVEL` 设成 `DEBUG` 跑一整天。** 媒体管道在 DEBUG 下会给每张图打三到四行，抓十万张图就是几十万行日志，磁盘写满只是时间问题。要看细节就开一次短跑，看完关掉。**这个坑的特点是不会立刻出事，等你发现的时候盘已经满了。**

**坑 7：`CONCURRENT_REQUESTS = 0`。** 这个写法表示不限并发。它的语义是把对方的承受能力当成无限的，而真实站点上从来不是。要压满本机能力的时候可以短跑测一下上限，别把这行留在生产配置里。

## 小结

这一篇如果只留一句话，我会留这句，先量再调。

前三块内容其实都在讲同一件事的不同侧面。蜘蛛的形状决定了并发的上限，链式翻页的蜘蛛并发开多大都只有 3.8 倍，天生并行的起点能拿 10.6 倍。并发这个旋钮本身有拐点，实测里 16 到 32 只多 0.3 倍。管道是第二条串行链路，它慢下来之后网络那侧的参数全都失效。

而域级并发失效那件事给了我一个提醒。**配置被读进去了，不等于配置生效了。** 那个 8 在运行时的槽位里明明白白读得到，行为上却跟 1 完全一样。要不是我去钩住出队那一步看 `transferring` 的实际值，光看日志是永远发现不了的。你手上的一个配置项到底有没有在干活，这件事得靠测量回答。

我自己的习惯是，每次要给爬虫提速之前先跑一次基线，把这几项记下来。`downloader/response_count`、`item_scraped_count`、总耗时，三个数除以一下就是两个吞吐。改一个参数再跑一次，两个吞吐都动了才算改对了地方。

这一篇里的数字都是在一台机器上一次跑出来的，绝对值换台机器就不一样。留着它们是让你看趋势，不是当硬指标用。

这篇里要是有哪里讲得不对，欢迎拍砖。


---

> 作者: XiongNeng  
> URL: https://xiongneng.me/posts/python/scrapy/scrapy-performance-tuning/  

