ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

爬虫限速与礼貌爬取:Requests频率控制防429封禁指南

爬虫限速与礼貌爬取:Requests频率控制防429封禁指南 你有没有经历过这样的时刻写了一个爬虫跑得很欢结果十分钟之后所有请求全部报错要么403要么429严重一点直接“您的IP已被封禁”连正常浏览器都访问不了那个网站。更离谱的是有些AI编程工具在替你连续干活的时候也会甩给你一句exceeded retry limit, last status: 429 too many requests。翻译过来就是——你请求得太快太密服务端已经不想搭理你了。这一节我们继续走Requests静态爬取这条路做一件特别重要但新手几乎都会忽略的事限速与礼貌爬取。说白了就是研究三个问题——并发怎么开、延迟怎么加、频率怎么控。很多零基础教程教到Requests的get和post就停了从不提频率控制结果读者一上手就写了一堆“一秒十连”的代码然后被网站拉黑还反过来怀疑是requests用错了。其实requests本身没做错什么错的是没有节奏感。这篇文章适合刚学完Requests基础、准备真正开始抓数据但还没摸过限流的人。我会从服务端视角讲清楚为什么网站要限流再手把手带你把单线程延迟、多线程并发、令牌桶限速、429重试这些实操全部落地顺带把最近几个月大家频繁遇到的“429 Too Many Requests”报错机制讲明白。1. 爬虫翻车现场不控频率的代价1.1 一个发生在凌晨两点的封IP事件我印象很深的一个案例有个朋友写了个脚本抓某公开数据接口思路很简单——一个for循环requests.get然后解析JSON保存。第一版跑起来三秒一个请求他嫌慢直接把循环改成并发20个线程。结果不到三分钟控制台开始刷红色异常接着整个IP段的访问都被服务器拒绝了。他当时很困惑我用的明明是公开接口怎么说不让访问就不让访问后来把报错信息拉出来一看清一色是429和403。再往后连他自己用浏览器打开那个网站都打不开了IP被临时封禁。这就是不控频率的典型代价——不仅是爬虫挂了还连累了自己正常的网络访问。很多零基础同学对这个事情没有体感觉得服务器不就是给人访问的吗我多访问几次怎么了但换个角度就很好理解了你去一家奶茶店门口贴着“排队取餐”你偏要一次挤进去二十个人同时点单店员忙不过来最后只能把你们这一伙人全部请出去。服务器和奶茶店一样都有自己的接待能力上限。1.2 服务端的真实负担服务端面对每一个请求至少要处理这几件事建立连接、解析HTTP报文、路由匹配、查询数据库或调用其他服务、渲染或组装响应、返回数据。如果网站是动态页面一次请求背后可能还要查三次数据库、调两个内部接口。这些操作都是要花钱的——数据库连接数有限带宽有限CPU和内存有限。当一个爬虫以毫秒级间隔发起请求时相当于把网站本来要给几百个正常用户的资源全部挤到你这一个来源上。网站运营者又不认识你凭什么让你把资源都吃掉所以他们会设置非常明确的规则单个IP在单位时间内超过N次请求就直接拒绝拒绝几次还不收手就封掉这个IP一段时间。这些规则不是针对爬虫的“恶意反制”而是网站保证自己活下去的基本手段。1.3 礼貌爬取的底线礼貌爬取这个词听起来很虚实际做起来就是四条底线遵守robots协议访问网站之前先看对方的robots.txt里面会告诉你哪些路径允许抓取、哪些禁止抓取。标识自己的身份在请求头里带上清晰的User-Agent最好还能包含联系方式让网站管理员知道是谁在访问、出了问题可以找谁。控制请求频率不让自己的流量明显超过一个正常用户的节奏。尊重数据用途抓下来的公开数据用于合法用途不恶意转载、不侵犯他人权益。这四条里面最容易做也最容易被忽视的就是频率控制也就是本文的核心。后面会逐步给出落地实现。2. 服务端限流机制429与你之间发生了什么2.1 429状态码怎么读429是HTTP协议里专门为“请求太多”设计的状态码全称是Too Many Requests。当你看到一个响应是429说明你的请求本身没问题、路径也没错、参数也合法但频率超了。它跟几个邻近状态码要区分清楚状态码含义爬虫通常遇到它的原因403Forbidden禁止访问IP被临时封禁、UA被识别为脚本、权限不足429Too Many Requests请求过多单位时间内请求次数超过网站限制503Service Unavailable服务不可用网站过载或正在维护也可能是被网关限流有时候网站会故意把429伪装成403甚至503目的就是不想暴露自己的限流策略。但大多数情况下搜索引擎里疯狂刷屏的“exceeded retry limit, last status: 429 too many requests”都指向同一个事实重试次数用完了最后一次依然被拒绝说明你从头到尾都没有把频率降下来。2.2 常见限流算法网站后端限流用的算法从简单到复杂大概有四类爬虫开发者最好都认识一下因为你观察到的现象完全取决于对方用哪种算法。固定窗口最简单假设限制是每分钟60次后端就开一个60秒的窗口里面放一个计数器到60秒清零。缺点也很明显——在一分钟的最后1秒请求60次再在下一分钟的第1秒请求60次实际上1秒内打了120次窗口根本拦不住。滑动窗口是对固定窗口的改良把时间切成更小的格子每次统计“过去60秒”的总数而不是“当前这一个整分钟”的数量。平滑不少但存储成本高。令牌桶是工程上最常用的方案系统以固定速率往桶里放令牌桶满则丢弃每个请求必须先拿一个令牌才能放行。它能容忍一定程度的突发流量因为桶里可以积攒令牌但同时又把平均速率锁死。漏桶则是把请求排成队以绝对恒定的速度放行做不到突发适合需要完全平滑的场景。算法允许突发实现成本典型效果固定窗口边界可突发很低窗口交界处容易被钻空子滑动窗口比较平滑较高限流更精准令牌桶允许突发中等常见于API网关漏桶不允许突发中等输出速率完全恒定站在爬虫开发者角度你不需要精确判断对方用的哪种算法只需要知道无论哪种算法只要我的请求频率超过对方设定值就必然触发429。所以与其猜算法不如老老实实把自己的请求频率控制在一个保守的区间里。2.3 Retry-After服务端留给你的回旋余地很多限流响应并不会只丢一个429给你还会带上一个关键的响应头Retry-After。它的值可能是秒数也可能是一个具体的日期时间。HTTP/1.1 429 Too Many Requests Content-Type: application/json Retry-After: 30这个30表示“30秒后再来”。有些实现会用HTTP日期格式比如Retry-After: Wed, 12 Jun 2024 08:30:00 GMT这个字段的意义在于服务器不是要永远拒绝你只是要求你等一等。你如果能正确读取并尊重它很多临时性的429根本不会升级成封禁。import time import requests resp requests.get(url, timeout(3, 10)) if resp.status_code 429: retry_after resp.headers.get(Retry-After) if retry_after and retry_after.isdigit(): wait int(retry_after) else: wait 30 print(f触发限流等待 {wait} 秒后重试) time.sleep(wait)就这几行已经比大多数“无脑重试三次然后放弃”的脚本靠谱了。3. Requests单线程节奏控制3.1 固定sleep最简单也最实用的起点单线程下控制频率最直白的写法就是在每次请求之后睡一会儿。比如你希望每秒最多请求1次那就import time import requests url https://example.com/api/article/1 timeout (3, 10) for article_id in range(1, 101): resp requests.get( f{url}/{article_id}, timeouttimeout ) print(article_id, resp.status_code) time.sleep(1) # 固定延迟1秒一个time.sleep(1)就把请求频率锁死在每秒1次。但是固定延迟有一个问题它太规律了。如果你观察某个网站的访问日志你会发现真实用户的访问间隔是参差不齐的——有时候连续点两三个链接有时候停下来读五分钟文章。而固定间隔的请求看起来就像一个节拍器这种特征很容易被反爬系统识别。固定sleep最大的价值是“先用起来”先把频率上限控制住再去优化节奏的拟真程度。3.2 随机延迟拒绝节拍器特征把固定延迟改成随机延迟几乎不增加任何成本却能显著降低被识别的概率import random import time import requests def fetch_with_jitter(url): resp requests.get(url, timeout(3, 10)) # 每次请求后随机休息 1~3 秒 time.sleep(random.uniform(1, 3)) return resprandom.uniform(1, 3)会在1到3秒之间均匀取一个随机值平均间隔是2秒。这种方式比固定sleep更接近人的行为也能避免多个线程同时醒来形成请求尖峰。随机延迟还可以逐步升级成“正态分布”或者“带权重的区间”比如在1秒附近波动比在2~3秒附近少。但对绝大多数项目来说均匀分布已经够用不要为了拟真把代码搞复杂。3.3 自适应延迟让网站告诉你该多快有些网站虽然限流但并不会把限流阈值写在文档里。这时候可以靠“响应速度”来反推一个安全的频率。思路是这样的每次请求记录总耗时比如一个请求花0.3秒返回那我就在0.3秒的基础上再加一段缓冲时间作为下一次请求前的等待。如果网站响应变慢了说明它的负载在上升或者它正在试探性地拖慢你那就把延迟也拉长。import time import requests base_wait 0.5 resp requests.get(url, timeout(3, 10)) elapsed resp.elapsed.total_seconds() # 响应耗时越长等待越久至少保留 base_wait 作为底线 wait base_wait elapsed * 2 time.sleep(wait)resp.elapsed是requests帮我们统计的“从发送请求到收到响应”的耗时。让它参与延迟计算爬虫就能形成一种负反馈网站越慢我越慢我越慢网站越不容易限我。这也是“自适应频率控制”的一种朴素实现很多成熟的爬虫框架做得远比这个复杂但核心思想一模一样。3.4 超时设置不设timeout的代价限速讲的是“不要太快”而timeout讲的是“不要无限等”。这两件事同样重要。很多新手写requests不用timeout参数结果遇到某个服务器连接一直挂起程序卡在resp requests.get(url)这一行一等就是几分钟、几十分钟。更严重的是在多线程环境下卡住的请求会一直占用线程导致整个线程池被拖死。# 推荐写法连接超时3秒读取超时10秒 resp requests.get(url, timeout(3, 10))传一个元组时第一个值是连接超时第二个值是读取超时。如果只传一个数字表示连接和读取都用这个值。对于大部分静态页面抓取连接3秒、读取10秒是比较合理的起步配置。要是对方接口确实慢可以先调到连接5秒、读取30秒而不是直接不设超时。4. 并发爬取与限速的平衡4.1 并发不是越多越好单线程限速虽然安全但速度上限很明显。假设你设定每秒2个请求抓10000个页面需要5000秒差不多一个半小时。这时候自然会想到能不能开多个线程同时抓答案是可以但有个前提——并发必须被纳入频率控制体系而不是绕过它。很多人开线程池的方式是10个线程每个线程里无脑发请求结果总请求量直接翻了10倍。这就是前面说的翻车场景。记住一个公式整体频率 单线程频率 × 线程数。如果你的目标整体频率是每秒5次那么开5个线程时每个线程内部只能做到每秒1次如果开10个线程每个线程只能每2秒发一次。并发不会降低对服务器的压力它改变的只是压力分布的形状。4.2 ThreadPoolExecutor最小实现Python里做线程池并发最顺手的是concurrent.futures.ThreadPoolExecutor。先看一个没有限速的基础版本from concurrent.futures import ThreadPoolExecutor, as_completed import requests urls [fhttps://example.com/api/page/{i} for i in range(1, 101)] timeout (3, 10) def fetch(url): resp requests.get(url, timeouttimeout) return url, resp.status_code, resp.text with ThreadPoolExecutor(max_workers5) as executor: futures {executor.submit(fetch, url): url for url in urls} for future in as_completed(futures): url futures[future] try: url, code, text future.result() print(url, code, len(text)) except Exception as e: print(url, 出错了, e)这段代码展示了三件事用submit提交任务、用as_completed按完成顺序处理结果、用result()拿结果并捕获异常。max_workers5意味着同时最多只有5个请求在飞行。4.3 线程池内的限速方案线程池里做限速我推荐三种方案按工程复杂度递增。方案一每个线程内部单独sleep。最简单但不同线程之间没有协调可能出现短时间内的请求尖峰。def fetch(url): resp requests.get(url, timeout(3, 10)) time.sleep(1) # 每个线程自己做延迟 return url, resp.status_code5个线程各自每秒1次总频率大约是每秒5次但因为是各睡各的前几秒可能扎堆。方案二全局锁共享下次请求时间。用一个锁保护一个全局变量保证任意时刻最多只有一个请求在发出并且严格保证请求间隔。import threading import time import requests lock threading.Lock() next_request_time 0.0 interval 0.2 # 整体每秒5次 def rate_limited_fetch(url): global next_request_time with lock: now time.time() wait next_request_time - now if wait 0: time.sleep(wait) next_request_time time.time() interval resp requests.get(url, timeout(3, 10)) return url, resp.status_code这种做法的好处是无论你开5个线程还是20个线程真正的请求频率始终被锁在interval所定义的速率内。线程数只影响“等待调度的并发度”不会放大对服务器的压力。方案三把限速调度独立出来。不直接在请求函数里做限速而是通过一个独立的调度器比如后面要讲的令牌桶来发令牌。这个方案更适合请求流程复杂的场景因为限速逻辑和抓取逻辑完全解耦了。4.4 并发爬取的异常处理与结果回收并发下的异常处理比单线程麻烦很多。ThreadPoolExecutor的submit本身不会抛异常异常是在你调用future.result()的时候才抛出来的。如果你在循环里忘了捕获一个请求出错就可能让整个汇总流程中断。我的习惯是fetch函数内部只负责请求和最小限度的解析所有可能出错的环节全部抛异常主线程里统一用try...except包住future.result()把失败的任务记下来最后统一重试。另外要注意结果回收的顺序。as_completed返回的是按完成时间排序的future适合“拿到一个处理一个”的流式场景如果要求结果必须保持输入顺序就不要用as_completed直接遍历原始的futures列表再逐个取result()。5. 工程化的频率控制方案5.1 令牌桶限速器如果你有多个爬虫脚本要维护或者同一个脚本里有多处请求函数把限速逻辑写进每个函数里是很糟糕的。这时候应该做一个独立的限速器。令牌桶是个很好的选择。import threading import time class TokenBucket: def __init__(self, rate, capacityNone): rate: 每秒补充的令牌数 capacity: 桶容量默认为 rate表示最多积攒1秒的令牌 self.rate rate self.capacity capacity or rate self.tokens self.capacity self.last_time time.time() self.lock threading.Lock() def acquire(self, tokens1, timeout10): 获取令牌timeout为最多等待秒数超时返回False start time.time() while True: with self.lock: now time.time() # 按时间补充令牌 self.tokens min( self.capacity, self.tokens (now - self.last_time) * self.rate ) self.last_time now if self.tokens tokens: self.tokens - tokens return True if time.time() - start timeout: return False time.sleep(0.05)用法很简单bucket TokenBucket(rate5, capacity10) def fetch_with_limit(url): if not bucket.acquire(tokens1): raise RuntimeError(等待令牌超时限速太严或请求太多) return requests.get(url, timeout(3, 10))rate5表示平均每秒放行5个请求capacity10表示允许短时间突发10个请求。这种设计既保护了服务器又不会因为偶发的小批量任务而过早触发限流。5.2 用队列做匀速调度令牌桶解决的是“每个请求来之前拿令牌”的问题但有时候你更希望整个抓取任务像生产线一样匀速流转。这时候可以用queue.Queue做一个调度队列。基本思路一个生产者把URL不断放入队列固定数量的工人线程从队列取URL、请求、处理。要控速就在生产者一侧控制放入速率或者在工人一侧配合令牌桶。import queue import threading import requests url_queue queue.Queue() def worker(): while True: url url_queue.get() if url is None: break try: resp requests.get(url, timeout(3, 10)) print(url, resp.status_code) finally: url_queue.task_done() threads [] for _ in range(5): t threading.Thread(targetworker) t.start() threads.append(t) for i in range(100): url_queue.put(fhttps://example.com/api/item/{i}) url_queue.join() for _ in threads: url_queue.put(None) # 用 None 作为退出信号队列方案的好处是工人线程数、任务生产节奏、异常处理都是独立调节的。想限速只需要在url_queue.put之前做一次节流想暂停任务不需要停线程只要停止生产就行。5.3 整体频率控制在多进程场景的扩展Python爬虫跑到一定规模单机器多线程可能不够用会上分布式或者多进程。这时候单机内存里的令牌桶就失效了需要把限流状态放到一个所有进程都能访问的地方比如Redis。用Redis实现令牌桶通常借助Lua脚本保证原子性或者用简单的INCR配合过期时间实现滑动窗口。这一块对零基础读者还太早我这里只提一个原则分布式爬虫的频率控制必须中心化不能让每个进程自己算一套否则整体频率直接乘以节点数必被限流。对当前阶段来说单机单进程的令牌桶已经能解决绝大部分问题。6. 429之后重试策略与完整排查链路6.1 指数退避别在风口上撞墙429一旦出现最忌讳的就是立刻重试。服务器正在限流你偏要在0.1秒内再撞一次只会让封禁来得更快。正确处理是退避重试——每次失败后等待时间指数增长并加上随机抖动。import random import time import requests MAX_RETRIES 5 def fetch_with_backoff(url, timeout(3, 10)): for attempt in range(MAX_RETRIES): resp requests.get(url, timeouttimeout) if resp.status_code 200: return resp if resp.status_code 429: retry_after resp.headers.get(Retry-After) if retry_after and retry_after.isdigit(): wait int(retry_after) else: # 2^attempt 加上随机抖动避免多个线程同时醒来重试 wait 2 ** attempt random.uniform(0, 1) print(f第 {attempt 1} 次触发429等待 {wait:.2f} 秒) time.sleep(wait) continue # 403/404等直接抛异常不值得重试 resp.raise_for_status() raise RuntimeError(f重试 {MAX_RETRIES} 次后仍然失败最后一次状态码 429)指数退避的核心是“等待时间随尝试次数翻倍”0次失败后等1秒1次后等2秒2次后等4秒3次后等8秒。加随机抖动是为了避免多线程同步重试形成新的尖峰。6.2 从报错到恢复的完整排查路径当你看到“429 too many requests”接连出现不要急着改代码按这个顺序过一遍第一步确认是否整体频率超标。把脚本当前的并发数、每个线程的sleep值、请求总耗时排出来算一下实际的QPS。绝大多数429都是因为算出来的整体频率超过了网站的隐式限额。第二步检查响应头和日志。看有没有Retry-After字段看网站是不是用503或403表达限流看错误出现的规律是“均匀间隔触发”还是“突然一次性触发”。前者通常是固定窗口限流后者可能是令牌桶耗尽。第三步降级验证。把并发线程数减半、sleep加倍跑10分钟看还报不报错。如果不再报错说明之前的频率就是超了如果依旧报错再去检查是不是IP被临时封禁或者UA被识别。第四步做好本地缓存与断点续抓。把已经抓成功的URL记录到本地文件下次启动时先跳过它们。这样即使中途被限流打断恢复后也不用从头再来极大降低重复请求带来的额外压力。6.3 观察自己的行为日志与监控限速做得好不好不能靠感觉要看数据。我给自己的爬虫项目加过一段简单的请求日志中间件每次请求记录URL、状态码、耗时、等待时间import logging import time import requests logging.basicConfig( levellogging.INFO, format%(asctime)s %(levelname)s %(message)s ) logger logging.getLogger(__name__) def fetch_with_log(url, timeout(3, 10)): start time.time() resp requests.get(url, timeouttimeout) elapsed time.time() - start logger.info( furl{url} status{resp.status_code} felapsed{elapsed:.2f}s retry_after{resp.headers.get(Retry-After)} ) return resp把日志累积起来看一眼就能发现很多有意思的规律哪些时间段网站响应慢、哪些URL经常触发429、目前的频率是否还有余量。日志是频率控制的地基没有日志的限速配置等于闭着眼睛开车。关于礼貌爬取最后再讲个容易被忽略的细节requests默认的User-Agent是python-requests/x.y.z有些网站不喜欢会直接把它当成脚本特征。我不是建议你去伪装成浏览器而是建议你在请求头里放一个能标识项目身份的UA——比如加上你的联系方式。headers { User-Agent: DataCollector/1.0 (contact: your_emailexample.com) } resp requests.get(url, headersheaders, timeout(3, 10))说实话我早期爬数据也不在意这些总觉得“能抓到就行”。直到有一次我的爬虫被对方管理员写邮件过来告诉我访问量已经影响到了他们的正常服务我才意识到频率控制不只是技术问题更是做爬虫的基本素养。你抓的每一个字节背后都是别人真金白银搭起来的服务器。把并发、延迟、频率控制这三件事做好你的爬虫才算真正入了门——不是只会发请求的那种入门而是知道自己应该怎么发请求的那种入门。
返回列表