2.2 去重与队列:URL 仓库怎么建

场景:跑十次就有十份重复

最小爬虫跑通之后,下一个坑来得比想象的快。加了一个简单的循环之后,重复抓取就出现了:同一条新闻昨天采过,今天列表里还在,又被采了一遍;还有的页面被两个不同入口同时发现,先后抓了两次。第一天测试就在 MySQL 里看到重复行,虽然业务侧还没发现,但我们知道这个问题不解决,平台没法上线。

重复抓取不只是浪费带宽。详情页更新频率很低,每天的重复请求几乎都是无效流量,还增加被目标站识别为爬虫的风险。所以在单机阶段,我们就把 URL 仓库当作核心组件来设计,而不是顺手写的辅助函数。

URL 仓库这个词是我们自己造的,它包含三样东西:一个待抓队列、一个已抓集合、一套指纹规则。三样合起来回答两个问题——下一个抓谁,这个抓过没有。听起来简单,但它后来演变成 master 的核心模块,在 3.1 节你会看到它长成什么样子。

方案:指纹加双层去重

去重的核心是 URL 指纹。直接用完整 URL 做判断会被查询参数坑——同一个页面带上不同的 utm 参数、时间戳就是不同的串,但内容完全相同。所以指纹的生成规则必须先规范化:小写化、去掉跟踪类参数、统一末尾斜杠、去掉 fragment。规范化之后算一个 MD5 或 SHA1,作为 URL 的唯一身份。

去重分两层,应对两种重复:

去重层 判定对象 存哪里 生命周期
URL 层 是否已经抓过这个 URL Redis 集合 长期保留
内容层 页面正文是否变化 MySQL 结果表 与结果同生命周期

URL 层之外还要有内容层,这是因为有几个站的详情页 URL 不变但内容会更新(比如榜单页),只看 URL 会漏采。内容层的实现是给正文算一个内容指纹存进结果表,每次抓取比对,变了就更新记录。这套机制在 3.3 节断点续爬里还会演进。

这里有一个反直觉的决策:URL 层的判定故意做得宽松。只要 URL 规范化之后不完全相同就算新 URL,宁可多抓也不漏抓——漏采是业务灾难,多抓只是浪费一点带宽。内容层负责兜底,把多抓的重复内容在落库时合并。用宽松的入口加严格的出口,而不是入口就卡死,是采集系统比搜索系统不一样的地方。

队列部分的选型我们纠结过一阵:

方案 优点 被我们否掉的原因
Go channel 零依赖,速度快 进程重启全丢
MySQL 表加状态字段 持久可靠 高频轮询写库压力大
Redis 有序集合 内存快、可持久化、带优先级 单点要主从
本地文件队列 简单 多 worker 无法共享

最后选了 Redis:单机原型阶段它和爬虫跑在同一台机器上,成本为零;到第三篇拆成 master 加 worker 时,它自然成为 master 上的中央队列,架构没推翻。这算是 1.4 节技术地图里消息层选择的实际兑现。后来量大了我们也给 Redis 配了主从,不过那是 5.1 节之后的事。

多说一句选择 Redis 的另一个原因:有序集合这个结构天然把优先级队列和去重集合统一在同一个引擎里,省了引入第二个组件。小团队的原则是组件能少则少,每个组件都是一份运维负担。

配置与代码:指纹与优先级队列

指纹生成的核心逻辑,规则越早定越省心:

func urlFingerprint(rawURL string) string {
    u, err := url.Parse(rawURL)
    if err != nil {
        return ""
    }
    u.Fragment = ""
    q := u.Query()
    for _, k := range []string{"utm_source", "utm_medium", "ts"} {
        q.Del(k)
    }
    u.RawQuery = q.Encode()
    u.Path = strings.TrimSuffix(u.Path, "/")
    h := md5.Sum([]byte(strings.ToLower(u.String())))
    return hex.EncodeToString(h[:])
}

这里删除的跟踪参数列表是随业务补充的,早期只有 utm 两项,后来陆续加上时间戳、来源标识等噪声参数。判断要不要删的标准是:这个参数会不会让两个内容相同的 URL 长得不一样。会就删,不会就留。宁可漏删几个,也别误删业务参数——误删会导致不同页面被算成同一个指纹,属于更严重的错误。

入队与判重是一体的,靠 Redis 的原子操作保证不会重复:

const queueKey = "crawl:queue"
const seenKey = "crawl:seen"

