Go并发抓取实战:muffet源码中的HTTP客户端池与并发控制机制
1. 为什么值得花时间读muffet的源码
第一次翻muffet源码的人,多半是冲着"一个用Go写的网站链接检查工具"这个标签去的。但真正读进去会发现,它最有嚼头的部分根本不在"检查链接"这个业务逻辑上,而在于它怎么把成千上万个URL塞进一个可控的并发管道里,既不把目标站点打挂,也不把自己的内存撑爆。这两件事——HTTP客户端池和并发控制——才是muffet源码里密度最高、复用价值最大的部分。
我自己是在一个批量URL健康巡检的需求里被逼着读这套代码的。当时手头有个场景要定期扫几万个页面链接,早期版本用最朴素的go func()一把梭,结果跑起来不是连接数爆炸被目标端限流,就是goroutine数量失控把内存吃到报警。后来把muffet的并发模型拆开看了一遍,才明白"并发"和"并发控制"完全是两码事:前者是能力,后者是纪律。muffet的源码恰好把这条纪律写得很干净,值得逐层拆。
这篇内容适合三类人:一是写过Go但没系统研究过HTTP客户端复用的人;二是做过爬虫或链接巡检、被连接池和限流坑过的人;三是想找一个中等规模、结构清晰的Go项目来学习并发编排的人。全文围绕muffet源码里的HTTP客户端池和并发控制机制两条主线展开,会讲到设计取舍、关键参数、实操复现步骤,以及我在实际改造中踩过的坑。读完之后你应该能自己搭一个结构类似的并发抓取骨架,而不是只会调http.Get。
需要先说明一点:muffet本身是个开源工具,源码结构会随版本演进,下面涉及的函数名和字段名以我读过的版本为准,如果你手上的版本对不上,按逻辑对应过去即可,核心机制是稳定的。另外文中所有场景、项目、数据均为虚构代称,只谈机制不谈具体业务。
2. 整体架构拆解:muffet是怎么组织一次抓取的
2.1 从入口到工作池的调用链路
muffet的主流程其实很短:解析命令行参数、构造一个抓取器、把起始URL丢进去、等所有任务结束、输出结果。真正复杂的是中间那个"抓取器"内部。它的组织方式不是"一个URL一个goroutine"的朴素模型,而是典型的生产者-消费者 + 固定工作池结构。
具体来说,主协程负责把待抓取的URL放进一个队列(channel),同时启动固定数量的worker协程从队列里取任务。每个worker拿到URL后,用共享的HTTP客户端发请求、解析响应、提取页面里的新链接,再把新链接判断去重后重新塞回队列。整个过程中,队列的写入和读取、去重表的读写、结果输出,都需要并发安全的协调。
这个结构的关键在于:worker数量是固定的,队列是带缓冲的,去重是全局的。固定worker保证了并发上限可控,带缓冲队列吸收了生产和消费的速度差,全局去重避免了同一个URL被反复抓。三者缺一不可——只固定worker不加缓冲队列,生产端会被消费端拖慢;只加队列不做去重,环形链接会让队列无限膨胀。
我见过不少人自己写抓取器时只做了worker池,结果遇到页面互相链接的场景直接死循环。muffet在这一点上处理得很克制:它在把新链接入队之前就做了一次去重判断,而不是等worker取出来再判断,这样队列里永远不会出现重复项,内存占用是可预测的。
2.2 为什么选择固定工作池而不是信号量限流
这里有个设计取舍值得说清楚。控制并发常见两种做法:一是固定数量的worker从队列取任务,二是无限制起goroutine但用信号量(带缓冲channel当令牌桶)限制同时执行数。muffet选了前者。
两种方案在"限制并发数"这个目标上等价,但行为差异不小。信号量方案下,goroutine是先创建再阻塞等令牌,也就是说任务量大时你会有一大批goroutine处于阻塞态,虽然单个goroutine很轻,但数量上去了调度和内存还是有开销,而且这种"先起后堵"的模型在任务取消时清理起来更麻烦。固定worker方案则是"有多少worker干多少活",goroutine总数从一开始就是常数,队列里排队的只是数据不是协程,内存曲线平滑得多。
另一个隐性好处是可观测性。固定worker模型下,你想知道当前有多少任务在跑,看worker数量就知道上限;想加监控,在每个worker的循环里埋点即可,粒度清晰。信号量模型下并发度是动态的,埋点位置分散,排查问题时不够直观。
代价也有:固定worker模型下,如果某个任务特别慢(比如某个URL响应超时很久),它会占住一个worker,降低整体吞吐。muffet对此的缓解手段是给HTTP客户端设置超时,让慢任务尽快失败释放worker,而不是靠动态扩容去消化。这个取舍背后的判断是:链接检查场景里,慢请求大概率是目标端有问题,与其扩容去等,不如超时放弃。这个判断不一定适用于所有场景,但逻辑是自洽的。
2.3 队列、去重表与结果输出的三方协调
一次抓取里其实有三份共享状态在同时被读写:待抓队列、已访问集合、结果收集。muffet对这三者的处理策略不一样,这个差异很能说明问题。
待抓队列用channel,天然并发安全,生产者和消费者通过它解耦。已访问集合用带锁的map(或者同步map),因为需要"判断是否存在+写入"这个复合操作的原子性,单纯用channel做不到。结果输出则通常走一个单独的收集channel或者加锁的切片,由主协程统一汇总打印,避免多个worker同时往标准输出写导致内容交错。
这里有个容易忽略的细节:队列的关闭时机。worker从队列读任务,如果队列被关闭,读操作会立即返回零值,worker需要能识别这种情况并退出。但队列什么时候关?不能在生产端随便关,因为可能有多个生产者在往里写(每个worker解析出新链接都是生产者)。muffet的处理方式是引入一个"在途任务计数":每入队一个任务计数加一,每完成一个任务计数减一,当计数归零时说明不会再有新任务产生,此时关闭队列,worker读到关闭信号后退出。这个计数机制是整套并发控制能正确收敛的核心,后面会单独展开讲。
3. HTTP客户端池:复用到底复用了什么
3.1 连接复用与客户端复用的区别
很多人把"HTTP客户端池"理解成"搞一个http.Client全局共用",这只对了一半。Go的http.Client本身不是连接池,真正的连接池藏在它内部的Transport里。http.Client复用解决的是配置一致性问题,Transport复用解决的才是TCP连接复用问题。muffet的源码里这两层是分开处理的,理解这个分层是读懂客户端池的前提。
http.Transport内部维护着按目标主机(准确说是scheme+host)分组的空闲连接池。当你用同一个Transport连续请求同一个域名时,第二次请求会优先从空闲池里捞一条已经建立好的连接,省掉TCP握手和TLS握手的时间。这个优化在链接检查场景里收益巨大,因为一个站点内部的链接往往指向同一个域名,连接复用率很高。
但连接复用有个前提:响应体必须被完整读取并关闭。如果只读了响应头就丢弃body,这条连接不会被放回空闲池,而是直接被关掉。这是新手最容易踩的坑——代码看起来在复用客户端,实际上每条请求都在新建连接,性能还不如不复用。muffet在读取响应后会把body读干净再关闭,这个细节在源码里不起眼,但决定了连接池是否真的生效。
3.2 Transport关键参数怎么调
muffet对Transport的配置不是默认值照搬,而是针对"大量短请求、目标分散"的场景做了调整。几个关键参数值得逐个说清楚。
MaxIdleConns控制整个Transport的空闲连接总数上限。默认值是100,对于要扫几万个URL的工具来说偏小,muffet会把它调高。这个值的经验算法是:不小于你的并发worker数量乘以一个系数(比如2),保证每个worker对应的活跃连接在空闲时都有位置放。设太小会导致连接刚空闲就被关,复用率下降;设太大则空闲连接占用的文件描述符和内存偏多。
MaxIdleConnsPerHost控制单个主机的空闲连接上限,默认只有2。这个默认值对链接检查是灾难性的——如果你有50个worker同时打同一个域名,只有2条连接能被复用,其余48条用完即关,连接建立开销全浪费了。muffet会把它调到和并发度匹配的量级。这里要注意别调得比MaxIdleConns还大,否则单主机上限形同虚设。
MaxConnsPerHost控制单个主机的总连接数(含活跃和空闲),默认0表示不限制。这个参数是防止把单个目标站点打挂的关键闸门。链接检查时如果所有worker都扑向同一个域名,没有这个限制就会瞬间建立大量连接,轻则被限流重则被封。muffet会根据场景设置一个合理值,让单主机并发受控。
IdleConnTimeout控制空闲连接存活时间,默认90秒。抓取任务通常持续时间不长,这个值一般不用大改,但如果你的任务有长时间停顿(比如分批处理中间歇很久),调小一点能更快释放资源。
下面这张表是我在实际调优时总结的参数对照,数值是经验区间,具体要按你的并发度和目标分布来定:
| 参数 | 默认值 | 链接检查场景建议 | 作用与调优逻辑 |
|---|---|---|---|
| MaxIdleConns | 100 | 并发数×2 起 | 全局空闲连接上限,太小复用率低 |
| MaxIdleConnsPerHost | 2 | 与单主机并发度匹配 | 单主机空闲连接上限,默认值严重偏小 |
| MaxConnsPerHost | 0(不限) | 10~30 | 单主机总连接闸门,防打挂目标 |
| IdleConnTimeout | 90s | 30~60s | 空闲连接存活时间,按任务节奏调 |
| Timeout(Client层) | 无 | 10~20s | 单请求总超时,防慢任务占worker |
3.3 客户端复用的边界与陷阱
客户端池不是万能的,有几个边界必须清楚。第一,不同配置需要不同客户端。如果你要给某些请求设不同的超时、不同的Header、不同的重定向策略,就不能共用一个Client,否则配置会互相污染。muffet在需要区分场景时会构造多个Client,每个绑定自己的Transport,而不是在一个Client上动态改配置——因为Client的字段在并发使用中改是不安全的。
第二,Transport是复用的核心,Client只是壳。多个Client可以共享同一个Transport,这样连接池是共享的,配置却可以不同。这个技巧在需要"同一批连接、不同请求策略"时很有用。但要注意共享Transport意味着连接池参数也是共享的,不能指望不同Client有独立的连接上限。
第三,重定向会消耗连接。默认Client会跟随重定向,每次重定向都是一次新请求,可能用到新连接。链接检查场景里重定向很常见,如果目标站点重定向链很长,连接消耗会比预期大。muffet对重定向次数做了限制,避免无限跳转拖垮连接池。
提示:判断连接池是否真的生效,最直接的办法是在Transport里挂一个
DialContext钩子打日志,看实际建立TCP连接的次数。如果请求数和建连数接近1:1,说明复用没生效,八成是响应体没读干净或者连接被提前关了。
4. 并发控制机制:从worker池到收敛退出
4.1 worker池的启动与任务分发
muffet的worker池启动逻辑很朴素:循环N次,每次起一个goroutine,每个goroutine里跑一个for循环不断从任务channel取URL。N的取值来自命令行参数(并发度),默认值不大,因为链接检查的瓶颈通常在目标端而不是本地。
任务分发靠channel,但这里有个设计细节:任务channel是带缓冲的。缓冲大小的选择有讲究。缓冲太小,生产端(解析出新链接的worker)会频繁阻塞在入队操作上,降低解析吞吐;缓冲太大,队列里堆积的待抓URL会占用内存,而且任务取消时清理更慢。muffet的缓冲大小通常和并发度挂钩,取一个能让生产和消费大致平衡的值。
worker取到任务后的处理流程是固定的:发请求、读响应、判断状态码、解析HTML提取链接、对每个新链接做去重判断、未访问过的入队。这个流程里每一步都可能出错(网络错误、解析错误、编码问题),muffet对错误的处理策略是"记录但不中断"——单个URL失败不影响整体抓取,错误信息收集起来最后统一报告。这个策略对批量任务很关键,否则一个坏链接就能让整个抓取停摆。
4.2 在途计数:让并发正确收敛的关键
前面提过队列关闭时机的难题,这里展开讲。问题的本质是:多个worker既是消费者又是生产者,你无法预知"还有没有新任务会产生",因为新任务依赖于尚未完成的请求的响应内容。如果过早关闭队列,worker会提前退出,漏抓链接;如果永不关闭,worker会永远阻塞在取任务上,程序无法结束。
muffet用的方案是在途任务计数(in-flight counter)。维护一个原子计数器,规则是:任何任务入队前计数加一,任何任务处理完(无论成功失败)计数减一。当计数减到零时,说明队列里没有待处理任务、也没有正在处理的任务,且不可能再有新任务产生(因为产生新任务的唯一途径是处理任务,而处理任务会让计数先加后减,不会凭空归零)。此时关闭任务channel,所有阻塞在取任务上的worker读到关闭信号后退出。
这个机制的正确性依赖于一个前提:计数加一必须发生在入队之前或同时,且不能被遗漏。如果某个入队路径忘了加计数,计数会提前归零导致漏抓;如果加计数和入队之间程序崩溃,计数会虚高导致永不退出。所以muffet把"加计数+入队"封装成一个原子操作,所有入队都走这个封装,杜绝遗漏。
我在自己项目里复现这个机制时踩过一个坑:最初把计数减一放在worker循环的末尾,结果某个错误分支里continue跳过了减一操作,计数永远不归零,程序挂死。后来改成用defer保证减一一定执行,问题才解决。这个教训说明:计数减一必须放在无论如何都会执行的位置,defer是最稳妥的选择。
4.3 去重与并发安全的平衡
去重表是另一个并发热点。每个worker解析出新链接后都要查一次"访问过没有",没访问过就写入并标记。这个"查+写"必须是原子的,否则两个worker可能同时判定某个URL未访问,导致重复抓取。
muffet用的是带读写锁的map,或者Go标准库的同步map。读写锁的选择有讲究:去重场景是"读多写少"还是"读写均衡"取决于链接的重复率。如果页面之间互相链接多,重复率高,读操作远多于写操作,读写锁能让多个worker并发读,只在写时互斥,吞吐更好。如果重复率低,每次读之后大概率要写,读写锁的优势就不明显了。
这里有个性能陷阱:锁的粒度。如果整个去重表一把大锁,高并发下所有worker都在这把锁上排队,并发度形同虚设。muffet的做法是把锁的范围控制在单次查写操作内,不跨操作持锁。更激进的做法是分片锁(把map按key哈希分成多片,每片一把锁),进一步降低锁竞争,但实现复杂度上升。对于链接检查这种量级,单锁通常够用,分片锁属于过度优化。
还有一个容易忽略的点:去重表的key规范化。同一个资源可能有多种URL写法(带不带末尾斜杠、带不带查询参数、大小写差异、锚点片段),如果不去规范化,去重会失效,同一个页面被抓多次。muffet在入队前会对URL做一定程度的规范化处理,这个处理逻辑虽然不属于并发控制,但直接影响去重效果和队列规模,值得单独留意。
4.4 超时、取消与资源回收
并发控制不只是"限制同时跑多少",还包括"跑不完的怎么收场"。muffet在这方面的处理分三层。
第一层是单请求超时,设在Client层。每个请求有总时长上限,超时即失败,worker拿到错误后记录并继续下一个任务。这保证了慢请求不会永久占住worker。
第二层是整体取消,通过context实现。主协程持有一个可取消的context,所有请求都带上它。当用户中断(比如Ctrl+C)或者达到某个终止条件时,取消context,所有在途请求立即返回错误,worker快速排空退出。这个机制让程序能被优雅中断,而不是等所有任务跑完。
第三层是资源回收,主要是响应体关闭和连接归还。每个请求处理完必须关闭响应体,否则连接泄漏。muffet用defer resp.Body.Close()保证这一点,同时确保body被读干净(哪怕只是io.Copy(io.Discard, resp.Body)),这样连接才能回到空闲池。
这三层配合起来,才能做到"并发可控、可中断、不泄漏"。缺任何一层都会出问题:没超时,慢请求拖垮吞吐;没取消,中断后程序卡死;没回收,跑久了文件描述符耗尽。
5. 实操复现:搭一个同构的并发抓取骨架
5.1 环境与依赖准备
复现这套机制不需要muffet的全部代码,只需要Go标准库。我建议用一个独立的模块来练手,避免和现有项目纠缠。初始化一个模块,Go版本选1.20以上(同步map和context的成熟度更好),不需要任何第三方依赖——HTTP客户端、并发原语、HTML解析(标准库的golang.org/x/net/html算半标准)都在可控范围内。
如果你要解析HTML提取链接,golang.org/x/net/html是常用选择,它比正则可靠得多。如果只是练并发骨架,可以先不解析,用固定URL列表模拟任务产生,把精力集中在池和计数上。我的建议是分两步:先跑通固定任务的并发骨架,确认收敛退出正确,再接入真实抓取和链接提取。
5.2 核心骨架代码与逐段说明
下面是我按muffet思路写的最小骨架,去掉了业务细节,只保留并发控制和客户端池两条主线。你可以直接拿去改。
package main import ( "context" "io" "net/http" "sync" "sync/atomic" "time" ) type Crawler struct { client *http.Client taskCh chan string visited sync.Map inflight int64 wg sync.WaitGroup ctx context.Context cancel context.CancelFunc } func NewCrawler(workers, queueSize int) *Crawler { transport := &http.Transport{ MaxIdleConns: workers * 2, MaxIdleConnsPerHost: workers, MaxConnsPerHost: 20, IdleConnTimeout: 60 * time.Second, } ctx, cancel := context.WithCancel(context.Background()) return &Crawler{ client: &http.Client{ Transport: transport, Timeout: 15 * time.Second, }, taskCh: make(chan string, queueSize), ctx: ctx, cancel: cancel, } } func (c *Crawler) enqueue(url string) bool { if _, loaded := c.visited.LoadOrStore(url, struct{}{}); loaded { return false } atomic.AddInt64(&c.inflight, 1) c.taskCh <- url return true } func (c *Crawler) worker() { defer c.wg.Done() for url := range c.taskCh { c.process(url) if atomic.AddInt64(&c.inflight, -1) == 0 { close(c.taskCh) } } } func (c *Crawler) process(url string) { req, err := http.NewRequestWithContext(c.ctx, "GET", url, nil) if err != nil { return } resp, err := c.client.Do(req) if err != nil { return } defer resp.Body.Close() io.Copy(io.Discard, resp.Body) // 此处解析HTML提取新链接,对每个链接调用 c.enqueue } func (c *Crawler) Run(seeds []string, workers int) { c.wg.Add(workers) for i := 0; i < workers; i++ { go c.worker() } for _, s := range seeds { c.enqueue(s) } c.wg.Wait() }逐段说几个关键点。enqueue里先做去重再计数再入队,顺序不能乱:去重在前保证重复URL不计数,计数在入队前保证计数和队列一致。worker里用range读channel,channel关闭时循环自然退出,这是Go的惯用法。计数减一后判断是否归零,归零则关闭channel,这个判断必须在减一之后立即做,不能延后。process里defer resp.Body.Close()和io.Copy(io.Discard, ...)配合,保证连接归还。
有个细节要特别注意:close(c.taskCh)只能执行一次,如果两个worker同时减到零(理论上不会,因为原子减一只有一个能减到零),或者减到零后又有任务入队,都会panic。原子减一保证了只有一个worker能观察到归零,所以关闭是安全的。但前提是归零之后不能再有入队,这由"归零意味着没有在途任务"这个不变量保证。
5.3 参数选择与压测验证
骨架跑起来后,参数怎么定要靠压测。我的做法是准备一个本地测试服务,故意设置不同的响应延迟(比如10ms、100ms、1s各一批),然后调整worker数和队列大小,观察总耗时和内存占用。
worker数的经验起点是CPU核数的2到4倍,因为抓取是IO密集型,worker大部分时间在等网络。但这不是铁律,真正的上限由目标端的承受能力和你的连接池配置决定。如果MaxConnsPerHost设得小,worker开再多也会在连接层排队,等于白开。
队列大小的起点可以设成worker数的几倍,保证生产端不会频繁阻塞。压测时重点看两个指标:一是总耗时随worker数的变化曲线,找到拐点(再增加worker耗时不再下降甚至上升);二是内存占用是否随任务量线性增长(如果超线性,说明队列或去重表有问题)。
我实测下来,在目标端响应稳定的情况下,worker数从10加到50,耗时下降明显;从50加到100,收益递减;超过100后因为连接竞争和调度开销,耗时反而略升。这个拐点因环境而异,但"存在拐点"这件事是普遍的,盲目堆并发不可取。
6. 常见问题与排查实录
6.1 程序不退出:计数不归零的几种成因
这是复现这套机制时最高频的问题。程序跑完所有该抓的链接却挂在那里不退,八成是计数没归零。成因有几种,排查思路不一样。
第一种是入队路径漏了计数。比如某个分支直接往channel写而没走enqueue封装,计数少加了,但任务实际存在,处理完后减一,计数变成负数或者提前归零后又入队。排查办法是全局搜索所有往taskCh写的地方,确认都走了封装。
第二种是减一被跳过。前面提过,错误分支里continue或return跳过了减一。解决办法是把减一放进defer,或者把处理逻辑包成一个函数,减一放在函数返回后。我现在的习惯是worker循环里先defer一个减一,再处理任务,这样无论怎么返回都会减。
第三种是channel关闭后仍有入队。这通常发生在归零判断和关闭之间有时间窗,另一个worker恰好入队。虽然原子操作让归零只有一个观察者,但如果归零后还有代码路径能入队(比如某个延迟触发的逻辑),就会panic或者卡死。根治办法是入队前检查context是否已取消,取消后拒绝入队。
6.2 连接数暴涨:客户端池没生效的排查
现象是跑起来后netstat看到大量TIME_WAIT或者ESTABLISHED连接,远超预期。按可能性从高到低排查。
先看响应体有没有读干净。只读header不读body,连接不会复用。用io.Copy(io.Discard, resp.Body)兜底,确保body被消费完。再看MaxIdleConnsPerHost是不是还是默认的2,如果是,单主机复用上限极低,连接建了就用一次。然后看是不是每个请求都新建了Client或Transport,那样连接池根本不共享。最后看目标是不是分散在大量不同域名上,域名分散时连接复用天然受限,这不是bug是场景特性。
还有一个隐蔽原因:重定向。如果目标大量返回3xx,每次重定向都是新请求,可能用新连接。限制重定向次数或者对重定向目标做去重能缓解。
6.3 去重失效:同一页面被抓多次
去重失效通常不是锁的问题,而是key不规范。同一个资源的不同URL写法被当成不同key。常见差异包括:末尾斜杠有无、查询参数顺序、大小写、锚点片段、默认端口显式写出与否。解决办法是在入队前做规范化:统一小写host、去掉锚点、排序查询参数、统一末尾斜杠策略。
另一个原因是去重和入队的顺序。如果先入队后去重,或者去重判断和入队之间有并发窗口,就可能重复。用LoadOrStore这种原子操作能保证"判断+写入"一步完成,避免窗口。
下面这张速查表汇总了常见现象和对应排查方向:
| 现象 | 最可能原因 | 排查动作 |
|---|---|---|
| 程序不退出 | 在途计数不归零 | 检查所有入队是否计数、减一是否被跳过 |
| 连接数暴涨 | 连接池未生效 | 查body是否读净、PerHost上限、Client是否复用 |
| 同一页面重复抓 | 去重key未规范化 | 检查URL规范化逻辑、去重与入队顺序 |
| 内存持续增长 | 队列无界或去重表泄漏 | 查队列缓冲大小、去重表是否有清理机制 |
| 吞吐上不去 | worker被慢请求占住 | 查超时设置、单主机连接闸门是否过小 |
| 中断后卡死 | context未贯穿 | 查请求是否带context、取消后worker能否退出 |
6.4 几个我踩过的坑
第一个坑是在worker里同步写结果导致锁竞争。早期我把结果直接往一个加锁的切片里append,worker一多,这把锁成了瓶颈,吞吐上不去。后来改成结果走单独的channel,由主协程统一收集,worker只负责发,锁竞争消失。
第二个坑是context取消后worker没及时退出。原因是worker阻塞在c.taskCh <- url上(队列满),取消context并不会让channel发送返回。解决办法是在入队时用select同时监听context的Done通道,取消时放弃入队。这个细节不处理,中断后程序会卡在发送上。
第三个坑是Transport参数设得过于激进。有次把MaxConnsPerHost设得很大想提速,结果目标端直接返回大量连接错误,整体成功率暴跌。后来明白单主机连接闸门的意义就是保护目标端,设大等于放弃保护。现在我的习惯是先设保守值,压测确认目标端能承受再逐步放宽。
注意:并发度和单主机连接上限是两个独立旋钮。worker数决定本地同时处理多少任务,MaxConnsPerHost决定对单个目标同时开多少连接。目标集中时,后者才是真正的瓶颈和保护闸门,调前者没用。
7. 这套机制还能怎么用
muffet这套"固定worker池+带缓冲队列+在途计数+共享Transport"的组合,本质上是一个通用的并发任务编排骨架,链接检查只是它的一个应用。我自己后来把它挪用到过几个场景:批量接口健康探测、静态资源可用性巡检、站点地图生成前的可达性验证。骨架不变,只换process里的业务逻辑和解析部分。
扩展方向有几个值得提。一是分级并发,对不同域名分配不同的并发配额,避免某个大站占满所有worker。实现上可以把单主机连接闸门从Transport层提到调度层,按域名做令牌桶。二是优先级队列,重要URL先抓,用多个channel或者堆实现。三是断点续抓,把已访问集合定期落盘,中断后恢复。这几个扩展都不改动核心的计数收敛机制,属于在骨架上加挂件。
不过要提醒一句:扩展越多,并发正确性的验证成本越高。每加一个共享状态,就要重新审视它在并发下的读写是否安全、是否影响计数收敛。我见过有人加了优先级队列后忘了在新路径上维护在途计数,结果程序偶发不退出,排查了大半天。核心机制越简单越可靠,扩展要克制。
最后分享一个我个人的习惯:每次改完并发相关代码,先用go test -race跑一遍,再用小规模数据跑通收敛,最后才上真实量级。竞态问题在低并发下往往不显现,等上线了才爆发,那时候排查成本高得多。这套骨架我前后改过七八版,每一版都是靠race检测和小规模验证兜住的,没出过线上事故。