目录
4、ProcessWindowFunction和reduce结合
一、基本概念
1、窗口计算目的
窗口计算是流式计算中常用的数据计算方式之一,通过按照固定时间或长度将数据流切分成不同的窗口,再对数据进行相应的聚合操作,得到一定时间范围内的统计结果,例如统计最近5分钟内某网站的点击数,此时,点击数据在不断产生,通过5分钟窗口将数据限定在固定时间范围内,就可以对该范围内的有界数据执行聚合,得到最近5分钟的网站点击数。
2、窗口计算函数分类
• Flink提供了四种类型的窗口计算函数,分别是ReduceFunction、AggregateFunction、FoldFunction和ProcessWindowFunction。
• 根据计算原理,ReduceFunction、AggregateFunction和FlodFunction属于增量聚合函数,而ProcessWindowFunction则属于全量聚合函数。
增量聚合函数是一种迭代计算(来一条计算一条,保存中间状态计算结果)基于是中间状态计算结果的,窗口中只维护中间状态结果值,不需要缓存原始的数据;而全量聚合函数在窗口触发时对所有的原始数以后在进行汇总计算,因此相对性能会较差。
FoldFunction也是增量聚合函数,但在Flink 1.9.0中已被标为过时(可用AggregateFunction代替)。 WindowFunction也是全量聚合函数,已被更高级的ProcessWindowFunction逐渐代替。
二、窗口计算函数介绍
1、ReduceFunction
ReduceFunction定义了对输入的两个相同类型的数据元素按照指定的计算方法进行聚合计算,然后输出类型相同的一个结果元素。
val sumStream = stockPriceStream
.keyBy(s => s.stockId)
.timeWindow(Time.seconds(1))
.reduce((s1, s2) => StockPrice(s1.stockId,s1.timeStamp, s1.price + s2.price))
使用reduce
的好处是窗口的状态数据量非常小,实现一个ReduceFunction
也相对比较简单,可以使用Lambda表达式,也可以重写函数。缺点是能实现的功能非常有限,因为中间状态数据的数据类型、输入类型以及输出类型三者必须一致,而且只保存了一个中间状态数据,当我们想对整个窗口内的数据进行操作时,仅仅一个中间状态数据是远远不够的。
2、AggregateFunction
Flink的AggregateFunction是一个基于中间计算结果状态进行增量计算的函数。由于是迭代计算方式,所以,在窗口处理过程中,不用缓存整个窗口的数据,所以效率执行比较高。
AggregateFunction比ReduceFunction更加通用,它定义了3个需要复写的方法,
add()定义了数据的添加逻辑, getResult()定义了累加器计算的结果, merge()定义了累加器合并的逻辑。
-
AggregateFunction这个类是一个泛型类,这里面有三个参数,IN, ACC, OUT。IN就是聚合函数的输入类型,ACC是存储中间结果的类型,OUT是聚合函数的输出类型。
public interface AggregateFunction<IN, ACC, OUT> extends Function, Serializable {...}
-
createAccumulator 这个方法首先要创建一个累加器,要进行一些初始化的工作,比如我们要进行count计数操作,就要给累加器一个初始值。
-
add add方法就是我们要做聚合的时候的核心逻辑,比如我们做count累加,其实就是来一个数,然后就加一。
-
merge 因为flink是一个分布式计算框架,可能计算是分布在很多节点上同时进行的,比如上述的add操作,可能同一个用户在不同的节点上分别调用了add方法在本地节点对本地的数据进行了聚合操作,但是我们要的是整个结果,整个时候,我们就需要把每个用户各个节点上的聚合结果merge一下,整个merge方法就是做这个工作的,所以它的入参和出参的类型都是中间结果类型ACC。
-
getResult 这个方法就是将每个用户最后聚合的结果经过处理之后,按照OUT的类型返回,返回的结果也就是聚合函数的输出结果了。
在计算之前要创建一个新的ACC,这时ACC还没有任何实际表示意义,当有新数据流入时,Flink会调用add
方法,更新ACC,并返回最新的ACC,ACC是一个中间状态数据。当有一些跨节点的ACC融合时,Flink会调用merge
,生成新的ACC。当所有的ACC最后融合为一个ACC后,Flink调用getResult
生成结果。
3、FoldFunction
FoldFunction决定了窗口中的元素如何和一个输出类型的元素进行结合。对于每个进入窗口的元素而言,FoldFunction会被增量调用。窗口中的第一个元素将会和这个输出类型的初始值进行结合。需要注意的是,FoldFunction不能用于会话窗口和那些可合并的窗口。
//前面的代码和ReduceWindowFunctionTest程序中的代码相同,因此省略
val sumStream = stockPriceStream
.keyBy(s => s.stockId)
.timeWindow(Time.seconds(1))
.fold("CHINA_"){ (acc, v) => acc + v.stockId }
4、ProcessWindowFunction
前面提到的ReduceFunction和AggregateFunction都是基于中间状态实现增量计算的窗口函数,虽然已经满足绝大多数场景的需求,但是,在某些情况下,统计更复杂的指标可能需要依赖于窗口中所有的数据元素,或需要操作窗口中的状态数据和窗口元数据,这时就需要使用到 ProcessWindowFunction,因为它能够更加灵活地支持基于窗口全部数据元素的结果计算。
使用时,Flink将某个Key下某个窗口的所有元素都缓存在Iterable<IN>
中,我们需要对其进行处理,然后用Collector<OUT>
收集输出。我们可以使用Context
获取窗口内更多的信息,包括时间、状态、迟到数据发送位置等。它在源码中的定义如下:
/**
* IN 输入类型
* OUT 输出类型
* KEY keyBy中按照Key分组,Key的类型
* W 窗口的类型
*/
public abstract class ProcessWindowFunction<IN, OUT, KEY, W extends Window> extends AbstractRichFunction {
/**
* 对一个窗口内的元素进行处理,窗口内的元素缓存在Iterable<IN>,进行处理后输出到Collector<OUT>中
* 我们可以输出一到多个结果
*/
public abstract void process(KEY key, Context context, Iterable<IN> elements, Collector<OUT> out) throws Exception;
/**
* 当窗口执行完毕被清理时,删除各类状态数据。
*/
public void clear(Context context) throws Exception {}
/**
* 一个窗口的上下文,包含窗口的一些元数据、状态数据等。
*/
public abstract class Context implements java.io.Serializable {
// 返回当前正在处理的Window
public abstract W window();
// 返回当前Process Time
public abstract long currentProcessingTime();
// 返回当前Event Time对应的Watermark
public abstract long currentWatermark();
// 返回某个Key下的某个Window的状态
public abstract KeyedStateStore windowState();
// 返回某个Key下的全局状态
public abstract KeyedStateStore globalState();
// 迟到数据发送到其他位置
public abstract <X> void output(OutputTag<X> outputTag, X value);
}
}
ProcessWindowFunction
相比AggregateFunction
和ReduceFunction
的应用场景更广,能解决的问题也更复杂。但ProcessWindowFunction
需要将窗口中所有元素作为状态存储起来,这将占用大量的存储资源,尤其是在数据量大窗口多的场景下,使用不慎可能导致整个程序宕机。比如,每天的数据在TB级,我们需要Slide为十分钟Size为一小时的滑动窗口,这种设置会导致窗口数量很多,而且一个元素会被复制好多份分给每个所属的窗口,这将带来巨大的内存压力。
5、Window聚合分类
增量聚合
-
窗口中每进入一条数据,就进行一次计算
-
reduce(reduceFunction)
-
aggregate(aggregateFunction)
全量聚合
-
等属于窗口的数据到齐,才开始进行聚合计算【可以实现对窗口内的数据进行排序等需求】
-
apply(windowFunction)
-
process(processWindowFunction)
processWindowFunction比windowFunction提供了更多的上下文信息。
三、窗口计算开发实践
1、ReduceFunction应用
1)需求:
-
实现股票价格的累加
-
实现类似于以下SQL功能:
select stockId,sum(price) from StockPrice group by stockId
2)实践:
case class StockPrice(stockId:String,timeStamp:Long,price:Double)
object ReduceWindowFunctionTest {
def main(args: Array[String]) {
//设置执行环境
val env = StreamExecutionEnvironment.getExecutionEnvironment
//设置程序并行度
env.setParallelism(1)
//设置为处理时间
env.setStreamTimeCharacteristic(TimeCharacteristic.ProcessingTime)
//创建数据源,股票价格数据流
val stockPriceStream: DataStream[StockPrice] = env
//该数据流由StockPriceSource类随机生成
.addSource(new StockPriceSource)
//确定针对数据集的转换操作逻辑
val sumStream = stockPriceStream
.keyBy(s => s.stockId)
.timeWindow(Time.seconds(1))
.reduce((s1, s2) => StockPrice(s1.stockId,s1.timeStamp, s1.price + s2.price))
//打印输出
sumStream.print()
//程序触发执行
env.execute("ReduceWindowFunctionTest")
}
class StockPriceSource extends RichSourceFunction[StockPrice]{
var isRunning: Boolean = true
val rand = new Random()
//初始化股票价格
var priceList: List[Double] = List(10.0d, 20.0d, 30.0d, 40.0d, 50.0d)
var stockId = 0
var curPrice = 0.0d
override def run(srcCtx: SourceContext[StockPrice]): Unit = {
while (isRunning) {
//每次从列表中随机选择一只股票
stockId = rand.nextInt(priceList.size)
val curPrice = priceList(stockId) + rand.nextGaussian() * 0.05
priceList = priceList.updated(stockId, curPrice)
val curTime = Calendar.getInstance.getTimeInMillis
//将数据源收集写入SourceContext
srcCtx.collect(StockPrice("stock_" + stockId.toString, curTime, curPrice))
Thread.sleep(rand.nextInt(1000))
}
}
override def cancel(): Unit = {
isRunning = false
}
}
}
/*
StockPrice(stock_1,1602036130952,39.78897954489408)
StockPrice(stock_4,1602036131741,49.950455275162945)
StockPrice(stock_2,1602036132184,30.073529000410154)
StockPrice(stock_3,1602036133154,79.88817093404676)
StockPrice(stock_0,1602036133919,9.957551599687758)
StockPrice(stock_1,1602036134385,39.68343765292602)
*/
2、AggregateFunction的应用
1)需求:
-
用户所在地区(userRegion),出游交通方式(traffic)两个维度,实时累计计算 订单数,用户数,最大费用,总费用,人员数。
-
实现类似于以下sql的功能
SELECT userRegion,traffic ,COUNT(orderid) orders ,COUNT(DISTINCT userid) users ,MAX(fee) max_fee ,SUM(adult+yonger+baby) member ,SUM(fee) AS total_fee ,AVG(fee) AS avg_free FROM OrderData GROUP BY userRegion,traffic
2)实践(测试通过)
AggregateFunction和WindowFunction结合使用。因为WindowFunction要等所有数据到齐才计算比较占内存,因此可以提前做聚合计算,等窗口结束时将增量计算结果发送给WindowFunction
作为输入再进行处理。
import org.apache.flink.api.common.functions.AggregateFunction
import org.apache.flink.streaming.api.TimeCharacteristic
import org.apache.flink.streaming.api.scala.{DataStream, StreamExecutionEnvironment}
import org.apache.flink.streaming.api.windowing.assigners.TumblingEventTimeWindows
import org.apache.flink.streaming.api.windowing.time.Time
import org.apache.flink.api.scala._
import org.apache.flink.streaming.api.scala.function.WindowFunction
import org.apache.flink.streaming.api.windowing.windows.TimeWindow
import org.apache.flink.util.Collector
import scala.collection.mutable
//import scala.collection.mutable
import scala.collection.mutable.Set
import scala.collection.mutable.ListBuffer
/**
*订单明细数据
* 订单ID,用户ID,注册来源,交通工具,价格,费用,成人,儿童,婴儿,时间
*/
case class OrderData(orderID:String, userID:String, userRegion:String, traffic:String,price:Int, fee:Int, adult:String, yonger:String, baby:String, ct:Long)
//订单统计维度列集
case class OrderAggDimData(userRegion:String, traffic:String)
//订单宽表时间窗口统计度量列集
case class OrderTimeAggMeaData(orders:Long, usersList:Set[String], maxFee:Long, totalFee:Long, members:Long)
//订单宽表时间窗口统计结果列集
case class OrderTimeAggDimMeaData(userRegion:String, traffic:String, startWindowTime:Long, endWindowTime:Long,orders:Long,users:Long, maxFee:Long, totalFee:Long, members:Long, avgFee:Double)
/**
* 订单数据
* 需求:
* 根据 用户所在地区(userRegion),出游交通方式(traffic)两个维度,实时累计计算orders, users,maxFee, totalFee, members
*/
object OrdersAggHandler {
/**
* 订单明细数据基于时间的窗口处理函数(预聚合操作)
*/
class OrderTimeAggFun extends AggregateFunction[OrderData, OrderTimeAggMeaData, OrderTimeAggMeaData] {
/**
* 创建累加器,,并初始化
*/
override def createAccumulator(): OrderTimeAggMeaData = {
OrderTimeAggMeaData(0l,Set(), 0l, 0l, 0l)
}
/**
* 累加开始
*/
override def add(value: OrderData, accumulator: OrderTimeAggMeaData): OrderTimeAggMeaData = {
//var userList: mutable.Set[String] = Set[String]()
val usersList = accumulator.usersList +=value.userID
val orders = accumulator.orders + 1
var maxFee = accumulator.maxFee.max(value.fee)
val totalFee = value.fee + accumulator.totalFee
val orderMembers = value.adult.toInt + value.yonger.toInt + value.baby.toInt
val members = orderMembers + accumulator.members
println("a",orders,usersList, maxFee, totalFee, members)
OrderTimeAggMeaData(orders,usersList, maxFee, totalFee, members)
}
/**
* 获取结果数据
*/
override def getResult(accumulator: OrderTimeAggMeaData): OrderTimeAggMeaData = {
accumulator
}
/**
* 合并中间数据
*/
override def merge(a: OrderTimeAggMeaData, b: OrderTimeAggMeaData): OrderTimeAggMeaData = {
val usersList = a.usersList ++ b.usersList
val orders = a.orders + a.orders
var maxFee = a.maxFee.max(b.maxFee)
val totalFee = a.totalFee + b.totalFee
val members = a.members + b.members
print("b",orders,usersList, maxFee, totalFee, members)
OrderTimeAggMeaData(orders,usersList, maxFee, totalFee, members)
}
}
/**
* 订单明细数据的窗口处理函数
*/
class OrderTimeWindowFun extends WindowFunction[OrderTimeAggMeaData, OrderTimeAggDimMeaData, OrderAggDimData, TimeWindow]{
override def apply(key: OrderAggDimData, window: TimeWindow, input: Iterable[OrderTimeAggMeaData], out: Collector[OrderTimeAggDimMeaData]): Unit = {
//分组维度
val userRegion = key.userRegion
val traffic = key.traffic
//度量计算
var outOrders = 0l
var outMaxFee = 0l
var outFees = 0l
var outMembers = 0l
var usersList = Set[String]()
//将窗口的输入数据进行循环计算
for(meaData: OrderTimeAggMeaData <- input){
print(meaData)
usersList = usersList ++ meaData.usersList
outOrders = outOrders + meaData.orders
outMaxFee = outMaxFee.max(meaData.maxFee)
outFees = outFees + meaData.totalFee
outMembers = outMembers + meaData.members
}
val outAvgFee = outFees / outOrders
val users = usersList.size
//窗口时间
val startWindowTime = window.getStart
val endWindowTime = window.getEnd
val owDMData = OrderTimeAggDimMeaData(userRegion, traffic, startWindowTime, endWindowTime,outOrders,users, outMaxFee, outFees, outMembers, outAvgFee)
//输出
println(owDMData)
out.collect(owDMData)
}
}
def main(args: Array[String]): Unit = {
//1、获取环境
val env = StreamExecutionEnvironment.getExecutionEnvironment
//2、读取数据
//2.1 订单数据
//数据格式:
/**
order01,user01,app,airplane,100,50,2,1,0,1653188891000
order02,user02,app,airplane,100,60,3,3,1,1653188892000
order03,user02,app,airplane,100,50,1,0,1,1653188893000
order03,user03,app,airplane,100,70,1,0,1,1653188895000
order03,user03,app,airplane,100,50,1,0,1,1653188898000
*/
val orderDStream: DataStream[String] = env.socketTextStream("127.0.0.1", 9999)
env.setParallelism(1)
//设置env的时间类型
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
//设置水位间隔时间
env.getConfig.setAutoWatermarkInterval(1 * 1000l)
var orderDataDStream: DataStream[OrderData] = orderDStream.map(_.split(",")).map(v => OrderData(v(0), v(1),v(2),v(3),v(4).toInt,v(5).toInt,v(6),v(7),v(8),v(9).toLong))
orderDataDStream.print("orderDataDStream=>")
val orderDataPeriodicAssigner = new OrderDataPeriodicAssigner(5l)
orderDataDStream = orderDataDStream.assignTimestampsAndWatermarks(orderDataPeriodicAssigner)
val result: DataStream[OrderTimeAggDimMeaData] = orderDataDStream
.keyBy((order) => OrderAggDimData(order.userRegion, order.traffic))
.window(TumblingEventTimeWindows.of(Time.seconds(5)))
.allowedLateness(Time.seconds(0))
.aggregate(new OrderTimeAggFun(), new OrderTimeWindowFun())
result.print("===>")
env.execute("OrdersAggHandler")
}
}
时间辅助器自定义类:
import org.apache.flink.streaming.api.functions.AssignerWithPeriodicWatermarks
import org.apache.flink.streaming.api.watermark.Watermark
/**
* 订单业务事件时间辅助器
*
* @param maxOutOfOrderness 最大延迟时间
*/
class OrderDataPeriodicAssigner(maxOutOfOrderness :Long) extends AssignerWithPeriodicWatermarks[OrderData]{
//当前时间戳
var currentMaxTimestamp :Long = java.lang.Long.MIN_VALUE
/**
* 水位生成
* (1) 默认最小值
* (2) 水位=当前时间-延迟时间
* @return
*/
override def getCurrentWatermark: Watermark ={
var waterMark :Long = java.lang.Long.MIN_VALUE
if(currentMaxTimestamp != java.lang.Long.MIN_VALUE){
waterMark = currentMaxTimestamp - maxOutOfOrderness
}
new Watermark(waterMark)
}
/**
* 事件时间提取
* @param element 实时数据
* @param previousElementTimestamp 之前数据的事件时间
* @return
*/
override def extractTimestamp(element: OrderData, previousElementTimestamp: Long): Long = {
//事件时间设置
val eventTime = element.ct
currentMaxTimestamp = Math.max(eventTime, currentMaxTimestamp)
eventTime
}
}
3、ProcessWindowFunction应用
1.1)需求:
-
求最近5秒内平均气温
-
实现类似于以下SQL功能:
select city,time,avg(temperature) from WeatherSource group by city,time
1.2)实践(主要代码):
public class AvgTemperatureStream {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
DataStreamSource<WeatherRecord> stream = env.addSource(new WeatherSource());
stream.keyBy(r->r.city)
.timeWindow(Time.seconds(5))
.process(new AvgTemp())
.print();
env.execute();
}
public static class AvgTemp extends ProcessWindowFunction<WeatherRecord,String,String, TimeWindow>{
@Override
public void process(String s, Context context, Iterable<WeatherRecord> iterable, Collector<String> collector) throws Exception {
Double sum=0.0;
Long count=0L;
for(WeatherRecord record:iterable){
sum += record.temperature;
count++;
}
collector.collect("截止窗口结束时间:"+ context.window().getEnd()+",平均气温为:"+sum/count);
}
}
}
2.1)需求:
-
对股票价格出现的次数做了统计,选出出现次数最多的输出。
-
实现类似于以下SQL功能:
select price,cnt,row_number() over(partition by price order by cnt desc) as rn from ( select price,count(*) as cnt from StockPrice group by price ) a where a.rn = 1
2.2)实践(主要代码):
case class StockPrice(symbol: String, price: Double)
class FrequencyProcessFunction extends ProcessWindowFunction[StockPrice, (String, Double), String, TimeWindow] {
override def process(key: String, context: Context, elements: Iterable[StockPrice], out: Collector[(String, Double)]): Unit = {
// 股票价格和该价格出现的次数
var countMap = scala.collection.mutable.Map[Double, Int]()
for(element <- elements) {
val count = countMap.getOrElse(element.price, 0)
countMap(element.price) = count + 1
}
// 按照出现次数从高到低排序
val sortedMap = countMap.toSeq.sortWith(_._2 > _._2)
// 选出出现次数最高的输出到Collector
if (sortedMap.size > 0) {
out.collect((key, sortedMap(0)._1))
}
}
}
senv.setStreamTimeCharacteristic(TimeCharacteristic.ProcessingTime)
val input: DataStream[StockPrice] = ...
val frequency = input
.keyBy(s => s.symbol)
.timeWindow(Time.seconds(10))
.process(new FrequencyProcessFunction)
4、ProcessWindowFunction和reduce结合
1)需求:
-
计算股票的最大,最小价格
-
-实现类似以下SQL功能:
select symbol,max(price),min(price) from StockPrice group by symbol
2)实践(主要代码):
case class StockPrice(symbol: String, price: Double)
case class MaxMinPrice(symbol: String, max: Double, min: Double, windowEndTs: Long)
class WindowEndProcessFunction extends ProcessWindowFunction[(String, Double, Double), MaxMinPrice, String, TimeWindow] {
override def process(key: String,
context: Context,
elements: Iterable[(String, Double, Double)],
out: Collector[MaxMinPrice]): Unit = {
val maxMinItem = elements.head
val windowEndTs = context.window.getEnd
out.collect(MaxMinPrice(key, maxMinItem._2, maxMinItem._3, windowEndTs))
}
}
senv.setStreamTimeCharacteristic(TimeCharacteristic.ProcessingTime)
val input: DataStream[StockPrice] = ...
// reduce的返回类型必须和输入类型相同
// 为此我们将StockPrice拆成一个三元组 (股票代号,最大值、最小值)
val maxMin = input
.map(s => (s.symbol, s.price, s.price))
.keyBy(s => s._1)
.timeWindow(Time.seconds(10))
.reduce(
((s1: (String, Double, Double), s2: (String, Double, Double)) => (s1._1, Math.max(s1._2, s2._2), Math.min(s1._3, s2._3))),
new WindowEndProcessFunction
)
参考资料:
《Flink基础编程》-- 林子雨
基础篇(三):Flink window窗口计算_桥~的博客-优快云博客_flink窗口计算