Flink 实战之流式数据去重


系列文章

流式数据是一种源源不断产生的数据,没有预定的开始与结束,至少理论上来说,它的数据输入永远不会结束。因此流式数据处理与传统的批处理技术不同,必须具备持续不断地对到达的数据进行处理的能力。

因为流式数据源源不断地产生,对流式数据做去重就十分困难,因为一条数据重复与否需要与之前的数据痕迹作比对,数据是无穷尽产生的,倘留存之前的数据,势必占据大量的存储空间,判重的过程也会随着数据量的增加而变得复杂耗时。

本文探索了一种流式大数据的实时去重方法,不一定适用于所有场景,不过或许可以给面对相似问题的你一点点启发。

Bloom 过滤器

海量数据的去重,很容易联想到 Bloom 过滤器。Bloom过滤器是由一个长度为 m 比特的数组与 k 个哈希函数组成的数据结构。

当要插入一个元素时,将数据分别输入到 k 个哈希函数,产生 k 个哈希值,以哈希值作为位数组中的索引,将相应的比特位置为 1。

如下图所示,是由 3 个哈希函数 + 18 个比特位组成的 Bloom 过滤器:

当元素 "hello" 插入时,3 个哈希函数分别计算得到 3 个哈希值,将哈希值对应的比特位置为 1。

当元素 "world" 插入时,3 个哈希函数分别计算再次得到 3 个哈希值,将哈希值对应的比特位置为 1。

Bloom 过滤器的巧妙之处就在于用一张位图来留存数据的痕迹,无需存储数据本身,用有限的空间和极低的时间复杂度即可完成过滤。

当要查询一个元素时,同样将其输入 k 个哈希函数,然后检查对应的 k 个比特,如果有任意一个比特为 0,表明该元素一定不在集合中;如果所有比特均为 1,表明该元素有(较大的)可能性在集合中。为什么无法百分之百确定元素在集合中呢?以元素 "test" 为例:

我们假设 "test" 经过哈希函数计算后得到的哈希值恰好是之前的数据 "hello" + "world" 的哈希值的子集,此时 Bloom 就会产生误判,误以为 "test" 已经在集合中。

不过这个误判率可以通过增加哈希函数的个数和位图的大小来控制在极低的范围内,给定预计输入的元素总数 n 和预期的假阳性率 p ,经过严格的数学推导可以得到哈希函数的个数 k 和位图的大小 m 的理论值:

\[k = \frac{m}{n}ln2 \]

\[m = - \frac{nlnp}{(ln2)^2} \]

Bloom 过滤器去重流数据

使用 Bloom 对流式数据去重时,由于 Bloom 的位图空间有限而流数据是源源不断产生的,有限的位图空间无法应对无限的数据,而如果定时重置过滤器,重置将导致已保存状态位的丢失,从而引入重复记录,无法做到 "无缝" 衔接。示意图如下:

t 1 时刻重置过滤器时,将导致 t 1 时刻之前的 01,03 数据标记丢失,重置后再次出现的数据 03 将穿透过滤器,同理在 t 2 时刻、t 3 时刻、t4 时刻重置过滤器后,数据 06、08、09 也将穿透过滤器,造成去重结果不准确。

Bloom 过滤器队列去重流数据

既然一个 Bloom 无法应对流数据的去重,如果用多个 Bloom 过滤器能否实现预期效果呢?

我们采用 Bloom 过滤器队列对数据流进行去重,队列中的 Bloom 过滤器是按时间依次补位到队列中的,重点在 "依次",每个过滤器的 TTL (Time To Live) 相同,但存活的起止时间不同。

如图所示:

过滤器-1 的存活起止时间是[t 0, t3];

过滤器-2t 1 时刻补充到队列中,存活起止时间是 [t 1, t4];

过滤器-3t 2 时刻补位到队列中,存活起止时间是 [t 2, t5];

过滤器-4t 3 时刻补位到队列中,存活起止时间是 [t 3, t 6],t 3 时刻,过滤器-1 的生命周期结束,从过滤器队首移除,新的队首是 过滤器-2

