《關於我因爲flink成爲spark源碼貢獻者這件小事》

各位讀者老爺請放下手上的板磚,我可真沒有標題黨,且容老弟慢慢道來。

spark和flink本身相信我不用做過多的介紹,後端同學不管搞沒搞過大數據,應該都多多少少聽過。
如果沒聽過,簡單說,spark和flink之於大數據,就好比vue和react之於前端,就好比spring家族之於java。

從2015年開始接觸大數據,2016年開始使用spark,到2022年初能夠爲spark社區貢獻一點微博的貢獻,成爲spark項目的contributor,對我來說是一段奇特的經歷。

這段經歷來源於一次spark窗口計算,由於我覺得並不能完全滿足要求,想通過源碼改造一下。
這一下子進了源碼就誤入歧途了。

什麼要求之類的按不表,進入正題。

什麼是窗口計算(可跳過)

在大數據領域,基於窗口的計算是非常常見的場景,特別在於流式計算(flink只不叫實時和離線,只區分有界無界)。

如果這說起來還是有點抽象,那舉個例子,相信你很快就能明白。

比如微博熱搜,我需要每分鐘計算過去半小時的熱搜詞取top50;
比如新能源車,行駛過程中每秒或每兩秒鐘上報各信號項,如果30秒鐘內沒有收到該車的信號項,我們認爲該車出現故障,便進行預警(假設場景);
等等。

前者微博熱搜是一個典型的時間窗口,後者新能源車是一個典型的會話窗口。

時間窗口(timewindow)

時間窗口又分爲滑動窗口(sliding window)和滾動窗口(tumbling window)。反正意思就是這麼個意思,在不同的大數據引擎裏叫法略有不同,在同一個引擎裏不同的API裏叫法也略有區別(比如,flink 滑動窗口在DataSet&DataStream api和Table(sql) api裏分別叫作sliding window 和HOP window)。

總之時間窗口有一個長度距離(m)和滑動距離(n),當m=n時,這就是一個滾動窗口,相鄰窗口兩兩並不相交重疊。

當m>n時,稱爲滑動窗口,這時相鄰的兩個窗口就有了重疊部份。

在多數場景下,m爲n的正整數倍。即m%n=0;除非產品經理認爲我們應該每61秒統計過去7.3分鐘的微博熱搜(???)。
這個例子可能極端了些,但m%n != 0的實際應用場景肯定是有的。

會話窗口(sessionwindow)

session窗口相對抽象一點。大家可以把session對應到web應用上,理解爲一個連接session。
當大數據引擎接收到一條數據相當於一個連接session,當在設定的時間範圍內連續沒有接收到數據,相當於session會話已斷開,這裏觸發窗口結束。
因此會話窗口長度是不固定的,沒有固定的開始和結束。而且相鄰的窗口也不會相交重疊。

到這裏,大家對大數據的窗口計算應該有了一個簡單的感性認識,我們今天討論的重點是時間窗口,而且只是時間窗口下的一個小小的切點。

今天的主題

大數據引擎是怎樣劃分窗口的,當接收到一條數據的時候,數據的時間戳會落到哪些窗口?

先來簡單看一點源碼。不多,就一點點。

spark獲取窗口的主要代碼邏輯:

一時看不懂沒關係,我第一次看到spark這段代碼的時候也有點懵。藉助spark的註釋來梳理一下。

爲了不水字數把spark源代碼註釋摺疊
* The windows are calculated as below:
   * maxNumOverlapping <- ceil(windowDuration / slideDuration)
   * for (i <- 0 until maxNumOverlapping)
   *   windowId <- ceil((timestamp - startTime) / slideDuration)
   *   windowStart <- windowId * slideDuration + (i - maxNumOverlapping) * slideDuration + startTime
   *   windowEnd <- windowStart + windowDuration
   *   return windowStart, windowEnd

然後,我們再假設一個簡單的場景,將原僞代碼進行微調,並配合註釋講解一下。

假設窗口長度(windowDuration )爲10滑動距離(slideDuration)爲5,即每5分鐘計算過去10分鐘的數據。簡單化流程,窗口偏移時間爲0。
現在spark集羣收到一條數據,它的事件時間戳爲13,然後需要計算13會落到哪些窗口裏面。

