布隆过滤器在UV统计中的应用

一.内存瓶颈

用Flink统计UV(Unique Visitor:网站独立访客数)这个指标时,需要根据UserId进行去重(同一个用户的多个操作,只会统计一次)。开始的思路是通过一个Set数据结构,保存一个窗口中所有的userID,因为Set数据结构本身具有去重功能,最后计算Set的Size即可得到统计指标。

但是当时并未考虑将一个计算窗口中的所有数据存入Set中内存会是一个瓶颈。试想:一个小时的窗口,发生的用户访问行为,在某些大型网站上,可能是千万,甚至上亿级别。

下面通过简单的定量分析说明:

假如UserId 是一个Long 类型,占8Byte,假设1小时访问量1亿,则保存这些ID需要占用:

    8 * 10^8  = 800 * 10^6  Byte

10^6大概是兆(M)级别,所以大概需要800M内存,而实际中UserID可能不止8Byte, 所以为了去重,需要额外申请将近 1GB内存。

而这仅仅是保存在Set中的UserId的内存,在触发窗口计算前,每一条操作日志都会保存在窗口状态中,这里假设日志平均大小为 1KB,则大约需要占用

(1KB/8Byte) * 800M =  100GB  

这个数量显然已经无法接受了。如果再考虑到设置延迟数据,可能会有多个窗口同时在计算,那简直不可想象。

所以通过全窗口在内存中去重的思路完全不切实际,有人会说这里用户量估计的太大了,其实根据上面的估算,即使是百万级别的用户量,也得需要数GB的内存,也是无法承受的。当然,如果用户量真的少的可怜,那建议你还是不要上Flink,因为杀鸡焉用宰牛刀。

所以,要解决这个问题,布隆过滤器再合适不过了!

二.什么是布隆过滤
概念

布隆过滤器(Bloom Filter)是1970年由布隆提出。它实际上是由一个很长的二进制向量(又称位数组 或 二进制数组)和一系列随机映射函数(hash函数)组成。

它的主要功能就是用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远超一般的算法。

想到判断元素是否存在,我们可以轻易想到比如 :

  • 链表
  • 散列表(又叫哈希表,Hash table)

但是随着集合中元素的增加,这些数据结构存储空间会越来越大,同时检索速度也越来越慢,上述三种结构的检索时间复杂度分别为 O(n),O(log n),O(1)。

布隆过滤器的原理是

当一个元素被加入集合时,通过K个散列函数将这个元素映射成一个二进制数组中的K个点,把它们置为1。检索时,我们只要看看这些点是不是都是1就(大约)知道集合中有没有它了:如果这些点有任何一个0,则被检元素一定不在;如果都是1,则被检元素很可能在。这就是布隆过滤器的基本思想。

【插入一个状态】
在这里插入图片描述

【判断是否存在集合中】
在这里插入图片描述

这里解释下大约的语义:

因为hash计算,存在hash冲突的可能,所以有可能存在误判的情况,所以布隆过滤的难点就是如何设计合理的映射函数,避免冲突。之所以设计多个hash函数就是为了降低冲突的概率,两个元素经过多个hash函数映射后,每个结果都相等的概率就会大大降低。同时,实际中可能会让二进制数组空间相对稀疏一点,这样也可以避免冲突。

我们简单计算一下为何布隆过滤非常节省内存:直观上,布隆过滤用一个bit位来记录存在还是不存在,0和1刚好可以代表true和false。假设需要保存1亿个状态,则仅需要内存12.5M

10^8 * 1bit = 10^8/8 Byte =  10^6 * 12.5 M

保存一亿个状态值,仅仅需要数十兆内存,我们完全可以接受。

按照这个思路,其实统计上亿级别用户的UV其实很简单,我们可以申请一个非常大的二进制向量空间,为了保持一定稀疏度,我们申请容量为2^29次方 个状态位,大约是5亿多个状态位【一般为了方便内存划分,会取2的整次方幂】,这5亿多个状态会占用的内存为:

