URL去重的几种方法

   在爬虫启动工作的过程中,我们不希望同一个网页被多次下载,因为重复下载不仅会浪费CPU机时,还会为搜索引擎系统增加负荷。而想要控制这种重复性下载问题,就要考虑下载所依据的超链接,只要能够控制待下载的URL不重复,基本可以解决同一个网页重复下载的问题。 
   非常容易想到,在搜索引擎系统中建立一个全局的专门用来检测,是否某一个URL对应的网页文件曾经被下载过的URL存储库,这就是方案。 
接着要考虑的就是如何能够更加高效地让爬虫工作,确切地说,让去重工作更加高效。如果实现去重,一定是建立一个URL存储库,并且已经下载完成的URL在进行检测时候,要加载到内存中,在内存中进行检测一定会比直接从磁盘上读取速度快很多。 
我们先从最简单的情况说起,然后逐步优化,最终得到一个非常不错的解决方案。 


第一,基于磁盘的顺序存储。 
    这里,就是指把每个已经下载过的URL进行顺序存储。你可以把全部已经下载完成的URL存放到磁盘记事本文件中。每次有一个爬虫线程得到一个任务URL开始下载之前,通过到磁盘上的该文件中检索,如果没有出现过,则将这个新的URL写入记事本的最后一行,否则就放弃该URL的下载。 
    这种方式几乎没有人考虑使用了,但是这种检查的思想是非常直观的。试想,如果已经下载了100亿网页,那么对应着100亿个链接,也就是这个检查URL是否重复的记事本文件就要存储这100亿URL,况且,很多URL字符串的长度也不小,占用存储空间不说,查找效率超级低下,这种方案肯定放弃。


第二,基于Hash算法的存储。 
    对每一个给定的URL,都是用一个已经建立好的Hash函数,映射到某个物理地址上。当需要进行检测URL是否重复的时候,只需要将这个URL进行Hash映射,如果得到的地址已经存在,说明已经被下载过,放弃下载,否则,将该URL及其Hash地址作为键值对存放到Hash表中。 
    这样,URL去重存储库就是要维护一个Hash表,如果Hash函数设计的不好,在进行映射的时候,发生碰撞的几率很大,则再进行碰撞的处理也非常复杂。而且,这里使用的是URL作为键,URL字符串也占用了很大的存储空间。
    为了尽快把整个爬虫搭建起来,最开始的URL去重采用方案是一个内存中的HashSet,这是最直观的方法,所有人都能想得到。HashSet中放置的就是URL的字符串,任何一个新的URL首先在HashSet中进行查找,如果HashSet中没有,就将新的URL插入HashSet,并将URL放入待抓取队列。


优势:去重效果精确,不会漏过一个重复的URL。
劣势:Out Of Memory。因为随着抓取网页的增加,HashSet会一直无限制的增长。


另外,网络中的很多URL其实是很长的,有大量的URL长度达到上百个字符。
   简单估算一下,假设单个URL的平均长度是100 byte(我觉着这已经非常保守了),那么抓取1000万的URL就需要:
    100 byte * 10 000 000 = 1 GB
而1000万URL在整个互联网中实在是沧海一粟。可以了解,需要多大的内存才能装下所有URL的HashSet。


第三,基于MD5压缩映射的存储。 
    MD5算法是一种加密算法,同时它也是基于Hash的算法。这样就可以对URL字符串进行压缩,得到一个压缩字符串,同时可以直接得到一个Hash地址。另外,MD5算法能够将任何字符串压缩为128位整数,并映射为物理地址,而且MD5进行Hash映射碰撞的几率非常小,这点非常好。从另一个方面来说,非常少的碰撞,对于搜索引擎的爬虫是可以容忍的。况且,在爬虫进行检测的过程中,可以通过记录日志来保存在进行MD5时发生碰撞的URL,通过单独对该URL进行处理也是可行的。
    貌似有不少paper中讨论过如何对URL进行压缩,包括新浪微博中的短URL其实也是个不错的方案,为了偷懒,我直接用MD5对URL做编码。
    MD5的结果是128 bit也就是16 byte的长度。相比于之间估计的URL平均长度100byte已经缩小了好几倍,可以多撑好多天了。
    当然,哪怕找个一个可以压缩到极致的算法,随着URL越来越多,终有一天会Out Of Memory。所以,这个方案不解决本质问题。


   在Java中有一个Map类非常好,你可以将压缩后的URL串作为Key,而将Boolean作为Value进行存储,然后将工作中的Map在爬虫停止工作后序列化到本地磁盘上;当下一次启动新的爬虫任务的时候,再将这个Map反序列化到内存中,供爬虫进行URL去重检测。 


