数据去重方案(汇总)

数据去重方案
0、总结
1、精准去重

1)Java 数据结构
HashSet\LinkedHashSet\TreeSet

2)对数据编码分组
MD5编码\Hash分组

3)BitMap
RoaringBitMap\Roaring64NavigableMap

4)借助外部存储
主键\去重键

2、近似去重

1)BloomFilter

2)HyperLogLog
1、Java 数据结构
1)HashSet 去重

应用场景:当数据量较小,能够全部加载到内存中,可以使用HashSet去重。

2)LinkedHashSet 去重

LinkedHashSet 是 HashSet 的子类,去重的同时,保留了元素的插入顺序。

应用场景:当数据量较小,需要保持元素的插入顺序时,可以使用LinkedHashSet进行去重。

3)TreeSet去重

TreeSet 是有序的集合,使用红黑树存储元素,保证了元素的唯一性。

应用场景:当数据量较小,需要对元素进行排序时,可以使用TreeSet进行去重。

4)对数据内容求MD5值

MD5值的特点

1.压缩性:任意长度的数据,算出的MD5值长度都是固定的。

2.容易计算:从原数据计算出MD5值很容易。

3.抗修改性:对原数据进行任何改动,哪怕只修改1个字节,所得到的MD5值都有很大区别。

4.强抗碰撞:已知原数据和其MD5值,想找到一个具有相同MD5值的数据(即伪造数据)是非常困难的。

根据MD5值的特点,对每条记录的维度数据内容计算MD5值,然后根据MD5值判断重复记录,对数据入库之后利用sql直接查出重复数据,然后将重复数据移除或者标记。

应用:

选择特定的字段(能够唯一标识数据的字段),使用加密算法(MD5,sha1)将字段加密,生成字符串,存入Redis的集合中;

后续新来一条数据,同样的方式加密,如果得到的字符串在Redis中存在,说明数据存在,对数据进行更新,否则说明数据不存在,对数据进行插入。

5)hash分组

有两份50G的数据去重,内存4G?

将50G的数据做hash%1000,分成1000个文件,如果有重复,那么A和B的重复数据一定在相对同一个文件内,因为hash结果是一样的,将1000个文件分别加载进来,比对是否有重复数据。

思想:先把所有数据按照相关性分组,相关的数据会处于同样或者接近的位置,再将小文件进行对比。

2、布隆过滤器(BloomFilter)
1)数据结构

BloomFilter 是由一个**长度为m比特的位数组(bit array)k个哈希函数(hash function)**组成的数据结构,位数组均初始化为 0,哈希函数可以把输入数据尽量均匀的散列。

2)增删改查
1.插入

插入一个元素时,将其数据分别输入k个哈希函数,产生k个哈希值,以哈希值作为位数组中的下标,将所有k个对应的比特置为1。

2.查询

当要查询(即判断是否存在)一个元素时,同样将其数据输入哈希函数,然后检查对应的k个比特,如果有任意一个比特为0,表明该元素一定不在集合中。

注意:

如果所有比特均为1,则该数据有较大可能在集合中,因为一个比特被置为1有可能会受到其它元素的影响。

3)参考
1、Guava中的布隆过滤器:com.google.common.hash.BloomFilter类
2、开源java实现(Counting BloomFilter、Redis BloomFilter):https://github.com/Baqend/Orestes-Bloomfilter
3、Redis BloomFilter:https://oss.redis.com/redisbloom/,基于redis做存储后端的BloomFilter实现,可以将bit位存储在redis中,防止计算任务在重启后,当前状态丢失的问题。
4、BloomFilter不支持删除,CuckooFilter可以支持删除操作:https://github.com/MGunlogson/CuckooFilter4J 
4)案例
1.背景

上游产生的消息为三元组,三个元素分别代表站点ID、子订单ID和数据,数据源为AtLeastOnce,会重复投递子订单数据,导致下游各统计结果偏高,现引入 Guava 的 BloomFilter 去重。

2.去重逻辑

先按照站点ID为key分组,然后在每个分组内创建存储子订单ID的布隆过滤器。

布隆过滤器的期望最大数据量应该按每天产生子订单最多的那个站点来设置,这里设为100万,可容忍的误判率为1%,单个布隆过滤器需要8个哈希函数,其位图占用内存约114MB。

每当一条数据进入时,调用BloomFilter.mightContain()方法判断对应的子订单ID是否已出现过,当没出现过时,调用put()方法将其插入BloomFilter,并交给Collector输出。

注册第二天凌晨0时0分0秒的processing time计时器,在onTimer()方法内重置布隆过滤器,开始新一天的去重。

3.代码
  // dimensionedStream 为 DataStream>
  DataStream dedupStream = dimensionedStream
    .keyBy(0)
    .process(new SubOrderDeduplicateProcessFunc(), TypeInformation.of(String.class))
    .name("process_sub_order_dedup")
    .uid("process_sub_order_dedup");
--------------------------------------------------------------------------------------------------
  public static final class SubOrderDeduplicateProcessFunc
    extends KeyedProcessFunction, String> {
    private static final long serialVersionUID = 1L;
    private static final Logger LOGGER = LoggerFactory.getLogger(SubOrderDeduplicateProcessFunc.class);
    private static final int BF_CARDINAL_THRESHOLD = 1000000;
    private static final double BF_FALSE_POSITIVE_RATE = 0.01;

    private volatile BloomFilter subOrderFilter;

    @Override
    public void open(Configuration parameters) throws Exception {
      long s = System.currentTimeMillis();
      subOrderFilter = BloomFilter.create(Funnels.longFunnel(), BF_CARDINAL_THRESHOLD, BF_FALSE_POSITIVE_RATE);
      long e = System.currentTimeMillis();
      LOGGER.info("Created Guava BloomFilter, time cost: " + (e - s));
    }

    @Override
    public void processElement(Tuple3 value, Context ctx, Collector out) throws Exception {
      long subOrderId = value.f1;
      if (!subOrderFilter.mightContain(subOrderId)) {
        subOrderFilter.put(subOrderId);
        out.collect(value.f2);
      }
      ctx.timerService().registerProcessingTimeTimer(UnixTimeUtil.tomorrowZeroTimestampMs(System.currentTimeMillis(), 8) + 1);
    }

    @Override
    public void onTimer(long timestamp, OnTimerContext ctx, Collector out) throws Exception {
      long s = System.currentTimeMillis();
      subOrderFilter = BloomFilter.create(Funnels.longFunnel(), BF_CARDINAL_THRESHOLD, BF_FALSE_POSITIVE_RATE);
      long e = System.currentTimeMillis();
      LOGGER.info("Timer triggered & resetted Guava BloomFilter, time cost: " + (e - s));
    }

    @Override
    public void close() throws Exception {
      subOrderFilter = null;
    }
  }

  // 根据当前时间戳获取第二天0时0分0秒的时间戳
  public static long tomorrowZeroTimestampMs(long now, int timeZone) {
    return now - (now + timeZone * 3600000) % 86400000 + 86400000;
  }
