各位讀者老爺請放下手上的板磚,我可真沒有標題黨,且容老弟慢慢道來。
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。想想還有點小激動。
第一次給這種級別的開源社群提交PR。兩眼一摸黑,比如PR要怎樣寫才規範,要不要寫test case。要不要@社群大佬等等。
等了兩天後,終於有大佬理我了。
我這點渣渣英語,藉助翻譯軟體才完成了PR描述。自然不懂cc是啥,FYI是啥,nit.是啥?看到不認識的自然複製貼上到翻譯軟體。
嗯?
現在翻譯軟體都這樣先進了嗎?它居然看穿了我是個廢物!
第1次提交犯了很多小錯誤,這是沒看原始碼貢獻指南的後果。(其實是看不太懂)
這裡面很詳細,怎樣拉下原始碼編譯,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
,喲呵,這個我熟啊。
點進去一看,尷尬了。
大家看一下,這次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/39843#issuecomment-1418436041
(完)