Flink 流計算編程--看看別人怎麼用 Session Window

點擊上方zhisheng,選擇“設爲星標

回覆“666”,獲取精選資料

1、簡介

流處理在實際生產中體現的價值越來越大,Apache Flink這個純流式計算框架也正在被越來越多的公司所關注並嘗試使用其流上的功能。

在2017年波蘭華沙大數據峯會上,有一家叫做GetInData的公司,分享了一個關於他們內部如何使用Flink的session window的例子,並因此獲評最佳演講。PPT:STREAMING ANALYTICS BETTER THAN CLASSIC BATCH – WHEN AND WHY ?

基於此演講,該公司後續寫了一個系列blog(2篇),詳細的闡述了使用session window的來龍去脈。本文基於這兩篇blog,做些簡要的說明,也正好參考下別人是如何從傳統的批的方式(spark),轉而使用現代的流處理技術(Flink)來更好的實現其業務功能的。

2、User Sessionization

該公司是一家信息技術服務公司,由前Spotify員工組建。他們的這個案例受Spotify的啓發,基於user session進行實時的數據分析。

關於Spotify提供的每週歌曲推薦,可以參考InfoQ上的一篇文章:Spotify每週歌曲推薦算法解析。

首先,基於用戶的session,你可以作如下事情:

1、儀表板上顯示一些KPI,例如用戶在每週的Discover Weekly播放列表中聽了多長時間,或者連續聽了多少首歌曲等。

2、通過這些指標,你可以改進你的推薦算法,併發出一些警告及時的捕獲一些變化較大的信息,例如澳大利亞用戶聽Discover Weekly播放列表中歌曲的時間太短。

3、而且,基於當前的數據分析,我們可以根據不同的用戶,做些個性化的歌曲推薦和廣告推薦。

3、古典的批處理架構

許多公司使用類似於Kafka、Hadoop、Spark、Oozie來分析用戶session問題。

古典的批處理架構如下:用戶會話的數據被實時的發送到kafka,之後使用批處理工具例如Campus(Gobblin)將kafka中的數據定時發送到HDFS,這裏假設1小時抽取一次;之後由Spark來每小時進行一次批處理的Job,以計算用戶session的數據分析。

但是這裏有一個問題是用戶可能會一直在聽歌曲,因此session持續的時間很長,這樣得到的結果就是不正確的。一種可選的解決方法就是通過維護一個每小時的中間結果來連接Job。關於使用古典的批處理來實現user session的問題,有一本專門的書來說明:Hadoop Application Architectures。

儘管批處理可以處理user session問題,但是依然有很多缺點:

1、首先,這條pipeline上邊的組件太多,例如Gobblin,你需要部署額外的組件或者寫更多的代碼來維持pipeline的可用性。

2、延遲性太高。這種架構不能實時的給出alerts,所有的結果必須等到1個小時後才能得到。

4、微批架構

降低延遲並縮短結果反饋時間最簡單的方式看起來就是使用類似於Spark Streaming這種微批架構了。

這種架構省去了Gobblin、Oozie、HDFS分區等組件,通過配置Spark Streaming的Job以每10分鐘、5分鐘或1分鐘的批次,來實現更低的延遲。

但是,Spark Streaming本身沒有內建支持Session問題的處理,由於其微批架構,用戶不得不通過自定義的代碼實現user session,同時這種方式不得不自己維持每個批次的狀態信息。你可以通過mapWithState方法來維護每個user的session狀態,有很多文章都提到如何構建一個user session,但是都沒有提到實現過程中可能遇到的諸多問題。

5、現實世界的事件流

在現實世界,數據是無界的。有可能產生亂序、延遲現象。例如用戶在飛機上是飛行模式(離線模式),此時正在聽spotify的歌曲,但是直到飛機降落才上線,此時數據的產生就是亂序的數據。而且由於經過kafka,由於並行處理的網絡等原因,遲到的數據也是無處不在。

