系列文章

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

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

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

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 的位图空间有限而流数据是源源不断产生的,有限的位图空间无法应对无限的数据,而如果定时重置过滤器,重置将导致已保存状态位的丢失,从而引入重复记录,无法做到 "无缝" 衔接。示意图如下:

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

Bloom 过滤器队列去重流数据

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

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

如图所示:

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

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

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

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

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

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

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

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

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

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

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

[t0, t1] 时间段,队列中只有 1 个过滤器:过滤器-1,数据 01,01,03 依次到达后,经 过滤器-1去重后的结果是 01,03,在 过滤器-1 中记录 [t0, t1] 时间段流经所有数据记录的状态位;

[t1, t2] 时间段,队列中有 2 个过滤器:过滤器-1过滤器-2,当数据 03,03,04 依次到达后,03 被 过滤器-1 拦截,04 可以通过过滤器队列,因此去重后的结果是 04,同时在 过滤器-1过滤器-2 中记录 [t1, t2] 时间段流经所有数据记录的状态位;

[t2, t3] 时间段,队列中有 3 个过滤器:过滤器-1过滤器-2过滤器-3。当数据 04,06,06 依次到达后,04 被 过滤器-1过滤器-2 拦截,06 可以通过过滤器队列,因此去重后的结果是 06,同时在 过滤器-1过滤器-2 中记录 [t2, t3] 时间段流经所有数据记录的状态位,过滤器-2 就是过滤器-1 在 [t1, t3] 时间段的备份;因为 [t2, t3] 时刻 过滤器-1 的状态已经复制到了 过滤器-2 中,过滤器-3 在[t2, t3] 时间段就不必留存数据记录了 (图中用灰色表示);

t3 时刻,过滤器-4 补位到队尾,过滤器-1从队首移除 (t3 时刻之后,如果还有 t3 时刻之前出现过的数据再次出现,将会穿透过滤器队列,我们可以通过设置过滤器的存活时间和队列的大小来尽量避免这一情况的发生);

[t3, t4] 时间段,队列中有 3 个过滤器:过滤器-2过滤器-3过滤器-4,当数据 06,08,07 依次到达后,06 被 过滤器-2 拦截,08 和 07 可以通过过滤器队列,因此去重后的结果是 08,07,同时在 过滤器-2过滤器-3 中记录 [t3, t4] 时间段流经所有数据记录的状态位 (过滤器-3 作为 过滤器-2 在 [t3, t4] 时间段的备份),因为 [t3, t4] 时刻 过滤器-2 的状态已经复制到了 过滤器-3 中,过滤器-4 在[t3, t4] 时间段就不必留存数据记录了 (图中用灰色表示);

t4 时刻,过滤器-5 补位到队尾,过滤器-2 从队首移除 (t4 时刻之后,如果还有 t2 时刻之前出现过的数据再次出现,将会穿透过滤器队列,我们可以通过设置过滤器的存活时间和队列的大小来避免这一情况的发生);

[t4, t5] 时间段,队列中有 3 个过滤器:过滤器-3过滤器-4过滤器-5,当数据 08,08,09依次到达后,08 被 过滤器-3 拦截,09 可以通过过滤器队列,因此去重后的结果是 09,同时在 过滤器-3过滤器-4 中记录 [t3, t4] 时刻流经所有数据记录的状态位 (过滤器-4 作为 过滤器-3 在 [t4, t5] 时间段的备份),因为 [t4, t5] 时间段 过滤器-3 的状态已经复制到了 过滤器-4 中,过滤器-5 在 [t4, t5] 时刻就不必留存数据记录了 (图中用灰色表示);

t5 时刻,过滤器-6 补位到队尾,过滤器-3 从队首移除 (t5时刻之后,如果还有 t3 时刻之前出现过的数据再次出现,将会穿透过滤器队列,我们可以通过设置过滤器的存活时间和队列的大小来避免这一情况的发生);