第四,基于嵌入式Berkeley DB的存储。 


   Berkeley DB的特点就是只存储键值对类型数据,这和URL去重有很大关系。去重,可以考虑对某个键,存在一个值,这个值就是那个键的状态。 


   使用了Berkeley DB,你就不需要考虑进行磁盘IO操作的性能损失了,这个数据库在设计的时候很好地考虑了这些问题,并且该数据库支持高并发,支持记录的顺序存储和随机存储,是一个不错的选择。 


   URL去重存储库使用Berkeley DB,压缩后的URL字符串作为Key,或者直接使用压缩后的URL字节数组作为Key,对于Value可以使用Boolean,一个字节,或者使用字节数组,实际Value只是一个状态标识,减少Value存储占用存储空间。 
   我终于明白我所需要的其实是一个可以放在disk上的去重方案,这样,内存溢出将永远成不了可能。很早就知道有BerkeleyDB这么一个东西,但第一次真正了解还是在Amazon的Dynamo那篇论文中提到过采用了BerkeleyDB作为单机上的底层存储。当时觉着这东西真另类,原来还有叫做“DB”的东西却不支持SQL。那时候还没有NOSQL这词,把这样的东西叫做non-relational database。
BerkeleyDB是一个key-value database,简单的说,就是一个在disk上的hash表,这也是为什么它可以被用来做URL去重的原因。它另外一个另类的地方是,它是和程序运行在同一个进程空间中的,而不像一般的db,是做为单独的程序运行。
这里附上Heritrix中使用BerkeleyDB做URL去重的代码,一探究竟:(代码位于Heritrix源代码的org.archive.crawler.util.BdbUriUniqFilter)
   有一堆做初始化和配置的函数就直接忽略了,真正相关的函数就只有两个:
   [java] view plaincopy 
/** 
 * Create fingerprint. 
 * Pubic access so test code can access createKey. 
 * @param uri URI to fingerprint. 
 * @return Fingerprint of passed <code>url</code>. 
 */  
public static long createKey(CharSequence uri) {  
    String url = uri.toString();  
    int index = url.indexOf(COLON_SLASH_SLASH);  
    if (index > 0) {  
        index = url.indexOf('/', index + COLON_SLASH_SLASH.length());  
    }  
    CharSequence hostPlusScheme = (index == -1)? url: url.subSequence(0, index);  
    long tmp = FPGenerator.std24.fp(hostPlusScheme);  
    return tmp | (FPGenerator.std40.fp(url) >>> 24);  
}  




[java] view plaincopy 
    /** 
     * value: only 1 byte 
     */  
    private static DatabaseEntry ZERO_LENGTH_ENTRY = new DatabaseEntry(  
            new byte[0]);  


    protected boolean setAdd(CharSequence uri) {  
        DatabaseEntry key = new DatabaseEntry();  
        LongBinding.longToEntry(createKey(uri), key);  
        long started = 0;  


        OperationStatus status = null;  
        try {  
            if (logger.isLoggable(Level.INFO)) {  
                started = System.currentTimeMillis();  
            }  
            status = alreadySeen.putNoOverwrite(null, key, ZERO_LENGTH_ENTRY);  
            if (logger.isLoggable(Level.INFO)) {  
                aggregatedLookupTime +=  
                    (System.currentTimeMillis() - started);  
            }  
        } catch (DatabaseException e) {  
            logger.severe(e.getMessage());  
        }  
        if (status == OperationStatus.SUCCESS) {  
            count++;  
            if (logger.isLoggable(Level.INFO)) {  
                final int logAt = 10000;  
                if (count > 0 && ((count % logAt) == 0)) {  
                    logger.info("Average lookup " +  
                        (aggregatedLookupTime / logAt) + "ms.");  
                    aggregatedLookupTime = 0;  
                }  
            }  
        }  
        if(status == OperationStatus.KEYEXIST) {  
            return false; // not added  
        } else {  
            return true;  
        }  
    }  
