简介:这份C/C++网络爬虫源码包,面向网络编程与搜索引擎方向的初学者,演示了从网页请求、内容解析到页面权重计算的完整链路。项目基于套接字编程实现客户端与服务器交互,支持常用请求方式,并用异步I/O提升并发处理能力;底层模块覆盖网页头解析、链接抽取、哈希表与布隆过滤器去重、待抓取队列调度以及权重迭代计算,基本还原了爬虫系统的核心构成。压缩包仅31KB,共43个文件,以C源文件、头文件、目标文件为主,另有构建脚本与测试文本;其中源文件对应各功能模块的实现,头文件声明相关接口,可一键编译,便于直接阅读和复现实验。已有176人浏览学习,适合正在学习C/C++网络编程、爬虫原理及权重算法的读者参考。解压后可从程序入口入手,结合各功能模块源码,理解链接提取、队列调度、权重迭代排序的具体实现,并以此为基础扩展自己的爬虫项目。
1. network-spider 是什么:一个 C/C++ 写成的网络编程抓取项目,值不值得接手
network-spider 这个由 C/C++ 写成的网络编程抓取项目,拿到手第一件事不是读代码,而是先想清楚:你为什么要用 C/C++ 写网络爬虫?我的答案是,这类项目解决的从来不是“能不能抓到”,而是“长时间抓取时,连接、内存、超时、重试是否都在你掌控中”。它适合两类人,一类是想从 socket 层搞懂 HTTP 请求怎么发出去的网络编程初学者,另一类是需要在受限环境里部署单文件抓取器、不在乎生态只在乎可控性的工程团队。
这篇笔记按一线实战的顺序走——先立住抓取器背后的网络编程原理,再解包编译把最小用例跑通,然后补上 URL 规范化、去重、重试这些工程模块,最后把最容易翻车的地方整理成排查手册。整个过程你不用拥有原作者手稿,照着常见工程做下来,就能把这个项目当成自己的抓取骨架用。如果你只是想要一个现成的 Python 爬虫,这一篇可能帮不上忙;但如果你想弄明白 C/C++ 抓取器的状态机、并发模型和资源边界,接下来几章可以照着抄。
2. 网络爬虫的实现逻辑:从 socket 状态机到并发模型,先把地基铺好
在把代码跑起来之前,值得先讲清楚抓取器背后的网络编程模型。C/C++ 写网络爬虫和 Python 最大的不同是:没有框架替你兜底,每一个连接的生命周期、每一次读写的结束条件、每一个错误码从哪一层来,都需要你心里有数。看不透这一层,后面所有排错都像在猜。
2.1 为什么用 C/C++ 而不是 Python:效率、控制力与代价
Python 是大多数人的默认选择,requests 两行就能下载一个页面。但 network-spider 这类 C/C++ 方案能一直存在,是因为有一批场景 Python 并不舒服:连续抓取几百万页时,Python 解释器和 GIL 带来的额外开销、内存里几十万个小对象堆积、部署时需要带上解释器和一堆依赖,都会变成运维负担。C/C++ 的抓取器编译成一个二进制,跑在最小化 Linux 里,包体只有几 MB,内存曲线平坦,句柄管理显式可控,这是它在数据采集方向一直有市场的原因。
代价也很明显。没有现成的爬虫框架,字符串处理稍不留神就出越界,HTTP 细节要自己面对。“网络编程”这四个字在这个项目里的分量,就是你能不能把 DNS 解析、TCP 握手、HTTP 请求与响应解析这一条链路讲清楚。C/C++ 的网络编程做到最后,本质是在管理一条条 TCP 连接的生命周期:连接何时建、何时复用、何时断开、错误码从哪里来。libcurl 帮你把这些封装了,但它不替你决定策略;用原生 socket 写更是每一步都要自己处理。所以我一般会建议:先在一台机器上把 libcurl 版本跑通,再去看 socket 版本,排错时你会感谢这两个选择之间的对照。
2.2 抓取主循环的状态机:从 URL 输入到 HTML 落盘
任何一个单线程抓取器,核心都是这样一个状态循环:取一个 URL → 解析主机名 → 建立 TCP 连接(HTTPS 还要完成 TLS 握手)→ 发送 HTTP 请求 → 读取响应头与正文 → 按需求提取链接或保存正文 → 回到队首。用 C/C++ 写,这个循环的每个状态都会对应一组错误返回,这是和 Python 最大的体验差异:Python 抛异常,C++ 返回码或者 errno 不足以说明失败到底发生在哪一层。
一个常见的做法是把这个循环建模成显式的状态机,每个步骤设一个超时上限。例如 DNS 解析用 getaddrinfo 阻塞不超过 5 秒;TCP 连接用非阻塞 socket 加 poll 轮询,IO 超时设 10 秒;HTTP 读包体时记录已读字节数和预期 Content-Length,两者不一致就判定截断并重试。我不建议在一个函数里用几十个 if 把所有这些状态堆在一起,那会变成很难维护的黑匣子。把状态转移表拆成小函数,每个函数只干一件事,出问题时日志里能直接打出“当前状态 + 上一步错误码 + 失败原因”,排查成本会低得多。
2.3 并发模型怎么选:多线程、线程池还是 epoll
抓取是 IO 密集型的活儿,一个连接在等待响应时 CPU 完全闲着。C/C++ 里常见的并发方案有三种:每个线程一个连接、线程池复用连接、epoll 事件循环。每线程一连接写起来最直白,但线程数量一旦超过目标站点能接受的连接上限,就会先把自己的文件描述符和内存吃光。线程池是工程上最不容易翻车的中间方案,也是我在这个项目里推荐的主力:按 CPU 核数乘 2 再加少量余量设线程数,每个线程用一个独立的 CURL 句柄并且复用同一个连接缓存,避免频繁握手。
epoll 方案适合单机抓取域名多、单连接等待时间长的场景,它能用很少的线程管理几万个连接,但 HTTP 解析、TLS 状态、内存缓冲都要自己做,工程量明显上去。如果你的目标是先把 network-spider 跑通、把抓取产出用起来,我建议从线程池开始,不要一上来就上 epoll。线程数、队列长度、单线程重试上限这三个参数,是抓取器最容易出故障的可调点,下面给出我习惯的默认值。
2.4 资源控制参数:超时、连接复用、限速和重试的默认值
C/C++ 抓取器参数散落在代码里,是后期排查成本高的主要原因。我习惯把下面这些参数写进配置文件,而不是散落在代码里。这张表是我的默认起点,按目标站点的响应情况再调整。
| 参数 | 默认值 | 说明 |
|---|---|---|
| 连接超时 | 10 秒 | 超过即放弃该次 TCP 连接 |
| 总超时 | 30 秒 | 从请求到响应全部读完的时间上限 |
| 低速率判定 | 10 字节/秒持续 5 秒 | 挂死的连接判为重试 |
| 重试次数 | 3 次 | 指数退避,退避基数为 2 秒 |
| 下载限速 | 不做硬限速 | 通过并发数和请求间隔控制 |
| DNS 缓存 | 300 秒 | 避免每个请求都查 DNS |
还有一个值得强调的习惯:libcurl 默认会复用连接,只要你对同一主机先后有多个请求,使用同一 CURL 句柄或共享缓存,TCP 握手次数会少一大截。显式调用 curl_easy_reset 会清掉连接缓存,没必要时别调用。这些参数在排错时比代码逻辑更值得先检查,因为超时和重试策略几乎决定了抓取的成败与效率。
3. 解包、编译与最小抓取用例:把 network-spider 在本地跑通的 4 个步骤
拿到 network-spider.tar.gz,第一步永远是解包,不要直接在压缩包里翻文件。这个包解开后,目录结构多半是 src、include、CMakeLists.txt 或者 Makefile,README 写得全的话会告诉你依赖什么库、用什么构建系统;写得偷懒的就只有一行编译命令。先把结构看清楚,再决定下一步怎么下手。
3.1 解包与依赖检查:先看 README 再做任何事
# 只列出压缩包内容,不解压,先确认目录层级 tar -tzf network-spider.tar.gz | head -40 # 正式解压,出现同名目录时不会覆盖已有文件 tar -xzf network-spider.tar.gz cd network-spider* ls -latar -t 是列名单,-x 是解压,-z 表示 gzip 压缩。先列后解,能避免把文件直接散落在当前目录造成混乱。解开后先读 README 和任何 *.md 文档,别急着编译;包里通常会有 CMakeLists.txt 或 Makefile,都缺的话就按源码里的 #include 推断依赖。
C/C++ 网络项目最常见的依赖是 libcurl、openssl、pthread,用 pkg-config 确认开发头文件是否就位最省事:
# pkg-config 返回 0 表示存在且版本满足 pkg-config --exists libcurl && echo "libcurl: OK" || echo "libcurl: MISSING" pkg-config --exists openssl && echo "openssl: OK" || echo "openssl: MISSING"缺依赖时在 Debian/Ubuntu 上用 apt 安装 libcurl4-openssl-dev 和 libssl-dev。这里有两个环境坑提前说:在 VSCode 里配 C/C++ 编译环境时,如果遇到“c/c++ 扩展二进制文件不兼容或不匹配”一类报错,多半是本机工具链版本和扩展自带的调试组件不一致,把 VSCode 和 C/C++ 扩展升到同一版本线,或者在设置里显式指定 gcc 路径就能解决;如果你是在 termux 这类移动端 Linux 环境里编译,ARM 工具链的路径和桌面 x86 完全不同,直接复用宿主编译路径会出现 exec format error,先装 termux 自己的 gcc 和 cmake 再继续。
3.2 用 CMake 构建:三行命令并解释每行
假设包内是 CMake 工程,构建命令如下:
# 创建独立构建目录,避免污染源码目录 mkdir -p build && cd build # Release 优化 + 显式指定 C++17 标准 cmake .. -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_STANDARD=17 # 并行编译,用满当前机器 CPU 核数 cmake --build . -j$(nproc)mkdir 和 cd 把构建产物和源码隔离;cmake 那行的 Release 决定优化等级,C++17 是当前 C/C++ 项目比较通用的标准线,兼容老的 C 接口也没问题;最后 -j 参数让编译器并行编译。构建失败时,看第一行报错而不是最后一行,绝大多数问题出在头文件路径或链接库顺序上,库顺序错了会报 undefined reference。构建成功后跑一下帮助参数确认启动路径:
./network-spider --help这一步的常见翻车点是动态库找不到,表现为“error while loading shared libraries”。用 ldd 查看二进制依赖哪些动态库,缺哪个补哪个:
# 过滤出标记为 not found 的动态库 ldd ./network-spider | grep "not found"注意:如果 CMakeLists.txt 或 Makefile 把可执行文件输出在 src 目录而不是 build 目录,后面 VSCode 调试的 program 路径要同步修改,否则断点永远不会命中。
另外顺手验证一下二进制文件属性,确认它确实是 ELF 格式而不是某种脚本,常有人在解压不完整时遇到这类伪文件。
3.3 一个能跑的最小抓取用例:从 URL 到磁盘文件
工具编译通过后,先不要接入业务,跑一个最小用例。目标:输入一个 URL,输出一个 HTML 文件,返回值 0 表示成功。下面这段代码用 libcurl 官方推荐的 easy 接口,适合作为这个项目的功能验证器。
#include <curl/curl.h> #include <stdio.h> /* 回调:把收到的数据块追加写入文件 */ static size_t write_cb(char *ptr, size_t size, size_t nmemb, void *userdata) { size_t total = size * nmemb; if (fwrite(ptr, 1, total, (FILE *)userdata) != total) { return 0; /* 写入失败,返回非期望值让 libcurl 终止传输 */ } return total; } int main(int argc, char **argv) { if (argc < 2) return 2; FILE *fp = fopen("page.html", "wb"); if (!fp) return 3; CURL *curl = curl_easy_init(); curl_easy_setopt(curl, CURLOPT_URL, argv[1]); /* 目标 URL */ curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb); /* 数据写入回调 */ curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp); /* 回调的 userdata 参数 */ curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); /* 自动跟随 301/302 跳转 */ curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L); /* 整个请求硬上限 30 秒 */ curl_easy_setopt(curl, CURLOPT_USERAGENT, "network-spider/1.0"); /* 声明客户端身份 */ CURLcode res = curl_easy_perform(curl); if (res != CURLE_OK) { fprintf(stderr, "curl error: %s\n", curl_easy_strerror(res)); } curl_easy_cleanup(curl); fclose(fp); return res == CURLE_OK ? 0 : 1; }编译命令:
gcc -o fetch_min fetch_min.c -lcurl ./fetch_min https://example.com这段代码做了几件最关键的事:回调函数负责把数据块落盘,避免把完整响应堆进内存;FOLLOWLOCATION 让跳转自动处理;TIMEOUT 给整个请求一个硬上限,不设这个参数,遇到挂死的连接会一直等下去。编译参数里的 -lcurl 必须放在源码文件后面,如果你看到 undefined reference to curl_easy_init,说明库顺序写反了。
3.4 用 VSCode 配置与调试:launch.json 和 core dump
功能验证通过后,下一步是把断点放到抓取循环里看数据流。用 VSCode 打开源码目录,装好 C/C++ 扩展,最关键的是 launch.json 里的 program 和 cwd 要对上可执行文件实际位置:
{ "version": "0.2.0", "configurations": [ { "name": "Debug fetch_min", "type": "cppdbg", "request": "launch", "program": "${workspaceFolder}/build/network-spider", "args": ["https://example.com"], "stopAtEntry": false, "cwd": "${workspaceFolder}", "environment": [], "externalConsole": false, "MIMode": "gdb" } ] }program 指向构建产物;args 传入一个测试 URL;cwd 决定相对路径输出位置;externalConsole 建议关掉,否则调试时不容易同时看日志。断点命中后,重点看几个变量:已读字节数、HTTP 状态码、当前 URL。抓取进程崩溃时,系统会把 core dump 写到指定路径,先开启核心转储再运行,然后进 gdb 看崩溃栈:
ulimit -c unlimited ./network-spider https://example.com gdb ./network-spider core -batch -ex bt如果 gdb 里全是 ?? 符号,说明编译时没加 -g 或者二进制被 strip 过。这个技巧在后面的字符串越界排查里几乎每天都要用。
4. 工程化补全:URL 规范化、去重、重试与字符集识别的落地代码
抓取器能跑只是第一步。从最小用例到能长时间运转的抓取任务,中间还隔着四个工程模块:URL 规范化、去重、重试策略、字符集识别。这四个模块不做,抓取量一上来就会出现大量重复请求和脏数据。下面按模块给可抄作业的代码和参数。
4.1 URL 规范化:用一段 C++ 代码处理绝大多数相对链接
抓取器的起点是种子 URL,页面解析后得到的是各式各样的链接字符串,不先做规范化,去重和入队都是浪费。规范化的主要动作:去掉首尾空白、去掉 fragment(# 后面的锚点)、把相对路径按基准 URL 解析成绝对地址。下面是这个项目里我常用的一个 minimal 版本。
#include <string> #include <cctype> #include <algorithm> std::string normalize_url(const std::string &base, const std::string &raw) { std::string url = raw; auto trim = [](std::string &s) { s.erase(s.begin(), std::find_if(s.begin(), s.end(), [](unsigned char c) { return !std::isspace(c); })); s.erase(std::find_if(s.rbegin(), s.rend(), [](unsigned char c) { return !std::isspace(c); }).base(), s.end()); }; trim(url); // 去掉锚点,fragment 不参与抓取 auto sharp = url.find('#'); if (sharp != std::string::npos) url.erase(sharp); // 绝对地址直接返回 if (url.rfind("http://", 0) == 0 || url.rfind("https://", 0) == 0) return url; // 以 / 开头的站内绝对路径:主机部分从 base 取 if (!url.empty() && url[0] == '/') { auto scheme_pos = base.find("://"); auto host_end = base.find('/', scheme_pos + 3); return base.substr(0, host_end) + url; } // 相对路径:拼到 base 最后一次出现目录分隔符之后 auto slash = base.find_last_of('/'); return base.substr(0, slash + 1) + url; }trim 那段去掉了肉眼看不见的空白,这是抓取里最常见的脏数据来源;fragment 去掉可以让同一个页面不同锚点的链接收敛成一个;主机部分从 base 推导,避免自己造主机名解析逻辑。url.rfind 前缀判断比 substr 比较更省临时对象,批量去重前这项开销会被放大。这套逻辑没有处理协议相对地址和目录回退,如果源站大量使用这类写法,后续需要扩展,但作为抓取器起步够用了。
4.2 去重:布隆过滤器与精确去重的组合
URL 去重常见的思路有哈希表、布隆过滤器、数据库唯一索引。哈希表在 URL 数量过亿时内存撑不住;全用数据库又太慢;布隆过滤器是 C/C++ 工程里性价比最高的方案:固定大小的位图,两个哈希函数,内存占用只和预期 URL 数量有关,代价是有一点点误判率。误判意味着某个 URL 可能被跳过,对抓取器来说通常可接受,因为少抓一个不重要的页面不致命。
一个极简实现如下,能直接嵌入抓取循环:
#include <stdint.h> #include <stdlib.h> #define BLOOM_BITS (1 << 20) static uint8_t bloom[BLOOM_BITS / 8]; /* 两个不同的字符串哈希,乘子不同,减少碰撞重叠 */ static uint32_t hash1(const char *s) { uint32_t h = 5381; while (*s) h = h * 33 + (unsigned char)*s++; return h; } static uint32_t hash2(const char *s) { uint32_t h = 0; while (*s) h = h * 31 + (unsigned char)*s++; return h; } void bloom_add(const char *s) { uint32_t h1 = hash1(s) & (BLOOM_BITS - 1); uint32_t h2 = hash2(s) & (BLOOM_BITS - 1); bloom[h1 / 8] |= 1u << (h1 % 8); bloom[h2 / 8] |= 1u << (h2 % 8); } int bloom_contains(const char *s) { uint32_t h1 = hash1(s) & (BLOOM_BITS - 1); uint32_t h2 = hash2(s) & (BLOOM_BITS - 1); return (bloom[h1 / 8] & (1u << (h1 % 8))) && (bloom[h2 / 8] & (1u << (h2 % 8))); }1MB 的位图可以容纳约千万级别的 URL 概率误判。两个哈希函数使用不同乘子,让同一字符串映射到两个独立位置,只有两个位都被置 1 才判重复。要精确兜底的话,可以保留一个 LRU 队列记录最近访问的 URL,布隆过滤掉大部分重复,LRU 只针对少量碰撞做复核。不要把布隆过滤器当精确数据结构用,它的名称本身就意味着你接受一定误报率。
4.3 重试:状态码分支与指数退避
抓取失败的原因可以分成三类:连接问题、超时问题、HTTP 状态码问题。重试策略要区分对待,不能一个逻辑套到底。连接问题和超时问题值得重试;状态码 4xx 是客户端请求本身有问题,重试大概率无效;5xx 和 429 限流可以重试,但要按 Retry-After 头等待。
我习惯把状态码分支写成一个独立函数:
enum retry_policy { RETRY_NONE, RETRY_WITH_BACKOFF, RETRY_IMMEDIATE, }; enum retry_policy decide_retry(CURLcode res, long http_code) { if (res != CURLE_OK) { /* 连接失败或超时:指数退避重试 */ return RETRY_WITH_BACKOFF; } if (http_code == 429 || http_code >= 500) { /* 限流与服务器错误:退避后重试 */ return RETRY_WITH_BACKOFF; } return RETRY_NONE; }CURLcode 与 HTTP 状态码是两个维度,前者是协议层通信是否完成,后者是应用层业务状态,混在一起判重试是最常见的错误。退避算法用指数加随机扰动,比如 base=2 秒,第 n 次等待 base 的 n 次方再加一个 0 到 1 秒的随机值,避免多线程同时重试把目标站点又打出一波故障。重试次数上限我设 3 次,超过即记日志换下一个 URL,不恋战。
4.4 字符集识别:从 Content-Type 头到页面 meta 的兜底
抓下来的页面是字节流,要正确提取文本必须先知道字符集。先从 HTTP 响应头的 Content-Type 里取 charset 参数,这一步能解决大部分问题;取不到再解析 HTML 前 1024 字节里的 meta 标签;两处都没有,就只能按统计概率猜,比如按 UTF-8 无 BOM 的规则试解码,或者用编码检测库。字符集识别放得太靠后,会在中文页面里出现一堆乱码,而且乱码会一路污染到后续的文本分析。
字符集识别在 C/C++ 里有个常见坑:把 char* 直接当多字节字符串处理,在 UTF-8 页面上还好,在 GBK 页面上随便一个字节的高位都可能触发越界判断。正确做法是先用 iconv 或 libiconv 转换到 UTF-8,再进解析流程。转换失败时保留原始字节并记录,不要让一个页面的编码问题拖垮整个工作线程。编码相关的日志要在测试期就打出每个页面的识别来源,否则生产环境排错时你根本不知道这页到底是从响应头识别的还是从 meta 兜底识别的。
5. C/C++ 抓取器的 5 个高频常见问题排查:现象、原因与解决
下面这 5 个问题是我在不同抓取任务里反复遇到的,每一条都按“现象 → 原因 → 解决”来写。前三条属于写代码时最容易踩的坑,后两条属于跑长任务才会暴露的隐性故障。
5.1 页面乱码或正文被截断:Content-Length 与 chunked 编码的账要算清
现象:抓下来的 HTML 文件比浏览器里看到的少一截,或者中文标题全乱。
原因:最常见的不是编码写错,而是传输逻辑只按“读到 EOF 就结束”处理。HTTP/1.1 有两种正文结束方式:Content-Length 声明长度,或者 Transfer-Encoding: chunked 按块传输。只认 EOF 的话,遇到 chunked 流会提前收工,或者被连接复用后的尾部数据污染;遇到 Content-Length 不对时又容易把下一个连接的响应并进来。
解决:把“响应读完”的判断统一改成两种:有 Content-Length 就按字节数收敛;是 chunked 就解析每个块的长度并按块消费完。libcurl 里这两个逻辑都已经封装好了,但如果你用的是原生 socket 实现,这一步必须自己处理。我踩过这个坑的代价是抓了几万页才发现一半页面是残的,后来在结构体里加了 read_bytes 和 expected_bytes 两个字段并写断言校验,问题才彻底暴露。
5.2 链接粘连与字符串越界:C 字符串处理在 HTML 解析里的血泪教训
现象:解析出的链接首尾多出不可见字符,甚至整个进程直接段错误。
原因:HTML 属性值不是可靠的 C 字符串边界,标签和引号之间经常有换行、转义空格、制表符。用 strstr 和 strcpy 去寻找子串并复制,遇到这种数据就会越界或把周围内容一起拷走。
解决:统一用 std::string 或自己维护“长度 + 指针”的结构,不要以 \0 为数据边界。凡是外部输入,一律按长度拷贝。配合编译选项跑一轮 AddressSanitizer,越界基本藏不住:
# ASan + UBSan 编译选项,越界和未定义行为会直接报调用栈 cmake .. -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -g -O1" cmake --build . -j$(nproc) ./network-spider https://example.comASan 会直接打印出越界的读写位置和调用栈,比 gdb 慢慢找快得多。段错误如果不是在调试器里抓的,就靠第 3 章说的 core dump 配 gdb 看栈。
5.3 错误码满天飞:CURLcode、errno 与 HTTP 状态码是三个世界
现象:代码里到处判断返回值,但有些 -1 看起来明明是“正常”的响应。
原因:libcurl 的 curl_easy_perform 返回的是 CURLcode,表示协议层通信是否顺利完成;HTTP 状态码要单独通过 CURLINFO_RESPONSE_CODE 查;errno 则是系统调用失败后的全局错误码。把三者混在一个变量里判断,结果就是把正常的 200 当作失败,或者把连接失败当作“页面不存在”。
解决:明确规定每个函数只负责一类错误。libcurl 调用后立刻用 curl_easy_strerror 打印描述;查状态码用 curl_easy_getinfo;系统调用失败再查 errno。调试时临时打开 CURLOPT_VERBOSE 看完整交互,配合 tcpdump 或 wireshark 这类抓包工具对比,能在一分钟内定位到底是 DNS、TLS 还是 HTTP 层出错。这个习惯值得长期保留。
5.4 内存一路上涨:句柄没释放与正则缓存不回收
现象:抓取器跑几个小时,RSS 从 200MB 涨到 2GB,最后被 OOM 杀掉。
原因:有两类典型。一是每个线程每次 HTTP 请求都创建 CURL 句柄,用后没有 curl_easy_cleanup,连接缓存随之泄漏;二是用 PCRE 这类正则引擎做链接提取时,反复编译的 pattern 存在线程私有缓存里,长时间运行越来越膨胀。这两种泄漏用肉眼都看不出来。
解决:线程内复用一个 CURL 句柄,只在需要完全重置连接缓存时才 curl_easy_reset;正则 pattern 初始化一次并复用,不要每次匹配都重新编译。用 valgrind --leak-check=full 跑一个小体量任务,能直接看到泄漏点在哪个函数;但 valgrind 会大幅拖慢抓取速度,所以只在小样本上验证,验证通过后再跑生产任务。
5.5 抓一阵就卡死:DNS 缓存、TCP 半开与连接池的连环问题
现象:任务开始阶段一切正常,运行一段时间后突然停止产出,线程既不退出也不报错。
原因:DNS 缓存过期后重新解析失败、TCP 半开连接(对端已经消失但本端不知道)没有被超时机制清理、连接池里的连接被远端静默断开,这三个问题常常同时出现,让进程挂在一个永远等不到响应的读操作上。只设一个总超时不够,因为阻塞的系统调用需要分别设置超时参数。
解决:三步一起做。把 CURLOPT_DNS_CACHE_TIMEOUT 调到 300 秒,避免 DNS 反复查;打开 CURLOPT_TCP_KEEPALIVE 和 CURLOPT_TCP_KEEPIDLE,给空闲连接做存活探活;读超时和连接超时分开设,别用同一个值。如果用的是原生 socket 实现,一定要给每个阻塞的 recv/poll 设置独立的超时上限。抓取器卡死的排查命令也很直接:gdb attach 上去看所有线程停在哪个系统调用上,卡住的位置一眼就能看出来。
6. 抓取完整性验证与性能调优:两个能直接抄的进阶技巧
6.1 用任务清单和日志重建抓取状态
抓取器跑完后,怎么知道它抓得完整不完整?我建议从一开始就把抓取任务设计成可对账的:每个 URL 生成时写一条任务记录,抓到正文后回填状态码、字节数、用时,落盘文件名和 URL 一一对应。最终对账动作就是两个命令:
# 统计 0 字节文件,这类文件多半是抓取中断留下的 find output -name '*.html' -size 0 | wc -l # 从日志里捞出返回 5xx 的 URL,重新入队前先人工确认 grep -E 'HTTP/[0-9.]+ 5[0-9][0-9]' logs/fetch.log | awk '{print $2}' | sort -u第一条统计 0 字节文件,第二条找出返回 5xx 的 URL。把这些 URL 重新喂回任务队首之前,先人工挑几个看看是不是站点本来就挂了,还是我们的请求方式有问题。对账之后再决定是否重抓,而不是闭着眼睛全量重跑——全量重跑在数据量变大以后会浪费大量时间和带宽。
6.2 限速与频率控制的默认参数
性能调优不是把并发调得越高越好,而是找到目标站点不出现 429 的并发上限。我的习惯是放缓启动:先以单线程跑 100 个请求,观察平均响应时间和错误率;再按每 10 秒增加一个线程的方式逐步加压,直到 429 或 5xx 明显上升,然后主动退一个档位。libcurl 限速参数可以直接帮上忙,比如单线程下载限速:
/* 接收速度上限 100KB/s,超过则主动放慢 */ curl_easy_setopt(curl, CURLOPT_MAX_RECV_SPEED_LARGE, 100L * 1024L); /* 持续 5 秒低于 10 字节/秒,判定僵尸连接并断开 */ curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 5L); curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 10L);三个参数的含义分别是:该连接接收速度上限 100KB/s;持续 5 秒低于 10 字节/秒时主动断开并返回超时错误;合起来盯住的是“慢而不死”的僵尸连接。另外,把目标站点的 robots 声明和抓取频率约定写进配置文件,交由运维评审,而不是让每个开发各自改并发数。抓取器能长期跑下去的边界,往往不是性能,而是对目标站点请求节奏的克制。
把这两个技巧落进去后,再回头看 network-spider 的核心链路,你会发现自己已经能控制住整个抓取过程的状态和边界了——抓取器的本事不是比别人快多少,而是同样跑三天不出事,出错后能在十分钟内说出哪里错了、为什么错、怎么改。这也是我做了几个抓取工程之后最深的体会,希望帮到你。
本文还有配套的精品资源,点击获取