☰
爬虫限速与礼貌爬取:Requests频率控制防429封禁指南
2026/9/30 8:49:58 网站建设 项目流程

你有没有经历过这样的时刻:写了一个爬虫,跑得很欢,结果十分钟之后所有请求全部报错,要么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}", timeout=timeout ) 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 resp

random.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 = [f"https://example.com/api/page/{i}" for i in range(1, 101)] timeout = (3, 10) def fetch(url): resp = requests.get(url, timeout=timeout) return url, resp.status_code, resp.text with ThreadPoolExecutor(max_workers=5) 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_workers=5意味着同时最多只有5个请求在飞行。

4.3 线程池内的限速方案

线程池里做限速,我推荐三种方案,按工程复杂度递增。

方案一:每个线程内部单独sleep。最简单,但不同线程之间没有协调,可能出现短时间内的请求尖峰。

def fetch(url): resp = requests.get(url, timeout=(3, 10)) time.sleep(1) # 每个线程自己做延迟 return url, resp.status_code

5个线程各自每秒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, capacity=None): """ 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, tokens=1, timeout=10): """获取令牌,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(rate=5, capacity=10) def fetch_with_limit(url): if not bucket.acquire(tokens=1): raise RuntimeError("等待令牌超时,限速太严或请求太多") return requests.get(url, timeout=(3, 10))

rate=5表示平均每秒放行5个请求,capacity=10表示允许短时间突发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(target=worker) t.start() threads.append(t) for i in range(100): url_queue.put(f"https://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, timeout=timeout) 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( level=logging.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, timeout=timeout) elapsed = time.time() - start logger.info( f"url={url} status={resp.status_code} " f"elapsed={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_email@example.com)" } resp = requests.get(url, headers=headers, timeout=(3, 10))

说实话,我早期爬数据也不在意这些,总觉得“能抓到就行”。直到有一次我的爬虫被对方管理员写邮件过来,告诉我访问量已经影响到了他们的正常服务,我才意识到频率控制不只是技术问题,更是做爬虫的基本素养。你抓的每一个字节,背后都是别人真金白银搭起来的服务器。把并发、延迟、频率控制这三件事做好,你的爬虫才算真正入了门——不是只会发请求的那种入门,而是知道自己应该怎么发请求的那种入门。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询