3、HyperLogLog(HLL)
1)概述

HyperLogLog 误差率小,内存占用小,在非精确去重场景下常用。

2)原理

HLL 支持各种数据类型,采用了哈希函数,将输入值映射成一个二进制字节,然后对这个二进制字节进行分桶和判断其首个1出现的最后位置,来估计目前桶中有多少个不同的值。

由于使用了哈希函数和概率估计,因此 HLL 算法的结果是非精确的,最高精度理论误差也超过了 1%。

3)优势

空间复杂度非常低(log(log(n)) ,故而得名 HLL),几乎不随存储集合的大小而变化;

根据精度的不同,一个 HLL 占用的空间从 1KB 到 64KB 不等,而 Bitmap 需要为每一个不同的 id 用一个 bit 位表示,它存储的集合越大,所占用空间也越大;存储 1 亿内数字的原始 bitmap,空间占用约为 12MB。

HLL 支持各种数据类型作为输入,Bitmap 只支持 int/long 类型的数字作为输入,如果原始值是 string 等类型,需要提前进行 string 到 int/long 的映射。

4)案例
1.背景

Flink 实现 WindowedStream 按天、分 key 统计 PV 和 UV

2.代码
WindowedStream windowedStream = watermarkedStream
  .keyBy("siteId")
  .window(TumblingEventTimeWindows.of(Time.days(1)))
  .trigger(ContinuousEventTimeTrigger.of(Time.seconds(10)));

// Tuple2 f0 为 PV,f1 为 UV
windowedStream.aggregate(new AggregateFunction, Tuple2>() {
  private static final long serialVersionUID = 1L;

  @Override
  public Tuple2 createAccumulator() {
    return new Tuple2<>(0L, new HLL(14, 6));
  }

  @Override
  public Tuple2 add(AnalyticsAccessLogRecord record, Tuple2 acc) {
    acc.f0++;
    acc.f1.addRaw(record.getUserId());
    return acc;
  }

  @Override
  public Tuple2 getResult(Tuple2 acc) {
    return new Tuple2<>(acc.f0, acc.f1.cardinality());
  }

  @Override
  public Tuple2 merge(Tuple2 acc1, Tuple2 acc2) {
    acc1.f0 += acc2.f0;
    acc1.f1.union(acc2.f1);
    return acc1;
  }
});
4、BitMap
1)BitMap 分类
1.BitMap

Bitmap 是按位存储,解决在去重场景里大数据量存储的问题,在Java中一个字节占8位,代表可以存储8个数字,存储结构如下:

在这里插入图片描述

存储1与5这两个数字:

在这里插入图片描述

将对应的bit下标置为1即可,每个bit位对应的下标就表示存储的数据。

Java中一个int类型占用4个字节32位,假设有一亿的数据量,使用普通的存储模式需要:100000000*4/1024/1024 约为381.5M的存储;使用bitmap存储模式需要:100000000/8/1024/1024 约为11.9M 的存储。

java.util包中提供了 BitSet 类型,其内部包含了一个long类型的数组,通过位运算实现bitmap功能

val bitSet:util.BitSet=new util.BitSet()
bitSet.set(0)
bitSet.set(1)
bitSet.get(1) //true

bitSet.clear(1) //删除
bitSet.get(1) //false
bitSet.cardinality()//2
bitSet.size() //64/8=8 字节
-----------------------------
// 存储一个10000的数字:

bitSet.set(10000)
bitSet.cardinality()//2
bitSet.size() //1.22kb

实际只存储了两个数字,但最后使用的存储大小为1.22k,比2*4=8字节要大很多,这是 bitmap 的弊端,稀疏数据会占用很大存储,对此需要使用压缩bitmap,即 RoaringBitmap。

2.RoaringBitmap

RoaringBitmap 是一种压缩bitmap,采用高低位存储方式,将一个 Int 类型的数据转换为高16位与低16位,即两个 short 类型的数据,高位存储在一个 short[] 里面,低位存储在 Container[] 中,short[] 下标与 Container[] 下标是一一对应的。

RoaringBitmap 依赖


    org.roaringbitmap
    RoaringBitmap
    0.8.6

RoaringBitmap 内部包含一个 RoaringArray 类型的 highLowContainer 变量,RoaringArray 包含一个 short[] 类型的 keys 变量与Container[] 类型的values变量。

数据 x 写入流程:

  1. 通过(short) (x >>> 16) 操作得到高16位,也就是 x 对应的key,将其存放在keys中

  2. 通过(short) (x & 0xFFFF)操作得到低16位,得到 value 存放在与 keys 下标对应的values中

数据 x 查找流程

  1. 通过(short) (x >>> 16) 操作得到key, 通过二分查找法从keys中查询出其对应的下标,由此可见keys是从小到大顺序排序的

  2. 通过(short) (x & 0xFFFF)操作得到value, 根据获取到的key对应下标从values里面查询具体的值

Container 是其低16位的处理方式,有三个不同的实现类ArrayContainer、BitmapContainer、RunContainer

ArrayContainer

ArrayContainer 是初始选择的 Container,内部包含一个 short[] 类型的 content 变量,short[] 的长度限制是4096,存储原始数据,不做任何处理,有序存储方便查找,由于其最大存储 4096 个数据,一个 short 类型占用2个字节,其最大限制是 8kb 的数据,其大小是呈线性增长的。

BitmapContainer

