《關於我因為flink成為spark原始碼貢獻者這件小事》

是奉壹呀發表於2023-02-16

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

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

(完)

相關文章