[原创]手把手教你写网络爬虫(7):URL去重
[原创]手把手教你写网络爬虫(7):URL去重
手把手教你写网络爬虫(7)
作者:拓海
摘要:从零开始写爬虫,初学者的速成指南!
封面:
本期我们来聊聊URL去重那些事儿。以前我们曾使用Python的字典来保存抓取过的URL,目的是将重复抓取的URL去除,避免多次抓取同一网页。爬虫会将待抓取的URL放在todo队列中,从抓取到的网页中提取到新的URL,在它们被放入队列之前,首先要确定这些新的URL是否被抓取过,如果之前已经抓取过了,就不再放入队列。
有别于单机系统,在分布式系统中,这些URL应该存放在公共缓存中,才能让多个爬虫实例共享,我们继续使用Redis缓存这些数据。URL既可以存储在Redis的Set数据结构中,也可以将URL作为Key存储为Redis的String类型。至于这两种方案各有什么优缺点,就留给读者自己去思考了。
直接存储URL
将URL以字符串的形式直接存储到内存中。保守估计一下URL的平均长度是100字节,那么1亿个URL所占的内存是: 100000000 * 0.0001MB = 10000MB,约等于10G。这也不是不能用,占用的空间再大都能通过扩容来解决。
问题是,如果一个服务器存不下这么多URL该怎么办呢?其实也简单,明确每台服务器的分工,也就是说得到一个URL就知道要交给哪台服务器存储,每台服务器只存储一类URL,比较简单的实现方式就是对URL先哈希再取模。虽然能用,但还是有很大优化空间的。
存储消息摘要
MD5是一个消息摘要算法,它的用途很广泛,我们这里用它来压缩URL。
消息摘要算法的特点:
- 无论输入的消息有多长,计算出来的消息摘要的长度总是固定的。
- 只要输入的消息不同,对其进行摘要以后产生的摘要消息也必不相同;但相同的输入必会产生相同的输出。
- 消息摘要是单向、不可逆的。只能进行正向的信息摘要,而无法从摘要中恢复出任何的原始消息。
以上特点说明我们可以通过存储URL的MD5来实现去重功能,因为不同的URL,MD5不同,相同的URL,MD5相同嘛。
对应的MD5值是这样的:d552b0b40e21d06d73a1a0938635eb1a
怎么样?省了不少空间吧?
有人说,拓海你不要骗我,这个算法的输入是个无穷集合,而输出是一个有限集合,必然会存在碰撞的,也就是存在不同的URL算出相同的MD5。这会导致去重时误判,少抓数据!
好吧,从理论上来说,必然会出现这种情况。可是出现这种情况的概率是多少呢?下面就算算两个不同URL产生相同消息摘要的概率。
以下是三种常见的消息摘要算法,分别是32、64、128字节,每个字节是十六进制数字的字符,它们的可能值数量分别是:
md5: 16^32 = 2^128 = 3.4 * 10^38
sha256: 16^64 = 2^256 = 1.2 × 10^77
sha512: 16^128 = 2^512 = 1.3 × 10^154
你可能会说,我是数字盲,我不知道这个数大不大。好吧,我为你找到了两个直观的参照物:
IPv6编码地址数:2^128(约3.4×10^38)
IPv6是IETF设计的用于替代现行版本IP协议(IPv4)的下一代IP协议,号称可以为全世界的每一粒沙子编上一个网址。
可观测宇宙中的原子总数:10^80
上图是哈勃望远镜对准天球上一个特定的区域(相当于整个天球面积的1/12700000)进行长时间的图像拍摄,最后在这个区域里面找到了约有10000个星系。这样可以合理的推测,目前我们能用天文望远镜观测到的宇宙范围内有1.27×10^11个星系。
一个星系的恒星数(行星忽略不计了)目前普遍接受的一个数量级是4×10^11个。
像太阳这样的恒星的质量是1.96×10^30kg。
这就可以算出宇宙的总质量为9.96×10^55kg。
一个氢原子的质量是1.66×10^-24g。
用宇宙质量除以一个氢原子的质量就得出了目前可观测宇宙中的近似原子个数是10^80个。
可见,不同URL产生相同消息摘要的可能性非常小,简直像大海捞针。。。不是,简直像宇宙中捞原子一样难,所以就放心使用吧。
消息摘要实现了对URL的压缩,但压缩后的大小还是和原来在一个数量级,空间效率并没有质的提升。有没有办法只用几个bit来唯一标识一个URL呢?有!布隆过滤器就是专门解决这类问题的。
布隆过滤器
Bloom Filter是一种空间效率很高的随机数据结构,它利用bit数组很简洁地表示一个集合,并能判断一个元素是否属于这个集合。Bloom Filter的这种高效是有一定代价的:在判断一个元素是否属于某个集合时,有可能会把不属于这个集合的元素误认为属于这个集合(false positive)。因此,Bloom Filter不适合那些“零错误”的应用场合。而在能容忍低错误率的应用场合下,Bloom Filter通过极少的错误换取了存储空间的极大节省。
它的原理很简单,首先需要准bit数组(所有位初始化为0)和k个独立hash函数。将hash函数对应的值的位数组置1,查找时如果发现所有hash函数对应位都是1说明存在,否则不存在。很明显这个过程并不保证查找的结果是100%正确的。
如何根据输入元素个数n,确定bit数组m的大小及hash函数个数呢?当hash函数个数k=(ln2)*(m/n)时错误率最小。在错误率不大于E的情况下,m至少要等于n*lg(1/E)才能表示任意n个元素的集合。但m还应该更大些,因为还要保证bit数组里至少一半为0,则m应该>=nlg(1/E)*lge ,大概就是nlg(1/E)的1.44倍。假设错误率为0.01,则此时m应是n的13倍。这样k大概是8个。
Google的Guava基础库里有布隆过滤器的实现,非常的简洁和有深度,我们一起来学习一下这段java代码。
1 public <T> boolean put(T object, Funnel<? super T> funnel, int numHashFunctions, BitArray bits) { 2 long bitSize = bits.bitSize(); 3 long hash64 = Hashing.murmur3_128().hashObject(object, funnel).asLong(); 4 int hash1 = (int) hash64; 5 int hash2 = (int) (hash64 >>> 32); 6 7 boolean bitsChanged = false; 8 for (int i = 1; i <= numHashFunctions; i++) { 9 int combinedHash = hash1 + (i * hash2); 10 // Flip all the bits if it's negative (guaranteed positive number) 11 if (combinedHash < 0) { 12 combinedHash = ~combinedHash; 13 } 14 bitsChanged |= bits.set(combinedHash % bitSize); 15 } 16 return bitsChanged; 17 }
01 函数的功能是把一条数据hash后保存到BitArray中,如果BitArray有变化则返回true,否则返回false。参数是数据、hash函数个数、BitArray地址。
03 使用murmur3 hash出一个long型的值。为什么是一个,不应该是numHashFunctions个吗?请往下看。
04 05 把hash64切成两半,变成hash1和hash2。
08 09 重点来了,numHashFunctions个hash函数原来是这么来的:hash1+(i*hash2)。Excuse me? 这种操作太随意了吧?不用担心,请看《Less Hashing, Same Performance: Building a Better Bloom Filter》,里面论述了这种操作不会影响布隆过滤器的性能:A standard technique from the hashing literature is to use two hash functions h1(x) and h2(x) to simulate additional hash functions of the form gi(x) = h1(x) + ih2(x). We demonstrate that this technique can be usefully applied to Bloom filters and related data structures. Specifically, only two hash functions are necessary to effectively implement a Bloom filter without any loss in the asymptotic false positive probability.这个优化非常有用,毕竟hash的代价还是很大的。
11 12 是负的就取反(这里的操作都很粗暴)。
14 设置BitArray里对应的bit,下面进入set()里看看。
1 boolean set(long index) { 2 if (!get(index)) { 3 data[(int) (index >>> 6)] |= (1L << index); 4 bitCount++; 5 return true; 6 } 7 return false; 8 } 9 10 boolean get(long index) { 11 return (data[(int) (index >>> 6)] & (1L << index)) != 0; 12 }
02 先get()一下,看看是不是已经置为1。
03 index右移6位就是除以64,说明data是long型的数组,除以64就定位到了bit所在的数组下标。1L左移index位,定位到了bit在long中的位置。
下一步
以上就是URL去重的一点思路,希望对大家有帮助。下期打算为大家介绍下字符编解码,以及乱码的完美解决方案。再见!