因此,如果還是採用Spark Streaming這種架構,這些問題的產生很可能不能正確的處理,這樣的結果就是不正確的。

6、解決流處理的問題

到這裏,讓我們問問我們自己,我們爲什麼要用傳統的批處理、微批處理的方式來對待流數據呢?

在GetInData,我們找到了最簡單的、最重要、最正確的流處理引擎—Apache Flink。通過使用Flink,實施起來不但十分簡單,代碼量很少,而且可以更快速的得到正確的結果。

7、實施案例

我們通過使用Flink很容易的解決了user session的問題,真的只有幾行代碼!!!

案例A表示一個用戶在一個獨立的session中聽了多久的歌曲

案例B表示一個用戶在播放列表中連續聽了多少首歌曲

首先,第一步我們需要從kafka中消費數據。通過Flink內部的檢查點機制,可以保證exactly once的處理,這僅僅需要提供幾個kafka的參數:

sEnv.addSource(new FlinkKafkaConsumer09[Event](conf.topic(),
    getSerializationSchema,
    kafkaProperties(conf.kafkaBroker()))
)

之後,我們基於用戶key,設置一個session window的gap,在同一個session window中的數據表示用戶活躍的區間,假如gap的時間是15分鐘:

.keyBy(_.userId)             
.window(EventTimeSessionWindows.withGap(Time.minutes(15)))

最後,我們應用一個window function,就可以用5行代碼實現亂序問題的處理:

val sessionStream : DataStream[SessionStats] = sEnv
    .addSource(new FlinkKafkaConsumer09[Event](...))
    .keyBy(_.userId)             
    .window(EventTimeSessionWindows.withGap(Time.minutes(15)))            
    .apply(new CountSessionStats())

8、高級的時間處理

上邊這5行代碼夠優雅麼?

答案是否定的,這裏即使基於Event Time以及應用watermark來處理亂序,依然不夠理想。考慮下面兩種情況:

1、例如15分鐘之後,突然來了一條之前5分鐘數據怎麼辦?這時之前的session就不應該產生gap。

2、再比如假如一個用戶一直在聽音樂,gap一直沒產生,那麼這個用戶的數據就一直無法及時產生。這個對於結果的反饋時間太長了。

對於第一種情況,Flink提供了allowedLateness來處理延遲的數據,假設我們預計有些數據最晚會延遲1小時到來,那麼我們可以通過allowedLateness設置一個參數,來處理那些延遲的數據:

.allowedLateness(Time.minutes(60))

這樣,當late data element到達時,我們依然可以正確的處理。

對於第二種情況,爲了縮短結果的反饋時間,我們可以自定義一個early firing trigger實現每隔一段時間就觸發一次計算:

.trigger(EarlyTriggeringTrigger.every(Time.minutes(10)))

例如,我們每隔10分鐘就觸發一次窗口計算。考慮一個簡單的例子,假如一個用戶session持續了1個小時,那麼通過這種觸發器,我們就可以每10分鐘便得到一個結果,之後的結果不斷更新之前的結果,最終趨於正確。後邊的結果相當於對前邊的結果的刷新。

儘管代碼相當簡單,但是其背後卻是Flink內援原理的支撐,例如低延遲、高吞吐、有狀態的處理、簡單的tasks等。

9、EarlyTriggeringTrigger的實現

class EarlyTriggeringTrigger(interval: Long) extends Trigger[Object, TimeWindow] {


  //通過reduce函數維護一個Long類型的數據,此數據代表即將觸發的時間戳
  private type JavaLong = java.lang.Long
  //這裏取2個註冊時間的最小值,因爲首先註冊的是窗口的maxTimestamp,也是最後一個要觸發的時間
  private val min: ReduceFunction[JavaLong] = new ReduceFunction[JavaLong] {
    override def reduce(value1: JavaLong, value2: JavaLong): JavaLong = Math.min(value1, value2)
  }


