每秒八千个 CAPTCHA,也就是每小时 28.8 百万个、每天 691 百万个。这种量级的数字通常被当作求解器的性能来引用,好像答案就是更快的模型或者更大的机器。并不是。在这些开始起作用之前很久,每秒 8,000 次求解就已经是一份并发预算,而这份预算的大小在一张餐巾纸上就能算出来。
吞吐量等于并发量除以服务时间。如果一次求解要两秒,而你希望每秒完成 8,000 次,那就必须始终有 16,000 次操作处于打开状态。这个数字与求解器有多聪明毫无关系,它来自你无法消除的延迟。
这重新定义了真正的工程问题。有意思的不是求解本身,而是围绕它的控制平面。必须有东西负责接收工作、给它设上界、在不崩溃的前提下让数万次操作同时在途,并且把自己度量得足够清楚,让你能指出是哪一级设下了上限。
配套的 Go 实现位于 ScraperHub/how-we-solve-8000-captchas-per-second ,它用四个文件搭起这个控制平面:一个有界队列、一个带背压的工作池、一个可替换的 Solver 接口(一个 mock 实现和一个 Crawlbase 实现),以及一个报告每秒求解数外加 p50 和 p99 的指标收集器。本文会逐层走一遍,跑完两个基准测试,然后诚实地算一算这些运行结果究竟证明了什么、又没有证明什么关于 8,000 的事。
- 吞吐量是并发量除以服务时间。按每次求解两秒计算,每秒 8,000 次意味着 16,000 个请求在途。
- Go 控制平面不是瓶颈。mock 运行在单机上稳定跑出每秒 32,507 次模拟求解,距离它自身的算术上限只差 0.6%。
- mock 无法给你看到真实的长尾。它的 p99 由构造决定,上限就是基础延迟加抖动。
- 从 6 个 worker 走到 16,000 个在途操作,最先坏掉的是三样东西:默认的 HTTP 连接池、无上界的延迟切片,以及把单个进程当成整套部署的假设。
- 求解这一级正是最不值得自己造的部分,而这恰恰是 Crawling API 替你吸收掉的。
Solver 接口背后的实现,而每个结果都汇聚到同一个收集器。队列的上界就是背压机制。吞吐量是并发量除以服务时间
利特尔法则用一行就写完了整份容量规划:在途操作数等于完成速率乘以每次操作的耗时。按眼下的问题改写一下:你能维持的速率就是并发量除以服务时间。
这值得拿仓库自己的数字验算一遍,而不是照信不疑。文档记录的 mock 运行使用 256 个 worker,报告 p50 为 7.83 毫秒。做个除法:256 个 worker、每次求解 7.83 毫秒,推算出每秒 32,695 次求解。实测是 32,507。队列、结果通道、汇聚收集器及其互斥锁合起来只花掉理论上限的 0.57%。
把同样的算式反过来算,本文的标题就变成了一张硬件采购单。
| 目标速率 | 服务时间 | 所需并发量 | 这意味着什么 |
|---|---|---|---|
| 32,507/秒 | 7.83 毫秒(模拟) | 256 | 单机上的 256 个 goroutine |
| 8,000/秒 | 1.9 秒(实测,真实路径) | 15,200 | 一个机群,而不是一个进程 |
| 8,000/秒 | 2 秒(取整) | 16,000 | 结论相同,心算更容易 |
这两行告诉你难点究竟在哪里。模拟工作在单次操作上比一次真实的网络往返快 250 倍,所以本地基准测试只需要少三个数量级的并发就能报出更大的数字。每秒 8,000 次真实求解里所有困难的部分都在那 16,000 个打开的套接字上,而不在那 8,000 次求解上。
四个文件,一个接口
规范的可运行模块是配套仓库的 final/ 目录,它被刻意做得很小。
pipeline.go bounded queue, worker pool, results fan-in solver.go the Solver interface and its two implementations metrics.go throughput and latency percentiles main.go load harness and flags config.go token and target URL from the environment
这个 Solver 接口就是那道让其余部分变得可测的接缝。流水线永远不知道某个挑战是交给了本地模拟还是发到了互联网另一端;它只提交工作并记录返回结果。正因如此,你才能在没有网络的情况下把编排层测到每秒 32,000 次操作,然后把同一条流水线指向真实端点,看着数字因为你说得清的原因掉下来。
第 1 步:队列与工作池
一个挑战就是一个结构体,队列则是一个带缓冲的 channel。channel 的容量就是全部的背压机制:缓冲一满,生产者的发送就会阻塞,直到某个 worker 腾出位置,于是过载的系统会放慢接收,而不是让堆一直长大。
func NewPipeline(workers, queueSize int, solver Solver, metrics *Metrics, target string) *Pipeline { return &Pipeline{ workers: workers, queue: make(chan Challenge, queueSize), // bounded => backpressure results: make(chan Result, queueSize), solver: solver, metrics: metrics, target: target, } }
每个 worker 都是一个在这个 channel 上迭代的 goroutine。在 channel 上迭代同时也是关停协议:生产者在没有工作时关闭队列,各个循环取完剩下的任务后退出,而 sync.WaitGroup 会告诉调用方最后一个何时结束。
func (p *Pipeline) worker(ctx context.Context, id int, wg *sync.WaitGroup) { defer wg.Done() for ch := range p.queue { started := time.Now() err := p.solver.Solve(ctx, ch) p.results <- Result{ ID: ch.ID, OK: err == nil, Latency: time.Since(started), Worker: id, } } }
注意 worker 不做什么:它不去碰指标结构体。它上报一个 Result 就继续干活。单独一个收集器 goroutine 把结果通道排空进收集器,于是计数器只有一个写入方,热路径也只是一次 channel 发送。
第 2 步:求解器接口及其两种实现
流水线只依赖两个方法,这就是全部契约。
来源: final/solver.go
type Solver interface { Solve(ctx context.Context, c Challenge) error Name() string }
MockSolver 用基础延迟、均匀抖动和一个失败率来替代求解这一级,并且完全不发网络请求。它存在的意义就是让你可以单独度量编排层。
func (s *MockSolver) Solve(ctx context.Context, _ Challenge) error { d := s.Base if s.Jitter > 0 { d += time.Duration(rand.Int63n(int64(s.Jitter))) } select { case <-time.After(d): case <-ctx.Done(): return ctx.Err() } if s.FailRate > 0 && rand.Float64() < s.FailRate { return errors.New("mock solve failed") } return nil }
CrawlbaseSolver 才是真实路径。它把目标 URL 发给 Crawling API,CAPTCHA 与反爬处理在抓取过程中完成,而一个干净的 200 加上被完整读空的响应体就算一次操作完成。把响应体读进 io.Discard 不是走形式:没被读完的响应体无法归还连接池,这一点马上就会变得非常重要。
func (s *CrawlbaseSolver) Solve(ctx context.Context, c Challenge) error { endpoint := fmt.Sprintf( "https://api.crawlbase.com/?token=%s&url=%s", url.QueryEscape(s.Token), url.QueryEscape(c.URL), ) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return err } resp, err := s.Client.Do(req) if err != nil { return err } defer resp.Body.Close() _, _ = io.Copy(io.Discard, resp.Body) if resp.StatusCode != http.StatusOK { return fmt.Errorf("crawlbase status %d", resp.StatusCode) } return nil }
签名相同,物理特性天差地别。工作池分不出它们的区别,而这正是重点。
第 3 步:把吞吐量和长尾一起度量
单看吞吐量会把最值得注意的故障藏起来。一条流水线可能报出健康的平均值,同时有一部分请求比其余请求慢一个数量级,而在并发系统里,恰恰是这些慢请求扣着你的并发预算不放。所以收集器保留每一次延迟,并据此计算分位数。
来源: final/metrics.go
func (m *Metrics) Report() Report { total := m.successes + m.failures elapsed := m.end.Sub(m.start) rate := 0.0 if elapsed > 0 { rate = float64(total) / elapsed.Seconds() } return Report{ Total: total, Successes: m.successes, Failures: m.failures, Elapsed: elapsed, SolvesPerSecond: rate, P50: m.percentile(50), P99: m.percentile(99), } }
由此得到四个信号:总量、成功与失败的比例、p50 和 p99。正是这一对让 worker 数量的调优从猜测变成测量。加 worker 时同时看这两项:如果速率上升而 p99 保持不动,多出来的并发被吸收了;如果速率走平而 p99 继续上升,说明你在下游堆积排队,此时再加 worker 只会更糟。
第 4 步:压测框架
main.go 把各个部分接起来,并把旋钮放到命令行上,而 config.go 通过一个不依赖任何第三方库的小型 .env 加载器从环境里读取令牌和目标 URL。
来源: final/main.go
metrics := NewMetrics(*requests) pipeline := NewPipeline( *workers, *queueSize, solver, metrics, cfg.TargetURL, ) report := pipeline.Run( context.Background(), *requests, ) fmt.Println(report)
可用的 flag 有 -solver、 -requests、 -workers、 -queue、 -base-ms、 -jitter-ms,以及 -fail-rate。默认值为 20,000 个请求、256 个 worker、大小为 1,024 的队列、5 毫秒基础延迟加 5 毫秒抖动,以及 1% 的失败率。
mock 运行证明了什么,又不能证明什么
先给控制平面加压,让网络不挡在路上:
go run . -requests 20000 -workers 256 -queue 1024
solver=mock requests=20000 workers=256 queue=1024 target=https://example.com total=20000 ok=19804 fail=196 elapsed=615ms solves/sec=32507 p50=7.83ms p99=10.354ms
这段输出里有三处值得细读,因为其中两处是确认,第三处是一道限制。
32,507 对应 32,695 的上限。 编排层只花掉 0.57%。channel 发送、一把互斥锁和单 goroutine 的汇聚并不是吞吐问题的藏身之处,而现在你手里有凭据,不只是直觉。
20,000 次里失败 196 次,也就是 0.98%, 对应配置的 1% 失败率。这说明错误路径确实被走到并且计数正确。想要一次干净的运行,就传 -fail-rate 0 ,但永远不会失败的求解器不是你要上线的那一个。
10.354 毫秒的 p99 并不是长尾。 mock 的服务时间是 5 毫秒加上一个小于 5 毫秒的均匀抖动,因此 10 毫秒是一个硬性的算术上界。实测的 p99 只比这个内建于模拟之中的上界高出 354 微秒。这是一次调度度量,不是延迟分布。真实的长尾由 DNS、TLS 握手、重试、缓慢的源站和一个倒霉的 IP 组成,而这些在这次运行里一个都不存在。相信来自 mock 的 p99,是在生产环境中被打个措手不及的最简单办法。
mock 基准测试是很好的第一步,却是很差的最后一步。它证明队列和工作池能在单机上记账每秒数万次操作,而这正是你在怪它们之前需要知道的。至于那真正要花两秒的一级,它什么也说不了。
真实路径受网络约束
现在换成同一条流水线、同一份代码,打到真实端点上。规模刻意做小:
go run . -solver crawlbase -requests 12 -workers 6 -queue 32
solver=crawlbase requests=12 workers=6 queue=32 total=12 ok=12 fail=0 elapsed=3.38s solves/sec=4 p50=1.924091s p99=2.409689s
每秒四次求解,而报出的数字是被格式化字符串四舍五入后的 3.55。十二个样本远不足以支撑有意义的分位数,所以把 p50 当作数量级来看:走真实路径的一次求解大约需要两秒,其中绝大部分是对端的抓取和反爬工作,而不是 Go 里发生的任何事情。
这一个数字才是容量规划真正依赖的,也正是 mock 给不了你的。两秒的服务时间,就是把每秒 8,000 次变成 16,000 个并发操作的原因。这里要对比的不是 mock 的 32,507 对真实的 4;而是一个留有三个数量级余量的控制平面,坐在一个决定了真实预算的依赖前面。
从 6 个 worker 到 16,000 个在途操作,有三样东西会坏掉
示例跑的是 6 个 worker,生产环境跑的是四位数。以下就是你路上会撞到的三面墙,按撞上的顺序排列。
先出问题的是连接池
NewCrawlbaseSolver 用最普通的方式构造它的客户端:
Client: &http.Client{Timeout: 30 * time.Second}
没有 Transport 字段意味着用的是 http.DefaultTransport,而 http.DefaultTransport 只为每个主机保留 两个 空闲连接。这就是 DefaultMaxIdleConnsPerHost,而只要 net/http 存在,这个值一直是 2。六个 worker 打一个 API 主机时没人会注意到。两千个 worker 打一个 API 主机时,除两条之外的所有连接都会在响应读完后立刻被拆掉,于是几乎每次求解都要在发出第一个字节之前先付一次新的 TCP 握手和一次新的 TLS 握手。你给一次两秒的操作又加上了一到两个往返,把 CPU 烧在握手上,还毫无必要地开始轮换临时端口。
按你真正想要的并发量来配置 transport:
transport := http.DefaultTransport.(*http.Transport).Clone() transport.MaxIdleConns = workers transport.MaxIdleConnsPerHost = workers // default is 2 transport.MaxConnsPerHost = workers // 0 means unlimited transport.IdleConnTimeout = 90 * time.Second client := &http.Client{Transport: transport, Timeout: 30 * time.Second}
设置 MaxConnsPerHost 同时也是让背压端到端保持诚实的办法。不设它,一个 16,000 个 worker 的池会乐呵呵地去开 16,000 个套接字;设了它,worker 会改为阻塞等待连接,这比耗尽文件描述符要好得多。同时也要提高进程限制,因为操作系统的默认值离四位数的并发套接字差得很远。
延迟切片变成内存泄漏
收集器每次求解追加一个 time.Duration ,并按请求数预先分配这个切片。对一次 20,000 请求的基准测试来说,这是 160 KB 和一次谁都不会注意到的排序;对一个长期运行的服务来说,它永远不会停止增长。
在每秒 8,000 次求解下,每个样本 8 字节就是每秒 62.5 KB、每小时 230 MB、每天 5.5 GB。更糟的是, percentile 每次调用都会复制整个切片并对副本排序,所以一份覆盖一小时流量的报告要对 28.8 百万个元素排序,并在此期间把内存占用翻倍。对压测框架而言这是正确行为,它的全部职责就是留住一次有界运行的每个样本;而对任何长期存活的东西来说这完全是错的。
修法是标准做法:一个固定大小的延迟直方图,用分桶计数,并在每个上报窗口重置或轮换。分位数变成对计数器的插值,而不是对历史数据的排序,内存占用变成常量,而数字也不会在运行时间超过其分配的那一刻开始骗你。
单个进程不再等于整套部署
一万六千个在途请求不是 goroutine 数量的问题。goroutine 很便宜;套接字、TLS 会话、文件描述符以及它们前面的网卡并不便宜。一旦超过对同一目的地几千条并发连接,形态就必须改变:多个 worker 实例、它们前面一个共享队列、结果流向某个持久的地方。
+----------------+ | shared queue | bounded, same as the channel +-------+--------+ | +--------------+--------------+ | | | v v v worker group worker group worker group | | | +--------------+--------------+ | v solve path (API) | v result stream
这个模型经得起搬迁,因为它里面没有任何东西假设过只有一个进程。一个 Go channel 变成共享队列,一个 goroutine 变成一个 worker 实例,汇聚变成指标流水线,而 queue to workers to solver to results 在两种规模下读起来完全一样。这才是把控制平面保持得这么小的真正理由:无论你跑 256 个 worker 还是 8 个各带 2,000 的实例,都是同样这四项职责。同样的推理在我们关于 构建分布式爬取引擎。
控制平面是值得自己写的那一部分,求解这一级不是:CAPTCHA 与反爬处理都在抓取内部完成,走的是轮换住宅 IP,返回给你的是一个干净的响应,你的 worker 要么计数、要么重试。把同一条流水线指过来,用 1,000 个免费请求开始,无需信用卡。
队列大小与 worker 数量做的是不同的事
这两个 flag 常被一起调整,也常被混为一谈。
worker 数量决定并发量。 它决定你愿意同时打开多少次求解操作,而按照利特尔法则,这是唯一能改变你可维持速率的杠杆。
队列大小决定缓冲。 它决定在生产者开始阻塞之前,你能吸收多少到达量与处理能力之间的临时失衡。
更大的队列买不到任何吞吐量。如果 worker 每秒能完成 1,000 次操作而生产者每秒产生 2,000 次,那么队列深度只决定它在多少秒后被填满。它会填满,生产者会阻塞,背压会恰好落到它该落的地方。给队列定大小,本质上是关于突发容忍度、以及你允许一个排队条目变得多陈旧的决定。
先调 mock,那里迭代不要钱: -base-ms 改变模拟出的服务时间, -jitter-ms 增加波动,而 -fail-rate 让你在负载下观察错误路径。在发出第一个真实请求之前,这三个旋钮就能复现你关心的大部分行为。
带进生产环境的几条
给工作设上界
无界队列并不会阻止过载;它只是把过载藏起来,直到内存成为故障机制,然后一次性全部崩掉,还不留下任何有用的信号。有界队列把同样的过载转换成背压,而背压是可见的、可承受的、可度量的。
把求解器留在接口后面
流水线应该搬运工作,而不是对工作如何完成持有意见。这里的两种实现就是论据:同一个控制平面先被压到每秒 32,507 次操作,然后被指向一个真实 API,而 pipeline.go。
度量的是某一级,而不是整个系统
吞吐量和长尾延迟只有放在一起才有意义,而且只有当你知道它们出自哪一级时才有意义。工作池永远不可能暴露出比求解器所能维持的更多的有效吞吐量,所以一个不再随着增加 worker 而变化的速率,说的是关于依赖的事。我们关于 扩展网页抓取项目 的笔记,以及这份 每月 10 亿请求的案例研究 ,都回到了同一个习惯:按级埋点。
运行配套仓库
仓库需要 Go 1.22 或更高版本。mock 路径不需要账号;Crawlbase 路径需要一个令牌。
git clone https://github.com/ScraperHub/how-we-solve-8000-captchas-per-second.git cd how-we-solve-8000-captchas-per-second/final cp .env.example .env # only needed for the crawlbase solver go build -o captcha-pipeline .
两个环境变量,都由 config.go:
| 变量 | 用途 |
|---|---|
CRAWLBASE_TOKEN |
用于 -solver crawlbase 路径的令牌。缺失时会打印 CRAWLBASE_TOKEN is required for the crawlbase solver 并退出。 |
TARGET_URL |
Crawlbase 求解器为每个挑战抓取的 URL,默认是 https://example.com。 |
final/ 是规范的可运行模块,而 steps/ 保存着上面每一步引入的那个文件的只读快照,因此你也可以读到第 1 步之后的流水线,而不只是它的最终形态。
| 章节 | 代码路径 |
|---|---|
| 第 1 步:队列与工作池 | final/pipeline.go |
| 第 2 步:求解器接口及其两种实现 | final/solver.go |
| 第 3 步:把吞吐量和长尾一起度量 | final/metrics.go |
| 第 4 步:压测框架 |
final/main.go、 final/config.go
|
结语
每秒处理 8,000 个 CAPTCHA 需要的是 16,000 个在途操作,而所有困难的部分都源自这一个数字,而不是求解本身。
Go 控制平面是容易的那一半,测量结果也这么说:一个有界队列、一个工作池、一个两方法接口和一个汇聚收集器,在每秒 32,507 次模拟操作下跑在自身算术上限的 0.6% 以内。困难的那一半是让四位数的真实连接保持打开、在此过程中让埋点的内存占用保持恒定,并在一台机器的套接字用尽后把整套东西铺到多个实例上。
所以这次演练有用的产出不是一个 worker 数量,而是两个你能拿出来辩护的数字:服务时间和并发量,加上一条埋点足够充分、能说出是哪一级掌握着上限的流水线。有了它们,目标速率就成了一个容量决策;没有它们,它仍然只是一个旁边挂着大数字的猜测。
常见问题
示例命令真的能每秒解出 8,000 个真实 CAPTCHA 吗?
不能,它也不是为此设计的。mock 命令在完全没有网络的情况下报告每秒 32,507 次模拟求解,而仓库里的 Crawlbase 命令使用刻意极小的 12 个请求负载,报出每秒约 4 次,因为它受网络约束。8,000 这个数字描述的是生产规模下的架构,也就是许多 worker 实例挂在一个共享队列后面,而不是单个本地进程。示例真正给你的,是给那套部署定规模所需的两个输入:控制平面的余量,以及真实路径的服务时间。
每秒 8,000 次求解需要多少个 worker?
用目标速率除以单个 worker 的完成速率。按每次求解大约两秒计算,一个 worker 每秒完成 0.5 次,所以每秒 8,000 次需要大约 16,000 个 worker 在途。至于这是 8 个各带 2,000 的实例还是 16 个各带 1,000 的实例,那是关于套接字、描述符和影响范围的问题,与 Go 无关。在相信这个两秒的数字之前,请针对你自己的目标测出你自己的 p50。
为什么要用 mock 求解器做基准测试?
为了在开始怪依赖之前,先搞清楚瓶颈是不是你自己的代码。mock 去掉了网络,用可配置的延迟、抖动和失败率来模拟求解,从而把队列、工作池和结果汇聚隔离出来。在这里它显示编排层只花掉理论上限的 0.57%,所以真实运行中的吞吐量缺口可以被证明不在控制平面里。
mock 运行给出的 p99 可信吗?
不可信。mock 的服务时间是基础延迟加上有界抖动,所以它的最坏情况由算术定死:5 毫秒加上最多 5 毫秒得到 10 毫秒的上界,而实测的 10.354 毫秒 p99 就是这个上界加上调度开销。真实的长尾来自 DNS、TLS、重试和缓慢的源站,而 mock 一个都不模拟。请只用 mock 的分位数来发现你自己代码里的调度问题,别用于其他任何判断。
为什么要用有界队列而不是无界队列?
因为无界队列并不消除过载,它只是把症状挪到内存里,并把故障推迟到无法挽回的时刻。当有界队列填满时,生产者会等待 worker,这就是背压:压力回传给生产工作的那一方,而等待中的工作则留在你有意选定的上限之下。
应该怎么调 worker 数量?
在 mock 路径上提高 -workers ,同时观察吞吐量和 p99。只要速率在上升而 p99 保持平稳,增加的并发就被吸收了。当速率走平而 p99 继续上升时,下游那一级已经成了上限,再加 worker 只会加深一条你看不见的队列。之后切到真实求解器再做一遍,因为这两条曲线的形状完全不同。
Crawlbase 求解器到底做了什么?
它把目标 URL 发给 Crawling API ,并把一个干净的 HTTP 200 加上被读空的响应体当作一次完成的求解。CAPTCHA 与反爬工作发生在那次抓取内部而不是你的进程里,所以这条路径上的一次“求解”就是一个请求加一次状态检查。从调用方一侧应对同一问题的做法,可以看我们关于 在网页抓取中绕过 CAPTCHA。
大规模爬取任何站点,无需与基础设施对抗。
Crawlbase 负责处理代理、指纹和 CAPTCHA,让你的团队专注于交付数据流水线,而非维护爬取管道。1,000 次请求免费,无需信用卡。