当一个 ArrayContainer 的存储大小超过 4096 就会自动转换为 BitmapContainer,其内部包含一个 long[] 类型的 bitmap 变量,其大小是1024个,使用 long[] 按位存储,可以存储1024 * 8 * 8=65536个数据,占用的空间大小是8kb,在初始化的时候就初始化了长度为1024 的 long[],占用固定大小为 8kb。

RunContainer

Run指的是Run Length Encoding,对于连续数据有较好的压缩效果,例如:1,2,3,4,5,6,7,8 会压缩成为1,8, 1代表起始数据,8表示长度,在RunContainer中包含一个short[]类型的valueslength的变量,valueslength中存储压缩的数据1,8。

使用 RunContainer 需要主动调用 roaringBitmap.runOptimize(),其会比较使用 RunContainer 与使用 ArrayContainer、BitmapContainer 所消耗的存储大小,优先会选择较小存储的 Container。

使用示例:

RoaringBitmap roaringBitmap = new RoaringBitmap();
for (int i = 1; i <= 4096; i++) {
    roaringBitmap.add(i);
}

添加数据:

roaringBitmap.add(4097);

执行优化:

roaringBitmap.runOptimize();

RoaringBitmap 处理的是 int 类型的数据,生产中如果使用 long 类型,可以使用 Roaring64NavigableMap。

3.Roaring64NavigableMap

Roaring64NavigableMap 使用拆分模式,将一个 long 类型数据,拆分为高32位与低32位,高32位代表索引,低32位存储到对应RoaringBitmap 中,其内部是一个 TreeMap 类型,会按照 signed 或者 unsigned 排序,key 代表高32位,value 代表对应的RoaringBitmap。

Roaring64NavigableMap roaring64NavigableMap=new Roaring64NavigableMap();
roaring64NavigableMap.addLong(1233453453345L);
roaring64NavigableMap.runOptimize();
roaring64NavigableMap.getLongCardinality();
2)Roaring Bitmap
1.概述

布隆过滤器和HyperLogLog,节省空间、效率高,但存在缺点:

  • 只能插入元素,不能删除元素;
  • 不保证100%准确,存在误差。
2.基本原理

将32位无符号整数按照高16位分桶,最多可能有2的16次方=65536个桶,称为container。

存储数据时,按照数据的高16位找到container(找不到就会新建一个),再将低16位放入container中。

依据不同的场景,有 3 种不同的 Container,分别是 Array Container、Bitmap Container 和 Run Container,分别使用不同的压缩方法,Roaring Bitmap 可以显著减小 Bitmap 的存储空间和内存占用。

3.场景

去重字段只能用整型:int或者long类型,如果要对字符串去重,需要构建一个字符串和整型的映射。

保证100%正确率。

4.应用

布隆过滤器 - 非精确去重,精度可以配置,但精度越高,需要的开销就越大,主流框架可以使用guava的实现,或者借助于redis的bit来自己实现,hash算法可以照搬guava的。

HyperLoglog - 基于基数的非精确去重,优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的空间总是固定的、并且是很小的。

BitMap - 优点是精确去重,占用空间小(在数据相对均匀的情况下),缺点是只能用于数字类型(int或者long)。

Flink基于RoaringBitmap的去重方案


    org.roaringbitmap
    RoaringBitmap
    0.8.13


    org.redisson
    redisson
    3.11.6

构建BitIndex

BitMap对去重的字段只能用int或者long类型;

如果去重字段不是int或者long,需要构建一个字段与BitIndex的映射关系表,bitIndex从1开始递增,比如{a = 1, b = 2, c = 3};使用时先从映射表里根据字段取出对应的bitindex,如果没有,则全局生成一个,这里用redis作为映射表如下:

public class BitIndexBuilderMap extends RichMapFunction, Tuple3> {

  private static final Logger LOG = LoggerFactory.getLogger(BitIndexBuilderMap.class);

  private static final String GLOBAL_COUNTER_KEY = "FLINK:GLOBAL:BITINDEX";

  private static final String GLOBAL_COUNTER_LOCKER_KEY = "FLINK:GLOBAL:BITINDEX:LOCK";

  private static final String USER_BITINDEX_SHARDING_KEY = "FLINK:BITINDEX:SHARDING:";

  /**
   * 把用户id分散到redis的100个map中,防止单个map的无限扩大,也能够充分利用redis cluster的分片功能
   */
  private static final Integer REDIS_CLUSTER_SHARDING_MODE = 100;

  private HashFunction hash = Hashing.crc32();

  private RedissonClient redissonClient;

  @Override
  public void open(Configuration parameters) throws Exception {
//    ParameterTool globalPara = (ParameterTool) getRuntimeContext().getExecutionConfig().getGlobalJobParameters();
    Config config = new Config();
    config.setCodec(new StringCodec());
    config.useClusterServers().addNodeAddress(getRedissonNodes("redis1:8080,redis2:8080,redis3:8080"))
        .setPassword("xxxx").setSlaveConnectionMinimumIdleSize(1)
        .setMasterConnectionPoolSize(2)
        .setMasterConnectionMinimumIdleSize(1)
        .setSlaveConnectionPoolSize(2)
        .setSlaveConnectionMinimumIdleSize(1)
        .setConnectTimeout(10000)
        .setTimeout(10000)
        .setIdleConnectionTimeout(10000);
    redissonClient = Redisson.create(config);
  }

  /**
   * 把userId递增化,在redis中建立一个id映射关系
   * @param in
   * @return
   * @throws Exception
   */
  @Override
  public Tuple3 map(Tuple2 in) throws Exception {
    String userId = in.f0;
    //分片
    int shardingNum = Math.abs(hash.hashBytes(userId.getBytes()).asInt()) % REDIS_CLUSTER_SHARDING_MODE;
    String mapKey = USER_BITINDEX_SHARDING_KEY + shardingNum;
    RMap rMap = redissonClient.getMap(mapKey);
    // 如果为空,生成一个bitIndex
    String bitIndexStr = rMap.get(userId);
    if(StringUtils.isEmpty(bitIndexStr)) {
      LOG.info("userId[{}]的bitIndex为空, 开始生成bitIndex", userId);
      RLock lock = redissonClient.getLock(GLOBAL_COUNTER_LOCKER_KEY);
      try{
        lock.tryLock(60, TimeUnit.SECONDS);
        // 再get一次
        bitIndexStr = rMap.get(userId);
        if(StringUtils.isEmpty(bitIndexStr)) {
          RAtomicLong atomic = redissonClient.getAtomicLong(GLOBAL_COUNTER_KEY);
          bitIndexStr = String.valueOf(atomic.incrementAndGet());
        }
        rMap.put(userId, bitIndexStr);
      }finally{
        lock.unlock();
      }
      LOG.info("userId[{}]的bitIndex生成结束, bitIndex: {}", userId, bitIndexStr);
    }
    return new Tuple3<>(in.f0, in.f1, Integer.valueOf(bitIndexStr));
  }