[t5, t6] 时间段,队列中有 3 个过滤器:过滤器-4过滤器-5过滤器-6,当数据 09,09,10 依次到达后,09 被 过滤器-4 拦截,10 可以通过过滤器队列,因此去重后的结果是 10,同时在 过滤器-4过滤器-5 中记录 [t5, t6] 时刻流经所有数据记录的状态位 (过滤器-5 作为 过滤器-4 在 [t5, t6] 时刻的备份),因为 [t5, t6] 时刻过滤器-4 的状态已经复制到了 过滤器-5 中,过滤器-6 在[t5, t6] 时刻就不必留存数据记录了 (图中用灰色表示);

实现

如何把上述设计在 Flink 中实现呢,Bloom 过滤器队列是随着时间动态变化的,因此需要用到 Flink 的 定时器KeyedProcessFunction 算子的 TimerService 就提供了定时器注册功能,可以注册 EventTimeTimerProcessingTimeTimer

BloomFilterProcessFunction.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<String, Trace, Trace> { 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<BloomFilter<String>> bloomFilterList;
// 是否已经注册定时器
private boolean registeredTimerTask = false; @Override
public void open(Configuration parameters) throws Exception {
bloomFilterList = new ArrayList<>(FILTER_QUEUE_SIZE);
BloomFilter<String> bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charset.forName("utf-8")),
EXPECTED_INSERTIONS, FPP);
bloomFilterList.add(bloomFilter);
} @Override
public void processElement(Trace trace, KeyedProcessFunction<String, Trace, Trace>.Context context,
Collector<Trace> out) throws Exception {
BloomFilter<String> firstBloomFilter = bloomFilterList.get(0);
String key = trace.getGid();
// 只要有一个bloom未hit该元素,就意味着该元素从未出现过,在队列中的所有过滤器留下该元素的标记
if (!firstBloomFilter.mightContain(key)) {
for (BloomFilter<String> 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<Trace> 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:

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<String> source = KafkaSource.<String>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<String> sourceStream = env.fromSource(source, WatermarkStrategy.noWatermarks(),
"Kafka Source");
sourceStream.setParallelism(1); // 设置source算子的并行度为1 // 3. 转换为Trace对象
SingleOutputStreamOperator<Trace> mapStream = sourceStream.map(new MapFunction<String, Trace>() { 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<Trace> deduplicatedStream = mapStream.keyBy(
new KeySelector<Trace, String>() { 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<Trace> 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 生产重复数据的测试程序,程序中模拟了数据乱序到达的情况。

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<String, String> producer = new KafkaProducer<String, String>(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<String, String>(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 的结果正确写入到数据库。实验过程耗时较长,简单看一下动态效果图:

可以看到,Flink 的处理速度非常快,去重结果的数值和 Kafka 中实际的 distinct id 值跟的非常紧,几乎是毫秒延迟!

Flink 实战之流式数据去重的更多相关文章

  1. 字节跳动流式数据集成基于Flink Checkpoint两阶段提交的实践和优化

    背景 字节跳动开发套件数据集成团队(DTS ,Data Transmission Service)在字节跳动内基于 Flink 实现了流批一体的数据集成服务.其中一个典型场景是 Kafka/ByteM ...

  2. Demo:基于 Flink SQL 构建流式应用

    Flink 1.10.0 于近期刚发布,释放了许多令人激动的新特性.尤其是 Flink SQL 模块,发展速度非常快,因此本文特意从实践的角度出发,带领大家一起探索使用 Flink SQL 如何快速构 ...

  3. Apache Hudi 0.9.0版本重磅发布!更强大的流式数据湖平台

    1. 重点特性 1.1 Spark SQL支持 0.9.0 添加了对使用 Spark SQL 的 DDL/DML 的支持,朝着使所有角色(非工程师.分析师等)更容易访问和操作 Hudi 迈出了一大步. ...

  4. Flink系列之流式

    本文仅是自己看书.学习过程中的个人总结,刚接触流式,视野面比较窄,不喜勿喷,欢迎评论交流. 1.为什么是流式? 为什么是流式而不是流式系统这样的词语?流式系统在我的印象中是相对批处理系统而言的,用来处 ...

  5. 「Flink」理解流式处理重要概念

    什么是流式处理呢? 这个问题其实我们大部分时候是没有考虑过的,大多数,我们是把流式处理和实时计算放在一起来说的.我们先来了解下,什么是数据流. 数据流(事件流) 数据流是无边界数据集的抽象 我们之前接 ...

  6. FunDA(2)- Streaming Data Operation:流式数据操作

    在上一集的讨论里我们介绍并实现了强类型返回结果行.使用强类型主要的目的是当我们把后端数据库SQL批次操作搬到内存里转变成数据流式按行操作时能更方便.准确.高效地选定数据字段.在上集讨论示范里我们用集合 ...

  7. 流式数据分析模型kafka+storm

    http://www.cnblogs.com/panfeng412/archive/2012/07/29/storm-stream-model-analysis-and-discussion.html ...

  8. 流式计算(三)-Flink Stream 篇一

    原创文章,谢绝任何形式转载,否则追究法律责任! ​流的世界,有点乱,群雄逐鹿,流实在太多,看完这个马上又冒出一个,也不知哪个才是真正的牛,据说Flink是位重量级选手,能流计算,还能批处理, 和其他伙 ...

  9. Flink 另外一个分布式流式和批量数据处理的开源平台

    Apache Flink是一个分布式流式和批量数据处理的开源平台. Flink的核心是一个流式数据流动引擎,它为数据流上面的分布式计算提供数据分发.通讯.容错.Flink包括几个使用 Flink引擎创 ...

  10. 流式处理新秀Flink原理与实践

    随着大数据技术在各行各业的广泛应用,要求能对海量数据进行实时处理的需求越来越多,同时数据处理的业务逻辑也越来越复杂,传统的批处理方式和早期的流式处理框架也越来越难以在延迟性.吞吐量.容错能力以及使用便 ...

随机推荐

  1. Error: Assertion failed (nimages > 0) in cv::calibrateCameraRO, file D:\opencv4\opencv\opencv-4.1.0\modules\calib3d\src\calibration.cpp, line 3691

    报错信息: Error: Assertion failed (nimages > 0) in cv::calibrateCameraRO, file D:\opencv4\opencv\open ...

  2. Linux 检查磁盘空间命令合集

    1. DF df 是检查Linux安装程序上可用分区空间的最常用的命令之一.可以使用"df -TH"以直观易读的格式打印分区类型和分区大小.此命令将显示每个部分的总可用空间.已用空 ...

  3. Solution -「SDOI 2017」「洛谷 P3706」硬币游戏

    \(\mathscr{Description}\)   Link.   给定 \(n\) 个长度为 \(m\) 且两两不同的字符串 \(S_{1..n}\), 字符集 \(|\Sigma|=2\). ...

  4. H5播放音频和视频

    H5播放音频和视频: <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> &l ...

  5. java基础知识回顾之java Thread类学习(二)--java多线程安全问题(锁)

    上一节售票系统中我们发现,打印出了错票,0,-1,出现了多线程安全问题.我们分析为什么会发生多线程安全问题? 看下面线程的主要代码: @Override public void run() { // ...

  6. selenium学习-常用方法

    id_#当前元素的ID  tag_name#获取元素标签名的属性  text#获取该元素的文本.  click()#单击(点击)元素  submit()#提交表单  clear()#清除一个文本输入元 ...

  7. 爬虫无限Debugger解决方案

    爬虫无限Debugger解决方案 在应对网站中的debugger语句以防止爬虫被调试时,一些网站会在代码中插入这些断点以干扰调试行为. 一种极端但直接的方法是通过禁用浏览器的断点激活功能来绕过所有de ...

  8. Huggingface使用

    目录 1. Transformer模型 1.1 核心组件 1.2 模型结构 1.3 Transformer 使用 1.3.1 使用 Hugging Face Transformers 库 1.3.2 ...

  9. 深入剖析实体-关系模型(ER 图):理论与实践全解析

    title: 深入剖析实体-关系模型(ER 图):理论与实践全解析 date: 2025/2/8 updated: 2025/2/8 author: cmdragon excerpt: 实体-关系模型 ...

  10. 探秘Transformer系列之(1):注意力机制

    探秘Transformer系列之(1):注意力机制 0x00 概述 因为各种事情,好久没有写博客了,之前写得一些草稿也没有时间整理(都没有时间登录博客和微信,导致最近才发现好多未读消息和私信,在这里和 ...