互联网在很多方面都有记忆。从旧网站的存档版本到搜索引擎缓存,通常都有办法挖掘过去并找到信息 - 即使是那些已不再活跃的网站。你可能听说过 Internet Archive(互联网档案馆),这是一个用于探索网络历史的流行工具,最近由于黑客攻击和其他挑战而出现故障。但如果没有 Internet Archive 呢?互联网是否仍然"记得"这些网站?
在本文中,我们将深入研究前1000万个域名,并揭示一个令人惊讶的发现:其中超过四分之一 - 27.6% - 实际上已经失效。下面,我将带您了解分析这些域名所涉及的步骤和基础设施,以及此研究的系统要求、代码片段和统计结果。
挑战:分析1000万个域名
感谢像 DomCop[1] 这样的资源,我们可以访问前1000万个域名的列表,这是我们的起点。处理如此大量的URL需要大量计算资源、并行处理和优化的HTTP请求处理。
为了快速获得准确的结果,我们需要一个设计良好的爬虫,能够在几分钟内处理数百万个请求。以下是我们的方法和系统设计的细节。
大规模域名爬取的系统设计
为了在合理的时间内分析1000万个域名,我们设定了10分钟内完成任务的目标。这需要一个系统能够每秒处理约16,667个请求。通过将负载分配给100个工作进程,每个进程需要处理每秒约167个请求。
1. 使用Redis进行高效队列管理
Redis凭借其轻松处理每秒超过10,000个请求的能力,在管理作业队列方面发挥了关键作用。然而,即使使用Redis,跟踪数百万个域名的状态码也可能使系统超载。为防止这种情况,我们利用了Redis管道,允许同时处理多个作业并减少Redis集群的负载。
// SPopN retrieves multiple items from a Redis set efficiently.
func SPopN(key string, n int) []string {
pipe := Redis.Pipeline()
for i := 0; i < n; i++ {
pipe.SPop(ctx, key)
}
cmders, err := pipe.Exec(ctx)
if err != nil { return nil }
results := make([]string, 0, n)
for _, cmder := range cmders {
if spopCmd, ok := cmder.(*redis.StringCmd); ok {
val, err := spopCmd.Result()
if err == nil && val != "" { results = append(results, val) }
}
}
return results
}
使用这种方法,我们可以以最小的性能影响从Redis中提取大批量数据,一次最多获取100个作业。
func (w *Worker) fetchJobs() {
for {
if len(w.Jobs) > 100 {
time.Sleep(time.Second)
continue
}
jobs := SPopN(w.Name+jobQueue, 100)
for _, job := range jobs {
w.AddJob(job)
}
}
}
2. 优化DNS请求
为了高效解析域名,我们使用了多个公共DNS服务器(如Google DNS、Cloudflare)并处理每秒高达16,667个请求。公共DNS服务器通常会限制大量请求,因此我们实现了DNS超时和限制错误的错误处理和重试机制。
var dnsServers = []string{
"8.8.8.8", "8.8.4.4", "1.1.1.1", "1.0.0.1", "208.67.222.222", "208.67.220.220",
}
通过在多个服务器之间平衡负载,我们可以避免单个DNS提供商施加的速率限制。
3. HTTP请求处理
为了检查域名状态,我们尝试对每个IP地址进行直接的HTTP/HTTPS请求。如果HTTP请求遇到协议错误,以下代码会使用HTTPS重试。
func (w *Worker) worker(job string) {
var ips []net.IPAddr
var err error
var customDNSServer string
for retry := 0; retry < 5; retry++ {
customDNSServer = dnsServers[rand.Intn(len(dnsServers))]
resolver := &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{}
return d.DialContext(ctx, "udp", customDNSServer+":53")
},
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
ips, err = resolver.LookupIPAddr(ctx, job)
if err == nil && len(ips) > 0 {
break
}
log.Printf("Retry %d: Failed to resolve %s on DNS server: %s, error: %v", retry+1, job, customDNSServer, err)
}
if err != nil || len(ips) == 0 {
log.Printf("Failed to resolve %s on DNS server: %s after retries, error: %v", job, customDNSServer, err)
w.updateStats(1000)
return
}
customDialer := &net.Dialer{
Timeout: 10 * time.Second,
}
customTransport := &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
port := "80"
if strings.HasPrefix(addr, "https://") {
port = "443"
}
return customDialer.DialContext(ctx, network, ips[0].String()+":"+port)
},
}
client := &http.Client{
Timeout: 10 * time.Second,
Transport: customTransport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
req, err := http.NewRequestWithContext(context.Background(), "GET", "http://"+job, nil)
if err != nil {
log.Printf("Failed to create request: %v", err)
w.updateStats(0)
return
}
req.Header.Set("User-Agent", userAgent)
resp, err := client.Do(req)
if err != nil {
if urlErr, ok := err.(*url.Error); ok && strings.Contains(urlErr.Err.Error(), "http: server gave HTTP response to HTTPS client") {
log.Printf("Request failed due to HTTP response to HTTPS client: %v", err)
// Retry with HTTPS
req.URL.Scheme = "https"
customTransport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return customDialer.DialContext(ctx, network, ips[0].String()+":443")
}
resp, err = client.Do(req)
if err != nil {
log.Printf("HTTPS request failed: %v", err)
w.updateStats(0)
return
}
} else {
log.Printf("Request failed: %v", err)
w.updateStats(0)
return
}
}
defer resp.Body.Close()
log.Printf("Received response from %s: %s", job, resp.Status)
w.updateStats(resp.StatusCode)
}
部署策略
我们的爬虫部署包含400个工作副本,每个处理200个并发请求。这种配置需要20个实例、160个vCPU和450GB内存。CPU使用率仅约30%,该设置效率高且经济实惠,如下所示。
apiVersion: apps/v1
kind: Deployment
metadata:
name: worker
spec:
replicas: 400
...
containers:
name: worker
image: ghcr.io/tonywangcn/ten-million-domains:20241028150232
resources:
limits:
memory: "2Gi"
cpu: "1000m"
requests:
memory: "300Mi"
cpu: "300m"
这种设置的大致成本约为每1000万个请求0.0116美元,整个分析的总成本不到1美元。
数据分析:实际有多少网站可访问?
爬虫的状态码数据让我们可以将域名分类为"可访问"或"不可访问"。以下是使用的标准:
可访问:状态码不是1000(DNS未找到)、0(超时)、404(未找到)或5xx(服务器错误)。
不可访问:具有上述状态码的域名,表明它们要么无法访问,要么不再服务。
accessible_condition = (
(df["status_code"] != 1000) &
(df["status_code"] != 0) &
(df["status_code"] != 404) &
~df["status_code"].between(500, 599)
)
inaccessible_condition = ~accessible_condition
汇总结果后,我们发现27.6%的域名要么不活跃,要么无法访问。这意味着前1000万个域名中有超过275万个域名已经失效。
状态码 | 数量 | 比率 |
---|---|---|
301 | 4,989,491 | 50% |
1000 | 1,883,063 | 19% |
200 | 1,087,516 | 11% |
302 | 659,791 | 7% |
0 | 522,221 | 5% |
结论
对于像1000万个域名这样大的数据集,必然存在影响准确性的格式不一致问题。例如,带有www
前缀的域名理想情况下应该与不带前缀的域名相同对待,但URL构造方式的变化可能导致不匹配。此外,某些域名服务于特定功能,如内容分发网络(CDN)或API端点,这些可能没有传统的主页或可能按设计返回404
状态。这在解释可访问性时增加了一层复杂性。
实现完全的数据清洁和统一格式需要大量额外的处理时间。然而,由于数据量庞大,微小的不一致可能仅占整个数据集的1%左右,这意味着它们不会显著影响最终结果:前1000万个域名中超过四分之一不再可访问。这表明随着时间推移,你在互联网上的历史和贡献可能会逐渐消失。
虽然爬虫本身在大约10分钟内完成任务,但达到这一点所需的研究、开发和测试花费了数天甚至数周的努力。
该项目的源代码可在 GitHub[3] 上获取。请负责任地使用它 - 这是为了道德和建设性使用,而不是为了压垮或滥用服务器。
感谢阅读,希望这项研究能让你更深入地理解互联网的无常性。
参考链接
DomCop: https://www.domcop.com/files/top/top10milliondomains.csv.zip
Patreon: https://www.patreon.com/tonywang_dev
GitHub: https://github.com/tonywangcn/ten-million-domains