  @Override
  public void close() throws Exception {
    if(redissonClient != null) {
      redissonClient.shutdown();
    }
  }

  private String[] getRedissonNodes(String hosts) {
    List nodes = new ArrayList<>();
    if (hosts == null || hosts.isEmpty()) {
      return null;
    }
    String nodexPrefix = "redis://";
    String[] arr = StringUtils.split(hosts, ",");
    for (String host : arr) {
      nodes.add(nodexPrefix + host);
    }
    return nodes.toArray(new String[nodes.size()]);
  }
}

通过 MapFunction 拿到字段对应的 BitIndex 之后,进行去重,比如要统计某个页面下的访问人数

public class CountDistinctFunction extends KeyedProcessFunction, Tuple2> {

  private static final Logger LOG = LoggerFactory.getLogger(CountDistinctFunction.class);

  private ValueState> state;

  @Override
  public void open(Configuration parameters) throws Exception {
    state = getRuntimeContext().getState(new ValueStateDescriptor<>("myState", Types.TUPLE(Types.GENERIC(RoaringBitmap.class), Types.LONG)));
  }

  @Override
  public void processElement(Tuple3 in, Context ctx, Collector> out) throws Exception {
    // retrieve the current count
    Tuple2 current = state.value();
    if (current == null) {
      current = new Tuple2<>();
      current.f0 = new RoaringBitmap();
    }
    current.f0.add(in.f2);

    long processingTime = ctx.timerService().currentProcessingTime();
    if(current.f1 == null || current.f1 + 10000 <= processingTime) {
      current.f1 = processingTime;
      // write the state back
      state.update(current);
      ctx.timerService().registerProcessingTimeTimer(current.f1 + 10000);
    } else {
      state.update(current);
    }
  }

  @Override
  public void onTimer(long timestamp, OnTimerContext ctx, Collector> out) throws Exception {
    Tuple1 key = (Tuple1) ctx.getCurrentKey();
    Tuple2 result = state.value();

    result.f0.runOptimize();
    out.collect(new Tuple2<>(key.f0, result.f0.getLongCardinality()));
  }
}

主程序

env.addSource(source).map(new MapFunction>() {
            @Override
            public Tuple2 map(String value) throws Exception {
                String[] arr = StringUtils.split(value, ",");
                return new Tuple2<>(arr[0], arr[1]);
            }
        })
            .keyBy(0) //根据userId分组
            .map(new BitIndexBuilderMap()) //构建bitindex
            .keyBy(1) //统计页面下的访问人数
            .process(new CountDistinctFunction())
            .print();

测试数据

shizc,www.baidu..com
shizc,www.baidu.com
shizc1,www.baidu.com
shizc2,www.baidu.com
shizc,www.baidu..com
shizc,www.baidu..com
shizc,www.baidu..com
shizc,www.hahaha.com
shizc,www.hahaha.com
shizc1,www.hahaha.com
shizc2,www.hahaha.com

输出 :
(www.baidu.com,4)
(www.hahaha.com,3)

注意:

如果数据字段已经是数字类型,可以不用构建BitIndex,但要确保你的字段是有规律,而且递增,如果是long类型还可以用Roaring64NavigableMap,但如果是雪花算法生成的id,因为不能压缩,占用空间非常大,之前用Roaring64NavigableMap,1000多万个id就达到了700多M。

在生成bitindex的时候会有性能瓶颈,应该预先构建BitIndex,把你的数据库当中的所有用户id,预先用flink批处理任务,生成映射。

基本代码如下:

// main方法
    final ExecutionEnvironment env = buildExecutionEnv();
   //如果没有找到好的方法保证id单调递增,就设置一个并行度
    env.setParallelism(1);

    TextInputFormat input = new TextInputFormat(new Path(MEMBER_RIGHTS_HISTORY_PATH));
    input.setCharsetName("UTF-8");
    DataSet source =  env.createInput(input).filter(e -> !e.startsWith("user_id")).map(
        new MapFunction() {
          @Override
          public String map(String value) throws Exception {
            String[] arr = StringUtils.split(value, ",");
            return arr[0];
          }
        })
        .distinct();
    source
        .map(new RedisMapBuilderFunction())
        .groupBy(0)
        .reduce(new RedisMapBuilderReduce())
        .output(new RedissonOutputFormat());

    long counter = source.count();
    env.fromElements(counter).map(new MapFunction>() {
      @Override
      public Tuple3 map(Long value) throws Exception {
        return new Tuple3<>("FLINK:GLOBAL:BITINDEX", "ATOMICLONG", value);
      }
    }).output(new RedissonOutputFormat());

// 注意分区逻辑和key要和stream的保持一致
public class RedisMapBuilderFunction implements MapFunction> {

  private static final String USER_BITINDEX_SHARDING_KEY = "FLINK:BITINDEX:SHARDING:";

  private static final Integer REDIS_CLUSTER_SHARDING_MODE = 100;

  private HashFunction hash = Hashing.crc32();
  private Integer counter = 0;

  @Override
  public Tuple3 map(String userId) throws Exception {
    counter ++;
    int shardingNum = Math.abs(hash.hashBytes(userId.getBytes()).asInt()) % REDIS_CLUSTER_SHARDING_MODE;
    String key = USER_BITINDEX_SHARDING_KEY + shardingNum;
    Map map = new HashMap<>();
    map.put(userId, String.valueOf(counter));
    return new Tuple3<>(key, "MAP", map);
  }
}

