这是我参与「第四届青训营 」笔记创作活动的第7天。
Window 介绍
窗口(Window)是处理无界流的关键所在。窗口可以将数据流装入大小有限的“桶”中,再对每个“桶”加以处理。
下面展示了 Flink 窗口在 keyed streams 和 non-keyed streams 上使用的基本结构。 我们可以看到,这两者唯一的区别仅在于:keyed streams 要调用 keyBy(...)后再调用 window(...) , 而 non-keyed streams 只用直接调用 windowAll(...)。留意这个区别,它能帮我们更好地理解后面的内容。
Keyed Windows
stream
.keyBy(...) <- 仅 keyed 窗口需要
.window(...) <- 必填项:"assigner"
[.trigger(...)] <- 可选项:"trigger" (省略则使用默认 trigger)
[.evictor(...)] <- 可选项:"evictor" (省略则不使用 evictor)
[.allowedLateness(...)] <- 可选项:"lateness" (省略则为 0)
[.sideOutputLateData(...)] <- 可选项:"output tag" (省略则不对迟到数据使用 side output)
.reduce/aggregate/apply() <- 必填项:"function"
[.getSideOutput(...)] <- 可选项:"output tag"
Non-Keyed Windows
stream
.windowAll(...) <- 必填项:"assigner"
[.trigger(...)] <- 可选项:"trigger" (else default trigger)
[.evictor(...)] <- 可选项:"evictor" (else no evictor)
[.allowedLateness(...)] <- 可选项:"lateness" (else zero)
[.sideOutputLateData(...)] <- 可选项:"output tag" (else no side output for late data)
.reduce/aggregate/apply() <- 必填项:"function"
[.getSideOutput(...)] <- 可选项:"output tag"
上面方括号([…])中的命令是可选的。也就是说,Flink 允许你自定义多样化的窗口操作来满足你的需求。
四种优化方法详解
以下说的所有的高级优化,都只限于在SQL中的window中才有。在DataStream中,用户需要自己通过代码来实现类似的能力。接下来主要介绍四种优化方法的原理及其针对的问题。
Mini-batch
一般来讲,Flink的状态比较大一些都推荐使用rocksdb statebackend,这种情况下,每次的状态访问就都需要做一次序列化和反序列化,这种开销还是挺大的。为了降低这种开销,我们可以通过降低状态访问频率的方式来解决,这就是mini-batch最主要解决的问题:即赞一小批数据再进行计算,这批数据每个key的state访问只有一次,这样在单个key的数据比较集中的情况下,对于状态访问可以有效的降低频率,最终提升性能。
这个优化主要是适用于没有窗口的聚合场景,字节跳动内部扩展了window来支持mini-batch,在某些场景下的测试结果可以节省20-30%的CPU开销。
Flink SQL中的Mini-Batch概念与Spark Streaming有些类似,即微批次处理。
在默认情况下,聚合算子对摄入的每一条数据,都会执行“读取累加器状态→修改状态→写回状态”的操作。如果数据流量很大,状态操作的overhead也会随之增加,影响效率(特别是RocksDB这种序列化成本高的Backend)。开启Mini-Batch之后,摄入的数据会攒在算子内部的buffer中,达到指定的容量或时间阈值后再做聚合逻辑。这样,一批数据内的每个key只需要执行一次状态读写。如果key的量相对比较稀疏,优化效果更加明显。
未开启和开启Mini-Batch聚合机制的对比示意图如下。
显然,Mini-Batch机制会导致数据处理出现一定的延迟,用户需要自己权衡时效性和吞吐量的重要程度再决定。
Mini-Batch聚合默认是关闭的。要开启它,可以设定如下3个参数。
val tEnv: TableEnvironment = ...
val configuration = tEnv.getConfig().getConfiguration()
configuration.setString("table.exec.mini-batch.enabled", "true") // 启用
configuration.setString("table.exec.mini-batch.allow-latency", "5 s") // 缓存超时时长
configuration.setString("table.exec.mini-batch.size", "5000") // 缓存大小
开启Mini-Batch并执行一个简单的无界流聚合查询,观察Web UI上展示的JobGraph如下。
注意LocalGroupAggregate和GlobalGroupAggregate就是基于Mini-Batch的Local-Global机制优化的结果,在分析完原生Mini-Batch后会简单说明。
Mini-batch 代码详解
- 产生水印
Mini-Batch机制底层对应的优化器规则名为MiniBatchIntervalInferRule(代码略去),产生的物理节点为StreamExecMiniBatchAssigner,直接附加在Source节点的后面。其translateToPlanInternal()方法的源码如下。
@SuppressWarnings("unchecked")
@Override
protected Transformation<RowData> translateToPlanInternal(PlannerBase planner) {
final Transformation<RowData> inputTransform =
(Transformation<RowData>) getInputEdges().get(0).translateToPlan(planner);
final OneInputStreamOperator<RowData, RowData> operator;
if (miniBatchInterval.mode() == MiniBatchMode.ProcTime()) {
operator = new ProcTimeMiniBatchAssignerOperator(miniBatchInterval.interval());
} else if (miniBatchInterval.mode() == MiniBatchMode.RowTime()) {
operator = new RowTimeMiniBatchAssginerOperator(miniBatchInterval.interval());
} else {
throw new TableException(
String.format(
"MiniBatchAssigner shouldn't be in %s mode this is a bug, please file an issue.",
miniBatchInterval.mode()));
}
return new OneInputTransformation<>(
inputTransform,
getDescription(),
operator,
InternalTypeInfo.of(getOutputType()),
inputTransform.getParallelism());
}
可见,根据作业时间语义的不同,产生的算子也不同(本质上都是OneInputStreamOperator)。先看processing time时间语义下产生的算子ProcTimeMiniBatchAssignerOperator的相关方法。
@Override
public void processElement(StreamRecord<RowData> element) throws Exception {
long now = getProcessingTimeService().getCurrentProcessingTime();
long currentBatch = now - now % intervalMs;
if (currentBatch > currentWatermark) {
currentWatermark = currentBatch;
// emit
output.emitWatermark(new Watermark(currentBatch));
}
output.collect(element);
}
@Override
public void onProcessingTime(long timestamp) throws Exception {
long now = getProcessingTimeService().getCurrentProcessingTime();
long currentBatch = now - now % intervalMs;
if (currentBatch > currentWatermark) {
currentWatermark = currentBatch;
// emit
output.emitWatermark(new Watermark(currentBatch));
}
getProcessingTimeService().registerTimer(currentBatch + intervalMs, this);
}
processing time语义下本不需要用到水印,但这里的处理非常巧妙,即借用水印作为分隔批次的标记。每处理一条数据,都检查其时间戳是否处于当前批次内,若新的批次已经开始,则发射一条新的水印,另外也注册了Timer用于发射水印,且保证发射周期是上述table.exec.mini-batch.allow-latency参数指定的间隔。
event time语义下的思路相同,只需要检查Source产生的水印的时间戳,并只发射符合周期的水印,不符合周期的水印不会流转到下游。RowTimeMiniBatchAssginerOperator类中对应的代码如下。
@Override
public void processWatermark(Watermark mark) throws Exception {
// if we receive a Long.MAX_VALUE watermark we forward it since it is used
// to signal the end of input and to not block watermark progress downstream
if (mark.getTimestamp() == Long.MAX_VALUE && currentWatermark != Long.MAX_VALUE) {
currentWatermark = Long.MAX_VALUE;
output.emitWatermark(mark);
return;
}
currentWatermark = Math.max(currentWatermark, mark.getTimestamp());
if (currentWatermark >= nextWatermark) {
advanceWatermark();
}
}
private void advanceWatermark() {
output.emitWatermark(new Watermark(currentWatermark));
long start = getMiniBatchStart(currentWatermark, minibatchInterval);
long end = start + minibatchInterval - 1;
nextWatermark = end > currentWatermark ? end : end + minibatchInterval;
}
- 攒批处理
在实现分组聚合的物理节点StreamExecGroupAggregate中,会对启用了Mini-Batch的情况做特殊处理。
final OneInputStreamOperator<RowData, RowData> operator;
if (isMiniBatchEnabled) {
MiniBatchGroupAggFunction aggFunction =
new MiniBatchGroupAggFunction(
aggsHandler,
recordEqualiser,
accTypes,
inputRowType,
inputCountIndex,
generateUpdateBefore,
tableConfig.getIdleStateRetention().toMillis());
operator =
new KeyedMapBundleOperator<>(
aggFunction, AggregateUtil.createMiniBatchTrigger(tableConfig));
} else {
GroupAggFunction aggFunction = new GroupAggFunction(/*...*/);
operator = new KeyedProcessOperator<>(aggFunction);
}
可见,生成的负责攒批处理的算子为KeyedMapBundleOperator,对应的Function则是MiniBatchGroupAggFunction。先来看前者,在它的抽象基类中,有如下三个重要的属性。
/** The map in heap to store elements. */
private transient Map<K, V> bundle;
/** The trigger that determines how many elements should be put into a bundle. */
private final BundleTrigger<IN> bundleTrigger;
/** The function used to process when receiving element. */
private final MapBundleFunction<K, V, IN, OUT> function;
- bundle:即用于暂存数据的buffer。
- bundleTrigger:与CountTrigger类似,负责在bundle内的数据量达到阈值(即上文所述table.exec.mini-batch.size)时触发计算。源码很简单,不再贴出。
- function:即MiniBatchGroupAggFunction,承载具体的计算逻辑。
算子内对应的处理方法如下。
@Override
public void processElement(StreamRecord<IN> element) throws Exception {
// get the key and value for the map bundle
final IN input = element.getValue();
final K bundleKey = getKey(input);
final V bundleValue = bundle.get(bundleKey);
// get a new value after adding this element to bundle
final V newBundleValue = function.addInput(bundleValue, input);
// update to map bundle
bundle.put(bundleKey, newBundleValue);
numOfElements++;
bundleTrigger.onElement(input);
}
@Override
public void finishBundle() throws Exception {
if (!bundle.isEmpty()) {
numOfElements = 0;
function.finishBundle(bundle, collector);
bundle.clear();
}
bundleTrigger.reset();
}
@Override
public void processWatermark(Watermark mark) throws Exception {
finishBundle();
super.processWatermark(mark);
}
每来一条数据,就将其加入bundle中,增加计数,并调用BundleTrigger#onElement()方法检查是否达到了触发阈值,如是,则回调finishBundle()方法处理已经收齐的批次,并清空bundle。当水印到来时也同样处理,即可满足批次超时的设定。
finishBundle()方法实际上代理了MiniBatchGroupAggFunction#finishBundle()方法,代码比较冗长,看官可自行查阅,但是流程很简单:先创建累加器实例,再根据输入数据的RowKind执行累加或回撤操作(同时维护每个key对应的状态),最后输出批次聚合结果的changelog。值得注意的是,MiniBatchGroupAggFunction中利用了代码生成技术来自动生成聚合函数的底层handler(即AggsHandleFunction),在Flink Table模块中很常见。
Local-global
local-global优化是分布式系统中典型的优化,主要是可以降低数据shuffle的量,同时也可以缓解数据的倾斜。
Local-Global优化即将原先的Aggregate分成Local和Global两阶段聚合,即MapReduce模型中Combine+Reduce处理模式。第一阶段在上游节点本地攒一批数据进行聚合(localAgg),并输出这次微批的增量值(Accumulator),这个阶段不需要数据shuffle,是直接跟在上游算子之后进行处理的;第二阶段再将收到的Accumulator merge起来还记得上面说的session window的merge么,这里要求是一样的。如果存在没有实现merge的聚合函数,那么这个优化就不会生效),得到最终的结果(globalAgg)。
Local-Global本质上能够靠localAgg聚合掉倾斜的数据,从而降低globalAgg热点,从而提升性能。
Local-Global用于提升SUM、COUNT、MAX、MIN和AVG等普通Aggregate性能,以及解决这些场景下的数据热点问题。
如下图所示,比如是要对数据做一个sum,同样颜色的数据表示相同的group by的key,这样我们可以再local agg阶段对他们做一个预聚合;然后到了global阶段数据倾斜就消除了。
示例代码
import org.apache.flink.table.functions.AggregateFunction;
public class CountUdaf extends AggregateFunction<Long, CountUdaf.CountAccum> {
//定义存放count udaf的状态的accumulator数据结构
public static class CountAccum {
public long total;
}
//初始化count udaf的accumulator
public CountAccum createAccumulator() {
CountAccum acc = new CountAccum();
acc.total = 0;
return acc;
}
//getValue提供了如何通过存放状态的accumulator计算count UDAF的结果的方法
public Long getValue(CountAccum accumulator) {
return accumulator.total;
}
//accumulate提供了如何根据输入的数据更新count UDAF存放状态的accumulator
public void accumulate(CountAccum accumulator, Object iValue) {
accumulator.total++;
}
public void merge(CountAccum accumulator, Iterable<CountAccum> its) {
for (CountAccum other : its) {
accumulator.total += other.total;
}
}
}
Distinct状态复用
对于distinct的优化,一般批里面的引擎都是通过把它优化成aggregate的方式来处理,但是在流式window中,我们不能直接这样进行优化,要不然算子就变成会下发retract的数据了。所以在流式中,对于count distinct这种情况,我们是需要保存所有数据是否出现过这样子的一个映射。
收益:可以复用 MapState 的Key,节省状态。
在SQL中,我们有一种方式可以在聚合函数上添加一些filter,如下面的SQL所示:
像这种情况,我们会对同一个字段用不同的filter来进行count distinct的计算。如果每个指标都单独用一个map来记录每条数据是否出现过,那状态量是很大的。
我们可以把相同字段的distinct计算用一个map的key来存储,在map的value中,用一个bit vector来实现就可以把各个状态复用到一起了。比如一个bigint有64位,可以表示同一个字段的64个filter,这样整体状态量就可以节省很多了。
限制条件
-
至少有一个可枚举维度且枚举值可以事先确定
-
当用于窗口聚合时,窗口函数必须是行语义,即不适用于集合语义的窗口。
这个优化会调整聚合算子的 Group Key。调整完后,当前窗口收到的数据集合可能就变了,因此这个优化不适用于具有集合语义的窗口。什么是行语义,什么是集合语义?
行语义:当前这条数据属于哪个窗口只取决于当前输入数据本身。比如 TUMBLE/HOP 窗口函数。
集合语义:当前这条数据属于哪个窗口不仅取决于当前输入数据,还取决于这个窗口收到过的历史数据集合。比如 SESSION 窗口函数。 -
另外各个维度值下的Distinct Key 得有重合,才可以节约状态。假设维度值是省份 id,计算各个省份下的UV,基本可以认为不同省份的 device_id 是不同的,这个时候复用 distinct key 是没有收益的。
为什么语法上不采用 Calcite 的 PIVOT/UNPOVIT 显示地表达行转列和列转行。
- 条件不具备,Calcite 中1.26版本才开始引入 PIVOT,1.27 版本才开始引入 UNPOVIT。
而 Flink 1.12 版本才开始依赖 Calcite 的1.26版本,至今依然是。- 用 PIVOT 和 UNPIVOT 语法来表达,SQL 会比现在的冗长很多。
适用场景
- 适用于无限流聚合和窗口聚合
- 适用于单个可枚举的维度和多个可枚举的维度
- 适用于简单的分组聚合和多维聚合
滑动窗口pane复用
滑动窗口如上面所述,一条数据可能会属于多个window。所以这种情况下同一个key下的window数量可能会比较多,比如3个小时的窗口,1小时的滑动的话,每条数据到来会直接对着3个窗口进行计算和更新。这样对于状态访问频率是比较高的,而且计算量也会增加很多。
优化方法就是,将窗口的状态划分成更小粒度的pane,比如上面3小时窗口、1小时滑动的情况,可以把pane设置为1h,这样每来一条数据,我们就只更新这条数据对应的pane的结果就可以了。当窗口需要输出结果的时候,只需要将这个窗口对应的pane的结果merge起来就可以了。
注意:这里也是需要所有聚合函数都有merge的实现的