// `獲取窗口個數,窗口長度(m)/滑動長度(n),當兩者相等時,就1個窗口;
// 當m%n=0時,窗口長度爲除數;當m%n!=0時,窗口長度爲除數向下的最小整數
// 這裏爲2個窗口
maxNumOverlapping <- ceil(windowDuration / slideDuration)
// 循環獲取當前時間戳在每個窗口的邊界,即開始時間和結束時間
for (i <- 0 until maxNumOverlapping)
  // 13/5 -> 2.6 通過ceil向下取整得到2,再+1 = 3
  windowId <- ceil(timestamp / slideDuration)
   // 第1次循環時,計算第1個窗口開始時間 :3 * 5 +(0 - 2)* 5 = 5
   // 第2次循環時,計算第2個窗口開始時間: 3 * 5 + (1 - 2) * 5 = 10
  windowStart <- windowId * slideDuration + (i - maxNumOverlapping) * slideDuration 
   // 第1次循環時,計算第1個窗口結束時間:5+10 = 15
   // 第2次循環時,計算第2個窗口結束時間:10+10 = 20
  windowEnd <- windowStart + windowDuration
  return windowStart, windowEnd

通過上面的代碼,我們知道,時間戳13最終會落到[5-15],[10-20]兩個窗口區間。



我們再來看看flink的實現邏輯。

可以看到其實原理類似,先求得窗口個數,略有區別的是,spark是先求得窗口編號windowId,再根據窗口編號求得每一個窗口的開始結束時間。
而spark是直接得到一個窗口開始時間lastWindowStart ,然後根據窗口開始時間+滑動距離=窗口結束時間。
再然後,窗口開始時間-窗口長度=另一個窗口的開始時間,再求得窗口的結束時間。

而不管是哪種方法,都有一個線頭。
spark是windowId

windowId <- ceil((timestamp - startTime) / slideDuration)

flink是lastWindowStart .

timestamp - (timestamp - offset + windowSize) % windowSize;


大家發現上面兩邊代碼對比有問題了嗎?

spark的兩個問題

===========================================5秒鐘思考線

點擊查看問題答案
問題1:重複計算。`windowId`只需要計算一次就夠了。
      乃至於`windowStart`也只需要計算一次,根據它,可以計算出當次windowEnd,同樣也可以計算出其它的窗口邊界。
問題2:ceil和mod(%模運算)的差異。

這兩個問題都不是BUG,是性能問題。
第1個問題,直接觀察代碼就可以得出結論。
第2個問題,需要通過代碼測試一下。
因爲scala本身也是JVM生態語言,底層都一樣。所以我直接使用java寫了一個基準測試,內容爲ceil和求模的性能差異。

@BenchmarkMode(Mode.AverageTime)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 3, time = 4)
@Threads(1)
@Fork(1)
@State(value = Scope.Benchmark)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class MathTest {
    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(MathTest.class.getSimpleName())
                .mode(Mode.All)
                .result("MathTest.json")
                .resultFormat(ResultFormatType.JSON).build();

        new Runner(opt).run();
    }
    @Benchmark
    public void ceil() {
        Math.ceil((double)1000/20);
        Math.ceil((double)1000/34);
    }

    @Benchmark
    public void mod() {
        int a = 1000 % 20;
        int b = 1000 % 34;
    }
}

大家感興趣的話,也可以將代碼複製到本地,引入JMH就可以運行。
得到的結果:

上圖表示的是執行耗時,柱圖越高性能越低。更多可以參考我的另一篇文章hashmap的一些性能測試

那麼可以從圖中明顯看到使用ceil的spark比使用mod的flink在這獲取窗口這塊功能的性能上肯定要差一些。

不管是第1還是第2個問題,都會隨着窗口長度放大。如果我需要每1分鐘計算過去60分鐘的數據

那麼每1條數據進來,它都會進行這樣的60次無效計算。

如果一個窗口批次有一萬條數據,它就會進行60萬次無效計算。

大數據場景下,它的性能損耗是多少呢?

是吧?


既然有性能損耗,它必然可以優化。對於我這種開源愛(投)好(機)者來說,這都是一個大好的機會啊。
原創是不可能原創的,這輩子都不可能,只能靠抄抄flink代碼才能成爲spark contributor什麼的。







好了,我們現在已經學會了兩個主流大數據引擎的窗口計算基本原理了,現在我們來寫一個大數據引擎吧

不是,來重(抄)構(襲)spark獲取時間窗口的代碼吧。

第一個PR

懷着忐忑的心情,給spark社區提了一個PR。想想還有點小激動。

https://github.com/apache/spark/pull/35362

第一次給這種級別的開源社區提交PR。兩眼一摸黑,比如PR要怎樣寫才規範,要不要寫test case。要不要@社區大佬等等。

等了兩天後,終於有大佬理我了。