public class RedisMapBuilderReduce implements ReduceFunction> {
  @Override
  public Tuple3 reduce(Tuple3 value1, Tuple3 value2) throws Exception {
    Map map1 = (Map) value1.f2;
    Map map2 = (Map) value2.f2;
    map1.putAll(map2);
    return new Tuple3<>(value1.f0, value1.f1, map1);
  }
}

//输出 到redis
public class RedissonOutputFormat extends RichOutputFormat> {
  
  private RedissonClient redissonClient;

  @Override
  public void configure(Configuration parameters) {

  }

  @Override
  public void open(int taskNumber, int numTasks) throws IOException {
    Config config = new Config();
    config.setCodec(new StringCodec());
    config.useClusterServers().addNodeAddress(getRedissonNodes("redis1:8080,redis2:8080,redis3:8080"))
        .setPassword("xxx").setSlaveConnectionMinimumIdleSize(1)
        .setMasterConnectionPoolSize(2)
        .setMasterConnectionMinimumIdleSize(1)
        .setSlaveConnectionPoolSize(2)
        .setSlaveConnectionMinimumIdleSize(1)
        .setConnectTimeout(10000)
        .setTimeout(10000)
        .setIdleConnectionTimeout(10000);
    redissonClient = Redisson.create(config);
  }

  /**
   * k,type,value
   * @param record
   * @throws IOException
   */
  @Override
  public void writeRecord(Tuple3 record) throws IOException {
    String key = record.f0;
    RKeys rKeys = redissonClient.getKeys();
    rKeys.delete(key);
    String keyType = record.f1;
    if("STRING".equalsIgnoreCase(keyType)) {
      String value = (String) record.f2;
      RBucket rBucket = redissonClient.getBucket(key);
      rBucket.set(value);
    } else if("MAP".equalsIgnoreCase(keyType)) {
      Map map = (Map) record.f2;
      RMap rMap = redissonClient.getMap(key);
      rMap.putAll(map);
    } else if("ATOMICLONG".equalsIgnoreCase(keyType)) {
      long l = (long) record.f2;
      RAtomicLong atomic = redissonClient.getAtomicLong(key);
      atomic.set(l);
    }
  }

  @Override
  public void close() throws IOException {
    if(redissonClient != null) {
      redissonClient.shutdown();
    }
  }

  private String[] getRedissonNodes(String hosts) {
    List nodes = new ArrayList<>();
    if (hosts == null || hosts.isEmpty()) {
      return null;
    }
    String nodexPrefix = "redis://";
    String[] arr = StringUtils.split(hosts, ",");
    for (String host : arr) {
      nodes.add(nodexPrefix + host);
    }
    return nodes.toArray(new String[nodes.size()]);
  }
}
5、外部存储去重
1)外部K-V数据库(如 Redis、HBase)存储需要去重的键

由于外部存储对内存和磁盘占用同样敏感,需要设定TTL,以及对大 key 压缩。

外部K-V存储独立于应用之外,一旦计算任务出现问题需要重启,外部存储的状态和内部状态的一致性(是否需要同步)要注意。

2)Clickhouse 或 StarRocks 支持幂等性的数据库

设置去重key后,会自动合并重复数据。

6、Flink去重实现
1)RocksDB状态后端

RocksDB本身是一个类似于HBase的嵌入式K-V数据库,本地性比较好,维护一个较大的状态集合很容易。

首先开启RocksDB状态后端并配置好相应的参数。

RocksDBStateBackend rocksDBStateBackend = new RocksDBStateBackend(Consts.STATE_BACKEND_PATH, true);
rocksDBStateBackend.setPredefinedOptions(PredefinedOptions.FLASH_SSD_OPTIMIZED);
rocksDBStateBackend.setNumberOfTransferingThreads(2);
rocksDBStateBackend.enableTtlCompactionFilter();

env.setStateBackend(rocksDBStateBackend);
env.setStreamTimeCharacteristic(TimeCharacteristic.ProcessingTime);
env.enableCheckpointing(5 * 60 * 1000);

由于状态空间大,打开增量检查点以及设定多线程读写RocksDB,可以提高 checkpointing 效率,同时检查点周期也不能太短。

为了避免状态无限增长下去,需要定期清理,除了注册定时器之外,也可以利用Flink提供的状态TTL机制,并打开RocksDB状态后端的TTL compaction filter,在RocksDB后台执行compaction操作时自动删除,状态TTL仅对时间特征为处理时间时生效,对事件时间无效。

应用

以<站点ID, 子订单ID, 消息载荷>三元组为例,有两种可实现的思路:

  • 仍然按站点ID分组,用存储子订单ID的MapState(当做Set来使用)保存状态;
  • 直接按子订单ID分组,用单值的ValueState保存状态。

如果用状态TTL控制过期,第二种思路更好,因为粒度更细。

  // dimensionedStream是个DataStream>
  DataStream dedupStream = dimensionedStream
    .keyBy(1)
    .process(new SubOrderDeduplicateProcessFunc(), TypeInformation.of(String.class))
    .name("process_sub_order_dedup").uid("process_sub_order_dedup");

  // 去重用的ProcessFunction
  public static final class SubOrderDeduplicateProcessFunc
    extends KeyedProcessFunction, String> {
    private static final long serialVersionUID = 1L;
    private static final Logger LOGGER = LoggerFactory.getLogger(SubOrderDeduplicateProcessFunc.class);

    private ValueState existState;

    @Override
    public void open(Configuration parameters) throws Exception {
      StateTtlConfig stateTtlConfig = StateTtlConfig.newBuilder(Time.days(1))
        .setStateVisibility(StateVisibility.NeverReturnExpired)
        .setUpdateType(UpdateType.OnCreateAndWrite)
        .cleanupInRocksdbCompactFilter(10000)
        .build();

      ValueStateDescriptor existStateDesc = new ValueStateDescriptor<>(
        "suborder-dedup-state",
        Boolean.class
      );
      existStateDesc.enableTimeToLive(stateTtlConfig);

      existState = this.getRuntimeContext().getState(existStateDesc);
    }

    @Override
    public void processElement(Tuple3 value, Context ctx, Collector out) throws Exception {
      if (existState.value() == null) {
        existState.update(true);
        out.collect(value.f2);
      }
    }
  }