2^20(M) * 2^3 (B) * 2^6(64) = 64MB

只需要大概64M内存即可。然后我们再设计一个hash函数,每来一个条访问记录,只需要将userId计算hash,得到一个偏移量,根据偏移量将二进制向量中对应位置的位置置为1.如果该用户已经统计过,则该位置已经置为1,不需要重复记录(达到去重目的)。最后只需要统计二进制向量中每一位上为1的个数,就是要统计的UV指标。

但布隆过滤器也不是完美无缺,首先就是有误判率,虽然可以通过优化降低误判率,但并没有消除。所以对于需要精准统计的场景,不适合用它。另一个缺点就是删除困难,随着使用时间增长,存入元素越来越多,误判率也会增加,最后只能重置。

布隆过滤场景应用场景
  • 网页爬取中防止重复URL去重

  • UV统计

  • 垃圾邮件过滤

    从数十亿个垃圾邮件列表中判断某邮箱是否为垃圾邮箱;

  • 缓存穿透:

    缓存穿透的问题主要是因为传进来的key在Redis中是不存在的,那么就会直接打在DB上,造成DB压力增大。针对这种情况,可以在Redis前加上布隆过滤器,预先把数据库中的数据加入到布隆过滤器中,因为布隆过滤器的底层数据结构是一个二进制向量,所以占用的空间并不是很大。这样

    在查询Redis之前先通过布隆过滤器判断是否存在,如果不存在就直接返回,
    
    如果存在的话,按照原来的流程还是查询Redis,Redis不存在则查询DB。
    
三.如何设计一个布隆过滤器
自定义方案

布隆过滤主要是由一个二进制数组和hash函数组成,因为redis中有关于bit位的操作,鉴于此,我们只需要一个简单的hash函数,再借助redis的位操作,就可以实现一个简单的布隆过滤器:

class Bloom(size: Long) extends Serializable {
  private val cap = size //默认cap应该是2的整次幂

  def hash(value: String, seed: Int): Long = {
    var result = 0
    for (i <- 0 until value.length) {
      result = result * seed + value.charAt(i)
    }

    //返回hash值,要映射到cap范围内。
    (cap - 1) & result
  }
}

当然,刚才的思路中有很多优化的地方,比如需要设计多个hash函数来降低hash碰撞概率,还有就是如何统计二进制数组中为1的个数,这里常规的遍历算法直觉上可能不行,因为二进制数组长度太大(5亿多),这里实际上涉及到另一个著名算法 “计算汉明重量”(Hamming Weight),这个算法就是通过一些巧妙的二进制位运算来实现。redis中的位操作正是这么做的。

工业级方案

作为一个非常常用的数据结构,布隆过滤器有优化程度很高的工业级算法。

前面说过redis支持bit位操作,实际上只需要在redis编译进相关的插件,即可实现非常高效的布隆过滤器。

除此之外,还有google的guava工具库中也有完整的方案,使用也非常简单:

1.添加依赖

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>19.0</version>
</dependency>

2.使用

创建一个布隆过滤器。

BloomFilter<String> filter = BloomFilter.create(Funnels.stringFunnel(Charset.defaultCharset()), 1<<29,0.001);

参数说明:

Funnels :第一个参数是一个存储类型的包装类,针对不同的数据类型,会使用不同的hash算法。

1<<29: 第二个参数,指明要申请的二进制向量空间的大小,这里申请的是2^29次方个状态位

0.001:第三个参数是传入一个期望的误判率,0.001表示期望误判率不超过千分之一。误判率越低,计算成本越高。

添加元素

filter.put("userid-101");//会计算hash,并更新二进制向量空间

判断元素是否存在集合中

filter.mightContain("userid-101");

另外Flink依赖中,scala版本中对google的api用scala进行了重写,内容基本一致。所以Flink中可以直接调用。