我這點渣渣英語,藉助翻譯軟件才完成了PR描述。自然不懂cc是啥,FYI是啥,nit.是啥?看到不認識的自然複製粘貼到翻譯軟件。

嗯?
現在翻譯軟件都這樣先進了嗎?它居然看穿了我是個廢物!

第1次提交犯了很多小錯誤,這是沒看源碼貢獻指南的後果。(其實是看不太懂)

https://spark.apache.org/contributing.html

這裏面很詳細,怎樣拉下源碼編譯,PR標題格式怎樣,PR描述規範,代碼stylecheck插件等等,事無鉅細,是立志於成爲spark社區大佬的新手啓航必備。

如果英語老師沒有騙我, could you please表示的應該是委婉,客氣。
社區大佬都很有禮貌,說話又好聽,我超喜歡的。

If you don't mind, could you please

在這位大佬發現我沒做性能測試後,(真的,現在想想,性能改進的代碼沒有基準測試你敢信),溫柔提醒我,在看穿我是個新(廢)手(物)後,親自寫了benchmark基準測試代碼。並得出新的計算邏輯比原有的性能提升30%到60%。

然後經過一番溝通修改代碼註釋什麼的,最終合併到了master.

尾聲

以上就是我的第一次spark PR之旅了。

如果你要問我感受的話。短暫的興奮過後就是空虛。


不玩梗地說,spark社區氛圍真的很好。在後面陸陸續續又給sparkT和flink提交了幾個PR。沒有對比就沒有傷害,比起flink,spark真的對新手非常友好了。
這過程中,踩了很多坑。也收穫很多。比如,這次30%到60%的性能提高,對於一個比較成熟的大數據產品來說,應該算是比較大的提升了吧?
但在後面的PR中,我做出遠超此次的性能提升,而且不是藉助flink的既有邏輯,完全獨立完成。

後面有時間也可以把這些寫出來,水水文章。


彩蛋

本來到這裏就結束。但是,偶然在flink社區PR區看到一個熟悉timewindow,喲呵,這個我熟啊。

https://github.com/apache/flink/pull/18982

點進去一看,尷尬了。
大家看一下,這次PR提交的主要代碼更改邏輯就知道了。

沒錯,就是之前我給spark 提交的代碼的借(抄)鑑(襲)來源。flink時間窗口分配窗口的核心代碼。而且這不是優化,而是修復BUG。

哦,原來這特麼的是彩蛋,這特麼的是驚喜啊!

這就好比,照着隔壁班裏第一名抄作業,老師給了個高分,然後被高手自爆,老師,我寫得有問題。


它是怎炸的呢?

原文已經說得非常清楚,我在這裏長話短說。
簡單畫了個圖:

假設時間戳13在一個長度15,滑動長度5的窗口邏輯裏,我們要知道它會分配到哪2個窗口裏,只需求得最後一個窗口開始的長度即可。

它最後一個窗口的開始長度爲 13%5 = 3,爲時間戳對滑動距離求模。即把上圖中紅色部份減去,或者向左偏移餘數部份,就是它最後一個窗口的開始長度。

不管怎樣,時間戳必須得落到開始時間後面,窗口必須包含時間戳。


好,很好,很有精神。沒有問題!no problem!


但是!如果時間戳是負數呢?比如-1呢?

我們開始求它的最後一個窗口開始時間,時間戳對滑動距離求模,即-1%5 = -1

-1 - (-1) = 0

這樣就導致不管是-1還是13都應該向左偏移的,結果跑向右邊了。

13:???
開始時間大於了時間戳本身,時間戳跑到窗口外面去了,這肯定是不正常的。

其實不僅僅是負的時間戳,是(timestamp - window.starttime)% window.slideduration <0的情況下都會有這種問題。
只說負的時間戳有問題,就顯得我的上個PR很無腦。顯得我無腦沒關係,這其實也是小看了spark,flink這種大範圍流行的開源框架。

通過spark的測試案例也能很清楚的看到,肯定是考慮到了時間戳落到1970-01-01之前的。
隨手截一個測試案例

只不過它的時間戳都在1970-01-01前後幾秒鐘範圍,落在了滑動距離之內。所以這個問題沒有及時暴露出來。

而且在我提交優化的PR之前,spark本身的代碼是不會出現這種問題的。所以這個鍋完全是我的,必須背了。

然後給社區提交了一個fix

https://github.com/apache/spark/pull/36737

由於種種原因,我倒是放了鴿子了。後來,被另一哥們重新提交合並。

https://github.com/apache/spark/pull/39843#issuecomment-1418436041

(完)

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