关于网页内容搜索项目的思考

由于需要做一个关于搜索的项目,数据来源于爬虫爬取的文本数据,那么就设计到一些业务问题的考虑。
首先是爬虫的技术选型,考虑到海量的数据,首先考虑的是Python的Scrapy框架,架构图如下:


image.png

原因当然是支持自动化爬取,只需要定义开始URL,以及解析数据的代码和定义自己需要的Pipeline,就可以通过自己的Scheduler自动调度DownLoader爬取内容并通过Pipeline输出。
由于我使用的是Java爬虫,所以可以使用一个开源的Java爬虫框架WebMagic,一个类似于Scrapy的爬虫框架,架构图如下:


image.png

爬虫项目的主要要求主要有:
1.支持大规模数据量。
2.异步请求速度快
3.自动化调度
4.网页内容去重
5.不可重复爬取
6.敏感词过滤
7.搜索性能高
关于前三个要求使用WebMagic就可以解决。

网页内容去重

关于网页内容去重,为什么要进行网页内容去重,在一些网站中,不同的URL可能爬取到的内容可能是相同的,为了解决这种数据冗余,这里介绍几种解决方案:

1.指纹码对比。

最常见的去重方案是生成文档的指纹码。例如对一篇文章进行MD5加密生成一个字符串,我们可以认为这是文章的指纹码,再和其他的文章指纹码对比,一致则说明文章重复。但是这种方式是完全一致则是重复的,如果文章只是多了几个标点符号,那仍旧被认为是重复的,这种方式并不合理。

2.布隆过滤器。

这种方式与url进行去重的方式一样,使用在这里的话,也是对文章进行计算得到一个数,再进行对比,缺点和方法1是一样的,如果只有一点点不一样,也会认为不重复,这种方式不合理。

3.KMP字符串匹配。

KMP算法是一种改进的字符串匹配算法。KMP算法的关键是利用匹配失败后的信息,尽量减少模式串与主串的匹配次数以达到快速匹配的目的。能够找到两个文章有哪些是一样的,哪些不一样。这种方式能够解决前而两个方式的“只要一点不一样就是不重复”的问题。但是它的时空复杂度太高了,不适合大数据量的重复比对
还有一些其他的去重方式:最长公共子串、后缀数组、字典树、DFA等等,但是这些方式的空间复杂度并不适合数据量较大的工业应用场景。

4.SimHash

simhash是由Charikar 在2002年提出来的,为了便于理解尽量不使用数学公式,分为这几步:

1、分词,把需要判断文本分词形成这个文章的特征单词。

2、hash,通过hash算法把每个词变成hash值,比如“美国”通过hash算法计算为100101,“51区”通过hash算法计算为101011。这样我们]的字符串就变成了一串串数字。

3、加权,通过2步骤的hash生成结果,需要按照单词的权重形成加权数字串,“美国”的hash值为“100101”,通过加权计算为“4 -4 -4 4 -4 4",“51区”计算为“5 -5 5 -5 5 5”。

4、 合并,把上而各个单词算出来的序列值累加,变成只有一个序列串。“美国”的“4 -4 -4 4 -4 4",“51区”的“5 -5 5 -5 5 5”,把每一位进行累加,"4+5 -4-5 -4+5 4+-5 -4+5 4+5" ---> "9 -9 1 -1 1 9"

5、降维,把算出来的"9 -9 1 -1 1 9"变成01串,形成最终的simhash签名。
签名距离计算:
我们文木都转换为simhash签名,并转换为long类型存储,空间大大减少。现在我们虽然解决了空间,但是如何计算两个simhash的相似度呢?

我们通过海明距离(Hamming distance) 就可以计算出两个simhash到底相似不相似。两个simhash对应二进制(01串)取值不同的数量称为这两个simhash的海明距离。

举例如下:10101 和00110从第一位开始依次有第一位、第四、第五位不同,则海明距离为3。对于二进制字符串的a和b,海明距离为等于在a XOR b运算结果中1的个数(普遍算法)。
SimHash主要应用于关于新闻内容的去重。

5.基于flink的内容去重。

Flink常见的去重方案:
1.基于状态后端
2.基于HyperLogLog
3.基于布隆过滤器(BloomFilter)
4.基于BitMap
5.基于外部数据库

1.基于状态后端

Flink状态后端的种类之一就是RocksDBStateBackend。他会将正在运行中的状态数据保存在RocksDB数据库中,该数据库默认将数据存储在TaskManager运行节点的数据目录下。

public class MapStateDistinctFunction extends KeyedProcessFunction,Tuple2> {
    private transient ValueState counts;
    @Override
    public void open(Configuration parameters) throws Exception {
        //我们设置 ValueState 的 TTL 的生命周期为24小时,到期自动清除状态
        StateTtlConfig ttlConfig = StateTtlConfig
        .newBuilder(org.apache.flink.api.common.time.Time.minutes(24 * 60))      .setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite)        .setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired)  
