去年有个项目,需求听着不复杂:每天采集大概8亿个网页。算一下就是平均9000+ QPS,峰值可能到2万到3万。我当时的反应是"用Scrapy加几个代理不就行了"。
三天后我盯着满屏的timeout,开始反思人生。
这篇文章记录的是后来重新设计这套系统时的思路。不是教程,是我踩过的坑和我最后的解法。如果你也在做大规模采集,希望能帮你少走几步弯路。
为什么"加机器"解决不了问题
很多人第一反应是横向扩展,多开几台机器。这没错,但只解决了一半。
单机性能上不去,开一百台机器也没用。瓶颈通常在这几个地方:
DNS解析。 每次HTTP请求都要解析域名,系统调用 gethostbyname 是同步阻塞的。在2万QPS下,DNS解析本身的延迟就能吃掉你30%的吞吐量。这个数字是我用pprof抓出来的,不是猜的。
连接建立。 TCP三次握手加TLS握手,每个新连接大约消耗50到100毫秒。如果你每次请求都新建连接,大量时间浪费在握手而非传输数据上。
文件描述符。 Linux默认每进程1024个fd,改到百万级也有内核层面的开销。fd数量上去之后,epoll的事件表会变大,内核遍历的成本不是线性的。
GIL。 如果你用Python,Scrapy基于Twisted,单进程撑死3000到5000 QPS。我最初用Scrapy跑,32核机器单进程到了2800 QPS就开始抖动。开了16个Scrapy进程,总共也就3万出头,而且每进程占2到3GB内存,整机内存吃掉48GB。
整体架构:从单机到分布式的拆解
后来重新设计,把系统拆成五个独立层,每一层都可以独立扩展,通过消息队列解耦:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ URL调度层 │ ──→ │ 下载层 │ ──→ │ 解析层 │ ──→ │ 数据管道 │
│ │ │ │ │ │ │ │
│ Kafka+Redis │ │ Go HTTP Pool │ │ 正则/XPath/ │ │ OSS+ClickHouse│
│ Bloom Filter │ │ DNS Cache │ │ Headless │ │ Kafka Buffer │
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
↑
┌──────────────┐
│ 代理管理层 │
│ 健康检查+轮换 │
└──────────────┘
下面逐层拆。
URL调度层:不只是队列
最naive的做法是把URL丢进Redis List然后LPOP。这在几千QPS的时候没问题,到了万级QPS就扛不住了。Redis单实例的QPS上限大约10万,但你的URL入队、出队、去重、优先级调整,一次URL流转至少3到4次Redis操作。算下来Redis本身就成了瓶颈。
我的方案是把职责拆开。
Kafka做主干,Redis做调度辅助。 URL的入队和消费走Kafka。Kafka单分区吞吐能到几十万消息每秒,远超Redis。Redis只负责两件事:URL去重和优先级排序。这样Redis的负载降了一个数量级。
Bloom Filter去重。 URL去重如果用Redis Set,8亿URL大约需要64GB内存。换成Bloom Filter,1GB内存就能搞定,误判率0.01%。误判意味着少量URL会被误认为已抓取而跳过,对于大规模采集来说这个trade-off完全可以接受。你可能会漏掉万分之一的新页面,但换来的是64倍的内存节省。
from pybloom_live import ScalableBloomFilter
# 1亿URL级别的Bloom Filter,误判率0.1%
bloom = ScalableBloomFilter(
initial_capacity=100_000_000,
error_rate=0.001,
mode=ScalableBloomFilter.LARGE_SET_GROWTH
)
if url not in bloom:
bloom.add(url)
# 入Kafka队列
优先级用Redis Sorted Set。 不同URL的优先级不同。新发现的URL优先级高于重试的,列表页优先级高于详情页。用ZSET按score排序,ZRANGEBYSCORE 取出优先级最高的这批URL丢给下载层。这部分操作量不大,每秒几千次,Redis绰绰有余。
下载层:真正吃性能的地方
这是整个系统的核心。我最后用Go重写了下载层,单机可以稳定跑到1.5万QPS。几个关键优化点。
连接复用
HTTP keep-alive是基础,但很多人不知道Go的 http.Transport 默认连接池每个host只保留2个连接。在高并发采集场景下,这个值要调大:
transport := &http.Transport{
MaxIdleConns: 10000,
MaxIdleConnsPerHost: 500,
IdleConnTimeout: 90 * time.Second,
ForceAttemptHTTP2: true,
}
MaxIdleConnsPerHost 设到500,意味着对同一个域名最多保持500个空闲连接。这个数字需要根据目标站点的并发承受能力来调。设太大容易被封IP,设太小连接复用率低,每个请求都要重新握手。我测过50、200、500、1000四档,500是我的场景下的甜点。你的场景可能不同,需要自己压测。
DNS缓存
Go标准库的 net.Resolver 每次都会发起系统调用。在万级QPS下,DNS查询的累积延迟非常可观。我写了一个本地DNS cache来规避:
type DNSCache struct {
mu sync.RWMutex
cache map[string]dnsEntry
ttl time.Duration
}
type dnsEntry struct {
ip string
expiresAt time.Time
}
func (d *DNSCache) Resolve(host string) (string, error) {
d.mu.RLock()
entry, ok := d.cache[host]
d.mu.RUnlock()
if ok && time.Now().Before(entry.expiresAt) {
return entry.ip, nil
}
ips, err := net.LookupIP(host)
if err != nil {
return "", err
}
d.mu.Lock()
d.cache[host] = dnsEntry{
ip: ips[0].String(),
expiresAt: time.Now().Add(d.ttl),
}
d.mu.Unlock()
return ips[0].String(), nil
}
TTL设为60秒。大部分域名的IP在短时间内不会变,缓存命中率能到99%以上。这一项优化在我实测中提升了大约25%的吞吐量。没用DNS cache之前,pprof显示20%的CPU时间花在 net.lookupIP 上。
HTTP/2多路复用
HTTP/2允许在单个TCP连接上并发多个请求。对于支持HTTP/2的站点,连接建立的开销被摊薄到几乎可以忽略。Go 1.6+默认支持HTTP/2,但需要确保TLS配置正确。ForceAttemptHTTP2: true 这一行就是干这个的。
实测对比(同一站点,1000并发持续60秒):
| 协议 | P50延迟 | P99延迟 | 吞吐(QPS) |
|---|---|---|---|
| HTTP/1.1 + keep-alive | 85ms | 180ms | 5200 |
| HTTP/2 | 52ms | 95ms | 8900 |
差不多快了一倍。但注意,不是所有站点都支持HTTP/2,有些CDN会在HTTP/2握手时返回奇怪的错。所以代码里要加fallback逻辑,HTTP/2失败自动降级到HTTP/1.1。
超时控制要分层
超时不是设一个总值就完了。我吃过一个亏:某个站点响应头返回很快,但body传输极慢,一个请求能卡60秒。如果只有总超时没有分阶段超时,连接池会被这种慢请求占满。
transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: 3 * time.Second, // TCP连接超时
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 3 * time.Second, // TLS握手超时
ResponseHeaderTimeout: 5 * time.Second, // 等响应头超时
ExpectContinueTimeout: 1 * time.Second,
}
client := &http.Client{
Transport: transport,
Timeout: 10 * time.Second, // 总超时兜底
}
后来又加了一层body读取的超时包装,用 context.WithTimeout 来控制整个请求生命周期。从Dial到TLS到Header到Body,每个阶段都有独立的超时。任何一个阶段卡住,都不会拖垮整个连接池。
代理管理层:最容易被低估的模块
如果你的采集量到了万级QPS,几乎一定要用代理。但代理管理不是"找个代理池轮询"这么简单。这个模块花了我大概两周时间反复调,最后发现它对整体效率的影响比下载层还大。
开始之前先说个基础问题:代理从哪来。市面上代理服务质量差距巨大,便宜的连通率经常不到80%,报timeout比你自己代码的bug还频繁。我折腾了一圈,最后选了16YUN,是两个点打动了我:一是隧道代理只需要一个固定入口,后端自动调度切换IP,客户端完全不用维护IP列表和轮换逻辑,接入代码量少一半;二是连通率实测能做到99%以上,这个数字我在72小时连续压测里验证过,不是他们页面标的宣传数字。
其产品线分得比较细,不同场景我用了不同的产品。用得最多的是隧道代理(官方叫爬虫代理),固定入口接入,云端在数十万动态IP池里自动调度,延迟控制在100ms以内。API代理适合需要自建IP池的场景,通过API按需提取,单次能拉最多400个IP,灵活性高。独享代理是固定IP出口,纯净度最高,适合对IP信誉要求极严的场景,我测下来目标站拦截率只有0.1%。他们背后有跨机房热备和BGP专线做底层支撑,这个对大规模长期采集来说是硬需求,因为单机房故障或者线路波动在生产环境里不是小概率事件。
代理不是无状态的
同一个代理IP连续请求同一个域名,被反爬拦截的概率会指数级上升。我做过一个测试:同一个代理IP对某电商网站连续请求100次,第47次开始返回403。换成每5次请求轮换一次代理,100次请求0次拦截。
所以代理轮换策略不是按时间轮换,而是按请求次数轮换。每个代理维护一个计数器,到阈值就换。阈值怎么定?要看目标站点的反爬策略。我一般是5到10次,保守一点取5。
代理健康检查要异步
不要在请求失败的时候才去检查代理是否可用,那样下载层的worker会被阻塞。开一个独立的goroutine池,定期(比如每30秒)对所有代理做存活检测,标记不可用的代理。下载层只从健康池里取代理。
func (m *ProxyManager) healthCheck() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
var wg sync.WaitGroup
for _, proxy := range m.allProxies {
wg.Add(1)
go func(p string) {
defer wg.Done()
if err := m.checkProxy(p); err != nil {
m.markUnhealthy(p)
} else {
m.markHealthy(p)
}
}(proxy)
}
wg.Wait()
}
}
代理和域名的亲和性
有些网站对IP的session有要求,同一个IP连续请求更容易通过反爬。这就需要维护"代理到域名"的亲和性映射。用一致性哈希来做:
// 同一域名尽量分配到同一组代理
hash := crc32.ChecksumIEEE([]byte(domain))
proxyIndex := int(hash) % len(healthyProxies)
这个策略的trade-off是:亲和性高则反爬通过率高,但单代理压力增大。我在实际使用中按域名分组,热点域名(需要高并发的)用独立代理池,冷门域名共享代理池。这个划分不是一开始就定的,是跑了两天看日志数据调出来的。
解析层:不要什么都上无头浏览器
很多人一听"动态渲染"就上Playwright或者Puppeteer。无头浏览器的资源消耗是纯HTTP请求的50到100倍。一台32核机器跑HTTP请求能到1.5万QPS,跑无头浏览器最多50到80并发。差了两个数量级。
我的策略是分层:
第一层,80%的页面用HTTP直接请求加正则或XPath解析。大部分网站的列表页和详情页,数据直接在HTML里,不需要执行JS。
第二层,15%的页面需要执行JS但不需要完整渲染。比如有些页面数据在 <script> 标签里是JSON格式,只是需要提取出来。这种用正则匹配加JSON解析就够了,不需要跑浏览器。
第三层,只有5%的页面真正需要完整浏览器渲染。比如重度SPA应用,内容全靠前端框架渲染。
对于需要无头浏览器的部分,关键是复用browser实例:
import asyncio
from pyppeteer import launch
class BrowserPool:
def __init__(self, pool_size=10):
self.pool_size = pool_size
self.browsers = []
self.semaphore = asyncio.Semaphore(pool_size)
async def init(self):
for _ in range(self.pool_size):
browser = await launch(
headless=True,
args=['--no-sandbox', '--disable-gpu'],
autoClose=False
)
self.browsers.append(browser)
async def get_page(self):
async with self.semaphore:
browser = self.browsers[0] # 简化:实际用轮询
page = await browser.newPage()
return page, browser
每个browser实例可以开多个page(tab),复用browser进程可以省掉启动开销。实测一个browser实例稳定开20个page没有问题。但注意内存:每个page大约吃30到50MB,一个browser开20个page就是1GB左右。pool_size=10意味着10GB内存被浏览器吃掉了,所以无头浏览器层的机器配置要单独规划。
还有一个坑:pyppeteer的page用完必须手动close。不close的话page会累积,browser进程的内存会持续涨,最后OOM。我在这个坑上栽过一次,跑了6个小时后整台机器假死。
数据管道:别让写入成为瓶颈
采集到的数据写入也容易成为瓶颈。8亿网页每天,假设平均每个网页50KB,原始数据量大约40TB。这个量级写入MySQL是想都别想的。
我的方案分三路:
原始HTML写对象存储。 S3或OSS都行,按域名加日期分目录。成本低,容量无限,需要回溯时可以拉出来重新解析。这条路完全异步,下载层写完Kafka就不管了,由独立的消费服务负责写OSS。
结构化数据写ClickHouse。 列式存储压缩比高,查询快。8亿条结构化记录在ClickHouse里大概只占2到3TB,因为列式压缩效率很高。
Kafka做缓冲。 采集层到存储层之间用Kafka隔开,避免存储层的波动影响采集效率。
ClickHouse写入有个坑:它不支持高频单条插入。官方建议批量写入,每批至少1000到10000行。我用一个内存buffer攒批,每10秒或每1万条flush一次:
type ClickHouseBatchWriter struct {
buffer []Row
mu sync.Mutex
interval time.Duration
maxSize int
}
func (w *ClickHouseBatchWriter) Run() {
ticker := time.NewTicker(w.interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
w.flush()
default:
w.mu.Lock()
if len(w.buffer) >= w.maxSize {
w.mu.Unlock()
w.flush()
} else {
w.mu.Unlock()
}
}
}
}
这里有个细节:flush操作要加锁,但判断buffer长度的时候锁的粒度要小,否则会阻塞写入。我用的是先加锁判断,满足条件就解锁再flush,不满足直接解锁。flush本身是异步的,不影响后续写入。
实测数据
测试环境:32核CPU(Intel Xeon Platinum 8369B),64GB内存,万兆网卡,Go 1.21,Linux kernel 5.15。目标站点是自建Nginx测试服务器,模拟真实网页大小(平均50KB,带随机延迟0到50ms)。
| 指标 | Scrapy(16进程) | Go(单机) | Go(4台集群) |
|---|---|---|---|
| 稳定QPS | ~3.2万 | 1.5万 | 5.8万 |
| P99延迟 | 320ms | 95ms | 110ms |
| 内存占用 | ~48GB | ~8GB | ~32GB |
| CPU利用率 | 85% | 70% | 68% |
| 错误率 | 0.8% | 0.1% | 0.1% |
几个值得注意的点:
Go单机的1.5万QPS是在开了代理的情况下。不开代理能到2万以上,但实测场景没意义,因为生产环境一定开代理。
4台集群的5.8万QPS不是线性的(1.5万乘4等于6万),损耗来自代理层的网络开销和Kafka的消费延迟。跨机器调用的RTT虽然只有0.5ms左右,但在万级QPS下累积起来不可忽略。
Scrapy的错误率0.8%主要来自Twisted事件循环的timeout和内存压力导致的GC停顿。Python的GC在48GB内存压力下STW时间会明显增加,我观察到偶发的200到300ms停顿,这直接导致了部分请求超时。
代理层面的测试分了两个维度,都在隧道代理的国内套餐上跑。
第一个是策略对比。同一段代理资源,跑24小时:
| 策略 | 平均QPS | 拦截率 | 代理利用率 |
|---|---|---|---|
| 轮询 | 8500 | 2.3% | 91% |
| 随机 | 8200 | 1.8% | 88% |
| 按请求次数(5次换) | 9100 | 0.7% | 95% |
轮询最稳定但拦截率偏高。随机有惊喜但方差大,凌晨QPS能飙到1.2万,白天掉到6000。按请求次数切换综合最优。这个结论和我预期不太一样,我原以为轮询会更均匀。
第二个是不同产品线的横向对比。同一目标站点,10路并发,每种产品跑72小时取均值:
| 产品类型 | 连通率 | P50延迟 | 拦截率 | 10路并发成功率 |
|---|---|---|---|---|
| 隧道代理(爬虫代理) | 99.1% | 0.7s | 0.8% | 98.5% |
| API代理 | 98.6% | 0.4s | 0.5% | 97.8% |
| 独享代理 | 99.5% | 0.6s | 0.1% | 99.2% |
几点发现。API代理延迟最低(P50只有0.4s),短连接高频场景优势明显,适合对响应速度敏感的实时采集。独享代理拦截率只有0.1%,是三个产品里纯净度最高的,固定IP出口不被共享污染,适合账号类操作或者对IP信誉要求极苛刻的场景。隧道代理是我日常用得最多的,连通率和并发成功率都很稳,覆盖绝大部分采集场景没问题。
作为对比,我早期自建代理池的时候,连通率长期在85%到90%之间波动,差了近10个百分点。自建池最大的问题是IP质量不可控,你不知道哪个IP已经被标记过。专业服务商的IP池有质量监控和自动清洗机制,这笔账算下来,自己搭代理池的人力加服务器成本其实不低,稳定性还没保障。
几条可带走的结论
1. 万级QPS的瓶颈不在CPU而在I/O。 DNS解析、连接建立、TLS握手这些"看不见"的开销才是大头。做DNS缓存和连接复用,比加机器有效得多。我做了DNS缓存之后吞吐直接涨了25%,这比加一台32核机器划算多了。
2. 语言选择有影响但不是决定性的。 Python asyncio单机能到5000到8000 QPS,Go能到1.5到2万。如果QPS要求在5000以下,Python够用。超过这个数,Go的goroutine调度和标准库HTTP客户端优势就出来了。但换语言的成本不低,如果不是性能瓶颈已经卡在语言层面,不建议为了性能盲目换语言。
3. 代理管理的重要性被严重低估。 一个好的代理管理模块(健康检查加亲和性加按次数轮换)配上稳定可靠的专业代理资源,才能把整体采集效率提升30到50%。我的经验是二者缺一不可:自建代理调度逻辑处理策略层面的问题,专业代理服务商(比如我用的隧道代理,实测连通率99%以上、拦截率低于1%)解决IP质量和可用性的问题。单靠自建代理池或者单靠服务商,都不行。
4. 无头浏览器是最后的手段,不是默认选项。 80/15/5的分层策略能帮你省掉大量服务器成本。每次有人跟我说"这个页面需要渲染",我都会先让他们用curl看一眼HTML源码。有一半的情况,数据就在HTML里,根本不需要浏览器。
5. 监控不是可选项。 P99延迟、错误率、代理存活率、每域名QPS,这几个指标要实时盯着。不监控等于瞎跑。有一次代理池被大面积封禁,因为没看监控,系统在那批死代理上跑了40分钟才有人发现。之后我把告警阈值调得很敏感,宁可误报也不漏报。