我自己在项目中选择的方案是 Flink重写版布隆过滤器

关键代码如下:

//Flink中重写的布隆过滤器。
class MyAllWindowFunc2 extends AllWindowFunction[UserBehavior, UVCount, TimeWindow] {
  lazy val bloom = BloomFilter.create(Funnels.stringFunnel(Charset.forName("UTF-8")),1<<29,0.0001D)
  lazy val jedis = new Jedis("192.168.111.12", 6379)

  override def apply(w: TimeWindow, iterable: Iterable[UserBehavior], collector: Collector[UVCount]): Unit = {
    //设置了触发器,每来一条数据,会触发一次计算
    //1.通过布隆过滤,判断该条数据的userId是否已经统计
    val userId = iterable.iterator.next().userId.toString

    val flag = bloom.mightContain(userId)

    //2.如果已经统计,则不做处理,如果没有则进行计数累加,并更新布隆过滤器
    if (!flag) {
      //先取出旧值
      val uvCountMap = "uvCount"
      val currentKey = w.getEnd.toString
      var count = 0L
      if (jedis.hget(uvCountMap, currentKey) != null) {
        count = jedis.hget(uvCountMap, currentKey).toLong
      }
      //累加后,存入
      jedis.hset(uvCountMap, currentKey, (count + 1).toString)
      bloom.put(userId)

    }


  }
}

四.思考延伸

我觉得软件开发更多的是工程性问题,很多时候要用正确的方法,才能事半功倍。采用一个正确的数据结构,可能比你费尽心思的优化来的更加高效。同时我们要善于站在巨人的肩膀上,避免重复造轮子,如果你知道ECDHE-RSA算法的诞生,是由5个图灵奖获得者一起研究出来的,一上来就说要弄个新算法超越它,可能性你觉得有多少?

在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
如今的大数据技术应用场景,对实时性的要求已经越来越高。作为新一代大数据流处理框架,由于非常好的实时性,Flink独树一帜,在近些年引起了业内极大的兴趣和关注。Flink能够提供毫秒级别的延迟,同时保证了数据处理的低延迟、高吞吐和结果的正确性,还提供了丰富的时间类型和窗口计算、Exactly-once 语义支持,另外还可以进行状态管理,并提供了CEP(复杂事件处理)的支持。Flink在实时分析领域的优势,使得越来越多的公司开始将实时项目向Flink迁移,其社区也在快速发展壮大。目前,Flink已经成为各大公司实时领域的发力重点,特别是国内以阿里为代表的一众大厂,都在全力投入,不少公司为Flink社区贡献了大量源码。如今Flink已被很多人认为是大数据实时处理的方向和未来,很多公司也都在招聘和储备了解掌握Flink的人才。本教程将Flink理论与电商数据分析项目实战并重,对Flink基础理论知识做了系统的梳理和阐述,并通过电商用户行为分析的具体项目用多个指标进行了实战演练。为有志于增加大数据项目经验、扩展流式处理框架知识的工程师提供了学习方式。二、教程内容和目标本教程主要分为两部分:第一部分,主要是Flink基础理论的讲解,涉及到各种重要概念、原理和API的用法,并且会有大量的示例代码实现;第二部分,以电商作为业务应用场景,以Flink作为分析框架,介绍一个电商用户行为分析项目的开发实战。通过理论和实际的紧密结合,可以使学员对Flink有充分的认识和理解,在项目实战对Flink和流式处理应用的场景、以及电商分析业务领域有更深刻的认识;并且通过对流处理原理的学习和与批处理架构的对比,可以对大数据处理架构有更全面的了解,为日后成长为架构师打下基础。三、谁适合学1、有一定的 Java、Scala 基础,希望了解新的大数据方向的编程人员2、有 Java、Scala 开发经验,了解大数据相关知识,希望增加项目经验的开发人员3、有较好的大数据基础,希望掌握Flink及流式处理框架的求职人员
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值