.build();
 
        //设置 ValueState 的默认值
        ValueStateDescriptor descriptor = new ValueStateDescriptor("skuNum", Integer.class);
        descriptor.enableTimeToLive(ttlConfig);
        counts = getRuntimeContext().getState(descriptor);
        super.open(parameters);
    }
 
    @Override
    public void processElement(Tuple2 value, Context ctx, Collector> out) throws Exception {
        String f0 = value.f0;
        //如果不存在则新增
        if(counts.value() == null){
            counts.update(1);
        }else{
            //如果存在则加1
            counts.update(counts.value()+1);
        }
        out.collect(Tuple2.of(f0, counts.value()));
    }
}

逻辑:定义一个MapStateDistinctFunction类,该类继承了KeyesProcessFunction。核心的处理逻辑在processElement中,当有一条数据经过时,会在MapState中判断这条数据是否已经存在,如果不存在那么计数为1,如果存在,那么在原来的计数加1.
注意:这里定义了状态的过期时间是24小时,在实际生产中大量的key会使得状态膨胀,可以对存储的key进行处理,比如使用加密方法把key加密成几个字节进行在存储。

2.基于HyperLogLog

HyperLogLog 是一种估计统计算法,被用来统计一个集合中不同数据的个数,也就是我们所说的去重统计。HyperLogLog 算法是用于基数统计的算法,每个 HyperLogLog 键只需要花费 12 KB 内存,就可以计算接近 2 的 64 方个不同元素的基数。HyperLogLog 适用于大数据量的统计,因为成本相对来说是更低的,最多也就占用 12KB 内存。
在不需要100%精确的业务场景喜爱,可以使用这种方法进行统计,新增依赖:


  net.agkn
  hll
  1.6.0

public class HyperLogLogDistinct implements AggregateFunction,HLL,Long> {
 
    @Override
    public HLL createAccumulator() {
        return new HLL(14, 5);
    }
 
    @Override
    public HLL add(Tuple2 value, HLL accumulator) {
        //value 为访问记录 <商品sku, 用户id>
        accumulator.addRaw(value.f1);
        return accumulator;
    }
 
    @Override
    public Long getResult(HLL accumulator) {
        long cardinality = accumulator.cardinality();
        return cardinality;
    }
 
    @Override
    public HLL merge(HLL a, HLL b) {
        a.union(b);
        return a;
    }
}

在上面的代码中,addRaw 方法用于向 HyperLogLog 中插入元素。如果插入的元素非数值型的,则需要 hash 过后才能插入。accumulator.cardinality() 方法用于计算 HyperLogLog 中元素的基数。

需要注意的是,HyperLogLog 并不是精准的去重,如果业务场景追求 100% 正确,那么一定不要使用这种方法。

3.基于布隆过滤器(BloomFilter)

public class BloomFilterDistinct extends KeyedProcessFunction {
    private transient ValueState bloomState;
    private transient ValueState countState;
    @Override
    public void processElement(String value, Context ctx, Collector out) throws Exception {
        BloomFilter bloomFilter = bloomState.value();
        Long skuCount = countState.value();
        if(bloomFilter == null){
            BloomFilter.create(Funnels.unencodedCharsFunnel(), 10000000);
        }
        if(skuCount == null){
            skuCount = 0L;
        }
        if(!bloomFilter.mightContain(value)){
            bloomFilter.put(value);
            skuCount = skuCount + 1;
        }
        bloomState.update(bloomFilter);
        countState.update(skuCount);
        out.collect(countState.value());
    }
}

使用 Guava 自带的 BloomFilter,每当来一条数据时,就检查 state 中的布隆过滤器中是否存在当前的 数据,如果没有则初始化,如果有则数量加 1。

4.基于BitMap

HyperLogLog 和 BloomFilter 虽然减少了存储但是丢失了精度, 这在某些业务场景下是无法被接受的。下面的这种方法不仅可以减少存储,而且还可以做到完全准确,那就是使用 BitMap。

Bit-Map 的基本思想是用一个 bit 位来标记某个元素对应的 Value,而 Key 即是该元素。由于采用了 Bit 为单位来存储数据,因此可以大大节省存储空间。

假设有这样一个需求:在 20 亿个随机整数中找出某个数 m 是否存在其中,并假设 32 位操作系统,4G 内存。在 Java 中,int 占 4 字节,1 字节 = 8 位(1 byte = 8 bit)
如果每个数字用 int 存储,那就是 20 亿个 int,因而占用的空间约为 (2000000000*4/1024/1024/1024)≈7.45G
如果按位存储就不一样了,20 亿个数就是 20 亿位,占用空间约为 (2000000000/8/1024/1024/1024)≈0.233G


   org.roaringbitmap
   RoaringBitmap
   0.8.0

public class BitMapDistinct implements AggregateFunction {
 
    @Override
    public Roaring64NavigableMap createAccumulator() {
        return new Roaring64NavigableMap();
    }
 
