flink、flink-sql中常见的去重方案
去重计算是数据分析业务里面常见的指标计算,例如网站一天的访问用户数、广告的点击用户数等等,离线计算是一个全量、一次性计算的过程通常可以通过distinct的方式得到去重结果,而实时计算是一种增量、长期计算过程,在面对不同的场景,例如数据量的大小、计算结果精准度要求等可以使用不同的方案。
1、利用状态去重
计算每个广告每小时的点击用户数,广告点击日志包含:广告位ID、用户设备ID、点击时间。
(1)实现步骤:
- 为了当天的数据可重现,这里选择事件时间也就是广告点击时间作为每小时的窗口期划分
- 数据分组使用广告位ID+点击事件所属的小时
- 选择processFunction来实现,一个状态用来保存数据、另外一个状态用来保存对应的数据量
- 计算完成之后的数据清理,按照时间进度注册定时器清理
(2)代码实现
主程序代码
package com.yyds.flink_distinct;
import com.yyds.utils.FlinkUtils;
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.functions.MapFunction;
import org.apache.flink.api.common.serialization.SimpleStringSchema;
import org.apache.flink.api.common.time.Time;
import org.apache.flink.api.java.functions.KeySelector;
import org.apache.flink.api.java.utils.ParameterTool;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.datastream.KeyedStream;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.table.runtime.operators.window.TimeWindow;
import java.time.Duration;
/**
* flink 去重
*
* 计算每个广告每小时的点击用户数,广告点击日志包含:广告位ID、用户设备ID(idfa/imei/cookie)、点击时间。
*
*
* 实现步骤:
* 1、为了当天的数据可重现,这里选择事件时间也就是广告点击时间作为每小时的窗口期划分
* 2、数据分组使用广告位ID+点击事件所属的小时
* 3、选择processFunction来实现,一个状态用来保存数据、另外一个状态用来保存对应的数据量
* 4、计算完成之后的数据清理,按照时间进度注册定时器清理
*/
public class _01_MapStateDistinct {
public static void main(String[] args) throws Exception {
// 1、从kafka中读取数据
ParameterTool parameterTool = ParameterTool.fromPropertiesFile(args[0]);
FlinkUtils.env.setParallelism(1);
DataStream<String> kafkaStream = FlinkUtils.createKafkaStream(parameterTool, SimpleStringSchema.class);
// 2、将数据进行切分,转换为javaBean
SingleOutputStreamOperator<_01_AdvertiseMentData> mapStream = kafkaStream.map(new MapFunction<String, _01_AdvertiseMentData>() {
@Override
public _01_AdvertiseMentData map(String value) throws Exception {
String[] arr = value.split(",");
return new _01_AdvertiseMentData(Integer.parseInt(arr[0]), arr[1], Long.parseLong(arr[2]));
}
});
// 3、注册水位线
SingleOutputStreamOperator<_01_AdvertiseMentData> watermarkStream = mapStream.assignTimestampsAndWatermarks(
WatermarkStrategy.<_01_AdvertiseMentData>forBoundedOutOfOrderness(Duration.ofMinutes(1L)) // 设置延迟时间为1min
.withTimestampAssigner(new SerializableTimestampAssigner<_01_AdvertiseMentData>() {
@Override
public long extractTimestamp(_01_AdvertiseMentData element, long recordTimestamp) {
return element.getTime();
}
})
);
// 4、分组(广告id、窗口结束时间)
KeyedStream<_01_AdvertiseMentData, _01_AdKey> keyedStream = watermarkStream.keyBy(new KeySelector<_01_AdvertiseMentData, _01_AdKey>() {
@Override
public _01_AdKey getKey(_01_AdvertiseMentData data) throws Exception {
int id = data.getId();
// 时间的转换选择TimeWindow.getWindowStartWithOffset Flink在处理window中自带的方法,使用起来很方便,
// 第一个参数 表示数据时间,
// 第二个参数offset偏移量,默认为0,正常窗口划分都是整点方式,例如从0开始划分,这个offset就是相对于0的偏移量,
// 第三个参数表示窗口大小,得到的结果是数据时间所属窗口的开始时间,这里加上了窗口大小,使用结束时间与广告位ID作为分组的Key。
long endTime = TimeWindow.getWindowStartWithOffset(data.getTime(), 0, Time.hours(1).toMilliseconds()) + Time.hours(1).toMilliseconds();
return new _01_AdKey(id, endTime);
}
});
keyedStream.process(new _01_DistinctProcessFunction());
FlinkUtils.env.execute("_01_MapStateDistinct");
}
}
udf代码
package com.yyds.flink_distinct;
import org.apache.flink.api.common.state.*;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.streaming.api.functions.KeyedProcessFunction;
import org.apache.flink.util.Collector;
/**
* 方便起见使用输出类型使用Void,这里直接使用打印控制台方式查看结果,在实际中可输出到下游做一个批量的处理然后输出
*/
public class _01_DistinctProcessFunction extends KeyedProcessFunction<_01_AdKey,_01_AdvertiseMentData,Void> {
// 定义第一个状态MapState
MapState<String,Integer> deviceIdState ;
// 定义第二个状态ValueState
ValueState<Long> countState ;
@Override
public void open(Configuration parameters) throws Exception {
MapStateDescriptor<String, Integer> deviceIdStateDescriptor = new MapStateDescriptor<>("deviceIdState", String.class, Integer.class);
/*
MapState,key表示devId, value表示一个随意的值只是为了标识,该状态表示一个广告位在某个小时的设备数据,
如果我们使用rocksdb作为statebackend, 那么会将mapstate中key作为rocksdb中key的一部分,
mapstate中value作为rocksdb中的value, rocksdb中value大小是有上限的,这种方式可以减少rocksdb value的大小;
*/
deviceIdState = getRuntimeContext().getMapState(deviceIdStateDescriptor);
ValueStateDescriptor<Long> countStateDescriptor = new ValueStateDescriptor<>("countState", Long.class);
/*
ValueState,存储当前MapState的数据量,是由于mapstate只能通过迭代方式获得数据量大小,每次获取都需要进行迭代,这种方式可以避免每次迭代。
*/
countState = getRuntimeContext().getState(countStateDescriptor);
}
@Override
public void processElement(_01_AdvertiseMentData data, Context context, Collector<Void> collector) throws Exception {
// 主要考虑可能会存在滞后的数据比较严重,会影响之前的计算结果
long currw = context.timerService().currentWatermark();
if(context.getCurrentKey().getTime() + 1 <= currw){
System.out.println("迟到的数据:" + data);
return;
}
String devId = data.getDevId();
Integer i = deviceIdState.get(devId);
if(i == null){
i = 0;
}
if( i == 1 ){
// 表示已经存在
}else {
// 表示不存在,放入到状态中
deviceIdState.put(devId,1);
// 将统计的数据 + 1
Long count = countState.value();
if(count == null){
count = 0L;
}
count ++;
countState.update(count);
// 注册一个定时器,定期清理状态中的数据
context.timerService().registerEventTimeTimer(context.getCurrentKey().getTime() + 1);
}
System.out.println("countState.value() = " + countState.value());
}
@Override
public void onTimer(long timestamp, OnTimerContext ctx, Collector<Void> out) throws Exception {
System.out.println(timestamp + " exec clean~~~");
System.out.println("countState.value() = " + countState.value());
// 清除状态
deviceIdState.clear();
countState.clear();
}
}
javaBean
package com.yyds.flink_distinct;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class _01_AdKey {
private int id;
private Long time;
}
2、利用Flink Sql进行去重
使用编码方式完成去重,但是这种方式开发周期比较长,我们可能需要针对不同的业务逻辑实现不同的编码,
对于业务开发来说也需要熟悉Flink编码,也会增加相应的成本,我们更多希望能够以sql的方式提供给业务开发完成自己的去重逻辑。
package com.yyds.flink_distinct;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.table.api.bridge.java.StreamTableEnvironment;
/**
* 使用编码方式完成去重,但是这种方式开发周期比较长,我们可能需要针对不同的业务逻辑实现不同的编码,
* 对于业务开发来说也需要熟悉Flink编码,也会增加相应的成本,我们更多希望能够以sql的方式提供给业务开发完成自己的去重逻辑
*/
public class _02_DistinctFlinkSQL {
public static void main(String[] args) {
// 创建表的执行环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
StreamTableEnvironment tenv = StreamTableEnvironment.create(env);
// 从kafka中读取数据
String sourceTable = "CREATE TABLE source_table (\n" +
" `id` string,\n" +
" `devId` string,\n" +
" `ts` bigint,\n" +
" `rt` AS TO_TIMESTAMP_LTZ(ts, 3) ,\n" +
" watermark for rt as rt - interval '60' second \n" +
") WITH (\n" +
" 'connector' = 'kafka',\n" +
" 'topic' = 'ad',\n" +
" 'properties.bootstrap.servers' = 'hadoop01:9092',\n" +
" 'properties.group.id' = 'testGroup',\n" +
" 'scan.startup.mode' = 'earliest-offset',\n" +
" 'format' = 'csv'\n" +
")";
tenv.executeSql(sourceTable);
String selectSql = "select \n" +
" window_start,\n" +
" window_end,\n" +
" count(distinct devId) as cnt\n" +
"from table (\n" +
" tumble(table source_table,descriptor(rt),interval '60' minute ) --滚动窗口 \n" +
")\n" +
"group by window_start,window_end";
tenv.executeSql(selectSql).print();
}
}
3、利用HyperLogLog进行去重(或者布隆过滤器,Flink-sql注册udaf函数)
关于HyperLogLog算法原理可以参考:https://www.jianshu.com/p/55defda6dcd2
udaf函数的实现
package com.yyds.flink_distinct;
import com.clearspring.analytics.stream.cardinality.HyperLogLog;
import org.apache.flink.table.functions.AggregateFunction;
/**
* 自定义udaf函数
*
* 返回类型是long 也就是去重的结果,accumulator是一个HyperLogLog类型的结构
*/
public class _03_HLLDistinctFunction extends AggregateFunction<Long, HyperLogLog> {
@Override
public HyperLogLog createAccumulator() {
return new HyperLogLog(0.001);
}
public void accumulate(HyperLogLog hll,String id){
hll.offer(id);
}
@Override
public Long getValue(HyperLogLog hll) {
return hll.cardinality();
}
}
flink-sql实现
package com.yyds.flink_distinct;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.table.api.bridge.java.StreamTableEnvironment;
/**
* 使用编码方式完成去重,但是这种方式开发周期比较长,我们可能需要针对不同的业务逻辑实现不同的编码,
* 对于业务开发来说也需要熟悉Flink编码,也会增加相应的成本,我们更多希望能够以sql的方式提供给业务开发完成自己的去重逻辑
*/
public class _03_HLLFlinkSQL {
public static void main(String[] args) {
// 创建表的执行环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
StreamTableEnvironment tenv = StreamTableEnvironment.create(env);
// 注册udaf函数
tenv.registerFunction("hllDistinct",new _03_HLLDistinctFunction());
// 从kafka中读取数据
String sourceTable = "CREATE TABLE source_table (\n" +
" `id` string,\n" +
" `devId` string,\n" +
" `ts` bigint,\n" +
" `rt` AS TO_TIMESTAMP_LTZ(ts, 3) ,\n" +
" watermark for rt as rt - interval '60' second \n" +
") WITH (\n" +
" 'connector' = 'kafka',\n" +
" 'topic' = 'ad',\n" +
" 'properties.bootstrap.servers' = 'hadoop01:9092',\n" +
" 'properties.group.id' = 'testGroup',\n" +
" 'scan.startup.mode' = 'earliest-offset',\n" +
" 'format' = 'csv'\n" +
")";
tenv.executeSql(sourceTable);
String selectSql = "select \n" +
" window_start,\n" +
" window_end,\n" +
" hllDistinct(distinct devId) as cnt\n" +
"from table (\n" +
" tumble(table source_table,descriptor(rt),interval '1' minute ) --滚动窗口 \n" +
")\n" +
"group by window_start,window_end";
tenv.executeSql(selectSql).print();
}
}
4、利用HyperLogLog进行去重(优化版本)
在HyperLogLog去重实现中,如果要求误差在0.001以内,那么就需要1048576个int,
也就是会消耗4M的存储空间,但是在实际使用中有很多的维度的统计是达不到这个数据量,那么可以在这里做一个优化
优化方式是:初始HyperLogLog内部使用存储是一个set集合,当set大小达到了指定大小(1048576)就转换为HyperLogLog存储方式。
这种方式可以有效减小内存消耗。
package com.yyds.flink_distinct;
import com.clearspring.analytics.hash.MurmurHash;
import com.clearspring.analytics.stream.cardinality.HyperLogLog;
import java.util.HashSet;
import java.util.Set;
/**
* HLL 优化
*
*
* 在HyperLogLog去重实现中,如果要求误差在0.001以内,那么就需要1048576个int,
* 也就是会消耗4M的存储空间,但是在实际使用中有很多的维度的统计是达不到这个数据量,那么可以在这里做一个优化
*
*
* 优化方式是:初始HyperLogLog内部使用存储是一个set集合,当set大小达到了指定大小(1048576)就转换为HyperLogLog存储方式。这种方式可以有效减小内存消耗。
*/
public class _04_HLLOptimization {
//hyperloglog结构
private HyperLogLog hyperLogLog;
//初始的一个set
private Set<Integer> set;
private double rsd;
//hyperloglog的桶个数,主要内存占用
private int bucket;
public _04_HLLOptimization(double rsd){
this.rsd=rsd;
// this.bucket=1 << HyperLogLog.log2m(rsd);
this.bucket=1 << (int)(Math.log(1.106D / rsd * (1.106D / rsd)) / Math.log(2.0D));
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();
}
}
自定义udaf函数
package com.yyds.flink_distinct;
import org.apache.flink.table.functions.AggregateFunction;
/**
* 自定义udaf函数
*/
public class _04_HLLDistinctFunctionPlus extends AggregateFunction<Long,_04_HLLOptimization> {
@Override
public _04_HLLOptimization createAccumulator() {
return new _04_HLLOptimization(0.001);
}
public void accumulate(_04_HLLOptimization hll,String id){
hll.offer(id);
}
@Override
public Long getValue(_04_HLLOptimization hll) {
return hll.cardinality();
}
}
主程序
package com.yyds.flink_distinct;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.table.api.bridge.java.StreamTableEnvironment;
/**
* 使用编码方式完成去重,但是这种方式开发周期比较长,我们可能需要针对不同的业务逻辑实现不同的编码,
* 对于业务开发来说也需要熟悉Flink编码,也会增加相应的成本,我们更多希望能够以sql的方式提供给业务开发完成自己的去重逻辑
*
*
* 在HyperLogLog去重实现中,如果要求误差在0.001以内,那么就需要1048576个int,
* 也就是会消耗4M的存储空间,但是在实际使用中有很多的维度的统计是达不到这个数据量,那么可以在这里做一个优化
*
* 优化方式是:初始HyperLogLog内部使用存储是一个set集合,当set大小达到了指定大小(1048576)就转换为HyperLogLog存储方式。这种方式可以有效减小内存消耗。
*/
public class _04_HLLFlinkSQLPlus {
public static void main(String[] args) {
// 创建表的执行环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
StreamTableEnvironment tenv = StreamTableEnvironment.create(env);
// 注册udaf函数
tenv.registerFunction("hllDistinctPlus",new _04_HLLDistinctFunctionPlus());
// 从kafka中读取数据
String sourceTable = "CREATE TABLE source_table (\n" +
" `id` string,\n" +
" `devId` string,\n" +
" `ts` bigint,\n" +
" `rt` AS TO_TIMESTAMP_LTZ(ts, 3) ,\n" +
" watermark for rt as rt - interval '60' second \n" +
") WITH (\n" +
" 'connector' = 'kafka',\n" +
" 'topic' = 'ad',\n" +
" 'properties.bootstrap.servers' = 'hadoop01:9092',\n" +
" 'properties.group.id' = 'testGroup',\n" +
" 'scan.startup.mode' = 'earliest-offset',\n" +
" 'format' = 'csv'\n" +
")";
tenv.executeSql(sourceTable);
String selectSql = "select \n" +
" window_start,\n" +
" window_end,\n" +
" hllDistinctPlus(distinct devId) as cnt\n" +
"from table (\n" +
" tumble(table source_table,descriptor(rt),interval '1' minute ) --滚动窗口 \n" +
")\n" +
"group by window_start,window_end";
tenv.executeSql(selectSql).print();
}
}
5、利用BitMap精确去重进行去重
在前面提到的精确去重方案都是会保存全量的数据,但是这种方式是以牺牲存储为代价的,
而hyperloglog方式虽然减少了存储但是损失了精度,
那么如何能够做到精确去重又能不消耗太多的存储呢,可以使用bitmap做精确去重。
package com.yyds.flink_distinct;
import org.roaringbitmap.longlong.Roaring64NavigableMap;
/**
* 自定义精确去重计算器
*/
public class _05_PreciseAccumulator {
private Roaring64NavigableMap bitmap;
public _05_PreciseAccumulator(){
bitmap=new Roaring64NavigableMap();
}
public void add(long id){
bitmap.addLong(id);
}
public long getCardinality(){
return bitmap.getLongCardinality();
}
}
package com.yyds.flink_distinct;
import org.apache.flink.table.functions.AggregateFunction;
/**
* 自定义udaf函数
*/
public class _05_BitMapDistinctFunction extends AggregateFunction<Long,_05_PreciseAccumulator> {
@Override
public _05_PreciseAccumulator createAccumulator() {
return new _05_PreciseAccumulator();
}
public void accumulate(_05_PreciseAccumulator preciseAccumulator,String id){
// 注意:此处有问题,用hash可能会存在冲突,需要用别的算法,生成唯一id
preciseAccumulator.add(Long.parseLong(id.hashCode()+""));
}
@Override
public Long getValue(_05_PreciseAccumulator preciseAccumulator) {
return preciseAccumulator.getCardinality();
}
}
package com.yyds.flink_distinct;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.table.api.bridge.java.StreamTableEnvironment;
/**
* 在前面提到的精确去重方案都是会保存全量的数据,但是这种方式是以牺牲存储为代价的,
*
* 而hyperloglog方式虽然减少了存储但是损失了精度,
*
* 那么如何能够做到精确去重又能不消耗太多的存储呢,可以使用bitmap做精确去重。
*/
public class _05_BitMapDistinct {
public static void main(String[] args) {
// 创建表的执行环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
StreamTableEnvironment tenv = StreamTableEnvironment.create(env);
// 注册udaf函数
tenv.registerFunction("bitmapDistinct",new _05_BitMapDistinctFunction());
// 从kafka中读取数据
String sourceTable = "CREATE TABLE source_table (\n" +
" `id` string,\n" +
" `devId` string,\n" +
" `ts` bigint,\n" +
" `rt` AS TO_TIMESTAMP_LTZ(ts, 3) ,\n" +
" watermark for rt as rt - interval '60' second \n" +
") WITH (\n" +
" 'connector' = 'kafka',\n" +
" 'topic' = 'ad',\n" +
" 'properties.bootstrap.servers' = 'hadoop01:9092',\n" +
" 'properties.group.id' = 'testGroup',\n" +
" 'scan.startup.mode' = 'earliest-offset',\n" +
" 'format' = 'csv'\n" +
")";
tenv.executeSql(sourceTable);
String selectSql = "select \n" +
" window_start,\n" +
" window_end,\n" +
" bitmapDistinct(distinct devId) as cnt\n" +
"from table (\n" +
" tumble(table source_table,descriptor(rt),interval '1' minute ) --滚动窗口 \n" +
")\n" +
"group by window_start,window_end";
tenv.executeSql(selectSql).print();
}
}