1. 为什么需要智能条件请求?
在爬虫开发中,我们经常会遇到一个尴尬的问题:目标网站的内容明明没有更新,但我们却反复下载相同的数据。这不仅浪费带宽资源,还可能触发反爬机制。我曾经在一个电商价格监控项目中,因为没处理好这个问题,导致服务器IP被短暂封禁——实际上那些商品价格每小时才更新一次,而我的爬虫每分钟都在全量请求。
HTTP协议早就为我们准备了解决方案:条件请求(Conditional Requests)。通过ETag和Last-Modified这两个响应头,服务器可以告诉客户端资源的唯一标识和最后修改时间。当下次请求时,客户端只需带上这些信息,服务器就会判断资源是否有变化。如果没有变化,就返回304 Not Modified状态码,而不返回实际内容。
关键事实:根据Akamai的统计,合理使用条件请求可以减少约30-50%的不必要数据传输,这对爬虫密集型应用意味着显著的带宽节省和性能提升。
2. ETag与Last-Modified机制深度解析
2.1 ETag:资源的数字指纹
ETag(实体标签)是服务器分配给资源的唯一标识符,通常是一个哈希值。当资源内容变化时,ETag也会改变。它的工作流程是这样的:
-
首次请求时,服务器在响应头中包含ETag:
http复制HTTP/1.1 200 OK ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4" -
后续请求时,客户端在If-None-Match头中带上这个ETag:
http复制GET /resource HTTP/1.1 If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4" -
服务器比较当前ETag与客户端提供的值:
- 如果匹配 → 返回304
- 不匹配 → 返回200和新内容
ETag有强弱之分:
- 强ETag:要求字节完全一致
- 弱ETag(以W/开头):允许语义相同但字节不同
2.2 Last-Modified:时间戳方案
Last-Modified记录资源最后修改时间,精度通常到秒级。其工作流程类似:
-
服务器响应:
http复制HTTP/1.1 200 OK Last-Modified: Wed, 21 Oct 2023 07:28:00 GMT -
客户端下次请求带上If-Modified-Since:
http复制GET /resource HTTP/1.1 If-Modified-Since: Wed, 21 Oct 2023 07:28:00 GMT -
服务器比较时间:
- 未修改 → 304
- 已修改 → 200
实际经验:有些服务器会同时使用ETag和Last-Modified,此时ETag的优先级更高,因为它能检测到时间戳相同但内容实际变化的情况(比如快速连续修改)。
3. Python实现智能条件请求
3.1 基础实现:requests库方案
使用Python的requests库实现条件请求非常简单。以下是一个完整示例:
python复制import requests
from urllib.parse import urlparse
import os
import json
class ConditionalRequest:
def __init__(self, cache_file='cache.json'):
self.cache = {}
self.cache_file = cache_file
self._load_cache()
def _load_cache(self):
if os.path.exists(self.cache_file):
with open(self.cache_file, 'r') as f:
self.cache = json.load(f)
def _save_cache(self):
with open(self.cache_file, 'w') as f:
json.dump(self.cache, f)
def get(self, url):
headers = {}
if url in self.cache:
if 'etag' in self.cache[url]:
headers['If-None-Match'] = self.cache[url]['etag']
if 'last_modified' in self.cache[url]:
headers['If-Modified-Since'] = self.cache[url]['last_modified']
response = requests.get(url, headers=headers)
if response.status_code == 304:
print(f"资源未修改,使用缓存: {url}")
return None # 或者返回缓存的旧数据
# 保存新的ETag和Last-Modified
self.cache[url] = {
'etag': response.headers.get('ETag'),
'last_modified': response.headers.get('Last-Modified')
}
self._save_cache()
return response.content
# 使用示例
crawler = ConditionalRequest()
content = crawler.get('https://example.com/api/data')
3.2 高级技巧:会话管理与缓存策略
对于更复杂的爬虫,建议结合requests.Session和缓存库:
- 使用Session保持连接复用
- 实现多级缓存(内存+磁盘)
- 添加缓存过期策略
改进版实现:
python复制from datetime import datetime, timedelta
import diskcache
class AdvancedConditionalCrawler:
def __init__(self, cache_dir='.webcache', expire_hours=24):
self.session = requests.Session()
self.cache = diskcache.Cache(cache_dir)
self.expire = timedelta(hours=expire_hours)
def get(self, url):
now = datetime.utcnow()
cache_key = f"meta::{url}"
cached = self.cache.get(cache_key, {})
headers = {}
if 'etag' in cached:
headers['If-None-Match'] = cached['etag']
if 'last_modified' in cached:
headers['If-Modified-Since'] = cached['last_modified']
response = self.session.get(url, headers=headers)
if response.status_code == 304:
content_key = f"content::{url}"
return self.cache.get(content_key)
# 更新缓存
metadata = {
'etag': response.headers.get('ETag'),
'last_modified': response.headers.get('Last-Modified'),
'cached_at': now.isoformat()
}
self.cache.set(cache_key, metadata)
self.cache.set(f"content::{url}", response.content)
return response.content
4. 实战中的问题与优化策略
4.1 常见陷阱与解决方案
问题1:服务器返回的ETag不稳定
有些服务器会生成随机的ETag(如包含时间戳),导致条件请求失效。
解决方案:
- 检查多个请求的ETag变化模式
- 必要时禁用ETag,仅使用Last-Modified
- 或者实现自定义缓存策略
问题2:时区不一致导致Last-Modified失效
服务器和客户端时区设置不同可能导致时间比较错误。
解决方案:
- 确保所有时间都使用UTC
- 使用datetime库正确处理时间转换
问题3:缓存污染
当URL相同但内容实际不同时(如个性化内容),会导致返回错误缓存。
解决方案:
- 在缓存键中包含用户标识
- 检查响应头中的Vary字段
- 对于API请求,将参数也纳入缓存键
4.2 性能优化进阶技巧
-
批量检查:对于多个URL,可以使用HEAD请求先检查哪些需要更新
python复制def needs_update(self, url): headers = {} if url in self.cache: cached = self.cache[url] if cached['etag']: headers['If-None-Match'] = cached['etag'] if cached['last_modified']: headers['If-Modified-Since'] = cached['last_modified'] response = requests.head(url, headers=headers) return response.status_code != 304 -
自适应策略:根据资源变化频率动态调整检查间隔
python复制def get_with_adaptive_check(self, url): now = time.time() last_checked = self.cache.get(f"last_checked::{url}", 0) min_interval = 60 # 至少60秒检查一次 if now - last_checked < min_interval: return self.cache.get(f"content::{url}") content = self.get(url) self.cache.set(f"last_checked::{url}", now) return content -
分布式爬虫协调:在多机部署时共享缓存状态
- 使用Redis存储ETag和Last-Modified
- 实现简单的发布-订阅机制通知内容更新
5. 真实案例分析:电商价格监控爬虫优化
我曾经接手过一个电商价格监控项目,原始版本每分钟全量请求200个商品页面,导致:
- 每月消耗约500GB流量
- 频繁触发反爬机制
- 服务器负载高
实施条件请求优化后:
- 首次运行建立完整缓存
- 后续请求带上ETag和Last-Modified
- 添加自适应检查间隔(价格频繁变动的商品检查更频繁)
优化结果:
- 流量下降至每月约80GB(减少84%)
- 反爬触发次数降为0
- 数据更新延迟仍在可接受范围内(大多数商品价格每小时才变化)
关键实现代码片段:
python复制class PriceMonitor:
def __init__(self):
self.redis = Redis()
self.session = requests.Session()
def check_price(self, product_id):
url = f"https://api.ecommerce.com/products/{product_id}"
cache_key = f"product:{product_id}"
# 获取缓存元数据
etag = self.redis.hget(cache_key, 'etag')
last_modified = self.redis.hget(cache_key, 'last_modified')
headers = {}
if etag:
headers['If-None-Match'] = etag
if last_modified:
headers['If-Modified-Since'] = last_modified
try:
response = self.session.get(url, headers=headers)
if response.status_code == 304:
return json.loads(self.redis.hget(cache_key, 'data'))
# 更新缓存
data = response.json()
self.redis.hset(cache_key, 'data', json.dumps(data))
self.redis.hset(cache_key, 'etag', response.headers.get('ETag', ''))
self.redis.hset(cache_key, 'last_modified', response.headers.get('Last-Modified', ''))
return data
except requests.RequestException as e:
logger.error(f"请求失败: {e}")
return None
这个案例展示了条件请求在实际业务中的巨大价值。通过合理利用HTTP协议特性,我们不仅节省了资源,还提高了爬虫的稳定性和隐蔽性。