  private val serializer: TypeSerializer[JavaLong] = LongSerializer.INSTANCE.asInstanceOf[TypeSerializer[JavaLong]]


  private val stateDesc = new ReducingStateDescriptor[JavaLong]("fire-time", min, serializer)
  //每個元素都會運行此方法
  override def onElement(element: Object,
                         timestamp: Long,
                         window: TimeWindow,
                         ctx: TriggerContext): TriggerResult =
    //如果當前的watermark超過窗口的結束時間,則清除定時器內容,觸發窗口計算
    if (window.maxTimestamp <= ctx.getCurrentWatermark) {
      clearTimerForState(ctx)
      TriggerResult.FIRE
    }
    else {
      //否則將窗口的結束時間註冊給EventTime定時器
      ctx.registerEventTimeTimer(window.maxTimestamp)
      //獲取當前狀態中的時間戳
      val fireTimestamp = ctx.getPartitionedState(stateDesc)
      //如果第一次執行,則將元素的timestamp進行floor操作,取整後加上傳入的實例變量interval,得到下一次觸發時間並註冊,添加到狀態中
      if (fireTimestamp.get == null) {
        val start = timestamp - (timestamp % interval)
        val nextFireTimestamp = start + interval
        ctx.registerEventTimeTimer(nextFireTimestamp)
        fireTimestamp.add(nextFireTimestamp)
      }
      //此時繼續等待
      TriggerResult.CONTINUE
    }
  //這裏不基於processing time,因此永遠不會基於processing time 觸發
  override def onProcessingTime(time: Long,
                                window: TimeWindow,
                                ctx: TriggerContext): TriggerResult = TriggerResult.CONTINUE
  //之前註冊的Event Time Timer定時器,當watermark超過註冊的時間時,就會執行onEventTime方法
  override def onEventTime(time: Long,
                           window: TimeWindow,
                           ctx: TriggerContext): TriggerResult = {
    //如果註冊的時間等於maxTimestamp時間,清空狀態,並觸發計算
    if (time == window.maxTimestamp()) {
      clearTimerForState(ctx)
      TriggerResult.FIRE
    } else {
      //否則,獲取狀態中的值(maxTimestamp和nextFireTimestamp的最小值)
      val fireTimestamp = ctx.getPartitionedState(stateDesc)
      //如果狀態中的值等於註冊的時間,則刪除此定時器時間戳,並註冊下一個interval的時間,觸發計算
      //這裏,前提條件是watermark超過了定時器中註冊的時間,就會執行此方法,理論上狀態中的fire time一定是等於註冊的時間的
      if (fireTimestamp.get == time) {
        fireTimestamp.clear()
        fireTimestamp.add(time + interval)
        ctx.registerEventTimeTimer(time + interval)
        TriggerResult.FIRE
      } else {
        //否則繼續等待
        TriggerResult.CONTINUE
      }
    }
  }
  //上下文中獲取狀態中的值,並從定時器中清除這個值
  private def clearTimerForState(ctx: TriggerContext): Unit = {
    val timestamp = ctx.getPartitionedState(stateDesc).get()
    if (timestamp != null) {
      ctx.deleteEventTimeTimer(timestamp)
    }
  }


  //用於session window的merge,判斷是否可以merge
  override def canMerge: Boolean = true


  override def onMerge(window: TimeWindow,
                       ctx: OnMergeContext): TriggerResult = {
    ctx.mergePartitionedState(stateDesc)
    val nextFireTimestamp = ctx.getPartitionedState(stateDesc).get()
    if (nextFireTimestamp != null) {
      ctx.registerEventTimeTimer(nextFireTimestamp)
    }
    TriggerResult.CONTINUE
  }
  //刪除定時器中已經觸發的時間戳,並調用Trigger的clear方法
  override def clear(window: TimeWindow,
                     ctx: TriggerContext): Unit = {
    ctx.deleteEventTimeTimer(window.maxTimestamp())
    val fireTimestamp = ctx.getPartitionedState(stateDesc)
    val timestamp = fireTimestamp.get
    if (timestamp != null) {
      ctx.deleteEventTimeTimer(timestamp)
      fireTimestamp.clear()
    }
  }


