flink countWindow計算每個學生的總成績

需求

假設學校的財務系統要出一個新功能,類似於年度賬單。統計每個學生過去一年往一卡通中的總充值金額。

其實這種需求完全不用開窗,可以直接使用批處理,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}

小結:

  1. 對於批處理的分組用的是groupBy(),它有三種重載的方法,接收的參數類型分別是KeySelector、int、String,其中後兩種可以傳入多個值。對於groupBy(int... fields)來說,只支持Tuple類型的數據流。
  2. 對於批處理的累加大概有三種方式,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}

小結:

  1. 流處理的分組用的是keyBy()
  2. 累加可以用sum()或reduce(),其中sum()也支持pojo對象
  3. 因爲是流式數據,因此在沒有開窗的情況下,每來一條數據就會進行一次計算和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,也就是不觸發。

小結:

  1. countwindow默認只能對當前窗口實例(per-window)進行聚合,而不能對當前分組的所有窗口數據進行最終的聚合。爲了解決這個問題,可以通過ProcessWindowFunction定義狀態數據,在不同窗口實例中共享狀態數據來完成。
  2. countwindow底層的窗口分配器是GolbalWindow,指定了計數的觸發器
  3. 默認情況下,countwindow的窗口中只有數據量達到窗口大小時纔會觸發窗口函數(FIRE_AND_PURGE),因此如果窗口中數據量不夠時,這部分數據默認是不會觸發窗口函數的。
  4. 爲了解決這個問題,需要自定義觸發器,讓窗口在數量或時間達到指定條件時都可以觸發。

下面先從解決不同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的同學來說,由於不滿足默認觸發器的觸發條件,導致一直不輸出。

下面就通過自定義觸發器解決這個問題,讓在滿足數據量或滿足超時時間時,觸發窗口函數。

(暫未完成。。。) 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章