上述代码中设定了状态TTL的相关参数:

  • 过期时间设为1天;
  • 在状态值被创建和被更新时重设TTL;
  • 已经过期的数据不能再被访问到;
  • 在每处理10000条状态记录之后,更新检测过期的时间戳,更新太频繁会降低compaction的性能,更新过慢会使得compaction不及时,状态空间膨胀。

在实际处理数据时,如果数据的key(即子订单ID)对应的状态不存在,说明它没有出现过,可以更新状态并输出。反之,说明已经出现过了,直接丢弃。

**注意:**若数据的key占用的空间比较大(如长度可能会很长的字符串类型),也会造成状态膨胀。可以将它 hash 成整型再存储,这样每个 key 最多只占用8个字节,不过哈希算法都无法保证不产生冲突,需要根据业务场景自行决定。

2)Flink去重-MapState

步骤:

  1. 为了当天的数据可重现,这里选择事件时间也就是广告点击时间作为每小时的窗口期划分
  2. 数据分组使用广告位ID+点击事件所属的小时
  3. 选择processFunction来实现,一个状态用来保存数据、另外一个状态用来保存对应的数据量
  4. 计算完成之后的数据清理,按照时间进度注册定时器清理

实现:

广告数据

case class AdData(id:Int,devId:String,time:Long) 

分组数据

case class AdKey(id:Int,time:Long) 

代码案例

val env=StreamExecutionEnvironment.getExecutionEnvironment

  env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)

    val kafkaConfig=new Properties()

    kafkaConfig.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,"localhost:9092")

    kafkaConfig.put(ConsumerConfig.GROUP_ID_CONFIG,"test1")

    val consumer=new FlinkKafkaConsumer[String]("topic1",new SimpleStringSchema,kafkaConfig)

    val ds=env.addSource(consumer)

      .map(x=>{

        val s=x.split(",")

        AdData(s(0).toInt,s(1),s(2).toLong)

      }).assignTimestampsAndWatermarks(new BoundedOutOfOrdernessTimestampExtractor[AdData](Time.minutes(1)) {

      override def extractTimestamp(element: AdData): Long = element.time

    })

      .keyBy(x=>{

        val endTime= TimeWindow.getWindowStartWithOffset(x.time, 0,

          Time.hours(1).toMilliseconds) + Time.hours(1).toMilliseconds

        AdKey(x.id,endTime)

      })

注意:

指定事件时间属性,设置允许1min的延时;

时间的转换选择TimeWindow.getWindowStartWithOffset 第一个参数表示数据时间,第二个参数offset偏移量,默认为0,正常窗口划分都是整点方式,例如从0开始划分,这个offset就是相对于0的偏移量,第三个参数表示窗口大小,得到的结果是数据时间所属窗口的开始时间,这里加上了窗口大小,使用结束时间与广告位ID作为分组的Key。

去重逻辑

自定义Distinct1ProcessFunction 继承了KeyedProcessFunction, 定义两个状态:MapState,key表示devId,value表示一个随意的值只是为了标识,该状态表示一个广告位在某个小时的设备数据,如果使用rocksdb作为statebackend,那么会将mapstate中key作为rocksdb中key的一部分,mapstate中value作为rocksdb中的value,rocksdb中value 大小是有上限的,这种方式可以减少rocksdb value的大小;另外一个ValueState,存储当前MapState的数据量,是由于mapstate只能通过迭代方式获得数据量大小,每次获取都需要进行迭代,这种方式可以避免每次迭代。

class Distinct1ProcessFunction extends KeyedProcessFunction[AdKey, AdData, Void] {

  var devIdState: MapState[String, Int] = _
  var devIdStateDesc: MapStateDescriptor[String, Int] = _
  var countState: ValueState[Long] = _
  var countStateDesc: ValueStateDescriptor[Long] = _

  override def open(parameters: Configuration): Unit = {

    devIdStateDesc = new MapStateDescriptor[String, Int]("devIdState", TypeInformation.of(classOf[String]), TypeInformation.of(classOf[Int]))

    devIdState = getRuntimeContext.getMapState(devIdStateDesc)

    countStateDesc = new ValueStateDescriptor[Long]("countState", TypeInformation.of(classOf[Long]))

    countState = getRuntimeContext.getState(countStateDesc)
  }

  override def processElement(value: AdData, ctx: KeyedProcessFunction[AdKey, AdData, Void]#Context, out: Collector[Void]): Unit = {

    val currW=ctx.timerService().currentWatermark()
    
    if(ctx.getCurrentKey.time+1<=currW) {
        println("late data:" + value)
        return
      }

    val devId = value.devId
    devIdState.get(devId) match {
      case 1 => {
        //表示已经存在
      }

      case _ => {
        //表示不存在
        devIdState.put(devId, 1)
        val c = countState.value()
        countState.update(c + 1)
        //还需要注册一个定时器
        ctx.timerService().registerEventTimeTimer(ctx.getCurrentKey.time + 1)
      }
    }
    println(countState.value())
  }

  override def onTimer(timestamp: Long, ctx: KeyedProcessFunction[AdKey, AdData, Void]#OnTimerContext, out: Collector[Void]): Unit = {
    println(timestamp + " exec clean~~~")
    println(countState.value())
    devIdState.clear()
    countState.clear()
  }
}

数据清理通过注册定时器方式ctx.timerService().registerEventTimeTimer(ctx.getCurrentKey.time + 1)表示当watermark大于该小时结束时间+1就会执行清理动作,调用onTimer方法。

在处理逻辑里面加了

val currW=ctx.timerService().currentWatermark()

if(ctx.getCurrentKey.time+1<=currW){
        println("late data:" + value)
        return
  }
3)Flink去重-SQL

Flink SQL 中提供了distinct去重方式,使用方式:

SELECT DISTINCT devId FROM pv 

表示对设备ID进行去重,在使用distinct统计去重结果通常有两种方式,以统计每日网站uv为例。

第一种方式

SELECT datatime,count(DISTINCT devId) FROM pv group by datatime 

该语义表示计算网页每日的uv数量,内部核心实现依靠DistinctAccumulator与CountAccumulator,DistinctAccumulator 内部包含一个map结构,key 表示的是distinct的字段,value表示重复的计数,CountAccumulator就是一个计数器的作用,这两部分都是作为动态生成聚合函数的中间结果accumulator,通过之前的聚合函数的分析可知中间结果是存储在状态里面的,也就是容错并且具有一致性语义的