  override def toString: String = s"EarlyTriggeringTrigger($interval)"
}


//類中的every方法,傳入interval,作爲參數傳入此類的構造器,時間轉換爲毫秒
object EarlyTriggeringTrigger {
  def every(interval: Time) = new EarlyTriggeringTrigger(interval.toMilliseconds)
}

10、Flink中的ContinuousEventTimeTrigger

Flink中,其實已經實現了一個early trigger的功能,即ContinuousEventTimeTrigger,其實現方式大致相同:

/**
 * A {@link Trigger} that continuously fires based on a given time interval. This fires based
 * on {@link org.apache.flink.streaming.api.watermark.Watermark Watermarks}.
 *
 * @see org.apache.flink.streaming.api.watermark.Watermark
 *
 * @param <W> The type of {@link Window Windows} on which this trigger can operate.
 */
@PublicEvolving
public class ContinuousEventTimeTrigger<W extends Window> extends Trigger<Object, W> {
    private static final long serialVersionUID = 1L;


    private final long interval;


    /** When merging we take the lowest of all fire timestamps as the new fire timestamp. */
    private final ReducingStateDescriptor<Long> stateDesc =
            new ReducingStateDescriptor<>("fire-time", new Min(), LongSerializer.INSTANCE);


    private ContinuousEventTimeTrigger(long interval) {
        this.interval = interval;
    }


    @Override
    public TriggerResult onElement(Object element, long timestamp, W window, TriggerContext ctx) throws Exception {


        if (window.maxTimestamp() <= ctx.getCurrentWatermark()) {
            // if the watermark is already past the window fire immediately
            return TriggerResult.FIRE;
        } else {
            ctx.registerEventTimeTimer(window.maxTimestamp());
        }


        ReducingState<Long> fireTimestamp = ctx.getPartitionedState(stateDesc);
        if (fireTimestamp.get() == null) {
            long start = timestamp - (timestamp % interval);
            long nextFireTimestamp = start + interval;
            ctx.registerEventTimeTimer(nextFireTimestamp);
            fireTimestamp.add(nextFireTimestamp);
        }


        return TriggerResult.CONTINUE;
    }