简单解释一下:


第一个函数createKey是在做URL的压缩,它将任意长度的URL转换成一个long型的值。long型的取值范围有2^64,因此两个URL映射成同一个long型值的概率应该挺低的。但我也没太细看这个函数,所以它的效果到底如何不确定。


第二个函数setAdd就是将被压缩的URL写入到BerkeleyDB。之前说过,BerkeleyDB是一个key-value database,它的每条记录都包括了一个key和一个value。但是在URL去重中,value不重要(比如我们之前内存中用的也是HashSet而不是HashMap),因此这里统一用一个byte长度的值来表示value,就是这个static变量ZERO_LENGTH_ENTRY。


别看setAdd有这么多行,真正有用的就这一行:


[java] view plaincopy 
status = alreadySeen.putNoOverwrite(null, key, ZERO_LENGTH_ENTRY);  
将压缩后得到的long型值作为key,ZERO_LENGTH_ENTRY作为value插入到BerkeleyDB中,如果db中已经有了这个long型值,就会返回OperationStatus.KEYEXIST,表示对应的URL之前已经抓取到了,那么这个URL就不会放入待抓取队列中。
第五,基于布隆过滤器(Bloom Filter)的存储。 


    使用布隆过滤器,设计多个Hash函数,也就是对每个字符串进行映射是经过多个Hash函数进行映射,映射到一个二进制向量上,这种方式充分利用了比特位。 
    基于内存的HashSet的方法存在一个本质的问题,就是它消耗的内存是随着URL的增长而不断增长的。除非能够保证内存的大小能够容纳下所有需要抓取的URL,否则这个方案终有一天会到达瓶颈。
   这时候就会想,要找一个类似于HashSet的但所消耗的内存相对固定而不会不断增长的方案,于是自然想到了Bloom Filter。关于Bloom Filter的概念这里就不多谈了,网上随处可以找到。我简单尝试了一下Bloom Filter,但是很快就放弃了。基于Bloom Filter的方案有几个问题:
第一个是理论上的。Bloom Filter会将一些正常的样本(在我这就是没有抓取过的URL)过滤掉,即所谓的False Positive。当然,这概率有多大,取决于Bloom Filter的参数设置。但这引出了下一个问题;
第二个是实践中的,即Bloom Filter的那几个参数应该如何设置?m,k,n应该设置成多少才合适,这个我没有经验,而且可能需要反复的实验和测试才能够比较好的确定下来;
    以上两个问题还不是我放弃Bloom Filter的根本原因,真实的原因是我在做的是一个爬虫框架,上面可以会启动很多的爬虫任务,每个任务可能抓取自己特定的URL,而且任务之间是独立的。这样,对于每个任务都需要有一个Bloom Filter,虽然对于单一任务它使用Bloom Filter所消耗的内存是固定的,但是任务的增多会导致更多的Bloom Filter,从而导致更多的内存消耗。仍然存在内存溢出的可能。
    但如果只是一个抓取任务,那么采用Bloom Filter应该是一个非常不错的选择。


可以参考Google的
http://www.googlechinablog.com/2007/07/bloom-filter.html






转自:
http://hi.baidu.com/shirdrn/blog/item/40ed0fb1ceac4d5c0923029d.html
http://blog.csdn.net/f81892461/article/details/8592505

猜你喜欢

转载自blog.csdn.net/hellozhxy/article/details/80942581
今日推荐