Appearance
创建一个查询代理池
在久远到我都记不清的时间前,查询程序想要拓展必须部署到不同的物理机上,显然大规模的拓展浪费的财力和人力是巨量的。后来我了解到了什么叫代理,什么叫隧道,就将代理服务改为了隧道服务,这样就只需要单机就能运行了。但是隧道的提供商的 IP 质量不好,跨洋访问 R 星很不稳定。所以最后我在各个大厂的云函数上部署了一套代理,省时省力
但是这也为查询服务的设计造成了巨大的麻烦,需要准备本地、隧道、代理、各家云函数等方法的控制器和容错机制,整体的指标也必须从控制台中查看。这就让我非常的不满,太浪费时间和精力了
而且由于查询代理和数据查询业务强耦合了,因此查询程序无法进行多机请求。也不方便之后做一个兼容超多平台的聚合查询项目,我希望到时候能做一个聚合了 qq、微信、贴吧、nga、b 站等各个平台的究极缝合数据中枢
思考
- 是否可以将查询部分提交给异步函数处理,接收和返回则是多线程
- 权限续期模块到底该给谁做呢?代理模块还是数据判断模块,给代理模块会增加复杂度,而且多个权限维护模块也不方便更新。给数据判断模块会导致一些定期更新程序无法使用
- 是否需要有回滚机制,方便权限续期后重新处理
设计目标
测试了一下本地数据库的性能,写入结果时单线程 qps 都接近了 5000。即使是每次请求都写入到数据库,也撑得住
设计的时候异想天开,希望能做到单机请求量 C10M(单机千万),然后计算了一下带宽,单页面 4KBx10M/s=327gbps。瞬间绷不住了,后期更换服务器也就最多双口 200G。C10K 则是 327 mbps(实际 100mbps),倒也还好
根据实测结果,2.5g 网卡的实际速度大概是 2gbps,虚拟机之间的 ovs 网桥大概 40gbps,本地环回最多 100gbps。考虑到外网可预见的部署情况最多万兆,试试看能不能优化到 C300k,或者按照目前的情况,C100K
我理想中的代理池程序需要有以下这些特征
- 高性能:每小时支撑的查询量至少要大于 10 万,或者说 TPS 要到 30 这个级别
- 简单:代码行数越低越好,使用方式越简单越好
- 可观测:有一个方便的接口来进行指标统计,在我这里就是 Prometheus 可采集的 metrics
- 带流控:查询服务是有一定规则的,包括但不限于需要做同账号优先复用 IP、流控和自动重试
程序设计
考虑到流控和并发量等问题,使用多线程模型进行开发,需要使用线程池
寻思了一下,还得是使用异步好了。考虑到请求性能以及信号量机制也不是不能做流控,所以独立模块还是按照异步来编程好了
代理终端
由于需要通过协议转发给代理,需要考虑多个场景
- 云函数:并发由云函数负责控制
- 本地代理:并发由本地负责控制
- 本地 IP 代理:使用本地 IP 进行查询
因此需要抽象出三个模块,负责多种不同的场景。考虑集体封装到同一个文件汇总,代理程序和云函数适配器作为普通程序,本地任务终端作为直接执行时调用的任务。云函数适配器需要考虑兼容 python3.6(云函数最低 fallback),主程序不考虑前向兼容
- 代理程序:最基础的请求和处理,使用标准参数进行入参
- 云函数适配器:对代理程序的二次封装,用于适配云厂商的入参和出参
- 本地任务终端:封装成使用本地 IP 的程序
请求方式
HTTP 请求基于 TCP 链接,因此完全可以使用 TCP Socket 来同时兼容 http 请求和 查询代理请求
甚至查询代理请求也可以走 HTTP,毕竟现有的请求代理方式也是基于 HTTP 的。http 库相比 socket 库有一个最大的好处就是不需要考虑 socket 的底层处理,get 完直接接受结果就行
bash
# HTTP 请求由原信息,请求头和请求体组成,考虑到HTTP部分不需要考虑兼容过多,因此可以使用一个自定义解析器
# METHOD PATH VERSION # HTTP请求头
GET /metrics HTTP/1.1
# JOB URI PROXY # 自定义请求方式请求体
GTA RockStarId TencentFaaS
一个实际请求的请求头大概如下,响应头也大差不差。解析其实只需要把首行的信息进行解包区分,标头作为一个字典打包,body 保持原样即可
bash
GET /metrics HTTP/1.1 # 元数据信息
Accept: image/gif # 剩下的都是请求标头
Connection: close
Body # 这部分是Body,GET其实也能传
- 请求方法 -> 代理任务名称
- 请求地址 -> 任务标识
- 请求版本 -> 代理方式选择
- 请求标头 -> 代理参数
- 请求数据 -> 暂时不需要
流控设计
主要需要完成基础且合理的流量控制,需要考虑如何简化流控设置。限流规则是置入模块还是交由调度器自行决定,复用优先机制如何处理
- 依据任务标识分配代理 IP,保证特性更加真实
- 查询波动自动重试机制
指标响应
需要反馈如下内容,能够自动转换成 Prometheus 的指标
- 整体请求量
- 各个调度器的工作状态
- 任务数量?
请求方式性能测试和选项
- 宿主机:5900x via Proxmox
- 测试环境:通过 ovs 虚拟网桥链接,虚拟机互联带宽约为 22gbps,本地环回带宽约为 200gbps
- win11:16t16g
- ubuntu server 22.04:16t32g,安装 nginx-core
摸底测试
原本按照常规的经验,认为 qps 应该先随着请求线程数的增加而增加,达到瓶颈后再减少。结果初测结果上来就打脸了
请求方式 | 线程数 | qps |
---|---|---|
win11 -> nginx | 1 | 688 |
win11 -> nginx | 4 | 563 |
win11 -> nginx | 10 | 319 |
win11 -> httpbin.org | 1 | 25 |
win11 -> httpbin.org | 4 | 100 |
不论是从 win11 还是从 linux localhost 访问(这里省略了),请求 nginx 的 qps 都是单线程的 qps 最高。并发量随着线程数的增加而大幅降低。而模拟正常请求 web 站点则是正常的
一开始我以为是由于 nginx 的性能问题,结果使用压测工具ab -c 100 -n 10000 http://127.0.0.1/
,测试走本地环回访问 nginx 的 qps 约为 13000。所以问题不是出在 nginx 上
由于本地服务的带宽和延迟相比互联网服务强到不知道那里去了,而且请求的又是非常简单的静态页面。因此瓶颈由传统爬虫的 web 服务器性能和网络 IO 变为爬虫程序自身 IO 能力的限制。由于此时多线程存在切换开销,因此 qps 反而随着线程数的增加而减少
性能测试方法说明
在当初设计程序的时候考虑到爬虫存在大量数据和信号量传递开销,所以没有专门针对传递过程的开销做优化,结果导致初始测试时发现爬虫的性能随着并发的增加而大幅减少,无法体现请求方面的能力。因此相比普通爬虫做了一些针对性优化,当请求线程数从 1 增加到 30 时,qps 仅从 850 降低到约 800
- 宿主机 bios 配置、Hypervistor 调度、网桥配置底层优化略过
- 请求非 ssl 加密的页面,降低 nginx aes 加解密的负载
- 请求模块做异步计数优化,每个并发独立维护一个计数变量,并且采用计时而非信号量检测机制来判断爬虫是否终止
- 为了避免网桥性能瓶颈,在 nginx 虚拟机本地发起请求,避免 ovs 网桥的瓶颈
- 目前同步模式使用 requests 发起请求,异步请求则使用 aiohttp,两种方法都会建立持久化链接。httpx 的性能太过拉跨,同步和异步都不考虑
- 测试选择多线程,多进程,协程,多进程协程四项。由于多进程可以绕开 gil 锁利用多核,接近线性叠加请求,因此作为基础调度单元。多线程和异步作为进程内运行的代理
- 出于方便测试的考虑,实际上单线程/进程比起只有一个主线程这种真正意义的单线程更类似于一个线程池,有一个调度线程和一个工作线程,存在少量的上下文切换开销
- 为了能够更准确的测量请求速率,避免计入调度初始化时间。每个调度都会在初始化时间后等待到一个共同的启动时间
性能测试结果分析
调度策略中的 p 代表进程数,t 代表线程或协程的并行数。单个代理的 qps 用来反应上下文切换开销带来的影响
调度策略 | 代理类型 | 请求总数 | qps | 单个代理的 qps |
---|---|---|---|---|
p1t1 | thread_agent | 17289 | 1728 | 1728 |
p1t4 | thread_agent | 15229 | 1522 | 380 |
p1t8 | thread_agent | 14407 | 1440 | 180 |
p1t16 | thread_agent | 13605 | 1360 | 85 |
p1t1 | thread_agent | 17868 | 1786 | 1786 |
p4t1 | thread_agent | 62421 | 6242 | 1560 |
p8t1 | thread_agent | 88178 | 8817 | 1102 |
p16t1 | thread_agent | 109042 | 10904 | 681 |
p1t1 | async_agent | 34994 | 3499 | 3499 |
p1t4 | async_agent | 46262 | 4626 | 1156 |
p1t8 | async_agent | 49209 | 4920 | 615 |
p1t16 | async_agent | 51396 | 5139 | 321 |
p1t16 | async_agent | 50446 | 5044 | 315 |
p4t16 | async_agent | 176462 | 17646 | 275 |
p8t16 | async_agent | 283648 | 28364 | 221 |
p16t16 | async_agent | 348180 | 34818 | 136 |
python 中,如果使用 requests 库,不论是多线程还是多进程,单个进程似乎的核心使用率都约为 75%,因此单个进程的并发被限制到大概 1800 qps 左右。但是多进程绕开了 gil 锁,能够充分利用多核性能,因此能得到接近线性的提升(14500 qps)。但是并非服务器 cpu 占用 100% 时 qps 才是最大的,而是进程数等于线程数时。这可能是由于 nginx 和系统进程也需要一定的 cpu 时间
而单进程 16 协程就能做到约 5000 qps,16 进程 16 协程并行时处理速度能达到 35000 qps。看上去请求效率似乎是 ab(apache bench) 的数倍了,但是和真正的压测神器 wrk (130000 qps) 比,还只是人家的零头。当然这么超级巨量的 qps 已经能够轻易的冲垮一个未接入 cdn 的网站了,何况真实世界中还要对爬取的数据做加工和存储(这也是测试多进程的原因)
从请求效率的角度上看,由于线程池模式存在一个额外的调度进程,而进程池模式的工作进程是真正单线程工作的,且单线程就能发挥一个进程的最大 qps(真实世界中由于响应延迟多线程有优势,测试场景也没有开销较大的跨进程数据传输任务),因此进程池很神奇的效果远好于线程池。而协程由于 aiohttp 的性能比 requests 强,因此即使是在同时只有单个并发无法发挥异步优势的情况下,qps 也接近纯单线程 requests 的两倍(符合真实情况)
调度策略 | 代理类型 | 请求总数 | qps | 单个代理的 qps |
---|---|---|---|---|
p1t1 | thread_agent | 17289 | 1728 | 1728 |
p1t16 | thread_agent | 13605 | 1360 | 85 |
p1t1 | thread_agent | 17868 | 1786 | 1786 |
p16t1 | thread_agent | 109042 | 10904 | 681 |
p1t1 | async_agent | 34994 | 3499 | 3499 |
p1t16 | async_agent | 50446 | 5044 | 315 |
协程请求方案真正可怕的地方在于由于 python 的 asyncio 套上了协程切换代价低、IO 多路复用降低了大量请求带来的系统开销的 BUFF,又有 aiohttp 的强劲性能加持。即使面对巨量并行请求,协程方案依然能保证极低的调度开销,提供接近线性的性能提升
调度策略 | 代理类型 | 请求总数 | qps | 单个代理的 qps |
---|---|---|---|---|
p1t1 | thread_agent | 17379 | 1737 | 1737 |
p1t256 | thread_agent | 10893 | 1089 | 4 |
p16t1 | thread_agent | 110204 | 11020 | 688 |
p256t1 | thread_agent | 104705 | 10470 | 40 |
p1t16 | async_agent | 47474 | 4747 | 296 |
p1t256 | async_agent | 40972 | 4097 | 16 |
p16t16 | async_agent | 341097 | 34109 | 133 |
p16t256 | async_agent | 315424 | 31542 | 7 |
回到之前并发量设计的话题,现在可以满足 C10K 的请求并发量,考虑到本机 nginx 的占用,甚至是 C40K(画风一转战锤)。如果在拥有更多核心的服务器上进行测试,并发量超过 10W 也是可以做到的,但是这和 C10M 可是差了足足 100 倍
但是更高的请求压力下,瓶颈会逐渐转移到语言本身甚至是内核上,Nginx 官方在 2016 年发表了一篇测试报告,提到了使用 wrk 测试,在一台 装有 40g 网卡的 36 核 E5 服务器上并发量最多能达到 C4M(在不返回 body 的情况下)
那么在一台最新装有 400g 网卡和 384 核的 zen5 服务器上,并发量达到 C10M 也不是什么难事。但是对于现代单机服务器来说,静态内容的处理已经没有什么意义了,这些任务由 CDN 完成更快更好。而动态内容的处理又会被数据库、消息队列和业务逻辑拖累,使得非常难以实现单机 C10M 的目标
不过按照 C10M 的处理能力以及峰值 QPS 和日活关系的计算,需要 C10M 处理能力的日活也得有千万了。在这种业务复杂度下,也不会只选择单机来承载整个系统的服务
技术选型
面对平均一天请求次数不到一万的查询代理来说,只要不被阻塞,传统的线程池能提供绰绰有余的性能。毕竟爬虫最大的瓶颈是目标网站的性能和限速
原本的开发的线程池也能提供十倍于代理极限的请求量,只不过出于统一技术栈和降低调度开销来提供更快的响应的考虑才准备更换的代理程序。正巧我所设计的新调度架构先天上也更适合异步加多进程,就是需要注意控制异步的错误追踪