过滤器-5t 4 时刻补位到队列中,存活起止时间是 [t 4, t 7],t 4 时刻,过滤器-2 的声明周期结束,从过滤器队首移除,新的队首是 过滤器-3

过滤器-6t 5 时刻补位到队列中,存活起止时间是 [t 5, t 8],t 5 时刻,过滤器-3 的声明周期结束,从过滤器队首移除,新的队首是 过滤器-4

过滤器队列中每隔固定时间间隔从队首移除一个旧的过滤器,同时补位到队尾一个新的过滤器,队列的规模一直保持固定的规模 (本例中为 3);

这个过滤器队列如何判别重复呢?

当接收到一个数据元素时,用过滤器队列中的 每个过滤器 来判断该数据是否出现过,只有当队列中的每个过滤器都判定为 "未出现过" 时,才认为是非重复数据,允许通过;只要队列中有任何一个过滤器判断为 "已出现过",则拦截该数据。

无论拦截或是放行该条数据,都在在当前队列中的 First 2 个过滤器中留存该数据记录的 "痕迹" (图中用相同位置的绿色 bit 标识数据的痕迹)。

还是以上图为例,介绍一下过滤器队列的工作过程:

*t* 0, *t* 1\] 时间段,队列中只有 1 个过滤器:*过滤器-1* ,数据 01,01,03 依次到达后,经 *过滤器-1* 去重后的结果是 01,03,在 *过滤器-1* 中记录 \[*t* 0, *t*1\] 时间段流经所有数据记录的状态位; \[*t* 1, *t* 2\] 时间段,队列中有 2 个过滤器:*过滤器-1* 、*过滤器-2* ,当数据 03,03,04 依次到达后,03 被 *过滤器-1* 拦截,04 可以通过过滤器队列,因此去重后的结果是 04,同时在 *过滤器-1* 和 *过滤器-2* 中记录 \[*t* 1, *t*2\] 时间段流经所有数据记录的状态位; \[*t* 2, *t* 3\] 时间段,队列中有 3 个过滤器:*过滤器-1* 、*过滤器-2* 、*过滤器-3* 。当数据 04,06,06 依次到达后,04 被 *过滤器-1* 、*过滤器-2* 拦截,06 可以通过过滤器队列,因此去重后的结果是 06,同时在 *过滤器-1* 和 *过滤器-2* 中记录 \[*t* 2, *t* 3\] 时间段流经所有数据记录的状态位,*过滤器-2* 就是过滤器-1 在 \[*t* 1, *t* 3\] 时间段的备份;因为 \[*t* 2, *t* 3\] 时刻 过滤器-1 的状态已经复制到了 过滤器-2 中,过滤器-3 在\[*t* 2, *t*3\] 时间段就不必留存数据记录了 (图中用灰色表示); *t* 3 时刻,*过滤器-4* 补位到队尾,*过滤器-1* 从队首移除 (*t* 3 时刻之后,如果还有 *t*3 时刻之前出现过的数据再次出现,将会穿透过滤器队列,我们可以通过设置过滤器的存活时间和队列的大小来尽量避免这一情况的发生); \[*t* 3, *t* 4\] 时间段,队列中有 3 个过滤器:*过滤器-2* 、*过滤器-3* 、*过滤器-4* ,当数据 06,08,07 依次到达后,06 被 *过滤器-2* 拦截,08 和 07 可以通过过滤器队列,因此去重后的结果是 08,07,同时在 *过滤器-2* 和 *过滤器-3* 中记录 \[*t* 3, *t* 4\] 时间段流经所有数据记录的状态位 (*过滤器-3* 作为 *过滤器-2* 在 \[*t* 3, *t* 4\] 时间段的备份),因为 \[*t* 3, *t* 4\] 时刻 *过滤器-2* 的状态已经复制到了 *过滤器-3* 中,*过滤器-4* 在\[*t* 3, *t*4\] 时间段就不必留存数据记录了 (图中用灰色表示); *t* 4 时刻,*过滤器-5* 补位到队尾,*过滤器-2* 从队首移除 (*t* 4 时刻之后,如果还有 *t*2 时刻之前出现过的数据再次出现,将会穿透过滤器队列,我们可以通过设置过滤器的存活时间和队列的大小来避免这一情况的发生); \[*t* 4, *t* 5\] 时间段,队列中有 3 个过滤器:*过滤器-3* 、*过滤器-4* 、*过滤器-5* ,当数据 08,08,09依次到达后,08 被 *过滤器-3* 拦截,09 可以通过过滤器队列,因此去重后的结果是 09,同时在 *过滤器-3* 和 *过滤器-4* 中记录 \[*t* 3, *t* 4\] 时刻流经所有数据记录的状态位 (*过滤器-4* 作为 *过滤器-3* 在 \[*t* 4, *t* 5\] 时间段的备份),因为 \[*t* 4, *t* 5\] 时间段 *过滤器-3* 的状态已经复制到了 *过滤器-4* 中,*过滤器-5* 在 \[*t* 4, *t*5\] 时刻就不必留存数据记录了 (图中用灰色表示); *t* 5 时刻,*过滤器-6* 补位到队尾,*过滤器-3* 从队首移除 (*t* 5时刻之后,如果还有 *t*3 时刻之前出现过的数据再次出现,将会穿透过滤器队列,我们可以通过设置过滤器的存活时间和队列的大小来避免这一情况的发生); \[*t* 5, *t* 6\] 时间段,队列中有 3 个过滤器:*过滤器-4* 、*过滤器-5* 、*过滤器-6* ,当数据 09,09,10 依次到达后,09 被 *过滤器-4* 拦截,10 可以通过过滤器队列,因此去重后的结果是 10,同时在 *过滤器-4* 和 *过滤器-5* 中记录 \[*t* 5, *t* 6\] 时刻流经所有数据记录的状态位 (*过滤器-5* 作为 *过滤器-4* 在 \[*t* 5, *t* 6\] 时刻的备份),因为 \[*t* 5, *t* 6\] 时刻*过滤器-4* 的状态已经复制到了 *过滤器-5* 中,*过滤器-6* 在\[*t* 5, *t*6\] 时刻就不必留存数据记录了 (图中用灰色表示); ## 实现 如何把上述设计在 Flink 中实现呢,Bloom 过滤器队列是随着时间动态变化的,因此需要用到 Flink 的 **定时器** 。`KeyedProcessFunction` 算子的 `TimerService` 就提供了定时器注册功能,可以注册 `EventTimeTimer` 或 `ProcessingTimeTimer`。 `BloomFilterProcessFunction.java`: ```java package org.example.flink.operator; import java.nio.charset.Charset; import java.util.ArrayList; import java.util.List; import org.apache.flink.configuration.Configuration; import org.apache.flink.streaming.api.functions.KeyedProcessFunction; import org.apache.flink.util.Collector; import org.example.flink.data.Trace; import com.google.common.hash.BloomFilter; import com.google.common.hash.Funnels; public class BloomFilterProcessFunction extends KeyedProcessFunction { private static final long serialVersionUID = 1L; // bloom预计插入的数据量 private static final long EXPECTED_INSERTIONS = 5000000L; // bloom的假阳性率 private static final double FPP = 0.001; // bloom过滤器TTL private static final long TTL = 60 * 1000; // bloom过滤器队列size private static final int FILTER_QUEUE_SIZE = 10; // bloom过滤器队列 private List> bloomFilterList; // 是否已经注册定时器 private boolean registeredTimerTask = false; @Override public void open(Configuration parameters) throws Exception { bloomFilterList = new ArrayList<>(FILTER_QUEUE_SIZE); BloomFilter bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charset.forName("utf-8")), EXPECTED_INSERTIONS, FPP); bloomFilterList.add(bloomFilter); } @Override public void processElement(Trace trace, KeyedProcessFunction.Context context, Collector out) throws Exception { BloomFilter firstBloomFilter = bloomFilterList.get(0); String key = trace.getGid(); // 只要有一个bloom未hit该元素,就意味着该元素从未出现过,在队列中的所有过滤器留下该元素的标记 if (!firstBloomFilter.mightContain(key)) { for (BloomFilter bloomFilter : bloomFilterList) { bloomFilter.put(key); } // 该元素从未出现过,为非重复数据 out.collect(trace); } if (!registeredTimerTask) { long current = context.timerService().currentProcessingTime(); // 注册处理时间定时器 context.timerService().registerProcessingTimeTimer(current + TTL); registeredTimerTask = true; } } @Override public void onTimer(long timestamp, OnTimerContext context, Collector out) throws Exception { // append新的bloomFilter到bloom过滤器队列 bloomFilterList .add(BloomFilter.create(Funnels.stringFunnel(Charset.forName("utf-8")), EXPECTED_INSERTIONS, FPP)); // 清理第一个bloomFilter if (bloomFilterList.size() > FILTER_QUEUE_SIZE) { bloomFilterList.remove(0); } // 创建一个新的timer task context.timerService().registerProcessingTimeTimer(timestamp + TTL); } @Override public void close() throws Exception { bloomFilterList = null; } } ``` 以下是主程序入口,实验场景还是设定为从 Kafka 消费数据,去重后写入到 MySQL: `StreamDeduplication.java`: ```java package org.example.flink; 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.java.functions.KeySelector; import org.apache.flink.configuration.Configuration; import org.apache.flink.connector.jdbc.JdbcConnectionOptions; import org.apache.flink.connector.jdbc.JdbcExecutionOptions; import org.apache.flink.connector.jdbc.JdbcSink; import org.apache.flink.connector.kafka.source.KafkaSource; import org.apache.flink.connector.kafka.source.enumerator.initializer.OffsetsInitializer; import org.apache.flink.contrib.streaming.state.EmbeddedRocksDBStateBackend; import org.apache.flink.streaming.api.datastream.DataStreamSink; import org.apache.flink.streaming.api.datastream.DataStreamSource; import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator; import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; import org.example.flink.data.Trace; import org.example.flink.operator.BloomFilterProcessFunction; import com.google.gson.Gson; public class StreamDeduplication { public static void main(String[] args) throws Exception { // 1. prepare Configuration configuration = new Configuration(); configuration.setString("rest.port", "9091"); StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(configuration); env.enableCheckpointing(2 * 60 * 1000); env.setStateBackend(new EmbeddedRocksDBStateBackend()); // 使用rocksDB作为状态后端 // 2. Kafka Source KafkaSource source = KafkaSource.builder() .setBootstrapServers("127.0.0.1:9092") .setTopics("trace") .setGroupId("group-01") .setStartingOffsets(OffsetsInitializer.latest()) .setProperty("commit.offsets.on.checkpoint", "true") .setValueOnlyDeserializer(new SimpleStringSchema()) .build(); DataStreamSource sourceStream = env.fromSource(source, WatermarkStrategy.noWatermarks(), "Kafka Source"); sourceStream.setParallelism(1); // 设置source算子的并行度为1 // 3. 转换为Trace对象 SingleOutputStreamOperator mapStream = sourceStream.map(new MapFunction() { private static final long serialVersionUID = 1L; @Override public Trace map(String value) throws Exception { Gson gson = new Gson(); Trace trace = gson.fromJson(value, Trace.class); return trace; } }); mapStream.name("Map to Trace"); mapStream.setParallelism(1); // 设置map算子的并行度为1 // 4. Bloom过滤器去重, 在去重之前要keyBy处理,保障同一gid的数据全都交由同一个线程处理 SingleOutputStreamOperator deduplicatedStream = mapStream.keyBy( new KeySelector() { private static final long serialVersionUID = 1L; @Override public String getKey(Trace trace) throws Exception { return trace.getGid(); } }) .process(new BloomFilterProcessFunction()); deduplicatedStream.name("Bloom filter process for distinct gid"); deduplicatedStream.setParallelism(2); // 设置去重算子的并行度为2 // 5. 将去重结果写入DataBase DataStreamSink sinkStream = deduplicatedStream.addSink( JdbcSink.sink("insert into flink.deduplication(gid, timestamp) values (?, ?);", (statement, trace) -> { statement.setString(1, trace.getGid()); statement.setLong(2, trace.getTimestamp()); }, JdbcExecutionOptions.builder() .withBatchSize(1000) .withBatchIntervalMs(200) .withMaxRetries(5) .build(), new JdbcConnectionOptions.JdbcConnectionOptionsBuilder() .withUrl("jdbc:mysql://127.0.0.1:3306/flink") .withUsername("username") .withPassword("password") .build()) ); sinkStream.name("Sink DB"); sinkStream.setParallelism(1); // 执行 env.execute("Stream Real-Time Deduplication"); } } ``` ## 测试 以下是向 Kafka 生产重复数据的测试程序,程序中模拟了数据乱序到达的情况。 ```java public static void main(String[] args) throws InterruptedException { Properties props = new Properties(); String topic = "trace"; props.put("bootstrap.servers", "127.0.0.1:9092"); props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer"); props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer"); Producer producer = new KafkaProducer(props); InputStream inputStream = KafkaDataProducer.class.getClassLoader().getResourceAsStream(TEST_DATA); Scanner scanner = new Scanner(inputStream, StandardCharsets.UTF_8.name()); String content = scanner.useDelimiter("\\A").next(); scanner.close(); JSONObject jsonContent = JSONObject.parseObject(content); int nonDuplicateNum = 100000; int repeatNum = 100; Random r = new Random(); for (int i = 0; i < nonDuplicateNum; i++) { String id = jsonContent.getString(GID); String newId = increase(id, String.valueOf(i)); jsonContent.put(GID, newId); // 制造重复数据 for (int j = 0; j < repeatNum; j++) { // 对时间进行随机扰动,模拟数据乱序到达 long current = System.currentTimeMillis() - r.nextInt(60) * 1000; jsonContent.put(TIMESTAMP, current); producer.send(new ProducerRecord(topic, jsonContent.toString())); } // wait some time Thread.sleep(5); } Thread.sleep(2000); System.out.println("\n"); System.out.println("finished"); producer.close(); } ``` 共生产了 10, 000, 000 条 ID,其中非重复的 ID 共计 100, 000 个。我们看一下 Flink 是否能做到实时去重,将 100, 000 个非重复 ID 的结果正确写入到数据库。实验过程耗时较长,简单看一下动态效果图: ![](https://img2024.cnblogs.com/blog/2850366/202503/2850366-20250320150212199-1297284632.gif) 可以看到,Flink 的处理速度非常快,去重结果的数值和 Kafka 中实际的 distinct id 值跟的非常紧,几乎是毫秒延迟!

相关推荐
viperrrrrrrrrr79 小时前
大数据学习(78)-spark streaming与flink
大数据·学习·flink·spark
逆袭的小学生1 天前
Flink TM数据传输时的内存分配
大数据·flink
AiryView1 天前
自画flink、spark源码学习流程大图分享
flink·spark·源码学习
eqwaak02 天前
实时数仓中的Pandas:基于Flink+Arrow的流式处理方案——毫秒级延迟下的混合计算新范式
大数据·分布式·python·学习·flink·pandas
徐一闪_BigData2 天前
Flink读取Kafka数据写入IceBerg(HiveCatalog)
大数据·flink·iceberg
小诸葛IT课堂3 天前
Flink 初体验:从 Hello World 到实时数据流处理
大数据·flink
肥仔哥哥19305 天前
springboot集成flink实现DM数据库同步到ES
spring boot·flink·dm数据库·dm数据库同步·同步dm到es
Eugene Jou5 天前
FlinkSQL实现实时同步和实时统计过程(MySQL TO MySQL)
数据库·mysql·flink·flinksql
24k小善6 天前
flinkOracleCdc任务报错kafkaConnectSchema
java·大数据·flink