func Push(ctx context.Context, rdb *redis.Client, fp string, score float64) error {
    n, err := rdb.SAdd(ctx, seenKey, fp).Result()
    if err != nil {
        return err
    }
    if n == 0 {
        return nil // 已存在,不入队
    }
    return rdb.ZAdd(ctx, queueKey, redis.Z{Score: score, Member: fp}).Err()
}

score 用优先级加时间组合:P0 任务用 100 加时间戳的倒数,让紧急任务总是排在前面。这套评分后来在 3.1 节的分片设计里原样搬到了 master 上。

配套的弹出逻辑也简单:ZPopMin 取分数最低的成员,取到空就休眠一小会儿再试,避免忙轮询把 CPU 烧满。休眠间隔从十毫秒起步,队列空时指数退避到一秒,这个退避逻辑后来在 3.2 节限速设计里又出现了一次。

持久化是单机阶段的隐形需求。Redis 默认的 RDB 快照间隔是可配置的,我们设成五分钟一次,最坏情况丢十分钟队列状态——配合任务状态表(见 3.3 节),这个丢失完全可承受。队列本来就是待办清单,丢了可以重建,真正丢不得的是任务执行状态,那个从一开始就写在 MySQL 里。

踩坑:指纹与队列的三个暗坑

第一个是规范化规则不统一。最开始 master 和 worker 各写了一份规范化函数,细节有差异,导致同一 URL 在两边算出的指纹不同,去重失效。后来把指纹函数收敛到一个公共包,任何服务调用都走它。规范化的细节必须只有一份实现。

第二个是 seen 集合只增不减。跑了几个月之后集合膨胀到几千万条,内存占用抬头。我们的对策是按站点分桶存储,并对确认长期下线的站点整桶清理。全量指纹的量级本身就是 1.1 节估算表里快照年增长那一行的兄弟数字。

第三个是队列积压无感知。有一次目标站突然更新洪峰,队列堆了十几万条没人发现,因为程序不报错只是慢。从那以后队列长度成为一个监控指标,超过阈值就告警,这条规则在 5.1 节的告警表里有。

第四个坑是去重判断的时机。有一版实现里,URL 弹出队列之后才检查是否抓过,导致重复条目频繁占位。正确做法是入队时就判重,队列里根本不存在重复成员,这一进一出差一个位置,吞吐稳定性差一截。

第五个坑是指纹里的协议与主机名。有一阵子同一个站同时存在 http 与 https 两种入口,www 与裸域名也混着出现,四种子组合各算一份指纹,重复抓取四遍。解法是在规范化里统一 https 与主域名(带跳转确认的站点才敢合并,不能硬合),并对确实等价的域名做映射表。域名归并是必须人工参与的环节,别指望全自动。

顺带一个数量级的提醒:五百万 URL 的指纹集合,每个 32 字节,带 Redis 的开销约两三百 MB,单机内存就行。但如果你用完整 URL 字符串做指纹(不哈希、不规范化),同样的量能吃掉一个多 GB,还会因为参数噪声继续膨胀。指纹这步设计,从第一天就在省钱。

小结

URL 仓库是采集平台的心脏:指纹管谁是谁,队列管谁先谁后。把指纹规范化规则收敛到单一实现、选一个能平滑过渡到分布式的队列方案,这两件事做对了,第三篇的分布式改造会顺很多。下一步把结果从终端挪进 MySQL,是 2.3 节。关于去重在多机环境下的演化,可以到 3.1 和 3.3 节继续看。

关于指纹库的规模还有一个具体的经验。单机阶段我们用 Redis 的 Set 存指纹,量到五百万之后内存开始紧张,于是引入布隆过滤器做前置过滤:不存在的指纹直接放行并落库,可能存在的再查一次精确集合。布隆过滤器误判率设为百分之一时,内存占用降到原来的二十分之一,而那百分之一的重复最多走一次队列,随后被精确层拦下。分层过滤是所有量级问题的通用解,花小钱办大事。顺带一句:布隆过滤器的初始容量按预估两年的指纹量一次性给足,容量中途扩容代价高,宁可一开始多留余量。另外布隆过滤器不能删除已存在的元素,我们的做法是每半年重建一次:新过滤器平行上线,写入双写一段时间,然后整体切换,旧过滤器直接丢弃。重建窗口期内误判率略升,重复请求会在队列精确层被拦下,业务侧完全无感知。

延伸阅读