    @Override
    public TriggerResult onEventTime(long time, W window, TriggerContext ctx) throws Exception {


        if (time == window.maxTimestamp()){
            return TriggerResult.FIRE;
        }


        ReducingState<Long> fireTimestamp = ctx.getPartitionedState(stateDesc);
        if (fireTimestamp.get().equals(time)) {
            fireTimestamp.clear();
            fireTimestamp.add(time + interval);
            ctx.registerEventTimeTimer(time + interval);
            return TriggerResult.FIRE;
        }


        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 {
        ReducingState<Long> fireTimestamp = ctx.getPartitionedState(stateDesc);
        Long timestamp = fireTimestamp.get();
        if (timestamp != null) {
            ctx.deleteEventTimeTimer(timestamp);
            fireTimestamp.clear();
        }
    }


    @Override
    public boolean canMerge() {
        return true;
    }


    @Override
    public void onMerge(W window, OnMergeContext ctx) throws Exception {
        ctx.mergePartitionedState(stateDesc);
        Long nextFireTimestamp = ctx.getPartitionedState(stateDesc).get();
        if (nextFireTimestamp != null) {
            ctx.registerEventTimeTimer(nextFireTimestamp);
        }
    }


    @Override
    public String toString() {
        return "ContinuousEventTimeTrigger(" + interval + ")";
    }


    @VisibleForTesting
    public long getInterval() {
        return interval;
    }


    /**
     * Creates a trigger that continuously fires based on the given interval.
     *
     * @param interval The time interval at which to fire.
     * @param <W> The type of {@link Window Windows} on which this trigger can operate.
     */
    public static <W extends Window> ContinuousEventTimeTrigger<W> of(Time interval) {
        return new ContinuousEventTimeTrigger<>(interval.toMilliseconds());
    }


    private static class Min implements ReduceFunction<Long> {
        private static final long serialVersionUID = 1L;


        @Override
        public Long reduce(Long value1, Long value2) throws Exception {
            return Math.min(value1, value2);
        }
    }
}

實現細節上省略了clearTimerForState(ctx)方法,同時增加了內部類Min實現求最小值的reduce方法。

11、後續的問題

儘管實現user session window很簡單,但是這僅僅是一個系統的第一步,還有後續的問題需要考慮:

(1)如何重新處理數據?

假如我改了程序,想用以前的數據測試對比下兩套程序的結果,Flink流上也可以實現麼?答案是可以的。你可以通過“savepoints”功能實現。例如每天夜裏12點定時產生savepoint,當你想重新消費數據時,就從那個savepoint開始重新消費kafka中的數據,相當於將時間回撥到了保存點的時間。

但這也有負面的影響,就是下游的輸出可能已經落地了,但是如何處理他們是系統外部的事情了,但這一點也不容忽視。

(2)如果kafka中的數據沒了怎麼辦?

這是個好問題,kafka具有持久化的能力,但大都由於磁盤限制,通過保留策略來保留一段時間的數據。假如我們想重新處理1年前的數據怎麼辦?一種可行的辦法是將kafka的數據抽取到HDFS上,然後通過重寫SourceFunction來重新消費這些數據。

但是這裏引入另外一個問題–亂序。因爲HDFS中的數據不保證是嚴格按事件發生的順序存放的,消費時就可能產生亂序。

還有一點問題未解決,即在kafka和HDFS之間切換來消費數據,但這對於維護offset信息太難了。

(3)一個流如何和其他的data Sets/stream進行join?

第一種情況是DataStream join DataStream,這個很簡單,雙流join的問題之前的blog中已經講過:

第二種情況是DataStream join DataSet。這個通常的做法是DataStream通過實現RichXXXFunction,重寫open方法,在open方法中將dataSet信息寫入一個集合容器,然後對DataStream中的每個元素去匹配這個集合。

4)我能用Flink做批處理麼?

當然可以,Flink支持批和流的處理,你甚至可以用Apache Beam或Flink的Table API來進行批處理。

(5)什麼時候適合用批處理?

批處理在很多場景下依然是非常好的選擇,例如ad-hoc查詢,像Hive、Spark-SQL、Presto、Kylin、Spark + R等就是非常好的工具。

當你要處理大量的HDFS上的數據時,也非常適合批處理。

而一些library的支持也僅僅在批處理上比較成熟,例如機器學習和圖計算。

12、最重要的建議

流處理並不僅僅是爲了觸發某些警告或者以更低的延遲獲得結果。

流處理通常是許多現實問題很自然的表達呈現形式,你可以在流上實現實時的ETL、統計一些KPI的指標、增強業務報告等。

像Flink這種現代的流處理框架,你可以用它很容易的、不斷的處理並獲得正確的結果。

總結起來就是:

當數據不斷產生時,不要以批的方式去處理流!!!

當數據不斷產生時,不要以批的方式去處理流!!!

當數據不斷產生時,不要以批的方式去處理流!!!

本文轉載自:https://blog.csdn.net/lmalds/article/details/69267056

END

關注我
公衆號(zhisheng)裏回覆 面經、ES、Flink、 Spring、Java、Kafka、監控 等關鍵字可以查看更多關鍵字對應的文章。

你點的每個贊,我都認真當成了喜歡

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