其处理流程是:

  1. 将devId 添加到对应的DistinctAccumulator对象中,首先会判断map中是否存在该devId, 不存在则插入map中并且将对应value记1,并且返回True;存在则将对应的value+1更新到map中,并且返回False
  2. 只有当返回True时才会对CountAccumulator做累加1的操作,以此达到计数目的

第二种方式

select count(*),datatime from(
select distinct devId,datatime from pv ) a
group by datatime

内部是一个对devId,datatime 进行distinct的计算,在flink内部会转换为以devId,datatime进行分组的流并且进行聚合操作,在内部会动态生成一个聚合函数,该聚合函数createAccumulators方法生成的是一个Row(0) 的accumulator 对象,其accumulate方法是一个空实现,也就是该聚合函数每次聚合之后返回的结果都是Row(0),通过之前对sql中聚合函数的分析(可查看GroupAggProcessFunction函数源码), 如果聚合函数处理前后得到的值相同那么可能会不发送该条结果也可能发送一条撤回一条新增的结果,但是其最终的效果是不会影响下游计算的。

在这里理解为在处理相同的devId,datatime不会向下游发送数据即可,也就是每一对devId,datatime只会向下游发送一次数据;

外部就是一个简单的按照时间维度的计数计算,由于内部每一组devId,datatime 只会发送一次数据到外部,那么外部对应datatime维度的每一个devId都是唯一的一次计数,得到的结果就是需要的去重计数结果。

两种方式对比

  1. 这两种方式最终都能得到相同的结果,但是经过分析其在内部实现上差异还是比较大,第一种在分组上选择datatime ,内部使用的累加器DistinctAccumulator 每一个datatime都会与之对应一个对象,在该维度上所有的设备id, 都会存储在该累加器对象的map中,而第二种选择首先细化分组,使用datatime+devId分开存储,然后外部使用时间维度进行计数,简单归纳就是: 第一种: datatime->Value{devI1,devId2…} 第二种: datatime+devId->row(0) 聚合函数中accumulator 是存储在ValueState中的,第二种方式的key会比第一种方式数量上多很多,但是其ValueState占用空间却小很多,而在实际中我们通常会选择Rocksdb方式作为状态后端,rocksdb中value大小是有上限的,第一种方式很容易到达上限,那么使用第二种方式会更加合适;
  2. 这两种方式都是全量保存设备数据的,会消耗很大的存储空间,但是计算通常是带有时间属性的,那么可以通过配置StreamQueryConfig设置状态ttl。
4)Flink去重-HyperLogLog

HyperLogLog算法是基数估计统计算法,预估一个集合中不同数据的个数,也就是常说的去重统计,在redis中也存在hyperloglog 类型的结构,能够使用12k的内存,允许误差在0.81%的情况下统计2^64个数据,能够减少存储空间的消耗,但是前提是允许存在一定的误差。

测试使用效果,准备了97320不同数据:

public static void main(String[] args) throws Exception{
        String filePath = "000000_0";
        BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(filePath)));

        Set values =new HashSet<>();
        HyperLogLog logLog=new HyperLogLog(0.01); //允许误差

        String line = "";
        while ((line = br.readLine()) != null) {
            String[] s = line.split(",");
            String uuid = s[0];
            values.add(uuid);
            logLog.offer(uuid);
        }
       
        long rs=logLog.cardinality();
    }

当误差值为0.01 时; rs为98228,需要内存大小int[1366]

当误差值为0.001时;rs为97304 ,需要内存大小int[174763]

误差越小也就越来越接近其真实数据,但是在这个过程中需要的内存也就越来越大,这个取舍可根据实际情况决定。

将hll与udaf结合

public class HLLDistinctFunction extends AggregateFunction {

    @Override public HyperLogLog createAccumulator() {
        return new HyperLogLog(0.001);
    }

    public void accumulate(HyperLogLog hll,String id){
      hll.offer(id);
    }

    @Override public Long getValue(HyperLogLog accumulator) {
        return accumulator.cardinality();
    }
}

定义的返回类型是long 也就是去重的结果,accumulator是一个HyperLogLog类型的结构。

测试:

case class AdData(id:Int,devId:String,datatime:Long)object Distinct1 {  def main(args: Array[String]): Unit = {
    val env=StreamExecutionEnvironment.getExecutionEnvironment
    val tabEnv=StreamTableEnvironment.create(env)
    tabEnv.registerFunction("hllDistinct",new HLLDistinctFunction)
    val kafkaConfig=new Properties()
   kafkaConfig.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,"localhost:9092")
    kafkaConfig.put(ConsumerConfig.GROUP_ID_CONFIG,"test1")
    val consumer=new FlinkKafkaConsumer[String]("topic1",new SimpleStringSchema,kafkaConfig)
    consumer.setStartFromLatest()
    val ds=env.addSource(consumer)
      .map(x=>{
        val s=x.split(",")
        AdData(s(0).toInt,s(1),s(2).toLong)
      })
    tabEnv.registerDataStream("pv",ds)
    val rs=tabEnv.sqlQuery(      """ select hllDistinct(devId) ,datatime
                                          from pv group by datatime
      """.stripMargin)
    rs.writeToSink(new PaulRetractStreamTableSink)
    env.execute()
  }
}

准备测试数据

1,devId1,1577808000000
1,devId2,1577808000000
1,devId1,1577808000000

得到结果

4> (true,1,1577808000000)
4> (false,1,1577808000000)
4> (true,2,1577808000000)
5)Flink去重-bitmap

ID-mapping

在使用bitmap去重需要将去重的id转换为一串数字,但是我们去重的通常是一串包含字符的字符串例如设备ID,那么第一步需要将字符串转换为数字,首先可能想到对字符串做hash,但是hash是会存在概率冲突的,那么可以使用美团开源的leaf分布式唯一自增ID算法,也可以使用Twitter开源的snowflake分布式唯一ID雪花算法,我们选择了实现相对较为方便的snowflake算法(从网上找的),代码如下:

public class SnowFlake {

    /**
     * 起始的时间戳
     */
    private final static long START_STMP = 1480166465631L;

