CategoryResourceRepost/极客时间专栏/数据结构与算法之美/高级篇/45 | 位图:如何实现网页爬虫中的URL去重功能?.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

132 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="45 | 位图如何实现网页爬虫中的URL去重功能" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fb/db/fb7ed125488ca7bdfba1fbabb1a864db.mp3"></audio>
网页爬虫是搜索引擎中的非常重要的系统,负责爬取几十亿、上百亿的网页。爬虫的工作原理是,通过解析已经爬取页面中的网页链接,然后再爬取这些链接对应的网页。而**同一个网页链接有可能被包含在多个页面中,这就会导致爬虫在爬取的过程中,重复爬取相同的网页。如果你是一名负责爬虫的工程师,你会如何避免这些重复的爬取呢?**
最容易想到的方法就是我们记录已经爬取的网页链接也就是URL在爬取一个新的网页之前我们拿它的链接在已经爬取的网页链接列表中搜索。如果存在那就说明这个网页已经被爬取过了如果不存在那就说明这个网页还没有被爬取过可以继续去爬取。等爬取到这个网页之后我们将这个网页的链接添加到已经爬取的网页链接列表了。
思路非常简单,我想你应该很容易就能想到。不过,我们该如何记录已经爬取的网页链接呢?需要用什么样的数据结构呢?
## 算法解析
关于这个问题,我们可以先回想下,是否可以用我们之前学过的数据结构来解决呢?
这个问题要处理的对象是网页链接也就是URL需要支持的操作有两个添加一个URL和查询一个URL。除了这两个功能性的要求之外在非功能性方面我们还要求这两个操作的执行效率要尽可能高。除此之外因为我们处理的是上亿的网页链接内存消耗会非常大所以在存储效率上我们要尽可能地高效。
我们回想一下,满足这些条件的数据结构有哪些呢?显然,散列表、红黑树、跳表这些动态数据结构,都能支持快速地插入、查找数据,但是在内存消耗方面,是否可以接受呢?
我们拿散列表来举例。假设我们要爬取10亿个网页像Google、百度这样的通用搜索引擎爬取的网页可能会更多为了判重我们把这10亿网页链接存储在散列表中。你来估算下大约需要多少内存
假设一个URL的平均长度是64字节那单纯存储这10亿个URL需要大约60GB的内存空间。因为散列表必须维持较小的装载因子才能保证不会出现过多的散列冲突导致操作的性能下降。而且用链表法解决冲突的散列表还会存储链表指针。所以如果将这10亿个URL构建成散列表那需要的内存空间会远大于60GB有可能会超过100GB。
当然对于一个大型的搜索引擎来说即便是100GB的内存要求其实也不算太高我们可以采用分治的思想用多台机器比如20台内存是8GB的机器来存储这10亿网页链接。这种分治的处理思路我们讲过很多次了这里就不详细说了。
对于爬虫的URL去重这个问题刚刚讲到的分治加散列表的思路已经是可以实实在在工作的了。不过**作为一个有追求的工程师,我们应该考虑,在添加、查询数据的效率以及内存消耗方面,是否还有进一步的优化空间呢?**
你可能会说散列表中添加、查找数据的时间复杂度已经是O(1)还能有进一步优化的空间吗实际上我们前面也讲过时间复杂度并不能完全代表代码的执行时间。大O时间复杂度表示法会忽略掉常数、系数和低阶并且统计的对象是语句的频度。不同的语句执行时间也是不同的。时间复杂度只是表示执行时间随数据规模的变化趋势并不能度量在特定的数据规模下代码执行时间的多少。
如果时间复杂度中原来的系数是10我们现在能够通过优化将系数降为1那在时间复杂度没有变化的情况下执行效率就提高了10倍。对于实际的软件开发来说10倍效率的提升显然是一个非常值得的优化。
如果我们用基于链表的方法解决冲突问题散列表中存储的是URL那当查询的时候通过哈希函数定位到某个链表之后我们还需要依次比对每个链表中的URL。这个操作是比较耗时的主要有两点原因。
一方面链表中的结点在内存中不是连续存储的所以不能一下子加载到CPU缓存中没法很好地利用到CPU高速缓存所以数据访问性能方面会打折扣。
另一方面链表中的每个数据都是URL而URL不是简单的数字是平均长度为64字节的字符串。也就是说我们要让待判重的URL跟链表中的每个URL做字符串匹配。显然这样一个字符串匹配操作比起单纯的数字比对要慢很多。所以基于这两点执行效率方面肯定是有优化空间的。
对于内存消耗方面的优化,除了刚刚这种基于散列表的解决方案,貌似没有更好的法子了。实际上,如果要想内存方面有明显的节省,那就得换一种解决方案,也就是我们今天要着重讲的这种存储结构,**布隆过滤器**Bloom Filter
在讲布隆过滤器前,我要先讲一下另一种存储结构,**位图**BitMap。因为布隆过滤器本身就是基于位图的是对位图的一种改进。
我们先来看一个跟开篇问题非常类似、但比那个稍微简单的问题。**我们有1千万个整数整数的范围在1到1亿之间。如何快速查找某个整数是否在这1千万个整数中呢**
当然这个问题还是可以用散列表来解决。不过我们可以使用一种比较“特殊”的散列表那就是位图。我们申请一个大小为1亿、数据类型为布尔类型true或者false的数组。我们将这1千万个整数作为数组下标将对应的数组值设置成true。比如整数5对应下标为5的数组值设置为true也就是array[5]=true。
当我们查询某个整数K是否在这1千万个整数中的时候我们只需要将对应的数组值array[K]取出来看是否等于true。如果等于true那说明1千万整数中包含这个整数K相反就表示不包含这个整数K。
不过很多语言中提供的布尔类型大小是1个字节的并不能节省太多内存空间。实际上表示true和false两个值我们只需要用一个二进制位bit就可以了。**那如何通过编程语言,来表示一个二进制位呢?**
这里就要用到位运算了。我们可以借助编程语言中提供的数据类型比如int、long、char等类型通过位运算用其中的某个位表示某个数字。文字描述起来有点儿不好理解我把位图的代码实现写了出来你可以对照着代码看下应该就能看懂了。
```
public class BitMap { // Java中char类型占16bit也即是2个字节
private char[] bytes;
private int nbits;
public BitMap(int nbits) {
this.nbits = nbits;
this.bytes = new char[nbits/16+1];
}
public void set(int k) {
if (k &gt; nbits) return;
int byteIndex = k / 16;
int bitIndex = k % 16;
bytes[byteIndex] |= (1 &lt;&lt; bitIndex);
}
public boolean get(int k) {
if (k &gt; nbits) return false;
int byteIndex = k / 16;
int bitIndex = k % 16;
return (bytes[byteIndex] &amp; (1 &lt;&lt; bitIndex)) != 0;
}
}
```
从刚刚位图结构的讲解中,你应该可以发现,位图通过数组下标来定位数据,所以,访问效率非常高。而且,每个数字用一个二进制位来表示,在数字范围不大的情况下,所需要的内存空间非常节省。
比如刚刚那个例子如果用散列表存储这1千万的数据数据是32位的整型数也就是需要4个字节的存储空间那总共至少需要40MB的存储空间。如果我们通过位图的话数字范围在1到1亿之间只需要1亿个二进制位也就是12MB左右的存储空间就够了。
关于位图我们就讲完了是不是挺简单的不过这里我们有个假设就是数字所在的范围不是很大。如果数字的范围很大比如刚刚那个问题数字范围不是1到1亿而是1到10亿那位图的大小就是10亿个二进制位也就是120MB的大小消耗的内存空间不降反增。
这个时候,布隆过滤器就要出场了。布隆过滤器就是为了解决刚刚这个问题,对位图这种数据结构的一种改进。
还是刚刚那个例子数据个数是1千万数据的范围是1到10亿。布隆过滤器的做法是我们仍然使用一个1亿个二进制大小的位图然后通过哈希函数对数字进行处理让它落在这1到1亿范围内。比如我们把哈希函数设计成f(x)=x%n。其中x表示数字n表示位图的大小1亿也就是对数字跟位图的大小进行取模求余。
不过你肯定会说哈希函数会存在冲突的问题啊一亿零一和1两个数字经过你刚刚那个取模求余的哈希函数处理之后最后的结果都是1。这样我就无法区分位图存储的是1还是一亿零一了。
为了降低这种冲突概率,当然我们可以设计一个复杂点、随机点的哈希函数。除此之外,还有其他方法吗?我们来看布隆过滤器的处理方法。既然一个哈希函数可能会存在冲突,那用多个哈希函数一块儿定位一个数据,是否能降低冲突的概率呢?我来具体解释一下,布隆过滤器是怎么做的。
我们使用K个哈希函数对同一个数字进行求哈希值那会得到K个不同的哈希值我们分别记作$X_{1}$$X_{2}$$X_{3}$,…,$X_{K}$。我们把这K个数字作为位图中的下标将对应的BitMap[$X_{1}$]BitMap[$X_{2}$]BitMap[$X_{3}$]BitMap[$X_{K}$]都设置成true也就是说我们用K个二进制位来表示一个数字的存在。
当我们要查询某个数字是否存在的时候我们用同样的K个哈希函数对这个数字求哈希值分别得到$Y_{1}$$Y_{2}$$Y_{3}$,…,$Y_{K}$。我们看这K个哈希值对应位图中的数值是否都为true如果都是true则说明这个数字存在如果有其中任意一个不为true那就说明这个数字不存在。
<img src="https://static001.geekbang.org/resource/image/94/ae/94630c1c3b7657f560a1825bd9d02cae.jpg" alt="">
对于两个不同的数字来说经过一个哈希函数处理之后可能会产生相同的哈希值。但是经过K个哈希函数处理之后K个哈希值都相同的概率就非常低了。尽管采用K个哈希函数之后两个数字哈希冲突的概率降低了但是这种处理方式又带来了新的问题那就是容易误判。我们看下面这个例子。
<img src="https://static001.geekbang.org/resource/image/d0/1a/d0a3326ef0037f64102163209301aa1a.jpg" alt="">
布隆过滤器的误判有一个特点,那就是,它只会对存在的情况有误判。如果某个数字经过布隆过滤器判断不存在,那说明这个数字真的不存在,不会发生误判;如果某个数字经过布隆过滤器判断存在,这个时候才会有可能误判,有可能并不存在。不过,只要我们调整哈希函数的个数、位图大小跟要存储数字的个数之间的比例,那就可以将这种误判的概率降到非常低。
尽管布隆过滤器会存在误判但是这并不影响它发挥大作用。很多场景对误判有一定的容忍度。比如我们今天要解决的爬虫判重这个问题即便一个没有被爬取过的网页被误判为已经被爬取对于搜索引擎来说也并不是什么大事情是可以容忍的毕竟网页太多了搜索引擎也不可能100%都爬取到。
弄懂了布隆过滤器,我们今天的爬虫网页去重的问题,就很简单了。
我们用布隆过滤器来记录已经爬取过的网页链接假设需要判重的网页有10亿那我们可以用一个10倍大小的位图来存储也就是100亿个二进制位换算成字节那就是大约1.2GB。之前我们用散列表判重需要至少100GB的空间。相比来讲布隆过滤器在存储空间的消耗上降低了非常多。
那我们再来看下,利用布隆过滤器,在执行效率方面,是否比散列表更加高效呢?
布隆过滤器用多个哈希函数对同一个网页链接进行处理CPU只需要将网页链接从内存中读取一次进行多次哈希计算理论上讲这组操作是CPU密集型的。而在散列表的处理方式中需要读取散列值相同散列冲突的多个网页链接分别跟待判重的网页链接进行字符串匹配。这个操作涉及很多内存数据的读取所以是内存密集型的。我们知道CPU计算可能是要比内存访问更快速的所以理论上讲布隆过滤器的判重方式更加快速。
## 总结引申
今天关于搜索引擎爬虫网页去重问题的解决我们从散列表讲到位图再讲到布隆过滤器。布隆过滤器非常适合这种不需要100%准确的、允许存在小概率误判的大规模判重场景。除了爬虫网页去重这个例子还有比如统计一个大型网站的每天的UV数也就是每天有多少用户访问了网站我们就可以使用布隆过滤器对重复访问的用户进行去重。
我们前面讲到布隆过滤器的误判率主要跟哈希函数的个数、位图的大小有关。当我们往布隆过滤器中不停地加入数据之后位图中不是true的位置就越来越少了误判率就越来越高了。所以对于无法事先知道要判重的数据个数的情况我们需要支持自动扩容的功能。
当布隆过滤器中,数据个数与位图大小的比例超过某个阈值的时候,我们就重新申请一个新的位图。后面来的新数据,会被放置到新的位图中。但是,如果我们要判断某个数据是否在布隆过滤器中已经存在,我们就需要查看多个位图,相应的执行效率就降低了一些。
位图、布隆过滤器应用如此广泛很多编程语言都已经实现了。比如Java中的BitSet类就是一个位图Redis也提供了BitMap位图类Google的Guava工具包提供了BloomFilter布隆过滤器的实现。如果你感兴趣你可以自己去研究下这些实现的源码。
## 课后思考
<li>
假设我们有1亿个整数数据范围是从1到10亿如何快速并且省内存地给这1亿个数据从小到大排序
</li>
<li>
还记得我们在[哈希函数(下)](https://time.geekbang.org/column/article/67388)讲过的利用分治思想,用散列表以及哈希函数,实现海量图库中的判重功能吗?如果我们允许小概率的误判,那是否可以用今天的布隆过滤器来解决呢?你可以参照我们当时的估算方法,重新估算下,用布隆过滤器需要多少台机器?
</li>
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。