批量检测网址状态码多线程实战:从线程池到报表
简介这份C#源码项目实现批量检测网址HTTP状态码面向网站维护、SEO优化及网络爬虫开发者。项目通过多线程并发发送HTTP请求可同时检查多个URL的返回码200、404、500等并具备异常处理、并发限制与结果记录能力适合用于死链扫描与站点健康监控。压缩包内含42个文件主体为C#工程.sln、.csproj、.cs及编译生成的dll与pdb调试文件另有json配置文件、txt说明文档及若干缓存文件整体仅516KB轻量易用。目前已有278人学习下载。借助完整工程结构和源码说明读者可直接打开调试学习多线程任务调度、HTTP状态码解析及结果输出流程也可在此基础上扩展为异步IO或定时巡检工具具有实用参考价值。1. 批量检测网址状态码(多线程)先算清这单活值不值得并发手里压着上千个 URL 要验证存活一个个requests.get循环下去等到下班也跑不完。批量检测网址状态码多线程这个需求表面看是把 for 循环换成线程池实际跑起来才发现线程数、超时、重定向、SSL 验证这些参数有一项没想清楚结果要么是对方服务器直接断开连接要么是报表里塞满无意义的异常。本篇按可落地的顺序给出多线程批量检测的完整实现从状态码归类、并发选型到线程池参数与输出报表再把我踩过的几个坑列在最后。这套做法适合刚接手爬虫数据清洗、SEO 死链核查和运维巡检存量链接的开发者能直接跑也能按业务改。2. 先把“状态码”和“多线程”都拆清楚判定逻辑决定脚本体量2.1 状态码的五档分类别再只输出 200/404 两个数批量检测网址状态码的核心不是“发多少个请求”而是“拿到状态码之后怎么归类”。一个 URL 返回 200 是活着返回 301/302 是不是活着对大多数业务来说它活着只是搬家了。返回 503 属于服务故障不是你链接写错返回 403 可能是被 WAF 拦住也不等于死链。批量结果里这几类必须分开统计否则一张报表把“服务端故障”和“链接失效”混在一起后续修复根本无从下手。我常用的分档是五类2xx 归为正常alive3xx 归为跳转redirect4xx 归为无效或受限client_error5xx 归为服务故障server_error请求抛异常或超过自定义时限的归为异常exception。其中 3xx 还要再决策一次跟不跟重定向。做 SEO 死链巡检时通常要跟随重定向以最终落地页的 200/404 作为判断做 CDN 回源配置检查时反而要看第一次请求的 301 有没有给对 Location。两个场景不要用同一份参数跑这是第一个要明确的需求边界。状态分类常见状态码业务含义alive200, 201, 204资源可访问redirect301, 302, 303, 307, 308资源已迁移配合 Location 判断client_error400, 401, 403, 404, 410请求不可达或被拒绝server_error500, 502, 503, 504服务端暂时不可用exception超时/连接失败/SSL 错误等网络或证书问题为什么单独分 exception批量场景里超时可能是对方网络抖动也可能是你线程开太多把对方连接池打爆。把 exception 单独列出来你才能回头查是脚本问题还是目标站问题而不是直接当“死链”删掉。这个分类会直接对应第 4 章报表里的 category 列脚本里所有逻辑都围绕它为结果服务。2.2 多线程、多进程、异步为什么这个需求优先选 python 多线程这个任务属于 IO 密集每个请求大部分时间在等网络响应CPU 几乎闲着。Python 里的多线程一直被 GIL 的话题困扰但其实网络请求期间底层 socket 等待会释放 GIL线程可以并发等待所以用多线程处理这类批量网络请求是合理的。多进程也能做但需要为每个子进程复制 URL 列表和结果队列进程切换和内存开销在几千条 URL 的场景下不划算。异步asyncio是理论上的更优解但代码写法要从“同步 for 循环”改成“await 信号量”对临时脚本来说改造成本偏高我在第 6 章再给替换版本。如果你要的是一个今天就能跑完的结果最常见做法是concurrent.futures.ThreadPoolExecutor。它比手写threading.Thread更省心提交任务后不用自己管理 join也不用手动维护线程安全队列as_completed可以按完成顺序拿结果。Python 中多线程脚本最怕两件事一是任务函数里异常没捕获线程崩掉但主流程不感知二是共享变量被多个线程乱写。对应解法也简单任务函数把异常当成“一种结果”返回线程内不做 print 汇总统一把结果放回列表最后一次性写文件。线程池内部的任务排队机制也值得讲清楚。pool.submit会把任务放进内部队列worker 线程空闲时取一个来执行。如果 URL 有一万条而max_workers16并不是“同时发一万个请求”而是队列里积压 9984 个待办worker 完成一个再取一个。真正影响整体耗时的不是任务总量而是单任务能不能准时结束。只要有一个任务因为超时设置过大而长时间不返回它占住的 worker 就不会去取下一个任务整体进度会被明显拖慢。所以批量检测中超时参数比线程数更敏感后面 3.2 和 5.3 会反复提到这一点。单线程里常用的requests.Session()默认不是线程安全的连接对象。Session 内部连接池的设计不支持多个线程安全共享同一个 Session常见做法是每个线程用threading.local()保存自己的 Session既避开了锁竞争也让 TCP 连接能复用。第 3 章的脚本就采用这种写法这也是多发请求能提速的关键之一。3. 跑通最小批量检测脚本ThreadPoolExecutor 版从建任务到出结果3.1 准备 URL 列表与最小脚本骨架先把输入文件准备好。一个urls.txt每行一个网址可带http://或https://也可裸写域名脚本会自动补协议并去重。下面是完整可运行版本Python 3.8只依赖 requests。import csv import threading import time from concurrent.futures import ThreadPoolExecutor, as_completed import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry import urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) INPUT_TXT urls.txt OUTPUT_CSV result.csv THREAD_NUM 16 # 并发线程数 CONNECT_TIMEOUT 5 # 连接超时单位秒 READ_TIMEOUT 10 # 读取超时单位秒 FOLLOW_REDIRECT True # 是否跟随重定向 UA Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 thread_local threading.local() def normalize_url(raw): raw raw.strip() if not raw: return None if not raw.startswith((http://, https://)): raw https:// raw return raw def get_session(): if not hasattr(thread_local, session): s requests.Session() retry Retry(total1, connect0, read1, status0, backoff_factor0.3) adapter HTTPAdapter(pool_connectionsTHREAD_NUM, pool_maxsizeTHREAD_NUM * 2, max_retriesretry) s.mount(https://, adapter) s.mount(http://, adapter) thread_local.session s return thread_local.session def categorize(status): if 200 status 300: return alive if 300 status 400: return redirect if 400 status 500: return client_error if 500 status 600: return server_error return other_status def check_one(task_id, raw_url): url normalize_url(raw_url) start time.perf_counter() try: s get_session() r s.get(url, timeout(CONNECT_TIMEOUT, READ_TIMEOUT), headers{User-Agent: UA}, allow_redirectsFOLLOW_REDIRECT, streamTrue, verifyFalse) # 读取极小一部分响应体确保连接完整结束并可回池 try: next(r.iter_content(chunk_size1)) except StopIteration: pass r.close() elapsed_ms int((time.perf_counter() - start) * 1000) return (task_id, url, r.status_code, categorize(r.status_code), r.url, elapsed_ms, ) except requests.exceptions.SSLError as exc: return (task_id, url, 0, exception, url, 0, ssl_error: str(exc)[:80]) except requests.exceptions.Timeout as exc: return (task_id, url, 0, exception, url, 0, timeout: str(exc)[:80]) except requests.exceptions.RequestException as exc: return (task_id, url, 0, exception, url, 0, str(exc)[:120]) def main(): with open(INPUT_TXT, encodingutf-8-sig) as f: lines [ln.strip() for ln in f if ln.strip()] # 保序去重 unique_urls list(dict.fromkeys(lines)) records [] with ThreadPoolExecutor(max_workersTHREAD_NUM) as pool: future_map {pool.submit(check_one, idx, u): idx for idx, u in enumerate(unique_urls)} for future in as_completed(future_map): task_id future_map[future] try: records.append(future.result()) except Exception as exc: # 兜底理论上 check_one 已回收一切异常 records.append((task_id, unique_urls[task_id], 0, exception, unique_urls[task_id], 0, future_error: str(exc)[:80])) # 按原始顺序输出 records.sort(keylambda r: r[0]) with open(OUTPUT_CSV, w, encodingutf-8-sig, newline) as f: writer csv.writer(f) writer.writerow([task_id, url, status_code, category, final_url, elapsed_ms, error]) writer.writerows(records) if __name__ __main__: main()逐段说明关键逻辑。get_session()为每个线程惰性创建一个requests.Session()Session 内部连接池按pool_maxsize复用连接。Retry(total1, read1)表示连接失败不重试读响应失败最多重试 1 次。实际重试次数不要太多批处理场景重试一次就够了重试越多失败时总耗时翻倍。check_one使用streamTrue的原因常被忽略如果不读响应体requests 的连接池无法确认 body 是否读完长连接可能带着残留数据影响复用。读一个字节再 close()连接要么干净回到池里要么被关闭不会泄漏。如果目标页面很大这也保证只读 1 字节而不是把整个页面下载下来。main() 的流程是读文件、去重、提交全部任务到线程池、用 as_completed 收结果、按原始顺序排序、写 CSV。其中dict.fromkeys去重会保留第一次出现的顺序同时把重复 URL 从清单里删掉。若业务上需要保留重复 URL 的多次检测结果这里要把“直接删除”改成“做重复标记”再把重复项重新提交。3.2 线程数与超时参数别一上来就 200 并发THREAD_NUM 没有普适最优值但有一个安全区间。对绝大多数中小型站点5~20 个并发已经能明显提速且不触发封禁测门户级站点或 CDN 边缘节点可以到 30~50再多就容易出现第 5 章要讲的连接重置。我建议初始值从 16 开始先取清单前 200 条试跑记录错误率和总耗时再决定往上调还是往下调。超时参数要分开设连接超时表示“连不上”的基线设 3~5 秒读取超时表示“已建立连接但迟迟不返回数据”设 8~15 秒。不要图省事只传一个 timeout10因为 requests 会把同一个值同时用于连接和读取连接超时 10 秒意味着一个不存在的 IP 可能让你干等 10 秒。脚本里拆成两个值是为了让最终报表里 timeout 类的耗时可控。参数建议值说明THREAD_NUM16初次运行建议 10~20观察错误率再调CONNECT_TIMEOUT5IP 不可达时的兜底等待READ_TIMEOUT10防止个别接口半连接卡死 workerRetry total1读超时最多重试一次FOLLOW_REDIRECTTrue巡检用 True抓 301 用 False连接池参数pool_connections和pool_maxsize也要跟着线程数走。脚本里分别取了 THREAD_NUM 和 THREAD_NUM*2意思是每个 Session 最多缓存两倍于线程数的连接。如果目标站点域名很多连接池太小会导致同一 Session 频繁关闭旧连接再建新连接反而比单线程还慢如果连接池开得太大又会占住大量 socket 直到 TIME_WAIT 堆积。这个参数不需要每次调优保持与线程数同量级即可。3.3 用 as_completed 收结果顺序问题必须靠 task_id 还原as_completed返回的是先完成的任务不是先提交的任务最终结果顺序必然和原始文件不一致。如果直接把结果写到 CSV你拿着报表去和 URL 清单一行行比对时会很难受。脚本里的做法是每个任务带上 task_id结果收集后按 task_id 排序保证 CSV 顺序和 urls.txt 完全一致。这里没有用锁也没有用队列直接让多个线程 append 同一个 records 列表。CPython 的 list.append 在字节码层面是原子的两个线程同时 append 不会互相覆盖也不会丢数据这个结论适用于一次几千条 URL 的场景。如果你用的是 PyPy 或其他解释器最稳妥的做法是每个线程收集自己的结果最后再合并。另一个不能省的动作是future.result()要包一层 try。正常情况下 check_one 已经把所有异常转成了结果记录但 future 层面仍可能被不可控因素打断兜底逻辑留着能避免整个脚本在收尾阶段突然退出。3.4 跑完第一批之后应该看什么全量跑完后第一个要看的是整体统计而不是某条 404。总耗时 T、有效 URL 数 N平均每个 URL 耗时 T / N。相比单线程16 线程的提速通常在 8~14 倍达不到这个倍数说明连接没有充分复用或队列空转。异常率超过 5% 时要么目标站确实有大量问题要么参数过猛。其次看五类占比是否合理。比如一份采集来的链接alive比例低于 60% 就值得警惕是数据源太旧还是 UA 被全局拦截分类统计我会直接复用 4.2 的 summarize 函数。这一步能帮你区分“目标真的挂了”和“脚本自己跑出问题了”。4. 判定与输出把批量检测结果落成一张能用的报表4.1 重定向跟随与最终 URL状态码不能只记一个数字第 3 章脚本默认 FOLLOW_REDIRECT True此时 r.status_code 是最终状态码r.url 是最终地址。final_url 常用于反向定位“这个 URL 跳去哪了”拿来做域名归并比看原始 URL 域名更接近业务事实。如果改成 Falser.status_code 会返回 301/302而下一跳地址在 r.headers[Location]。很多新手把 301 当成异常其实是要取 Location 分析跳转链。为兼容两种场景CSV 里加一列 next_hop跟随重定向时记最终 URL不跟随重定向时记 Location。代码片段如下def extract_next_hop(r): if not FOLLOW_REDIRECT and Location in r.headers: return r.headers[Location] return r.url一个容易翻车的细节当allow_redirectsFalse时r.url仍是原始请求地址不是最终地址。如果你把r.url当最终地址写进报表跳转场景的排查方向就全错了。4.2 分类统计与 CSV 编码Excel 打开不乱码的两个顺手处理批量检测跑完后把五类占比打印出来是最快的自检方式from collections import Counter def summarize(csv_path): counter Counter() with open(csv_path, encodingutf-8-sig) as f: reader csv.DictReader(f) for row in reader: counter[row[category]] 1 total sum(counter.values()) for cat, cnt in counter.most_common(): print(f{cat:16} {cnt:8} {cnt / total:8.1%})CSV 写入用encodingutf-8-sig是给 Excel 看的。普通 utf-8 写的 CSV 用 Excel 双击打开时中文会乱码因为 Excel 默认按 GBK 解析无 BOM 文件utf-8-sig自带 BOM 头Excel 能正确识别。这是批量检测报表交付时最常被忽略的细节数据都对客户打开乱码一票否决。统计结果的另一层价值是验证脚本本身没跑偏。假如一份 5000 条链接的清单里client_error占比突然超过 50%先别急着删链接回头看是不是 UA 被全局拦截或者是证书验证问题把大量请求归成了 exception。分类占比是脚本健康度的仪表盘不是单纯的完成报告。4.3 去重与补协议两个极易踩的输入数据预处理很多从爬虫导出的 URL 清单并不干净既有https://example.com/path也有example.com/path还有重复行、空行、带 BOM 的首行。第 3 章脚本已经包含两处处理按行 strip、dict.fromkeys去重。但还有一个隐藏问题如果第一行带 BOMutf-8-sig编码读取会把 BOM 吃掉处理后再以普通 utf-8 写回清单。补协议这一动作同样有业务影响。同一批源数据里www.example.com补成https://www.example.com之后可能返回 200也可能因为站点只开通了 HTTP 而返回 403。不要默认“加 https 最安全”。我一般先探测对裸域名尝试 https失败后回退 http。def guess_scheme(raw): candidates [https:// raw, http:// raw] for c in candidates: try: r requests.get(c, timeout3, allow_redirectsTrue) return r.url, r.status_code except requests.RequestException: continue return raw, 0这个函数只用于少量裸域名的补全不要在几千条 URL 的主流程里逐个预探测否则并发还没跑预检环节就把时间耗完了。主流程直接按“默认补 https”处理报表里对 status0 的那批再做二次回退即可。4.4 报表字段设计一次写全省得二次加工最终 CSV 字段建议固定为八列task_id、url、status_code、category、final_url、elapsed_ms、error、checked_at。列名含义筛选场景task_id与源清单对齐的序号按原始顺序核对status_code最终 HTTP 状态码数字筛选category五档业务分类统计占比final_url跟随重定向后的实际地址归并域名elapsed_ms单请求耗时毫秒看长尾慢请求error异常信息只填 status_code0 的行checked_at检测时间戳定时巡检比对两次结果elapsed_ms这一列很多人会省掉但它能帮你定位慢接口耗时超过 5 秒的 URL 即使返回 200也可能拖垮整体巡检需要单独处理。checked_at做定时巡检时很关键它能告诉你“这个 404 是今天出现还是上周就有”。5. 批量检测的五个常见坑并发风暴、证书误报与超时拖垮队列大规模跑状态码检测最容易翻车的点不在 requests 语法而在你没意识到的并发副作用。以下五条都是实践中反复出现的问题按“现象 → 原因 → 解决”记录。5.1 线程数一提高大量请求被重置或 502现象把 THREAD_NUM 从 16 调到 64 后报表里多了大量network_error错误内容多为Connection reset by peer或 HTTP 502。原因对方服务器或云防火墙按源 IP 统计连接频率短时间创建的 TCP 连接数超过阈值直接在网关层丢弃。批量检测再快也快不过封禁。解决线程数不是越多越好先按目标站点承载能力保守取 10~20。对紧急任务可以在脚本里加一个均匀延迟建议把 URL 列表打散成若干批每批之间 sleep 0.2~0.5 秒。连接复用比新建连接重要单线程 Session 复用率高的话16 线程已经能撑起每秒几十个请求的吞吐。5.2 SSL 证书问题全部被记成“异常”而不是 404现象同一批 URL 用浏览器能打开脚本却给大部分返回ssl_error最终 category 全是 exception报表没法看。原因目标站证书过期、自签名、证书链不完整浏览器有弹窗或其他机制兜底而 requests 默认verifyTrue碰到任何证书问题直接抛SSLError状态码根本拿不到。解决在可信场景下显式设置verifyFalse同时关闭 urllib3 的警告脚本顶部已经写了对应代码。注意这只适用于“检测 URL 可达性”的场景如果业务要求校验 TLS 证书有效性反而应该把证书错误单独统计。另外verifyFalse信任的是所有证书绝不要用在处理付款、登录这类敏感请求的代码里。5.3 一个永远等不到响应的 URL 拖死整个线程池现象总共 1000 个 URL前面 900 个很快跑完最后 100 个全部 timeout而且任务列表里明明只有两三个慢链接。原因线程池的max_workers是固定值。某个 worker 被一个不返回数据也不断开连接的服务器占住读取超时没到它就一直不释放连同其他几个 worker 一起被慢请求占满后续任务排队等待。前面请求越快慢请求对整体拖累越明显。解决把读取超时压到 8~10 秒并限制重试次数为 0 或 1。另一个常用做法是future.result(timeout...)但这对线程池里的任务只负责主线程等待无法真正打断 stuck 的读取真正可靠的手段还是把 READ_TIMEOUT 设到一个业务能接受的数值。若目标站点有特殊慢接口可以把它们单独分一批跑长超时。5.4 Windows 控制台直接崩溃UnicodeEncodeError 与 GBK 编码现象脚本在 Linux 上正常拷到 Windows 跑遇到中文域名或 URL 里的特殊字符直接报UnicodeEncodeError: gbk codec cant encode character进程中断。原因Windows 默认控制台代码页是 GBK代码里如果有print(url)url 含 GBK 无法映射的字符时 print 内部抛编码异常。这不是网络问题是终端编码问题。解决不要在任务函数里 print 每个 URL 的结果把打印集中到写完 CSV 后并显式指定sys.stdout.reconfigure(encodingutf-8, errorsreplace)更稳妥的是直接不打印明细只打印分类统计和耗时。第 3 章脚本用“collect-and-write”的方式绕开了这个坑。5.5 明明能打开的网址全部返回 403问题出在默认 UA现象批量检测结果里client_error占大多数抽样检查 403 的 URL 用浏览器都能打开。原因requests 默认User-Agent是python-requests/x.y很多 WAF 和反爬策略对非浏览器 UA 一律拦截。这不是目标站挂了而是脚本被识别。解决脚本里传一个完整浏览器 UA 字符串并在 Session 级别统一设置。上面的版本已经在check_one的headers里传了 UA如果你要全局生效可以在get_session()里执行s.headers.update({User-Agent: UA})这样重定向后的二次请求也会带上同一 UA。注意有些站点还会校验 Accept 头遇到个别站点再补Accept: text/html,application/xhtmlxml即可没必要给所有站点加。6. 从多线程进阶到异步三种提速思路与结果验证技巧线程池再好用也有一个上限max_workers决定了同时在途的请求数而大部分 worker 是在等网络线程切换本身还有开销。当 URL 量到几万条以上更常见做法是换成 asyncio aiohttp。异步不是“多线程”但它的目标更彻底用一个事件循环管理大量并发连接配上Semaphore(200)比线程池轻得多。核心替换片段如下import asyncio import aiohttp async def check_one(session, sem, url): async with sem: try: async with session.get(url, timeout10) as resp: await resp.read() return url, resp.status except (asyncio.TimeoutError, aiohttp.ClientError): return url, 0 async def run_batch(urls): sem asyncio.Semaphore(200) async with aiohttp.ClientSession() as session: tasks [check_one(session, sem, u) for u in urls] return await asyncio.gather(*tasks) # 调用asyncio.run(run_batch(unique_urls))Semaphore(200)是异步版的并发上限它不会创建 200 个线程而是控制同时打开多少个连接。这个方案适合目标站承受能力未知的海量检测但注意 aiohttp 没有自动重试机制超时和状态码要自己在函数里分类报表逻辑仍然沿用第 4 章。另一种折中思路是“多线程 每线程 Session 连接复用 批间延迟”这也是我处理几千条清单的默认组合。异步代码的排查成本略高只有量级大到线程池跑不满资源时才值得迁。最后说验证。批量检测脚本改完参数不要直接全量跑先抽 100 个 URL用单线程版本和当前版本各跑一遍逐一比对(url, status_code, final_url)三元组。两者不一致时优先怀疑 allow_redirects 和 verify 两个开关而不是并发本身并发只影响“快慢”不该影响“结果”。再把分类占比打印出来和上一次运行比正常波动应该在 2~3 个百分点以内超过就要回去翻 UA 或超时日志。我现在的习惯是包里常备这份脚本和一个小样本文件任何改动先用 100 条过验全量跑完再核对统计。批量检测网址状态码这件事最怕的不是慢是给了你一份自信满满的错误结果。希望这些踩过的坑和参数能帮到你把报表做准别让状态码骗过自己。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →