每次维护爬虫任务,我最怕听到的一句话就是"页面更新了,数据没抓到"。排查到最后,十次有八次是同一个原因:脚本不管三七二十一全量跑,把没变的页面也重新请求了一遍。不只是浪费时间,重复下载的流量、重复解析的CPU开销、以及给目标站点造成的无谓压力,才是真正让人头疼的事。
这篇文章聊的是一个纯HTTP层面的解法:基于ETag和Last-Modified实现增量缓存系统。说白了,就是让爬虫在每次请求前带上"上次拿到的版本标识",服务器告诉你"没变"咱就跳过,告诉你"变了"才重新拉取解析。适合已经能写简单爬虫、想进一步降低请求量、提升抓取效率的朋友参考。全文按原理、系统设计、代码实现、踩坑实录的顺序走,代码可以直接抄到项目里改。
1. 为什么爬虫需要增量缓存系统
1.1 重复抓取带来的三个大坑
第一个坑是时间浪费。我之前维护过一个资讯类爬虫,每天跑一遍,大概500个URL,每个请求加上页面解析平均要半秒,全量跑下来四到五分钟。这个耗时很尴尬:说多不多,但一旦你有二三十个这样的任务,服务器整晚都在跑,日志刷得飞快,看着压力很大。
后来加了增量缓存,我特意统计过一段时间,每天真正发生变化的URL其实不到40个,整体耗时直接从四分钟压到30秒以内。差距就在这:全量请求的绝大部分都是在做无用功。
第二个坑是资源浪费。请求一个页面,服务器要做路由、查缓存、渲染模板、返回响应,这一整套操作都是成本。你以为控制了请求频率就礼貌了?但如果100个请求里99个页面根本没变过,等于让服务器白白做了99次完整渲染,纯属打扰。
第三个坑是风险问题。现在很多站点的反爬策略会关注行为特征,高频请求未变化页面就是一个很典型的异常信号。爬虫工程师之间流传的经验是,请求量级越接近真实用户的"常访问但不高频",越不容易被盯上。增量缓存能把请求量降一个数量级,对降低封禁风险很有帮助。
1.2 增量抓取并不等于"定时跑一遍"
不少新手理解的增量抓取,就是写个cron到点全量跑一遍。这个理解不能说错,但它做的是"全量跑+覆盖写",不是真正的增量。
真正的增量语义应该是:我事先知道哪些URL大概率没变,所以根本不发请求;只有那些服务器告诉我"变了"的URL,才重新下载、重新解析。
问题来了:爬虫怎么知道服务器有没有变?靠猜肯定不行,但HTTP协议早就留好了口子——服务器返回响应时,响应头里通常会带Last-Modified和ETag,这两个字段就是服务器给的版本标识。下次请求时把这两个值原样带回,服务器一比对,内容没变,就返回状态码304,正文为空;内容变了,才返回200和完整页面。
这个机制是HTTP协议的标准玩法,不是某个站点的私有功能。只要目标站的响应头里有这两个字段,你就能用它做增量拉取。
注意:有些站点不返回ETag,或者Last-Modified的时间粒度太粗(只到秒),这两种情况要单独处理,后面第5章会讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. HTTP缓存协议拆解:ETag与Last-Modified到底怎么工作
2.1 Last-Modified 和 If-Modified-Since 的配对逻辑
Last-Modified是服务器在响应头里返回的时间戳,代表资源最后被修改的时间。第一次请求时,响应头可能是这样:
code复制Last-Modified: Tue, 22 Nov 2024 06:24:14 GMT
客户端把这个时间记下来。下次请求时,在请求头里带上:
code复制If-Modified-Since: Tue, 22 Nov 2024 06:24:14 GMT
服务器收到这个请求头之后,会拿它跟资源的实际修改时间做比较。如果资源的最后修改时间不晚于你给的时间,说明没改过,返回304;如果比这个时间新,说明改过了,返回200和完整内容。
你可以把它理解成一个不太精细的约定:"我上次见到这个文件是周二下午,之后它如果改了你得告诉我一声。"这个机制很直观,但缺陷也很明显——时间精度只有秒。如果资源在一秒内改了好几次,而两次请求之间的时间差又不到一秒,服务器靠秒级时间比较就会漏判,你会拿到一个304,数据就漏了。
2.2 ETag 和 If-None-Match 的配对逻辑
ETag本质上是一串版本标识,服务器在资源内容变化时重新生成。生成方式通常是内容哈希、inode和mtime组合、或者随机字符串,具体取决于服务器实现。
第一次请求时,响应头可能是这样:
code复制ETag: "686897696a7c876b7e"
下次请求时,带上:
code复制If-None-Match: "686897696a7c876b7e"
服务器把当前资源的ETag和你给的ETag进行比对,相同则返回304,不同则返回200。
ETag可以理解为内容的指纹。它比Last-Modified精确得多,因为哪怕修改时间只差几毫秒,只要内容真的变了,ETag就会变(前提是服务器实现靠谱)。很多强缓存校验的实现里,服务器还会把文件大小、inode等一起参与计算,尽可能保证唯一性。
2.3 两者如何协作
实践中靠谱的服务器通常两个字段都会返回。客户端两个都存、两个都带,服务器也是两个条件都判断:任何一个条件判定内容有变化,就返回200;只有同时判定未变化,才返回304。
两个字段是一层双保险。有些服务器对If-Modified-Since支持得好,对If-None-Match处理得一般;有些则反过来。都带上可以最大化缓存协商的命中概率。
我个人的判断优先级是:ETag优先,Last-Modified兜底。因为ETag精度更高,误判概率更低。如果服务器只给了ETag,那就只用ETag;如果只给了Last-Modified,那就退回用时间比较;如果俩都没给,那就只好走全量。
3. 增量缓存系统设计
3.1 元数据存储设计
要用这套机制,第一步得把每个URL上次请求到的ETag和Last-Modified存下来。存哪?我推荐SQLite,轻量、免部署、支持并发读,足够支撑中小型爬虫项目。
核心表结构如下:
sql复制CREATE TABLE IF NOT EXISTS crawl_meta (
url TEXT PRIMARY KEY,
etag TEXT,
last_modified TEXT,
last_status INTEGER,
updated_at INTEGER
);
字段说明:
url:页面唯一标识,主键etag:响应头里的ETag值,可能为空last_modified:响应头里的Last-Modified值,可能为空last_status:最后一次返回的状态码,方便排查updated_at:元数据最后更新时间,Unix时间戳
etag和last_modified允许为空,因为不是所有服务器都返回这两个字段。如果某次响应头里没有ETag,就把这个字段更新成NULL,下次请求时不带If-None-Match,只带其他有效的缓存字段。
3.2 带条件的请求流程设计
整个请求流程分为五个步骤:
- 按URL查本地库,拿到上次的etag和last_modified
- 有缓存值就在请求头里加If-None-Match或If-Modified-Since
- 发出请求
- 收到304,说明内容没变,跳过解析,直接结束
- 收到200,说明内容有更新,解析数据、入库、更新缓存元数据
这里有个容易被忽略的点:条件请求头不是每次都要带,而是本地有缓存记录的URL才带。第一次抓某个URL时,本地没有任何记录,必须正常发请求,拿到响应后再把ETag和Last-Modified存下来。
另外,如果某个URL第一次请求是404,要不要存?我的习惯是不存。404往往意味着资源暂时不存在或路径有问题,后续可能会恢复。存了之后,下次条件请求的表现取决于服务器,容易在排查时造成干扰。
3.3 状态码处理的边界情况
除了200和304,实际运行中还会遇到其他状态码。处理原则要提前定好:
301/302:requests默认跟随重定向,最终响应头是目标页面的。如果你想对重定向链做缓存,需要把最终URL也记录下来,否则下次带条件头请求原始URL,可能还是302,浪费一次请求。403/404:不要更新缓存元数据,保持之前的记录。特别要注意,别因为临时的一次403就把内存里的etag清掉,等封禁解除后可能还能用。429:限流了,应该等待一段时间重试,更不能更新缓存。用Retry-After响应头里的时间作为等待时长。500:服务器错误,同样不更新缓存,按重试策略处理。
一句话总结:只有成功的200响应,才允许更新etag和last_modified。
4. 核心代码实现:从零写一个增量缓存爬虫
4.1 项目结构和依赖
先按最简单的单文件结构来做,不引入框架。依赖三个:
requests:发HTTP请求sqlite3:Python自带,存元数据和结果beautifulsoup4:解析HTML
安装命令:
bash复制pip install requests beautifulsoup4
项目文件拆成三个模块,逻辑清晰一些:
code复制crawler/
├── cache_store.py # 元数据存储
├── fetcher.py # 带缓存协商的请求
└── news_crawler.py # 主流程
4.2 元数据存储模块
首先是cache_store.py,负责SQLite的读写:
python复制import sqlite3
import time
class CacheStore:
def __init__(self, db_path="crawler_cache.db"):
self.conn = sqlite3.connect(db_path)
self._init_db()
def _init_db(self):
self.conn.execute("""
CREATE TABLE IF NOT EXISTS crawl_meta (
url TEXT PRIMARY KEY,
etag TEXT,
last_modified TEXT,
last_status INTEGER,
updated_at INTEGER
)
""")
self.conn.commit()
def get_meta(self, url):
cur = self.conn.execute(
"SELECT etag, last_modified FROM crawl_meta WHERE url = ?",
(url,)
)
row = cur.fetchone()
if row:
return {"etag": row[0], "last_modified": row[1]}
return {}
def update_meta(self, url, etag, last_modified, status_code):
self.conn.execute(
"""
INSERT INTO crawl_meta (url, etag, last_modified, last_status, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(url) DO UPDATE SET
etag = excluded.etag,
last_modified = excluded.last_modified,
last_status = excluded.last_status,
updated_at = excluded.updated_at
""",
(url, etag, last_modified, status_code, int(time.time()))
)
self.conn.commit()
这里用ON CONFLICT语法,第一次插入,后续存在则更新,避免了"先查再改"的两步操作。注意SQLite的低版本可能不支持这个语法,一般Python 3.7以上自带的SQLite版本都支持,如果报语法错误,升级Python或改用INSERT OR REPLACE。
4.3 带缓存协商的请求模块
然后是fetcher.py,核心是构造条件请求头:
python复制import requests
class IncrementalFetcher:
def __init__(self, store, timeout=10):
self.store = store
self.timeout = timeout
def fetch(self, url):
meta = self.store.get_meta(url)
headers = {}
if meta.get("etag"):
headers["If-None-Match"] = meta["etag"]
if meta.get("last_modified"):
headers["If-Modified-Since"] = meta["last_modified"]
resp = requests.get(url, headers=headers, timeout=self.timeout)
if resp.status_code == 304:
return None
if resp.status_code == 200:
self.store.update_meta(
url,
resp.headers.get("ETag"),
resp.headers.get("Last-Modified"),
resp.status_code
)
return resp
resp.raise_for_status()
return None
几个细节说一下。
Requests库的响应头字典不区分大小写,resp.headers.get("ETag")和resp.headers.get("etag")都能取到,但统一大小写写法更规范,也避免别人看代码时困惑。
timeout一定要设置。没设超时的话,某些连接异常时的挂起时间会非常长,整个爬虫就像卡死一样。一般取5到15秒,根据目标站的响应速度来调。
304响应也有响应头,但resp.text是空的。有的服务器在304里也会附加新的ETag或者Last-Modified,理论上你可以顺手更新一下,不过通常没必要——内容没变,这两个值也不该变。
4.4 整体串起来的爬虫流程
主文件news_crawler.py:
python复制import time
from bs4 import BeautifulSoup
from cache_store import CacheStore
from fetcher import IncrementalFetcher
class NewsCrawler:
def __init__(self, store):
self.fetcher = IncrementalFetcher(store)
self.conn = store.conn
self._init_result_table()
def _init_result_table(self):
self.conn.execute("""
CREATE TABLE IF NOT EXISTS news_article (
url TEXT PRIMARY KEY,
title TEXT,
first_seen_at INTEGER
)
""")
self.conn.commit()
def _parse(self, html):
soup = BeautifulSoup(html, "html.parser")
items = []
for item in soup.select("ul.news-list li"):
link = item.find("a")
if link is None:
continue
title = link.get_text(strip=True)
href = link.get("href")
if title and href:
items.append({"title": title, "url": href})
return items
def crawl(self, url):
resp = self.fetcher.fetch(url)
if resp is None:
print(f"[skip] {url} 未变化")
return 0
items = self._parse(resp.text)
for item in items:
self.conn.execute(
"""
INSERT OR IGNORE INTO news_article (url, title, first_seen_at)
VALUES (?, ?, ?)
""",
(item["url"], item["title"], int(time.time()))
)
self.conn.commit()
print(f"[update] {url} 变化,解析到 {len(items)} 条")
return len(items)
if __name__ == "__main__":
store = CacheStore()
crawler = NewsCrawler(store)
crawler.crawl("https://example.com/news")
这是最简单的主流程。跑到第二次,如果页面没变,fetch返回None,crawl直接跳过;如果变了,就重新解析,新的新闻ID由于主键冲突会被忽略,已经存在的标题不会被覆盖,新出现的新闻才会被插入。
实际项目里通常要处理两类URL:列表页URL和详情页URL。列表页关心的是页面里有没有增加新的链接;详情页关心的是正文内容是否更新。两类URL都能用同一个IncrementalFetcher,只是解析逻辑不同。
4.5 分页场景怎么处理
分页列表页如/news?page=1到/news?page=10,可以每个分页URL单独存一份缓存元数据。服务器只要某页有变化,那一页就返回200,其余的返回304。
需要注意的现象是:如果某条新闻因为置顶操作从第二页被挤到第一页,第一页的HTML变了,ETag会变,返回200并更新;第二页的内容可能完全没变,返回304并跳过。这是对的。但如果某个CMS系统只在模板层做了缓存,ETag只依赖模板文件的mtime,不依赖数据库内容,那么即使新闻列表变了,ETag也不会变,你收到的会一直是304,数据就漏了。
遇到这种"ETag不可信"的站点,我的建议是为特定URL增加一个"强制刷新周期",比如每抓10次成功之后,强制忽略缓存重新拉一次。宁可偶尔多抓一次,也不能漏数据。
4.6 如何用日志验证缓存生效
不要只用print,最好输出结构化日志,方便统计缓存命中率。我在生产环境习惯记录三个字段:url、status_code、hit。示例:
python复制import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
# fetch返回None时打一条
logging.info(f"[hit] {url} status=304")
# 返回200时打一条
logging.info(f"[miss] {url} status=200 size={len(resp.content)}")
判断缓存生效的两个核心指标:
- 304命中率:304次数 / 总请求次数。稳定爬取阶段通常应该超过80%,如果低于60%,说明要么页面确实频繁变化,要么缓存元数据没存上。
- 平均下载体积:304响应几乎没有body,200响应才有完整HTML。统计一段时间内的平均body大小,如果明显下降,说明缓存协商生效。
要注意一点:requests在收到304时,resp.content是空的,所以在打印日志时如果直接取len(resp.content)没问题,但不要试图去解析它,更不要拿它跟200时的内容做对比。
5. 实战踩坑与常见问题排查
5.1 ETag每次都在变,缓存等于没用
我碰到过一个站点,每次请求返回的ETag都不同,因为它用随机字符串生成ETag,完全没做条件判断。这导致我每次带If-None-Match过去,服务器都比对不上,永远返回200,缓存形同虚设。
识别办法很简单,用curl连续请求两次同一个URL,对比响应头:
bash复制curl -sI https://example.com/news | grep -i etag
如果两次的ETag不同,说明这个服务器的ETag不可用。这时候有三个备选方案:
- 只依赖Last-Modified。内容更新不频繁的站点,秒级精度通常够用。
- 如果Last-Modified也没有,就在本地做内容哈希。下载完整HTML后计算MD5,与上次的值比对,相同则跳过解析。这个方案稍微重点,但能节省解析和入库开销。
- 如果连下载压力都扛不住,就在业务层做策略:比如只对最近24小时内创建的文章做更新,更早的跳过。
5.2 服务器不支持缓存协商
不是所有服务器都实现了条件请求。判断标准很简单:你带了If-None-Match,但它依然返回200而不是304,这说明服务器没做判断。
这种情况常见于CDN配置不当或后端未启用缓存协商。你可以尝试在请求头里固定Accept-Encoding,因为有的服务器对压缩前后的内容生成不同的ETag,导致每次都不匹配。固定成Accept-Encoding: gzip后,ETag就稳定了。
也有个别服务器只在URL带查询参数时才支持缓存协商。我遇到过一个站点,/news不支持,但/news?v=1支持,原因不明,可能是路由规则差异。这种只能逐个场景去测,没有统一答案。
5.3 并发下的SQLite写入冲突
爬虫一旦上并发,多个线程同时更新同一条URL的meta信息,SQLite会报database is locked。解决思路有三个,按复杂度递增:
- 给CacheStore的写操作加一个线程锁,简单粗暴,并发量不大时完全够用。
- 开启SQLite的WAL模式,读和写可以并行,减少锁冲突。
- 换成PostgreSQL或MySQL,适合并发量大且需要水平扩展的场景。
中小型项目我建议用WAL模式加线程锁:
python复制self.conn.execute("PRAGMA journal_mode=WAL;")
self.conn.execute("PRAGMA busy_timeout=5000;")
busy_timeout设置为5000毫秒,意思是锁冲突时最多等5秒,超过就报错。配合WAL,正常情况下并发写入不会频繁报错。
5.4 使用代理时的缓存失效问题
代理IP切换后,服务器看到的IP变了,但资源的ETag通常还是同一个,所以缓存协商依然有效。但有一种情况需要警惕:如果目标站点做了"同一资源、按地区/按用户返回不同版本"的处理,不同代理IP下返回的ETag可能不同。
我实际碰到过案例:访问同一个API,从两个不同地区的出口IP拿到的数据版本不一样,ETag也不一样。当时缓存key只用了URL,导致两个IP的数据互相覆盖,排查了很久才发现是地区差异。后来把缓存key扩展成URL + 地区标识,问题才彻底解决。
如果你遇到类似的"缓存内容不对"的诡异问题,可以先考虑是不是代理IP出口地域不同导致的。
5.5 内容没变但状态码是200的情况
还有一种常见情况:服务器每次都返回200,但内容完全一致。这通常是因为服务器根本没有实现304,或者动态渲染导致每次生成的HTML都有细微差别(比如嵌入时间戳),ETag总是变。
处理方式:在fetch里增加内容哈希比对兜底。
python复制import hashlib
def content_signature(html):
return hashlib.md5(html.encode("utf-8")).hexdigest()
流程变成:请求返回200后,先算签名。签名和上次一样,则跳过解析;不一样才继续解析入库,并更新签名。这样可以避免重复解析。
但这个策略不解决带宽问题——你依然下载了完整HTML。它只是把解析和入库的开销省下来。如果连带宽都想省,那只能寄希望于服务器启动缓存协商。
5.6 增量缓存对爬虫封禁策略的影响
最后说一个容易被忽视的点:增量缓存做得越到位,你发出的请求越少,对目标站的干扰越小。很多站点封禁爬虫,不是因为你爬了,而是因为你爬得没有节制、重复请求太多。带上条件请求头,相当于跟服务器说"如果内容没变就别理我",比闷头狂抓文明得多。
注意:做任何爬虫项目,都必须遵守目标网站的robots协议和使用条款,控制好请求频率,只抓取允许抓取的内容。这是底线,别越线。
6. 写了这么久之后的一些个人经验
这套增量缓存方案,我用在两个线上任务里超过一年了。一个每天抓500个资讯页,一个每两小时抓一次价格页。资讯页的304命中率稳定在85%到95%,价格页更新频繁一点,命中率低一些,但也在60%上下。换成这套方案之后,服务器负载和带宽成本都降了很多,最直接的感受是定时任务跑完的时间提前了一大批,日志也不再刷屏了。
我个人的体会是,做爬虫别急着上框架、上分布式、上消息队列,先把单机单进程的增量缓存做扎实,收益往往比想象中大得多。一个几百行的脚本加一个SQLite文件,就能把一个"每天傻跑一遍"的爬虫变成"只做有效劳动"的爬虫。
如果你要在自己的项目里落地,建议按这个顺序来:先跑通基本的ETag/Last-Modified缓存,然后加日志和命中率统计,最后再考虑并发和分布式。后面想扩展,可以把元数据存储换成Redis,把缓存策略做成可配置的,甚至结合URL优先级做调度。但地基还是今天讲的这套HTTP缓存协商机制,把它吃透了,上层怎么搭都顺手。
