背景
在IoT场景中,设备上报的感知数据,会先存储在消息队列中,比如:Kafka、Pulsar中。然后基于消息中间件做数据转换处理落盘,或者进行实时分析。今天我们说的流式分析场景,就是基于此技术架构。业务上想统计每个设备最近五分钟上报温度的平均值。
需求分析
如果是离线分析,所有数据落盘,入到具体的OLAP数据库,这样的需求是比较简单的,一个SQL就搞定了。若对实时性要求高,只能借助于流式计算的技术。现以Flink为例,先来简单说下涉及到的一些概念:
事件时间和处理时间(Event Time and Processing Time)
所谓的事件时间Event Time,就是指的本身的数据时间。以上面的架构为准,其时间一般取Kafka消息队列接收数据时的时间,就是receive_time。这里可能大家会有个疑问,为何不取终端设备上报时间呢。如果说你能保证此时间是准确的,那是最好的。但是一般来说对于物联网设备,尤其是廉价的设备,其时钟同步机制是不健全的,无法有效保证数据时间的准确性,这个时间我们称之为:data_time。Processing Time指的是系统的处理时间,就是Flink集群内的系统时间,接收到消息后,Flink会按当前机器的时间来进行开窗。
一般来说,使用系统处理时间的场景是非常少的。绝大多所数使用的是事件时间,也就是Event Time。这样比较贴切事实。试想有这样的场景,kafka机器一直正常,Flink中的任务由于异常执行失败,或者说客户认为的把聚合任务关停。那么如果再次恢复,使用Processing Time进行计算,就会存在将历史数据当成最新数据来计算,这样是不合业务逻辑的。所以我们应该使用Event Time来进行聚合操作。此时其实就是离线分析的典型场景了,把kafka当成离线存储。无论是离线分析还是实时分析,其实都会面临同样的业务问题,只是解决的方式方法不同而已。
水位线(Watermark)
水位线作用是重新明确数据的计算标识,以用于窗口计算,一般是时间类型的字段。举个简单的例子,我们将接受时间定义为数据时间,也就是data_time等同于receive_time,在我们使用Flink进行窗口计算的时候,熟悉离线分析的同学肯定会知道,以小时报为例,12点10分统计的小时报,按T+1的规则其统计的数据范围即上个小时的数据,即:11:00:00到12:00:00,这其实就是一小时的窗口,水位线就相当于你过滤数据的那个字段,一般就是data_time。
关于窗口
在Flink中支持多种窗口类型,分别为:滚动窗口(Tumbling Windows)、滑动窗口(Sliding Windows) 、滑动窗口(Sliding Windows)、滑动窗口(Sliding Windows)。具体的概念可以看下Flink的官网介绍,这里主要介绍下滚动窗口、滑动窗口。针对事件时间和处理时间相应的会有:TumblingEventTimeWindows、SlidingEventTimeWindows、TumblingProcessingTimeWindows、SlidingProcessingTimeWindows之分。我们在使用过程中遇到的一些问题。来看一下官网的关于窗口生命周期的一些说明:
简单来说,一个窗口在第一个属于它的元素到达时就会被创建,然后在时间(event 或 processing time) 超过窗口的"结束时间戳 + 用户定义的 allowed lateness (详见 [Allowed Lateness](https://nightlies.apache.org/flink/flink-docs-master/zh/docs/dev/datastream/operators/windows/#allowed-lateness))"时 被完全删除。Flink 仅保证删除基于时间的窗口,其他类型的窗口不做保证, 比如全局窗口(详见 [Window Assigners](https://nightlies.apache.org/flink/flink-docs-master/zh/docs/dev/datastream/operators/windows/#window-assigners))。 例如,对于一个基于 event time 且范围互不重合(滚动)的窗口策略, 如果窗口设置的时长为五分钟、可容忍的迟到时间(allowed lateness)为 1 分钟, 那么第一个元素落入 12:00 至 12:05 这个区间时,Flink 就会为这个区间创建一个新的窗口。 当 watermark 越过 12:06 时,这个窗口将被摧毁。
官方的介绍比较清晰的说明的窗口的生命周期,今天结合实际的应用场景,来深入分析下窗口。TumblingEventTimeWindows、SlidingEventTimeWindows他们默认的Trigger都是EventTimeTrigger,都是基于Event Time来进行窗口计算的。两者都有个共同的问题:假如某一时刻数据的源头不再发送数据了,那么最后一个窗口存在关闭不了的问题。可以通过自定义周期性水位生成器来解决。
周期性水位生成器
我是根据code-cookbook.readthedocs.io/zh-cn/main/...
的描述,来创建自己的周期水位生成器。其间有几处需要特别注意,先看代码:
JAVA
**
* A watermark generator for generating watermarks. This class is modified from {@link BoundedOutOfOrdernessWatermarks}.
*/
public class BoundedOutOfOrdernessWatermarksOnEventTime<T> implements WatermarkGenerator<T> {
/**
* The maximum timestamp encountered so far.
*/
private long maxTimestamp;
/**
* The maximum out-of-orderness that this watermark generator assumes.
*/
//允许延时的时间,针对乱序、迟到数据场景
private final long outOfOrdernessMillis;
/**
* Processing time of last event
*/
private long lastEventTimestamp;
/**
* Time to emit watermark if no event comes for a long time.
* The goal is to trigger the computation of the window even when no record is coming.
*/
//窗口超时时间
private final Duration waitTimeInMillsToEmitWatermark;
/**
* watermark generator interval
*/
private final long autoWatermarkInterval;
private long currentLogicalEventTimeMills;
/**
* Creates a new watermark generator with the given out-of-orderness bound.
*
* @param maxOutOfOrderness The bound for the out-of-orderness of the event timestamps.
* @param waitTimeInMillsToEmitWatermark A time allow flink to wait for in case of that no next element arrives for a long time.
* When the waiting time is up and no next element arrives, the watermark will still be generated and emitted
* @param autoWatermarkInterval watermark generator interval
*/
public BoundedOutOfOrdernessWatermarksOnEventTime(Duration maxOutOfOrderness, Duration waitTimeInMillsToEmitWatermark, long autoWatermarkInterval) {
this.waitTimeInMillsToEmitWatermark = waitTimeInMillsToEmitWatermark;
this.autoWatermarkInterval = autoWatermarkInterval;
Preconditions.checkNotNull(maxOutOfOrderness, "maxOutOfOrderness");
Preconditions.checkArgument(!maxOutOfOrderness.isNegative(), "maxOutOfOrderness cannot be negative");
this.outOfOrdernessMillis = maxOutOfOrderness.toMillis();
// start so that our lowest watermark would be Long.MIN_VALUE.
this.maxTimestamp = Long.MIN_VALUE + outOfOrdernessMillis + 1;
this.lastEventTimestamp = Long.MIN_VALUE + outOfOrdernessMillis + 1;
this.currentLogicalEventTimeMills = Long.MIN_VALUE + outOfOrdernessMillis + 1;
}
public BoundedOutOfOrdernessWatermarksOnEventTime(Duration maxOutOfOrderness, Duration waitTimeInMillsToEmitWatermark) {
this(maxOutOfOrderness, waitTimeInMillsToEmitWatermark, 200L);
}
/**
* Flink will call this method when events arrive for each record.
*
* @param event element in stream
* @param eventTimestamp the time an event happened
* @param output An output for watermarks. The output accepts watermarks and idleness (inactivity) status
*/
@Override
public void onEvent(T event, long eventTimestamp, WatermarkOutput output) {
maxTimestamp = Math.max(maxTimestamp, eventTimestamp);
lastEventTimestamp = maxTimestamp;
currentLogicalEventTimeMills = maxTimestamp;
}
/**
* Flink will call this method once in a while, the interval is defined by {@code ExecutionConfig.setAutoWatermarkInterval(...)},
* for example:
* <pre>{@code env.getConfig().setAutoWatermarkInterval(400L);}
* </pre>
* <p>
* If flink wait enough time, and still not get next record, so we need to emit a new watermark triggering a computation of last window.
* If we don't do that, the computation of last window will never be triggered.
* It is important when we want to get some status value from the last event.
*
* @param output An output for watermarks. The output accepts watermarks and idleness (inactivity) status
*/
@Override
public void onPeriodicEmit(WatermarkOutput output) {
currentLogicalEventTimeMills = currentLogicalEventTimeMills + autoWatermarkInterval;
if ((currentLogicalEventTimeMills - lastEventTimestamp) > waitTimeInMillsToEmitWatermark.toMillis()) {
output.emitWatermark(new Watermark(currentLogicalEventTimeMills - outOfOrdernessMillis - 1));
maxTimestamp = currentLogicalEventTimeMills;
} else {
output.emitWatermark(new Watermark(maxTimestamp - outOfOrdernessMillis - 1));
}
}
}
Main方法
JAVA
final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
KafkaSource<String> source = KafkaSource.<String>builder()
.setBootstrapServers(ConfigUtils.getString("bootstrap.servers"))
.setTopics(ConfigUtils.getString("temperature.sensor.topic"))
.setGroupId("temperatureSensor.g1")
.setStartingOffsets(OffsetsInitializer.latest())
.setValueOnlyDeserializer(new SimpleStringSchema())
.build();
DataStream<String> kafkaDataStream = env.fromSource(source, WatermarkStrategy.noWatermarks(), "Kafka Source");
SingleOutputStreamOperator<TemperatureSensor> sourceStream = kafkaDataStream
.map(new MapFunction<String, TemperatureSensor>() {
private static final long serialVersionUID = 1L;
@Override
public TemperatureSensor map(String value) {
TemperatureSensor temperatureSensor = null;
try {
//{"productKey":"cu1tfzwc4whywunZ","deviceKey":"bw0nbqwXNsHCGtM","temperature":35.1,"dataTime":"2023-12-11 08:39:12","receiveTime":1702255153000}
final JSONObject jsonObject = JSONObject.parseObject(value);
temperatureSensor = new TemperatureSensor();
if (jsonObject != null) {
temperatureSensor.setProductKey(jsonObject.getString("productKey"));
temperatureSensor.setDeviceKey(jsonObject.getString("deviceKey"));
temperatureSensor.setTemperature(jsonObject.getDouble("temperature"));
temperatureSensor.setDataTime(jsonObject.getString("dataTime"));
temperatureSensor.setReceiveTime(jsonObject.getLong("receiveTime"));
}
System.out.println(">>>>>"+ temperatureSensor.getDeviceKey());
} catch (Exception e) {
System.out.println("FlinkWindowTest map error");
}
return temperatureSensor;
}
});
WatermarkStrategy<TemperatureSensor> temperatureSensorWatermarkStrategy = WatermarkStrategy
//使用自定义水位线策略
.<TemperatureSensor>forGenerator((WatermarkGeneratorSupplier<TemperatureSensor>) context -> new BoundedOutOfOrdernessWatermarksOnEventTime(Duration.ofSeconds(5), Duration.ofSeconds(15), 200L))
.withIdleness(Duration.ofSeconds(60))
//提取数据中的时间作为EventTime
.withTimestampAssigner(new SerializableTimestampAssigner<TemperatureSensor>() {
@Override
public long extractTimestamp(TemperatureSensor element, long recordTimestamp) {
return element.getReceiveTime();
}
});
SingleOutputStreamOperator<String> process = sourceStream
.assignTimestampsAndWatermarks(temperatureSensorWatermarkStrategy)
//按产品设备,进行分组
.keyBy(new KeySelector<TemperatureSensor, Tuple2<String,String>>() {
@Override
public Tuple2<String,String> getKey(TemperatureSensor userBehavior) throws Exception {
return userBehavior.getGroup();
}
})
//基于Event的滑动窗口设置
.window(TumblingEventTimeWindows.of(Time.seconds(15)))
.process(new ProcessWindowFunction<TemperatureSensor, String, Tuple2<String,String>, TimeWindow>() {
@Override
public void process(Tuple2<String, String> key, Context context, Iterable<TemperatureSensor> elements, Collector<String> out) throws Exception {
long startTs = context.window().getStart();
long endTs = context.window().getEnd();
System.out.println("process:maxTimestamp:"+context.window().maxTimestamp());
String windowStart = DateFormatUtils.format(startTs, "yyyy-MM-dd HH:mm:ss.SSS");
String windowEnd = DateFormatUtils.format(endTs, "yyyy-MM-dd HH:mm:ss.SSS");
long count = elements.spliterator().estimateSize();
out.collect("分组 " + key + " 的窗口在时间区间: " + windowStart + "-" + windowEnd + " 产生" + count + "条数据,具体数据:" + elements.toString());
}
});
process.print();
env.execute();
注意点
1、自定义水位onPeriodicEmit方法
官方的解释如下:
Called periodically, and might emit a new watermark, or not.
The interval in which this method is called and Watermarks are generated depends on ExecutionConfig.getAutoWatermarkInterval()。
此方法是其由系统周期调用,默认是200ms一次,可以通过ExecutionConfig来配置此值。这块大家一定要注意在创建BoundedOutOfOrdernessWatermarksOnEventTime时候,其值需要与系统配置的值一致。如果过大,可能引起水位线升得过高,导致窗口不再触发。另外,如果使用了withIdleness来处理空闲source。如果确实存在了空闲源,那么onPeriodicEmit的实际调用次数就是idleTimeout与autoWatermarkInterval的比值,不会一直调用下去。
2、水位线的理解
窗口计算中,所有的计算还是基于窗口生命周期来的。结合业务场景水位线理解如下:
用户需求:统计10分钟内,每个设备的平均温度。
一个设备上报某一条消息:{"deviceId":"cuwksjakpglcnz3G:gRhEGpz7EvVH4HJ","data_time":"2023-12-14 15:28:00","temperature":30.5}。
此条消息,正常应该落在2023-12-14 15:20:00,2023-12-14 15:30:00时间区间内。
a)如果是此区间窗口未创建过,那么Flink会创建此窗口。如果此时水位已经超过了所设置的时间(包括延时),则直接触发窗口计算。如果没有,等待水位上升。持续不断的数据过来,或者通过周期水位生成器会引起水位的上升,直到上升到一定程度,触发窗口计算。
b)如果此区间窗口已经创建,未关闭。判断逻辑同a)。
c)如果此区间窗口已经关闭,那么这个区间数据再发送过来的时候,就会当成迟到数据,可以通过SideOutput来对迟到数据进行处理。其实,可离线分析的处理逻辑是类似的,在离线场景,一般会用第二次的统计来覆盖第一次。因为数据已经落盘,且通过SQL很容易处理迟到的数据,把其当成历史数据一部分即可。相反,实时分析的处理逻辑会复杂很多。