您好,登錄后才能下訂單哦!
大數據分析的性能優化,說道底,就優化一個事情:針對確定的一個計算任務(數據確定,結果確定),以最經濟的方案得到結果。
這個最經濟的方案主要考量三個成本:時間成本、硬件成本、軟件成本。
時間成本:根據計算任務的特點,能容忍的最長時間各不相同。那些 T+0 的計算任務,實時性要求就比較高,T+1 再算出結果就失去了意義。
硬件成本:可以使用的硬件資源,對一個公司來說一般不是經常變化的,機器配置、可集群數量就那么多。即便使用云計算產品,也只是多了擴容的靈活性,成本是少不掉的。
軟件成本:編寫出這個計算算法的人工費 + 軟件環境的成本。這個成本也與前兩項相關,程序控制力度粗獷一些,實現邏輯簡單一些,程序就容易編寫,那軟件成本就會低一些,帶來的副作用是運行時間超長或者需要昂貴的硬件。
這三個因素里面,一般對于計算任務來說,自然是越快越好,當然只要不慢過能容忍的時長,也就還算是有意義的計算;而硬件因素的彈性就比較小,有多少資源是相對固定的;所以,剩下的可以大做文章的就是軟件成本了。軟件成本里,程序員的工資是很重要的一項,而有沒有順手的軟件環境讓程序員能高效的把計算描述出來,就成了關鍵。最典型的例子就是理論上用匯編程序能寫出所有的程序,但它明顯不如 SQL 或 JAVA 做個常規計算來的容易。
說到 SQL 和 JAVA,成規模的計算中心的一些維護者估計也會皺眉,使用它們的時間越長,越能體會需求變動或優化算法過程中的痛苦,明明算法過程自己想的很清楚了,但編寫成可運行的程序就困難重重。這些困難主要來自兩個方面:
首先,一些基礎的數據操作方法是自己逐漸積累的,沒有經過整體的優化設計,這些個人工具對個人的開發效率有不錯的提升,但沒法通用,也不全面,這個困難主要表現在用 JAVA 等高級語言實現的一些 UDF 上。
第二,主要是思維方式上的,在生產場景下用習慣了 SQL 查詢,在計算場景下遇到的性能問題自然而然就想通過優化 SQL 語句的方式把問題緩解掉。但實際上這可能是個溫水煮青蛙的過程。越深入搞,把簡單的過程問題越可能搞成龐大不可拆分的邏輯塊,到最后只有原創作者或高手才敢碰它。我這個老程序員,十多年前剛入行的時候,八卦中耳聞過 ORACLE 的系統管理員,尤其是有性能優化能力的,比普通程序員貴多了,可見這個難題在數據規模相對較小的十年前已經凸顯了。
(注:生產場景和計算場景在初始階段的軟件系統里一般很難截然分開,數據都是從生產場景積累起來的,等積累多了,慢慢會增加計算需求,逐漸獨立出計算中心和數據倉庫。這個量變引起質變的過程,如果不在思維上轉變,不引入新辦法,那就將成為被煮的青蛙。)
為了節省讀者的時間,我們先把性能優化的常用手段總結一下,方便有需求的用戶逐條對比進行實際操作。
1、 只加載計算相關數據。
列存方式存儲數據;
常用的字段和不常用的分開存儲;
用獨立的維表存儲維的屬性,減少事實表的信息冗余;
按照某些常用作查詢條件的字段分開存儲,如按年份、性別、地區等獨立存儲;
2、 精簡計算涉及到的數據
用來分析時,一些冗長的編號,可以序號化處理,用 1、2、……替代 TJ001235-078、 TJ001235-079、……,這樣即能加快加載數據的速度,又能加快計算速度。
日期時間,如果用字符串類型按照我們熟悉的格式 (2011-03-08) 存儲,那加載和計算都會慢。前面這個日期可以存儲成 110308 這樣的數值類型,也可以存儲成相對于一個開始時間的毫秒數(如相對于最早的數據 2010-01-01 的毫秒數)。
3、 算法的優化
計算量小的條件寫在前面,如 boolean 類型的判斷,要早于字符串查找,這樣用較少的計算就能排除掉不符合要求的數據;
減少對大事實表的遍歷次數。具體方法有:在一次遍歷過程中,同時給多個獨立的運算操作提供數據(后面會提到的集算器里的管道概念),而不是每個運算操作遍歷一次數據;做 JOIN 時,在內存里的維表里檢索事實表數據,而不是用每條維表數據去遍歷一次事實表。
查找時借用 HASH 索引、二分法、序號直接對位等方式加快速度。
4、 并行計算
加載數據和計算兩個步驟都可以并行。考量計算特點,根據加載數據和運算哪個量更大來判斷瓶頸是計算機的磁盤還是 CPU,磁盤陣列適合并行加載數據,多核 CPU 適合并行運算。
多機集群的并行任務,要考慮主程序和子程序的通訊問題,盡量把復雜計算獨立到節點機上完成,網絡傳輸較慢,要減少節點機之間的數據交換。
兵馬未動糧草先行,有了上面這些指導思想,我們下面就切入正題實現漏斗計算的優化,看一下實際的優化效果。
數據:
程序:(附件中的 1-First.dfx,也附帶了測試數據文件,可在集算器里直接執行)
漏斗轉換計算核心代碼的邏輯細節在上一篇中詳細介紹過,這里就不再贅述。
結果:(注:之后的測試都以 118 萬條數據為基礎,成倍增加)
118 萬條記錄 /70MB/ 用戶數量 8000/31 秒;
590 萬條記錄 /350MB/ 用戶數量 4 萬 /787 秒。
分析:
數據量增加到 5 倍,但耗時增加到了 26 倍,性能下降得厲害,而且不是線性的。原因是被分析的用戶列表擴大了 5 倍,同時被分析的記錄數也擴大 5 倍,那檢索用戶次數理論上就擴大了 5*5 倍。接下來采用以下優化方式↓
程序:(2-BinarySearch.dfx)
B12 給 find 增加 @b 選項,指明用二分法查找;D13 中卻去掉 insert 的第一個位置參數 0 后,新用戶就不直接追加到最后了,而是按主鍵順序插入。
A | B | C | D | |
11 | …… | |||
12 | for A11 | >user=A10.find@b(A12.用戶 ID) | ||
13 | if user==null | if A12.事件 ID==events(1) | >A10.insert(,A12.用戶 ID: 用戶 ID,1:maxLen,[[A12. 時間,1]]:seqs) | |
14 | …… |
結果:
118 萬條記錄 /70MB/ 用戶數量 8000/10 秒;
590 萬條記錄 /350MB/ 用戶數量 4 萬 /47 秒。
分析:
優化后,1 倍的數據量耗時縮減到 1/3;5 倍的數據量提速比較明顯,縮減到 1/16。進一步觀察,5 倍數據量是 350MB,從硬盤載入數據的速度慢點算也會有 100M/ 秒,假如 CPU 夠快的話,極限速度應該能到 4 秒左右,而現在的 47 秒證明 CPU 耗時還比較嚴重,根據經驗可以繼續優化↓
程序:(3-BatchReadFromCursor.dfx)
12~17 行整體剪切后,向右移一個格子之后,在 A12 增加一個批量加載游標數據的循環,表示 A11 中的游標每次取 10000 條,B12 再對取出來的這 10000 條數據循環處理。
A | B | C | |
11 | …… | ||
12 | for A11,10000 | for A12 | …… |
13 | …… |
結果:
118 萬條記錄 /70MB/ 用戶數量 8000/4 秒;
590 萬條記錄 /350MB/ 用戶數量 4 萬 /10 秒;
5900 萬條記錄 /3.5GB/ 用戶數量 40 萬 /132 秒;
11800 萬條記錄 /7GB/ 用戶數量 80 萬 /327 秒。
分析:
優化后,1 倍數據量耗時縮減到 2/5;5 倍的數據量縮減到 1/5;新測試的 50 倍、100 倍性能也大體隨數據量保持了線性。注意到原始數據有一些字段用不到,用到的字段也可以通過序號化等手段再簡化,簡化后的文件會小幾倍,從而達到從硬盤減少讀取時間的目的,具體優化方式如下↓
思路:
先觀察一下原始數據:用戶 ID 用從 1 開始的序號替代,除了減少少許存儲空間外,還可以在后續計算時通過序號快速定位到用戶,減少查找時間。時間和年月日字段信息重復,去掉年月日,長整型的時間字段也可以進一步精簡成相對 2017-01-01 這個開始時間的毫秒數;事前我們知道只有 10 種事件,那事件 ID 和事件名稱可以單獨提取出個維表記錄,這個事實表里只保存序號化的事件 ID(1、2、3…10)就夠了;事件屬性是 JSON 格式,種類不多,那對于某一種事件,可以用序列存儲事件屬性的值,在序列中的位置表示某種屬性,這樣即縮減存儲空間,又能提升查找屬性的效率。
除了上面這些字段值的精簡,我們存儲數據的格式棄用文本方式,改變成集算器二進制格式,存儲空間更小,加載速度更快,精簡后的事實表如下:
實現:
精簡事實表數據之前,要先通過事實表生成用戶表、事件表兩個維表的(genDims.dfx,運行后生成 user.bin 和 event.bin):
A | |
1 | >beginTime=now() |
2 | >fPath="e:/ldsj/demo/" |
3 | =file(fPath+"src-11800.txt").cursor@t() |
4 | =channel(A3) |
5 | =A4.groups(#1:用戶 id) |
6 | =A3.groups(#3:事件 ID,#4: 事件名稱; iterate(~~.import@j().fname()): 屬性名稱) |
7 | =file(fPath+"event.bin").export@b(A6) |
8 | =file(fPath+"user.bin").export@b(A4.result()) |
9 | =interval@s(beginTime,now()) |
提取維表的這段程序,仍然有優化的手段體現。提取兩個維表,常規思維是每遍歷一遍數據,生成一個維表;從硬盤讀入大量數據進行遍歷,讀入慢,但讀入后的計算量卻非常小。針對這種情況,那有什么手段可以在讀入數據時,同時用于多種獨立的計算呢,答案就是“管道”,多定義了幾個管道,就多定義了幾種運算。A4 針對 A3 游標定義管道,A5 定義 A4 管道的分組計算,A6 定義另外一個分組計算,A7 導出 A6 的結果,A8 導出 A4 管道的結果。最終得到的兩個維表如下:
基于上面兩個維表對事實表進行精簡(toSeq.dfx),6.8G 的文本文件精簡后,得到 1.9G 的二進制文件,縮小了 3.5 倍。
A | B | C | D | |
1 | >beginTime=now() | |||
2 | >fPath="e:/ldsj/demo/" | |||
3 | =file(fPath+"src-11800.txt").cursor@t() | |||
4 | =file(fPath+"event.bin").import@b() | =A4.(事件 ID) | =A4.(屬性名稱 ) | |
5 | =file(fPath+"user.bin").import@b() | =A5.(用戶 ID) | ||
6 | ||||
7 | func | |||
8 | =A7.import@j() | |||
9 | =[] | |||
10 | for B7 | |||
11 | >B9.insert(0,eval("B8."+B10)) | |||
12 | return B9 | |||
13 | ||||
14 | for A3,10000 | |||
15 | =A14.new(C5.pos@b(用戶 ID): 用戶 ID,C4.pos@b( 事件 ID): 事件 ID, 時間: 時間,func(A7, 事件屬性,D4(C4.pos@b( 事件 ID))): 事件屬性 ) | |||
16 | =file(fPath+"src-11800.bin").export@ab(B15) | |||
17 | =interval@s(beginTime,now()) |
這段代碼出現了一個新的知識點,第 7~12 行定義了一個函數來處理 json 格式的事件屬性,B15 里精簡每一行數據時,調用了這個函數。B16 把每次精簡好的一萬條記錄追寫入同一個二進制文件。
程序:(4-Reduced.dfx)
在上一次程序的基礎上改造了這么幾個格子:
A3/A4 中的時間相對于 2017-01-01;
A6 事件序列改用序號;
A7 中屬性過濾,用精確匹配值的方式替換以前低效的模糊匹配字符串方式; A10 初始化用戶序列,長度為用戶數,該序列中的位置代表用戶的序號;
C12 用序號方式查找用戶;
E13 用序號方式存儲新用戶:
A | B | C | D | E | |
2 | …… | ||||
3 | >begin=interval@ms(date(2017,1,1),date(2017,1,1)) | ||||
4 | >end=interval@ms(date(2017,1,1),date(2017,3,1)) | ||||
5 | >dateWindow=10*24*60*60*1000 | ||||
6 | >events=[3,4,6,7] | ||||
7 | >filter="if(事件 ID!=4||(事件屬性.len()>0&& 事件屬性 (1)==\"Apple\");true)" | ||||
8 | |||||
9 | /開始執行漏斗轉換計算程序 | ||||
10 | =to(802060).(null) | ||||
11 | =file(dataFile).cursor@b().select(時間 >=begin&& 時間 <end && events.pos(事件 ID)>0 && ${filter}) | ||||
12 | for A11,10000 | for A12 | >user=A10(B12.用戶 ID) | ||
13 | if user==null | if B12.事件 ID==events(1) | >A10(B12.用戶 ID)=[B12. 用戶 ID,1,[[B12. 時間,1]]] | ||
14 | …… |
結果:
11800 萬條記錄 /1.93GB/ 用戶數量 80 萬 /225 秒。
分析:
優化后,100 倍數據量耗時縮減到上一步的 2/3。除了精簡涉及的查詢字段,我們再看看另一種能有效縮減查詢數據量的方法↓
思路:
如何拆分數據和查詢特點有關,這個例子中經常查詢不定時間段,那按照日期拆分比較合適,按照事件 ID 拆分就沒有意義了。
拆分數據的程序(splitData.dfx):
A4 每次取出 10 萬條數據;B4 循環 60 天;C6 按照日期查詢到數據后,通過 C9 追加到各自日期的文件里。
A | B | C | D | |
1 | =dataFile=file("e:/ldsj/demo/src-11800.bin").cursor@b() | |||
2 | >destFolder="e:/ldsj/demo/dates/" | |||
3 | >oneDay=24*60*60*1000 | |||
4 | for A1,100000 | for 60 | >begin=long(B4-1)*oneDay | |
5 | >end=long((B4))*oneDay | |||
6 | =A4.select(時間 >=begin && 時間 <end) | |||
7 | if (C6 == null) | next | ||
8 | >filename= string(date(long(date(2017,1,1))+begin), "yyyyMMdd")+".bin" | |||
9 | =file(destFolder+fileName).export@ab(C6) |
執行后生成 59 天的數據文件:
程序:(5-SplitData.dfx)
A2 中把以前被分析的文件定義換成目錄;
A3/A4 的起止日期條件有所變動,以前是查詢日期字段,現在變成查找日期文件;
A11 把目錄下的日期文件排序,選出要分析的多個日期文件,然后組合成一個游標之后再進行事件過濾就可以了。
A | |
1 | …… |
2 | >fPath="e:/ldsj/demo/dates/" |
3 | >begin="20170201.bin" |
4 | >end="20170205.bin" |
5 | …… |
11 | =directory(fPath+"2017*").sort().select(~>=begin&&~<=end).(file(fPath+~:"UTF-8")).(~.cursor@b()).conjx().select(events.pos(事件 ID)>0 && ${filter}) |
12 | …… |
結果:
目標數據選擇 2017-02-01 至 2017-02-05 這 5 天,全量掃描數據 168 秒;只掃描 5 個文件得到相同結果 7 秒,效果顯著。到目前為止,讀取數據和計算都是單線程的,下面我們再試試并行計算↓
程序:(6-mulit-calc.dfx)
增加 B 列,B2 中啟動 4 個線程處理 A12 里加載的 100000 條數據,C12 中依據用戶 ID%4 的余數分成 4 組,分別給 4 個線程進行運算。
A | B | C | |
11 | …… | ||
12 | for A11,100000 | fork to(4) | for A12.select(用戶 ID%4==B12-1) |
13 | …… |
結果:
11800 萬條 /1.93GB/ 用戶數 80 萬 /4 線程 / 一次性讀入 10 萬條數據 /262 秒;
11800 萬條 /1.93GB/ 用戶數 80 萬 /4 線程 / 一次性讀入 40 萬條數據 /161 秒;
11800 萬條 /1.93GB/ 用戶數 80 萬 /4 線程 / 一次性讀入 80 萬條數據 /233 秒;
11800 萬條 /1.93GB/ 用戶數 80 萬 /4 線程 / 一次性讀入 400 萬條數據 /256 秒。
分析:
筆者測試機器是單個機械硬盤,加載數據速度是瓶頸,所以對提速不太明顯。但調整單次加載的數據量,還是會有明顯的性能差異。每次處理 40 萬條數據時性能最優。
預處理:(splitDataByUserId.dfx)
雖然 4 個線程可以同時讀全量數據的同一個文件,但每個線程讀出 3/4 的無用數據必然拖慢速度,所以預先按照用戶 ID%4 拆分一下文件能更快些。C3 查詢出 ID%4 的數據,C6 把查詢的數據存入相應的拆分文件。
A | B | C | D | |
1 | =file("e:/ldsj/demo/src-11800.bin").cursor@b() | |||
2 | e:/ldsj/demo/users/ | |||
3 | for A1,100000 | for to(4) | =A3.select(用戶 ID%4==B3-1) | |
4 | if (C3 == null) | next | ||
5 | ="src-11800-"+string(B3)+".bin" | |||
6 | =file(A2+C5).export@ab(C3) |
程序:(6-mulit-read.dfx)
把多線程代碼前移到 A11,每個線程內讀取各自的文件進行計算 (B11)。
A | B | C | |
9 | …… | ||
10 | =to(802060).(null) | ||
11 | fork to(4) | =file(fPath+"src-11800-"+string(A11)+".bin").cursor@b().select(時間 >=begin&& 時間 <end && events.pos(事件 ID)>0 && ${filter}) | |
12 | for B11,10000 | …… | |
13 | …… |
結果:
11800 萬條記錄 /1.93GB/ 用戶數量 80 萬 /4 線程 /113 秒。
分析:
同樣受限于加載數據速度,提速也有限。如果用多臺機器集群,每臺機器處理 1/4 的數據,因為是多個硬盤并行,速度肯定會有大幅提升,下面我們就看一下如何實現多機并行↓
集算器如何部署集群計算,如何寫集群的主、子程序的知識點不是本文重點關注的,可以移步相關的文檔詳細了解:http://doc.raqsoft.com.cn/esproc/tutorial/jqjs.html。
主程序:(6-multi-pc-main.dfx)
A3 中用 callx 調用子程序 6-multi-pc-sub.dfx,參數序列 [1,2,3…] 傳入每個子程序控制處理哪一部分數據;返回的結果再通過 B6 匯總到一起,結果存放在 A4 格子里。
A | B | |
1 | >beginTime=now() | |
2 | [127.0.0.1:8281,127.0.0.1:8282] | |
3 | =callx("e:/ldsj/demo/6-multi-pc-sub.dfx",to(2),"e:/ldsj/demo/users/";A2) | |
4 | ||
5 | for A3 | |
6 | >A4=if(A4==null,A5,A4++A5) | |
7 | =interval@s(beginTime,now()) |
A3 得到結果序列:
A4 匯總出最終結果:
節點機子程序:(6-multi-pc-sub.dfx)
相比較上一步單機多線程加載數據的程序,去掉 A11 的多線程 fork to(4);節點機計算哪個拆分文件是通過 taskSeq 參數由主程序傳過來的(B11);A22 把 A20 里的結果返回給主程序。
A | B | C | |
9 | …… | ||
10 | =to(802060).(null) | ||
11 | =file(fPath+"src-11800-"+string(taskSeq)+".bin").cursor@b().select(時間 >=begin&& 時間 <end && events.pos(事件 ID)>0 && ${filter}) | ||
12 | for B11,10000 | …… | |
13 | …… | ||
22 | return A20 |
結果:
11800 萬條記錄 /1.93GB/ 用戶數量 80 萬 / 單節點機處理四分之一數據 /38 秒。主程序匯總的時間很短忽略不計,也就是 4 個 PC 的四塊硬盤并行加載數據時,能把速度提升到 38 秒。
程序和測試數據在百度網盤下載 。安裝好集算器,修改下程序里的文件路徑,就可以運行看效果了。
看到上面這么多的優化細節,估計有人質疑,這么費力的把這事做到極致,是不是吹毛求疵了?數據庫應該是內置了一些自動的優化算法,目前已有共識的是尤其 ORACLE 在這方面已經做的很細致,這些細節根本不需要用戶操心。確實,自動性能優化的重要意義是肯定的,但近幾年隨著數據環境的復雜化,數據量的劇增,更精細的控制數據的能力也就有了越來越多的應用場景,雖然會增加學習成本,但也會帶來更高的數據收益。而且這個學習成本除了解決性能問題外,還能更好地解決根本上的描述復雜計算、整理數據方面的業務需求,更何況這類問題是無法自動化的,因為是“決策要做什么”變復雜了,因此只能提供更方便的編程語言提高描述效率,正視問題。計算機再智能,也不能替代人類做決策。自動和手動兩種方式不是對立,而是互補的關系!
上面這些優化的思路是我們程序員能預先想到的,同時也大概能根據計算任務特點選擇效果顯著的優化方式。但我要說的是計算機系統太復雜了:特點迥異的計算需求、不穩定的硬盤讀寫速度、不穩定的網絡速度、無法估量的 CPU 具體計算量!所以實際業務中我們還需要依靠經驗根據實際優化的效果來選擇優化方法。
SPL 出現以前,因為優化方式的實現和維護都比較困難,因此試驗動作就難以密集進行,優化成果不多也就是自然的了;同時因為缺乏密集“倒騰”數據的鍛煉,優化經驗的積累也不容易,這也從另一個角度驗證了高級數據分析師人才昂貴的現狀。使用高效工具的第一批人,永遠是獲益最大的那一群人,第一批用弓箭的,第一批用槍的,第一批用坦克的,第一個用×××的……而你就是第一批用 SPL 的程序員。程序員的龐大隊伍里分化出一支專業搞數據處理、分析的數據程序員,形成一個有獨立技能的職業,這是必然的趨勢。您的職業規劃,方向選擇也要盡早有個打算,才有占領某一高地的可能。
最后還要說一句,目前這個結果仍然還有優化余地。如果再將數據壓縮存儲,還可以進一步減少硬盤訪問時間,而數據經過一定的排序并采用列式存儲后確實還可以再壓縮。另外,這里的集群運算拆分成了 4 個子任務,而即使配置相同的機器,也可能運算性能不同,這時候就會發生運算快的要等運算慢的,最終完成時間是以計算最慢的那臺機器為準,如果我們能把任務拆得更細一些,就可以做到更平均的效率,從而進一步提高計算速度。這些內容,我們將在后面的文章繼續講述。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。