    /**
     * 每一部分占用的位数
     */
    private final static long SEQUENCE_BIT = 12; //序列号占用的位数

    private final static long MACHINE_BIT = 5;   //机器标识占用的位数

    private final static long DATACENTER_BIT = 5;//数据中心占用的位数

    /**
     * 每一部分的最大值
     */
    private final static long MAX_DATACENTER_NUM = -1L ^ (-1L << DATACENTER_BIT);

    private final static long MAX_MACHINE_NUM = -1L ^ (-1L << MACHINE_BIT);

    private final static long MAX_SEQUENCE = -1L ^ (-1L << SEQUENCE_BIT);

    /**
     * 每一部分向左的位移
     */
    private final static long MACHINE_LEFT = SEQUENCE_BIT;

    private final static long DATACENTER_LEFT = SEQUENCE_BIT + MACHINE_BIT;

    private final static long TIMESTMP_LEFT = DATACENTER_LEFT + DATACENTER_BIT;

    private long datacenterId;  //数据中心

    private long machineId;     //机器标识

    private long sequence = 0L; //序列号

    private long lastStmp = -1L;//上一次时间戳

    public SnowFlake(long datacenterId, long machineId) {
        if (datacenterId > MAX_DATACENTER_NUM || datacenterId < 0) {
            throw new IllegalArgumentException("datacenterId can't be greater than MAX_DATACENTER_NUM or less than 0");
        }
        if (machineId > MAX_MACHINE_NUM || machineId < 0) {
            throw new IllegalArgumentException("machineId can't be greater than MAX_MACHINE_NUM or less than 0");
        }
        this.datacenterId = datacenterId;
        this.machineId = machineId;
    }

    /**
     * 产生下一个ID
     *
     * @return
     */
    public synchronized long nextId() {
        long currStmp = getNewstmp();
        if (currStmp < lastStmp) {
            throw new RuntimeException("Clock moved backwards.  Refusing to generate id");
        }

        if (currStmp == lastStmp) {
            //相同毫秒内,序列号自增
            sequence = (sequence + 1) & MAX_SEQUENCE;
            //同一毫秒的序列数已经达到最大
            if (sequence == 0L) {
                currStmp = getNextMill();
            }
        } else {
            //不同毫秒内,序列号置为0
            sequence = 0L;
        }

        lastStmp = currStmp;

        return (currStmp - START_STMP) << TIMESTMP_LEFT //时间戳部分
                | datacenterId << DATACENTER_LEFT       //数据中心部分
                | machineId << MACHINE_LEFT             //机器标识部分
                | sequence;                             //序列号部分
    }

    private long getNextMill() {
        long mill = getNewstmp();
        while (mill <= lastStmp) {
            mill = getNewstmp();
        }
        return mill;
    }

    private long getNewstmp() {
        return System.currentTimeMillis();
    }
}

snowflake算法的实现是与机器码以及时间有关的,为了保证其高可用做了两个机器码不同的对外提供的服务,整个转换流程如下图:

数据去重方案(汇总)_第1张图片

首先会从Hbase中查询是否有UID对应的ID,如果有则直接获取,如果没有则会调用ID-Mapping服务,然后将其对应关系存储到Hbase中,最后返回ID至下游处理。

UDF化

将其封装成为UDF, 由于snowflake算法得到的是一个长整型,因此选择了Roaring64NavgabelMap作为存储对象,由于去重是按照维度来计算,所以使用UDAF,首先定义一个accumulator:

public class PreciseAccumulator{

    private Roaring64NavigableMap bitmap;

    public PreciseAccumulator(){
        bitmap=new Roaring64NavigableMap();
    }

    public void add(long id){
        bitmap.addLong(id);
    }

    public long getCardinality(){
        return bitmap.getLongCardinality();
    }
}

udaf 实现

public class PreciseDistinct extends AggregateFunction {

    @Override 
    public PreciseAccumulator createAccumulator() {
        return new PreciseAccumulator();
    }

    public void accumulate(PreciseAccumulator accumulator,long id){
        accumulator.add(id);
    }

    @Override 
    public Long getValue(PreciseAccumulator accumulator) {
        return accumulator.getCardinality();
    }
}
6)Flink去重-优化HyperLogLog

在HyperLogLog去重实现中,如果要求误差在0.001以内,那么就需要1048576个int, 会消耗4M的存储空间,但是在实际使用中有很多的维度的统计是达不到这个数据量,那么可以在这里做一个优化,优化方式是:初始HyperLogLog内部使用存储是一个set集合,当set大小达到了(1048576)就转换为HyperLogLog存储方式,可以有效减小内存消耗。

实现代码:

public class OptimizationHyperLogLog {
    //hyperloglog结构
    private HyperLogLog hyperLogLog;
    //初始的一个set
    private Set set;
     
    private double rsd;
    
    //hyperloglog的桶个数,主要内存占用
    private int bucket;

    public OptimizationHyperLogLog(double rsd){
        this.rsd=rsd;
        this.bucket=1 << HyperLogLog.log2m(rsd);
        set=new HashSet<>();      
       }

   //插入一条数据
    public void offer(Object object){
        final int x = MurmurHash.hash(object);
        int currSize=set.size();
        if(hyperLogLog==null && currSize+1>bucket){ 
           //升级为hyperloglog
           hyperLogLog=new HyperLogLog(rsd);
           for(int d: set){
               hyperLogLog.offerHashed(d);
           }
           set.clear();
        }

        if(hyperLogLog!=null){
            hyperLogLog.offerHashed(x);
        }else {
            set.add(x);
        }
    }

    //获取大小
    public long cardinality() {
      if(hyperLogLog!=null) 
      	return hyperLogLog.cardinality();
      return set.size();
    }
}

初始化:入参同样是一个允许的误差范围值rsd,计算出hyperloglog需要桶的个数bucket,也就需要是int数组大小,并且初始化一个set集合hashset;

数据插入:使用与hyperloglog同样的方式将插入数据转hash, 判断当前集合的大小+1是否达到了bucket,不满足则直接添加到set中,满足则将set里面数据转移到hyperloglog对象中并且清空set, 后续数据将会被添加到hyperloglog中;

你可能感兴趣的:(flink,大数据)