需求
假設學校的財務系統要出一個新功能,類似於年度賬單。統計每個學生過去一年往一卡通中的總充值金額。
其實這種需求完全不用開窗,可以直接使用批處理,groupBy()後reduce()即可。
當然,也可以使用流處理通過開窗實現聚合。下面分別介紹。
批處理
public static void main(String[] args) throws Exception {
ExecutionEnvironment env = ExecutionEnvironment.getExecutionEnvironment();
List<Deposit> list = new ArrayList<>();
list.add(new Deposit(1,100));
list.add(new Deposit(2,100));
list.add(new Deposit(3,100));
list.add(new Deposit(1,50));
list.add(new Deposit(2,60));
list.add(new Deposit(1,60));
list.add(new Deposit(1,50));
DataSource<Deposit> source = env.fromCollection(list);
//這兩種只支持Tuple類型的數據
//source.aggregate(Aggregations.SUM,1);
//source.sum(1);
ReduceOperator<Deposit> reduceMoney = source.groupBy("studentID")
.reduce(new ReduceFunction<Deposit>() {
@Override
public Deposit reduce(Deposit value1, Deposit value2) throws Exception {
value1.setMoney(value1.getMoney() + value2.getMoney());
return value1;
}
});
reduceMoney.print();
}
public static class Deposit{
private int studentID;
private float money;
private String dateTime;
public Deposit() {
}
public Deposit(final int studentID, final float money) {
this.studentID = studentID;
this.money = money;
}
public Deposit(final int studentID, final float money, final String dateTime) {
this.studentID = studentID;
this.money = money;
this.dateTime = dateTime;
}
public int getStudentID() {
return this.studentID;
}
public void setStudentID(final int studentID) {
this.studentID = studentID;
}
public float getMoney() {
return this.money;
}
public void setMoney(final float money) {
this.money = money;
}
public String getDateTime() {
return this.dateTime;
}
public void setDateTime(final String dateTime) {
this.dateTime = dateTime;
}
@Override
public String toString() {
return "Deposit{" +
"studentID=" + studentID +
", money=" + money +
'}';
}
}
結果:
Deposit{studentID=1, money=260.0}
Deposit{studentID=2, money=160.0}
Deposit{studentID=3, money=100.0}
小結:
- 對於批處理的分組用的是groupBy(),它有三種重載的方法,接收的參數類型分別是KeySelector、int、String,其中後兩種可以傳入多個值。對於groupBy(int... fields)來說,只支持Tuple類型的數據流。
- 對於批處理的累加大概有三種方式,sum()、reduce()、aggregate(),其中sum()是aggregate(SUM,field)的語法糖,sum和aggregate都只支持Tuple類型的數據。
流處理不開窗
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
List<BatchReduce.Deposit> list = new ArrayList<>();
list.add(new BatchReduce.Deposit(1,100));
list.add(new BatchReduce.Deposit(2,100));
list.add(new BatchReduce.Deposit(3,100));
list.add(new BatchReduce.Deposit(1,50));
list.add(new BatchReduce.Deposit(2,60));
list.add(new BatchReduce.Deposit(1,60));
list.add(new BatchReduce.Deposit(1,50));
DataStreamSource<BatchReduce.Deposit> source = env.fromCollection(list);
SingleOutputStreamOperator<BatchReduce.Deposit> sum = source.keyBy("studentID")
.sum("money");
sum.print();
env.execute("stream reduce job");
}
結果:
6> Deposit{studentID=2, money=100.0}
6> Deposit{studentID=3, money=100.0}
5> Deposit{studentID=1, money=100.0}
5> Deposit{studentID=1, money=150.0}
5> Deposit{studentID=1, money=210.0}
5> Deposit{studentID=1, money=260.0}
6> Deposit{studentID=2, money=160.0}
小結:
- 流處理的分組用的是keyBy()
- 累加可以用sum()或reduce(),其中sum()也支持pojo對象
- 因爲是流式數據,因此在沒有開窗的情況下,每來一條數據就會進行一次計算和print
流處理開窗
窗口根據不同的標準可以做不同的劃分,按照是否是keyed stream可以分成window和windowAll兩種;這兩種類型下按照開窗條件劃分又有基於時間的timewindow/timeWindowAll,也有基於數量的countwindow/countWindowAll。
其中windowAll類型的窗口是單並行度的。
這裏因爲要根據studentID分組,因此採用的是countwindow。
窗口的聚合函數也有多種,對於每種的具體用法可以看官網,也可以看源碼:
- sum()
- aggregate()
- reduce()
- process()
其中reduce和aggregate是分別需要傳入自定義的ReduceFunction和AggregateFunction,這兩種窗口函數採用的是遞增聚合的方式,比全量緩存聚合函數ProcessWindowFunction要高效,性能也好。這個在另一篇也有介紹。
對於sum()來說,底層採用的也是aggregate()方法。
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
List<BatchReduce.Deposit> list = new ArrayList<>();
list.add(new BatchReduce.Deposit(1,100));
list.add(new BatchReduce.Deposit(2,100));
list.add(new BatchReduce.Deposit(3,100));
list.add(new BatchReduce.Deposit(1,50));
list.add(new BatchReduce.Deposit(2,60));
list.add(new BatchReduce.Deposit(1,60));
list.add(new BatchReduce.Deposit(1,50));
DataStreamSource<BatchReduce.Deposit> source = env.fromCollection(list);
SingleOutputStreamOperator<BatchReduce.Deposit> sum = source.keyBy("studentID")
.countWindow(2)
.sum("money");
sum.print();
env.execute("stream reduce job");
}
結果:
6> Deposit{studentID=2, money=160.0}
5> Deposit{studentID=1, money=150.0}
5> Deposit{studentID=1, money=110.0}
可以看到,因爲設置了窗口大小爲2,對於id爲3的同學由於只有一條數據,因此達不到觸發條件,導致數據“丟失”。
而對於id爲1的學生,總共有四條數據,因此開了兩個窗口,但是隻返回了當前窗口的計算結果,沒有累加所有窗口的結果,也不滿足需求。
這個我們可以通過countWindow的源碼證明,默認的觸發機制是窗口元素數量:
可以看到countWindow創建的是全局窗口GlobalWindows,並指定了觸發器PurgingTrigger(全局窗口必須指定觸發器,默認是永遠不觸發的)。
其中PurgingTrigger類源碼如下:
可以看到,PurgingTrigger類起到的類似於轉換作用,就是將傳入的任何觸發器轉換成一個purging類型的觸發器,返回FIRE_AND_PURGE(觸發計算,然後清除窗口內的元素)。
下面看一下CountTrigger觸發器的源碼,看一下觸發器是如何定義的:
/**
* A {@link Trigger} that fires once the count of elements in a pane reaches the given count.
*
* @param <W> The type of {@link Window Windows} on which this trigger can operate.
*/
@PublicEvolving
public class CountTrigger<W extends Window> extends Trigger<Object, W> {
private static final long serialVersionUID = 1L;
private final long maxCount;
private final ReducingStateDescriptor<Long> stateDesc =
new ReducingStateDescriptor<>("count", new Sum(), LongSerializer.INSTANCE);
private CountTrigger(long maxCount) {
this.maxCount = maxCount;
}
@Override
public TriggerResult onElement(Object element, long timestamp, W window, TriggerContext ctx) throws Exception {
ReducingState<Long> count = ctx.getPartitionedState(stateDesc);
count.add(1L);
if (count.get() >= maxCount) {
count.clear();
return TriggerResult.FIRE;
}
return TriggerResult.CONTINUE;
}
@Override
public TriggerResult onEventTime(long time, W window, TriggerContext ctx) {
return TriggerResult.CONTINUE;
}
@Override
public TriggerResult onProcessingTime(long time, W window, TriggerContext ctx) throws Exception {
return TriggerResult.CONTINUE;
}
@Override
public void clear(W window, TriggerContext ctx) throws Exception {
ctx.getPartitionedState(stateDesc).clear();
}
@Override
public boolean canMerge() {
return true;
}
@Override
public void onMerge(W window, OnMergeContext ctx) throws Exception {
ctx.mergePartitionedState(stateDesc);
}
@Override
public String toString() {
return "CountTrigger(" + maxCount + ")";
}
/**
* Creates a trigger that fires once the number of elements in a pane reaches the given count.
*
* @param maxCount The count of elements at which to fire.
* @param <W> The type of {@link Window Windows} on which this trigger can operate.
*/
public static <W extends Window> CountTrigger<W> of(long maxCount) {
return new CountTrigger<>(maxCount);
}
private static class Sum implements ReduceFunction<Long> {
private static final long serialVersionUID = 1L;
@Override
public Long reduce(Long value1, Long value2) throws Exception {
return value1 + value2;
}
}
}
可以看到,它的主要部分就是onElement()方法,用了一個ReducingStateDescriptor狀態數據來對窗口中的數據量進行累加,當數據量達到指定的窗口大小時,就會clear清空狀態數據並觸發窗口函數。
對於onEventTime()和onProcessingTime()都是返回的TriggerResult.CONTINUE,也就是不觸發。
小結:
- countwindow默認只能對當前窗口實例(per-window)進行聚合,而不能對當前分組的所有窗口數據進行最終的聚合。爲了解決這個問題,可以通過ProcessWindowFunction定義狀態數據,在不同窗口實例中共享狀態數據來完成。
- countwindow底層的窗口分配器是GolbalWindow,指定了計數的觸發器
- 默認情況下,countwindow的窗口中只有數據量達到窗口大小時纔會觸發窗口函數(FIRE_AND_PURGE),因此如果窗口中數據量不夠時,這部分數據默認是不會觸發窗口函數的。
- 爲了解決這個問題,需要自定義觸發器,讓窗口在數量或時間達到指定條件時都可以觸發。
下面先從解決不同per-window的數據無法彙總開始。
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
List<BatchReduce.Deposit> list = new ArrayList<>();
list.add(new BatchReduce.Deposit(1,100));
list.add(new BatchReduce.Deposit(2,100));
list.add(new BatchReduce.Deposit(3,100));
list.add(new BatchReduce.Deposit(1,50));
list.add(new BatchReduce.Deposit(2,60));
list.add(new BatchReduce.Deposit(1,60));
list.add(new BatchReduce.Deposit(1,50));
DataStreamSource<BatchReduce.Deposit> source = env.fromCollection(list);
/*SingleOutputStreamOperator<BatchReduce.Deposit> sum = source.keyBy("studentID")
.countWindow(2)
.sum("money");*/
SingleOutputStreamOperator<BatchReduce.Deposit> sum = source.keyBy(new KeySelector<BatchReduce.Deposit, Integer>() {
@Override
public Integer getKey(BatchReduce.Deposit value) throws Exception {
return value.getStudentID();
}
})
.countWindow(2)
.process(new ProcessWindowFunction<BatchReduce.Deposit, BatchReduce.Deposit, Integer, GlobalWindow>() {
private ValueState<Tuple2<Integer, Float>> valueState;
@Override
public void open(Configuration parameters){
// 創建 ValueStateDescriptor
ValueStateDescriptor descriptor = new ValueStateDescriptor("depositSumStateDesc",
TypeInformation.of(new TypeHint<Tuple2<Integer, Float>>() {}));
// 基於 ValueStateDescriptor 創建 ValueState
valueState = getRuntimeContext().getState(descriptor);
}
@Override
public void process(Integer tuple, Context context, Iterable<BatchReduce.Deposit> elements, Collector<BatchReduce.Deposit> out) throws Exception {
context.windowState();
Tuple2<Integer, Float> currentState = valueState.value();
// 初始化 ValueState 值
if (null == currentState) {
currentState = new Tuple2<>(elements.iterator().next().getStudentID(), 0f);
}
float sum = 0f;
for (BatchReduce.Deposit deposit:elements){
sum += deposit.getMoney();
}
currentState.f1 = currentState.f1 + sum;
// 更新 ValueState 值
valueState.update(currentState);
BatchReduce.Deposit deposit = new BatchReduce.Deposit();
deposit.setStudentID(currentState.f0);
deposit.setMoney(currentState.f1);
out.collect(deposit);
}
});
sum.print();
env.execute("stream reduce job");
}
結果:
6> Deposit{studentID=2, money=160.0}
5> Deposit{studentID=1, money=150.0}
5> Deposit{studentID=1, money=260.0}
可以看到,對於id爲1的同學,第二個窗口輸出的彙總結果是包含第一個窗口的彙總數據的。
但對於id爲3的同學來說,由於不滿足默認觸發器的觸發條件,導致一直不輸出。
下面就通過自定義觸發器解決這個問題,讓在滿足數據量或滿足超時時間時,觸發窗口函數。
(暫未完成。。。)