
做爬虫做了这么多年我一直觉得URL去重是那种看起来简单做起来全是坑的环节。前阵子帮朋友排查一个采集任务跑了一整夜第二天看数据库十二万条记录里将近四万条是重复的。查日志发现罪魁祸首特别蠢同一个商品详情页因为URL里的追踪参数不同被当成新页面爬了好几遍。更离谱的是还有一批URL带着百分号编码和没编码的版本混在一起指纹完全对不上。这种问题几乎每个爬虫项目都会遇到。URL去重不仅决定了你花了多少无效请求、占了多少无效存储还直接影响IP封禁风险和下游数据清洗成本。这篇内容我结合多年的一线经验从最基础的set去重讲到分布式场景下的布隆过滤器和Redis方案重点拆解那些看着一样其实不一样的URL陷阱以及我在实际项目中踩过的去重逻辑缺陷。适合正在写爬虫的初学者也适合负责数据采集管道、Web自动化巡检的工程师参考。1. 爬虫去重到底在去什么先把问题看透1.1 你以为去重是字符串比较其实它是内容指纹识别很多刚上手的朋友会把URL去重理解成判断两个字符串相不相等。这个理解放在玩具项目里没问题一旦放到真实业务里就漏洞百出。爬虫的URL去重本质上是给待抓取的资源做一个指纹标记防止同一份内容被多次下载。麻烦的是URL和内容不是一一对应的关系。同一个URL可能在不同时间返回不同内容比如首页、榜单页、带个人信息的页面不同的URL也可能指向同一份内容比如带追踪参数和没带追踪参数的商品链接、经过跳转的短链和原始长链。所以单纯比较URL字符串既可能漏掉重复也可能错误拦截。一个比较稳妥的认知是把URL去重当成基于URL形态的内容指纹识别先做URL规范化再做指纹计算最后才轮到集合判重。顺序不能反否则后面所有方案都是在错误数据上做文章。1.2 不去重要付出的真实代价去重做不好代价不是数据多点而已这么简单。我在几个项目里总结过四类直接损失。第一是流量和带宽浪费。大规模采集场景下链接池到亿级量级时重复率只要到5%就是几百万次无效请求对带宽和对方服务器都是负担。第二是IP封禁风险。重复请求会极大提高反爬系统对你的敌意值。同一个URL反复请求比分散请求更容易触发频率限制和封禁。很多站点就是靠同一资源的访问频次来判定爬虫的你去重做不好等于主动送人头。第三是下游数据膨胀和清洗成本。原始数据落地之后ETL阶段要做去重数据仓库要处理重复主键分析结果可能出现偏差。我在一个业务里看过因为去重缺陷同一个用户的订单被统计了三次最后业务方拿着报表来问这个责任非常尴尬。第四是爬取效率降低。每一次请求都有时间成本浪费在重复URL上的时间都意味着新数据抓取变慢。所以别觉得去重嘛set一下就好了。这是一个需要认真设计的问题值得在项目启动时就花半小时想清楚。2. URL的伪重复陷阱编码、参数与锚点2.1 URL编码的历史坑%3A、%2F与原始字符我最常遇到的去重失败案例是同一批URL以两种形态出现在待抓取队列里https://main.m.taobao.com/detail/index.html?id123 https%3A%2F%2Fmain.m.taobao.com%2Fdetail%2Findex.html%3Fid%3D123后者一般是从分享链接、二维码或第三方接口里直接提取出来的编码形态。如果不去解码、不做规范化这两条字符串会被当成完全不同的URL重复爬取就这么发生了。URL编码也叫百分号编码的来历很朴素早期URL只允许一部分ASCII字符直接出现中文字符、空格、冒号、斜杠这些在特定场景下必须转成%XX的形式。问题在于编码动作可能发生在不同层有的在前端JS里把整个URL用encodeURIComponent处理过有的只在query参数上编码有的来自第三方分享链接干脆把整条链接都编码了。到了爬虫这边如果采集管道里混用了多个数据源编码态和解码态就会共存。所以去重之前强烈建议做一次规范化处理标准动作是用urllib.parse.unquote做整体解码拆出scheme、netloc、path、query、fragment对必要的部分重新编码保留字符集对齐RFC 3986再做后续参数排序和指纹计算。解码失败的场景要专门提醒。有人见到%就无脑调unquote_plus结果把%2B加号等特殊字符搞坏反而破坏了URL语义。正确处理是解码不是越多越好而是要保证最终进入指纹库的URL是标准形态。你如果发现任务日志里有url解码失败先看数据来源别盲目全局修复。2.2 参数顺序与追踪参数同一个页面有无限个URL身份这是重灾区。电商链接最常见的就是这种https://detail.example.com/item?id1001spm123 https://detail.example.com/item?spm123id1001 https://detail.example.com/item?id1001utm_sourcewechatutm_mediumshare这三个URL指向的页面内容几乎可以肯定是一样的。但按字符串比较它们是三条完全不同的记录。追踪参数spm、utm_*、from、channel、campaign等是营销归因系统故意加进去的对内容本身毫无影响却让去重系统徒增无数无效记录。我的处理套路是解析query到有序结构用urllib.parse.parse_qsl过滤掉已知追踪参数白名单剩余参数按key排序重新拼URL。白名单要根据具体业务维护常见的有spm、utm_source、utm_medium、utm_campaign、from、ref、share_token等。注意有些参数看似是追踪参数实际会影响页面内容比如page、sort、filter这种不能乱删。所以这个白名单最好能让业务方确认一遍。2.3 锚点、大小写、默认端口与协议变体除了编码和参数还有一堆边角料问题单个看不严重凑一起就能让指纹库乱成一锅粥。锚点fragment#section后面的部分不会发送到服务器去重前直接去掉。大小写协议和域名部分大小写不敏感HTTP://EXAMPLE.COM和http://example.com等价但path部分是大小写敏感的。不能简单地把整个URL转小写。默认端口https://example.com:443/path和https://example.com/path是同一个页面:80、:443应该规范化掉。协议变体有些站点的http和https都可用内容也一致是否合并看业务判断。一般建议统一跟随跳转以最终URL为准。尾部斜杠/path和/path/是否等价取决于服务器配置大多数Web框架里两者等价但保险做法是跟随一次重定向后取最终URL。我把这些规则整理成一个normalize_url函数这里是简化版你可以直接拿去改from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode, unquote TRACKING_PARAMS {spm, utm_source, utm_medium, utm_campaign, from, ref} def normalize_url(raw_url: str) - str: # 1. 整体解码 decoded unquote(raw_url.strip()) # 2. 拆分 parts urlsplit(decoded) # 3. 去锚点 fragment # 4. 过滤追踪参数并按key排序 query_pairs parse_qsl(parts.query, keep_blank_valuesTrue) filtered [(k, v) for k, v in query_pairs if k not in TRACKING_PARAMS] filtered.sort(keylambda kv: kv[0]) new_query urlencode(filtered) # 5. 规范化默认端口 hostname parts.hostname or try: if parts.port in (80, 443): netloc hostname else: netloc parts.netloc except ValueError: netloc parts.netloc # 6. 重新拼接 return urlunsplit((parts.scheme.lower(), netloc, parts.path, new_query, fragment))这里第三行的unquote要小心。如果原始URL里query部分本身就有编码过的保留字符比如%2F整体解码之后再urlencode会重新编码一般没问题。但要注意parse_qsl默认把当作空格必要时用keep_blank_valuesTrue配合encodingutf-8来处理。3. 从单机到分布式五种URL去重方案的实现与选型3.1 内存里的list/set只适合玩具项目先给一段很多人第一版代码里出现过的内容seen set() def is_seen(url: str) - bool: normalized_url normalize_url(url) if normalized_url in seen: return True seen.add(normalized_url) return False逻辑没问题但seen是纯内存结构爬虫进程一重启就全丢了URL量大了以后内存吃紧。一亿条URL平均每条按200字节算就是2GB普通开发机已经很紧张。所以这个方案我只用在小规模临时脚本、或者单进程短任务的开关判断里不放进正式管道。它适合理解的入门不适合生产。3.2 哈希指纹加落盘存储轻量级抄作业方案既然把完整URL字符串存进内存太大那就压缩成固定长度的指纹。用MD5或SHA1把normalize_url的结果哈希成固定长度的十六进制串再存文件或数据库。import hashlib def url_fingerprint(url: str) - str: normalized_url normalize_url(url) return hashlib.sha1(normalized_url.encode(utf-8)).hexdigest()这样做的好处是存储体量小、比较稳定缺点是哈希碰撞理论存在MD5碰撞已经被证明可实现在意就换SHA256以及每来一个URL都要先算哈希才能判重CPU开销比直接比较字符串大一点。但这点开销在大规模爬虫里完全可接受。落盘存储的话最简单的做法是弄一个大文件按行存指纹每来一个就二分查找缺点是无序插入性能差。实际项目里我更推荐按天分桶或者直接用SQLite建一个唯一索引表。这个方案适合单机、任务可中断、不要求实时共享的场景。3.3 布隆过滤器用可容忍的误判率换内存当URL量级到了十亿级即使存哈希指纹也显得笨重。这时候布隆过滤器Bloom Filter就该上场了。布隆过滤器的原理不复杂一个长度为m的位数组配上k个哈希函数。插入一个URL时把k个哈希结果对应的位置置1查询时看这k个位是否都是1全是1就认为可能存在。它的代价是有误判率False Positive可能把没见过的URL判成见过但反过来见过的URL一定不会被漏判。爬虫场景里偶尔漏爬一个新URL的代价远低于重复爬取所以误判完全可以接受。我用一个简单实现演示核心逻辑import math import mmh3 class SimpleBloomFilter: def __init__(self, capacity: int, error_rate: float 0.001): bits_count math.ceil(- (capacity * math.log(error_rate)) / (math.log(2) ** 2)) self.m max(bits_count, 1) self.k max(round(self.m / capacity * math.log(2)), 1) self.bits bytearray((self.m 7) // 8) def _positions(self, url: str): return [mmh3.hash(url, i) % self.m for i in range(self.k)] def add(self, url: str): for pos in self._positions(url): self.bits[pos 3] | 1 (pos 7) def contains(self, url: str) - bool: for pos in self._positions(url): if not (self.bits[pos 3] (1 (pos 7))): return False return Truecapacity是预期插入量error_rate是接受的误判率。m和k算好之后内存就能提前估算比如预期1亿条、误判率千分之一大约需要1.4亿位也就是17MB左右内存。和2GB的set相比完全不是一个量级。布隆过滤器的关键限制是不能删除元素。爬虫场景里这反而是优点因为爬过的URL这个集合天然只增不减。如果你非要支持删除就得换Counting Bloom Filter内存翻好几倍一般用不上。生产环境直接用现成库就行比如pybloom_live、pyprobablesRedis Stack也内置了Bloom命令。自己造版本只适合学习理解。3.4 Redis SET分布式系统的事实标准单机方案做得再好多机爬虫一上就全乱套。每个节点各存一套seen同一个URL在不同节点间完全无法共享重复率直接翻倍。所以分布式爬虫里去重几乎绕不开Redis。Redis下的标准做法是用SET结构import redis r redis.Redis(host10.0.0.5, port6379, db0, decode_responsesTrue) KEY crawler:seen:urls # 返回True表示首次见到False表示已经见过 def seen_and_add(url_fp: str) - bool: return bool(r.sadd(KEY, url_fp))这里有个细节用SADD的返回值判断是不是新增比先SISMEMBER再SADD省一次网络往返也从根本上避免了先查后插的竞态问题。这个习惯建议所有写爬虫的都养成。单个SET在URL数量特别大的时候会变成大keyBig Key拖慢Redis。于是又有人用分片思路把KEY加个后缀按URL指纹的前两个字符分到256个key里def shard_key(url_fp: str) - str: return fcrawler:seen:urls:{url_fp[:2]}分片之后每个key的数据量是原来的1/256读写压力分布均匀。这样做到几亿条URL都还比较稳。如果你连Redis都不想维护也可以考虑用云上的托管Redis或者内存数据库原理都一样。3.5 数据库兜底用唯一约束挡住最后一层前面所有去重都是尽力而为真正保证不重复的最后一层是数据库的唯一约束。尤其你用SQLAlchemy存爬虫数据时应该在模型上加唯一索引from sqlalchemy import Column, String, UniqueConstraint, Integer from sqlalchemy.ext.declarative import declarative_base Base declarative_base() class Page(Base): __tablename__ pages id Column(Integer, primary_keyTrue) url_fp Column(String(64), nullableFalse, indexTrue) url Column(String(1024), nullableFalse) __table_args__ ( UniqueConstraint(url_fp, nameuq_pages_url_fp), )入库时用先查后插有个经典竞态两个线程同时查都发现不存在然后同时插入唯一约束就会爆。所以更稳的是使用数据库的原子操作。PostgreSQL用INSERT ... ON CONFLICT DO NOTHINGMySQL用INSERT IGNORE或者干脆捕获IntegrityError。SQLAlchemy里的写法大致是from sqlalchemy.dialects.postgresql import insert as pg_insert stmt pg_insert(Page).values(url_fpfp, urlurl) stmt stmt.on_conflict_do_nothing(index_elements[url_fp]) session.execute(stmt) session.commit()这样即使缓存层的去重出了bug数据库也能兜住最后一层只是浪费了一次请求而已。数据库去重适合做结果层兜底不适合做请求前过滤——你不可能每发一个请求都查一次数据库那样太慢了。4. 将去重嵌入爬虫管道多层防线与指纹服务4.1 在哪个环节做去重最合理去重不是抓之前查一次就完事更合理的做法是在管道多个环节布防。链接生成环节提取出来的原始链接先做规范化直接干掉大量无意义变体请求入队前指纹服务判一次过滤掉已经爬过的URL请求出队时再次判重防止同一个URL被不同线程或节点重复放入队列下载结果入库前数据库唯一约束兜底。每一层都有自己的作用和成本。入队前去重最省钱拦截在最前面出队时去重能防并发竞争入库前兜底是最后保险。这四层不是冗余而是应对不同环节的失败模式。4.2 去重与抓取解耦独立指纹服务的设计思路当一个爬虫项目大到有多个爬虫并行跑新闻爬虫、商品爬虫、评论采集器每个爬虫各搞一套去重逻辑最后数据中心就是一团乱麻。我后来习惯把去重抽成一个独立服务所有爬虫共用同一个指纹服务。接口很简单本质上就两个功能POST /is_seen {url_fp: xxx} - {seen: true/false} POST /mark_seen {url_fp: xxx} - {first_seen: true/false}内部可以先放一个布隆过滤器做内存层过滤未命中的请求再推给Redis确认。这样设计的好处是新增爬虫不需要重新实现去重去重策略调整只在一处改指纹服务本身可以单独监控看每秒判重请求量、误判率等指标。4.3 去重逻辑缺陷的排查思路一次完整的踩坑过程去年我处理过一个去重逻辑存在缺陷的工单爬虫日志显示某个商品详情页在一天内被下载了37次。看到这个现象我的第一直觉是seen集合丢了但检查Redis内存发现集合还在问题不在存储。顺着排查链路走先打印出37次请求的完整URL。结果发现URL形态五花八门有的带spm参数有的不带还有的带着百分号编码。检查normalize_url发现过滤追踪参数的逻辑虽然写了但参数排序忘了做导致?id1page2和?page2id1被算成两个指纹。继续追踪发现有的URL在入队前被unquote过有的没有。根源是不同爬虫子任务的数据来源不一样有的从JS上下文里提取有的从API响应里拼接形态天然不一致。最终修复所有URL统一在入口处走一遍normalize_url指纹函数只接收规范化后的URL。同时把去重KEY改成按业务维度隔开避免不同业务的URL互相干扰。这次排查给我的核心教训是绝大多数去重缺陷都不是集合没存好而是URL形态没有先统一。你去重之前先想清楚一件事你存下来的到底是什么形态的URL如果指纹库里既有编码态又有解码态既有带参态又有去参态那后面跑多久都会出问题。5. 真实项目里的去重边界Web自动化巡检、短链接与反爬5.1 Web自动化巡检里的去重差异这里得单独说一下web页面自动化巡检这种场景。它和传统爬虫不一样巡检关心的是页面在某个时间点的状态所以同一个URL是应该、也可以在不同轮次重复访问的。如果把URL去重做得太狠巡检任务直接变成只跑第一轮后面全被过滤掉。正确做法是把去重单元从URL升级成URL 时间窗口或者URL 任务轮次。用Redis的话KEY可以是crawler:seen:urls:{round_id}每轮巡检一个独立的去重空间。这和普通爬虫页面内容基本不变就不要再下的思路刚好相反设计时一定要先分清楚自己是在做采集爬虫还是巡检爬虫。另外SPA页面有个头疼问题路由不变、内容在变。比如一个数据大屏URL永远是/dashboard前端定时拉接口刷新数据。这时候URL去重完全失效唯一可行的方案是记录页面内容指纹比如关键DOM文本块的哈希来做变化检测。抓回来之后算HTML语义摘要摘要变了才算内容变化。纯URL去重在动态渲染场景下帮不上忙别硬套。5.2 短链接与重定向链展开之后再判重短链接是去重系统另一个大坑。微博、淘宝分享、短信营销链接几乎都是短链如果不展开bit.ly/xxx和bit.ly/yyy指向同一个长链接你根本看不出来。现在的标准做法是入队前遇到重定向301/302时获取response.url最终跳转地址用最终URL算指纹。requests库的allow_redirectsTrue会默认跟随跳转。但要注意部分反爬场景下跳转过程中会携带临时token直接用最终URL访问可能token失效。稳妥的办法是只把最终URL作为指纹输入但实际发起请求时仍然使用原始URL或当前可用的URL必要的话单独维护一份原始URL到最终URL的映射关系。还有一个细节页面内的相对链接也要先基于当前页面URL做urljoin再进入规范化流程。很多人直接拼接字符串拼出来的URL要么缺域名要么带./冗余路径这种形态也会导致去重错乱。5.3 反爬联动重试和去重要分开再说一个实战细节重试不等于重复爬取。请求超时、返回502、被验证码打断这些情况下你会想让同一个URL重新进队列。但如果直接把它当成已爬过去重掉了这个页面就永远错过了。我的做法是把去重状态和重试状态拆成两个独立标记一个VISITED表示这个URL已经成功拿到过结果另一个RETRYING表示这个URL当前正在等待重试。只有VISITED才参与去重判断。像unexpected status 502 bad gateway这类错误正是触发RETRYING的场景千万别因为它就把URL打入冷宫。实际开发中我会在URL记录里加一个状态字段配合数据库或Redis维护。入队时先判断VISITED如果已经成功抓过就直接丢弃如果只是RETRYING可以按策略放行。这样既保证不重复抓取又不会因为一次失败就永久错过数据。6. 给不同规模项目的去重选型建议说了这么多方案最后给一个按项目规模的选型速查表项目规模URL量级推荐方案理由临时脚本万级内存set简单直接进程结束就完事单机定时任务百万级哈希指纹 SQLite持久化重启不丢实现成本低单机常态爬虫千万级布隆过滤器内存占用可控误判可接受多机分布式爬虫亿级Redis SET分片共享去重空间实时一致超大规模平台十亿级以上独立指纹服务 Redis 数据库兜底多层防线可扩展可监控这里的量级划分不是绝对的比如单机任务如果内存充裕用set到千万级也能跑只是不优雅。要点是方案升级的本质是用工程复杂度换内存和一致性。我也建议你在落地方案时把规范化和指纹计算做成独立的纯函数写进一个公共模块这样不管是换存储还是换项目这一段都能直接复用。我自己的经验是规范化函数写一次受益所有爬虫项目。写了这么多年代码一个很深的体会是URL去重看起来是爬虫体系里最小的一环但它恰恰是最容易埋雷的一环。你可以在解析、存储、调度上都做得漂漂亮亮但只要URL形态不统一去重逻辑有漏洞最后数据质量就是一塌糊涂。我个人现在的习惯是新爬虫项目开写之前先花半小时把normalize_url写好把指纹方案选好再写下载和解析逻辑。顺序反了后面大概率要回头补课。最后再分享一个小技巧每次加新的URL处理规则时准备一个包含各种畸形URL的测试样本集覆盖编码模式、追踪参数、短链接、默认端口这些情况跑一遍规范化函数输出变了就说明有bug。这个习惯帮我省了太多排查时间。