各位讀者老爺請放下手上的板磚,我可真沒有標題黨,且容老弟慢慢道來。
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
(完)