    @Override
    public Roaring64NavigableMap add(Long value, Roaring64NavigableMap accumulator) {
        accumulator.add(value);
        return accumulator;
    }
 
    @Override
    public Long getResult(Roaring64NavigableMap accumulator) {
        return accumulator.getLongCardinality();
    }
 
    @Override
    public Roaring64NavigableMap merge(Roaring64NavigableMap a, Roaring64NavigableMap b) {
        return null;
    }
}

在上述方法中,我们使用了 Roaring64NavigableMap,其是 BitMap 的一种实现,然后我们的数据是每次被访问的 数据,把它直接添加到 Roaring64NavigableMap 中,最后通过 accumulator.getLongCardinality() 可以直接获取结果。

5.基于外部数据库

假如业务场景非常复杂,并且数据量很大。为了防止无限制的状态膨胀,也不想维护庞大的 Flink 状态,可以采用外部存储的方式,比如可以选择使用 Redis 或者 HBase 存储数据,只需要设计好存储的 Key 即可。同时使用外部数据库进行存储,我们不需要关心 Flink 任务重启造成的状态丢失问题,但是有可能会出现因为重启恢复导致的数据多次发送,从而导致结果数据不准的问题。

爬取URL去重

为什么要进行URL去重?
我们为了爬取性能尽量的高,一般采取分布式加多线程构建爬虫。
分布式意味着多台机器,那么爬取的URL可能会重复,怎么解决这个问题,以下解决方案提供参考。

1.HashSet

使用java中的HashSet不能重复的特点去重。
优点:容易理解。使用方便。
缺点:占用内存大,性能较低

2.Redis去重

使用Redis的set进行去重。

优点:速度快(Redis本身速度就很快),而且去重不会占用爬虫服务器的资源,可以处理更大数据量的数据爬取。

缺点:需要准备Redis 服务器,增加开发和使用成本。

具体实现可以这样构建:定义一个queue和一个set结构,set结构对URL进行去重,队列存储爬取URL的优先级,多个机器共享所定义的Set和Queue,就可以实现不会对同个URL的爬取,而且对于一个大型的网站可以实现优先爬取和后续爬取。此外使用多线程技术可以加快爬取速度。
Redis去重主流的去重方案,而且Scrapy集成Redis方便,WebMagic也是一样。

3.布隆过滤器(BloomFilter)

  • 使用布隆过滤器也可以实现去重。

    优点:占用的内存要比使用HashSet要小的多,也话合大量数据的去重操作。

    缺点:有误判的可能。没有重复可能会判定重复,但是重复数据一定会判定重复。

布隆过滤器(Bloom Filter)是由Burton Howard Bloom于1970年提出,它是一种space efficient的概率型数据结构,用于判断一个元素是否在集合中。在垃圾邮件过滤的黑白名单方法、爬虫(Crawler)的网址判重模块中等等经常被用到。

哈希表也能用于判断元索是否在集合中,但是布隆过滤器只需要哈希表的1/8或1/4的空间复杂度就能完成相同的问题。布隆过滤器可以插入元素,但不可以删除已有元素。其中的元素越多,误报率越大,但是漏报是不可能的。

以下是一个布隆过滤器的实现,可以参考:

public class BloomFilter {
    /**
     * BitSet 初始分配2^24个bit
     */
    private static final int DEFAULT_SIZE = 1 << 24;

    /**
     * 不同哈希函数的种子,一般应取质数
     */
    private static int[] seeds = new int[]{ 5, 7, 11, 13, 31, 37};

    private final BitSet bits = new BitSet(DEFAULT_SIZE);

    /**
     * 哈希函数对象
     */
    private SimpleHash[] func = new SimpleHash[seeds.length];

    public BloomFilter() {
        for (int i = 0; i < seeds.length; i++) {
            func[i] = new SimpleHash(DEFAULT_SIZE, seeds[i]);
        }
    }

    /**
     * 将str标记到bits中
     * @param str str
     */
    public void add(String str) {
        for (SimpleHash f : func) {
            bits.set(f.hash(str), true);
        }
    }

    /**
     * 说明:这个求hash的算法是在网上随便找的
     * 判断bits中是否已经包含str
     * @param str str
     * @return boolean
     */
    public boolean contains(String str) {
        // null "" "  "
        if (StringUtils.isBlank(str)) {
            return false;
        }

        boolean ret = true;
        // 遍历,该value对应的值是否都在bitset
        for (SimpleHash f : func) {
            ret = ret && bits.get(f.hash(str));
        }

        return ret;
    }
}

以下提供一个爬虫模块的实现架构:
zookeeper监控爬虫,使用Watcher节点上线与下线监控,使用spring boot实现定时与节点信息邮件发送功能。


image.png

注:zookeeper节点为临时节点。
如有更好的方案欢迎留言交流,如有错误欢迎指正。如果对您有用麻烦点个赞!!!
参考链接:https://blog.csdn.net/u013411339/article/details/112756745

你可能感兴趣的:(关于网页内容搜索项目的思考)