基於檔案系統實現可追加的資料集市
一 問題背景
絕大多數的應用系統中,一開始資料的儲存和計算基本都是由資料庫來完成的,同時服務於業務交易和報表查詢;不過在經過幾年資訊化建設和資料積累後,常常都會遇到資料庫壓力變大,從而導致效能瓶頸的問題。
究其原因,往往發現針對歷史資料查詢的報表在其中佔了很大比重。進一步分析會發現,這類報表通常都有如下特徵:
1、資料變化小:供查詢的歷史資料幾乎不再發生變化;
2、資料量大:資料量隨時間不斷增加;
由於大多數資料庫的JDBC效能都很低下(JDBC取數過程要做資料物件轉換,比從檔案中讀取資料會慢一個數量級),如果資料始終存放在資料庫中,當涉及資料量較大或併發較多的時候,報表的效能會急劇下降,進一步還會嚴重影響相關的業務操作,如市場營銷、資料整理再彙報等。
針對這一問題,常見的解決方案是在生產庫和應用之間再增加一個前置資料庫,利用ETL工具定時從生產庫中提取資料,清洗後再匯入到前置資料庫中,所有的歷史報表查詢都基於前置資料庫,從而和生產庫分離,緩解生產庫壓力。
不過這種方案增加了很多不必要的成本、多餘的元件和工作量,同時也加大了後期的管理和維護難度;更為重要的是,當資料量比較大時,報表查詢還是很慢,因為上面已經提到過的根本問題並沒有得到解決,大多數資料庫的IO效能遠低於檔案系統,而報表效能又嚴重依賴於資料庫取數環節,也就是說,沒能從根子上解決問題。
二 解決思路
要從根子上解決問題,我們可以假設如果檔案擁有計算能力的話,將這些變化不大的歷史資料搬出資料庫,採用檔案系統儲存,而不是前置資料庫,那麼將可能獲得比資料庫高得多的IO效能,這樣不僅能夠解決大資料量報表查詢慢的難題,我們還將獲得如下這些好處:
1、管理方便;檔案天然支援多級目錄,而且複製、轉移、拆分都比資料庫簡單、高效得多,這樣,使用者就可以按照業務模組、時間順序等規則分類管理資料,在應用程式下線時,也可以按照目錄刪除該應用對應的資料。資料管理因此變得簡單清晰,工作量顯著降低。
2、成本低廉;既然是檔案,那就可以簡單地儲存在廉價硬碟中,無需購買昂貴的資料庫專用軟硬體。
3、降低資料庫擴容壓力;資料庫吞吐負擔降低,就可以顯著推遲擴容臨界點的到來,資料庫可以繼續服役,也可以節省大量的擴容成本。
4、資源利用率高;用檔案來儲存資料並非要拋棄資料庫,相反的,檔案應當只儲存安全要求不高、但資料量巨大的外圍資料以及庫外檔案,而資料庫仍然儲存核心資料。如此一來,檔案儲存和資料庫儲存各司其職,資源利用率顯著提高。
那麼,如何才能有效地為檔案賦予計算能力呢?下面將要介紹的潤乾集算器,就是這樣一款利器,通過集算器,可以實現複雜計算與報表展現的分離,其內建的集算引擎可以使檔案擁有計算能力,輕鬆應對各種疑難雜症。下圖顯示了常規情況和引入集算器後的報表系統結構對比,應該說,引入集算器後,整個體系架構變得更加清新與合理了:
三 場景說明
接下來,我們通過一個典型的場景來說明集算器的作用和用法:
A表“商品銷售明細”的資料量上億,其中欄位areaid與B表“區域表”的主鍵id關聯。A表稱為事實表,B表稱為維表。A表中與B表主鍵關聯的欄位稱為A指向B的外來鍵,B也稱為A的外來鍵表。外來鍵表是多對一的關係。如下圖示:
下面,我們就通過製作“各區域銷售員每日銷售額日增長率報表”,來看一下集算器是如何利用檔案實現資料外接,從而提升報表查詢效率的。報表最終的展示效果如下圖:
在這張報表中,根據選擇開始日期、結束日期進行查詢,報表先按照區域名稱、銷售員程式碼、銷售日進行分組,統計每個銷售員每天的銷售額,以及每個銷售員每天銷售額的日增長率(演算法為“(當日銷售額-上一日的銷售額)/上一日的銷售額”)。報表上部的查詢按鈕是報表工具提供的“引數模板”功能,具體做法參見教程,這裡不再贅述。
3.1設計資料儲存組織
在利用檔案系統儲存資料的諸多優勢之前,我們首先應該先定義檔案的目錄儲存結構:
歷史資料的特徵是交易成型後資料落地不再變化,而且資料量龐大,由此我們可以將每年的資料按照業務模組、月份等規則進行劃分,即每個月份的資料存一份集檔案(集檔案利用集算器提供的壓縮格式,具有更好IO效能)。目錄結構就是:/業務模組/資料明細表/年月檔名,如下圖所示:
同時,我們還需要設定每天凌晨時段定時執行資料同步指令碼,把前一天的資料追加到當月集檔案中;而在每月1號,指令碼還會根據規則自動生成一個新的以年月命名的集檔案。
3.2同步資料
3.2.1同步歷史資料到檔案
先把2017年1到10月的歷史資料按不同月份搬出來(假定已有10個月的歷史資料),集算器的SPL指令碼如下:
A |
B |
C |
|
1 |
=connect("demo") |
||
2 |
=10.("SELECT * FROM sdrpts WHERE filedate>='2017-"/~/"-01'AND filedate<'2017-"/(~+1)/"-01'") |
||
3 |
for A2 |
=file("D:/進銷存/商品銷售明細/2017"+string(#A3,”00”)) |
|
4 |
=A1.cursor(A3) |
>[email protected](B4) |
|
5 |
>A1.close() |
A1:連線資料庫
A2:生成由10個SQL組成的集合,每個SQL分別查詢當月(1到10)範圍內的資料。寫法上,“10.”表示從1迴圈到10,在括號內的字串中用相應的1到10替換 ~符號。
A3:按照A2中的序列迴圈執行
B3:按路徑開啟每月資料的集檔案,路徑命名規則是4位年和2位月,利用string()函式進行格式化,其中#號代表迴圈序號
B4:根據每段sql建立資料庫遊標
C4:將遊標執行計算後的結果寫入到集檔案中。其中export()函式使用了@ab的選項,@b代表寫成集檔案格式,而由於在for迴圈裡面,需要執行多次,所以用@a指明追加方式,把結果逐步儲存到檔案中,保證檔案的完整性。迴圈生成完之後,檔案的儲存目錄結構如下圖:
A5:關閉資料庫連線
3.2.2同步昨天資料到檔案
編寫單次執行指令碼,獲取昨天的歷史資料追加到當月集檔案中,每天執行,當下月1號時,會自動生成新檔案,指令碼如下:
A |
B |
C |
|
1 |
=after(date(now()),-1) |
=year(A1) |
=month(A1) |
2 |
=file("D:/進銷存/商品銷售明細/"+string(B1)+string(C1,”00”)) |
||
3 |
=connect("demo") |
=A3.cursor("SELECT * FROM sdrpts WHERE DATE_FORMAT(fildate,'%Y-%m-%d')=?",A1) |
>[email protected](B3) |
4 |
>A3.close() |
A1:根據當前系統時間,獲取昨天的日期
B1-C1:分別獲取到年、月
A2:按路徑開啟需要匯出的集檔案,路徑規則是以4位年2位月命名,利用string()函式進行格式化
A3:連線資料庫
B3:根據sql建立資料庫遊標,獲取昨日資料,引數為昨天日期
C3:執行結果追加寫入到集檔案中
A4:關閉資料庫
3.3日誌追溯
在上面的步驟中,已經可以同步昨天的歷史資料到集檔案中;但總是有意外情況發生,假如歷史資料沒有同步成功怎麼辦呢?我們是不是可以通過記錄日誌資訊的方式,追溯歷史原因?這樣能夠及時發現問題,及時採取補救措施;比如:很小概率下指令碼可能會執行失敗,這時如果及時發現,就可以先手動執行指令碼重新生成集檔案,然後再排查原因,從而避免影響業務查詢。(由於集檔案目前不支援回滾動作,一旦匯出出錯,需要重新匯出資料生成當月集檔案。集算器高版本的組表支援回滾,以後會有專門的文章詳細介紹)。
3.3.1構造日誌表
第一步,可以先在資料庫中定義一張日誌表,包含五個欄位(事件名稱/狀態/異常資訊/執行時間/執行時長),資料結構如下圖示:
第二步,在集算器指令碼中定義4個引數名,分別是事件名稱/狀態/異常資訊/執行時長,引數定義如下圖所示:
第三步,當集算器指令碼接收來自外界引數資訊時,將引數值填寫到日誌表中:
A |
|
1 |
=connect("demo") |
2 |
=A1.execute("INSERT INTO sys_dfx_task_log (task_name,status,error_msg,excute_time,sec_num) VALUES (?,?,?,?,?)",taskName,status,errorMsg,now(),secNum) |
3 |
>A1.close() |
A1:連線資料庫
A2:接收來自外界傳入的引數值後,向資料庫的日誌表中執行SQL插入語句,包含五個欄位(事件名稱/狀態/異常資訊/執行時間/執行時長),其中now()函式代表獲取當期時間
A3:關閉資料庫
3.3.2設定日誌規則
我們將判斷同步操作是否成功的規則設定為:當每天定時匯出到集檔案的資料條數與查詢出來需要同步資料的總條數相差小於5條的時候,我們認為同步動作是成功的,否則認定同步失敗,然後把關鍵資訊寫入到日誌表中。按此規則改造同步資料的集算器指令碼如下:
A |
B |
C |
|
1 |
=after(date(now()),-1) |
=year(A1) |
=month(A1) |
2 |
=file("D:/進銷存/商品銷售明細/ "+string(B1)+string(C1,”00”)) |
=now() |
[email protected]().skip() |
3 |
=connect("demo") |
=A3.cursor("SELECT * FROM sdrpts WHERE DATE_FORMAT(fildate,'%Y-%m-%d')=?",A1) |
>[email protected](B3) |
4 |
=A3.query("SELECT COUNT(1) FROM sdrpts WHERE DATE_FORMAT(fildate,'%Y-%m-%d')=?",A1) |
[email protected]().skip()-C2 |
[email protected](B2,now()) |
5 |
if A4.#1-B4<5 |
>call("log.dfx","同步sdrpts"+string(A1)+"的資料完成,總記錄條數:"+string(A4.#1)+"總計匯出:"+string(B4),"完成","",C4/1000) |
|
6 |
else |
>call("log.dfx","同步sdrpts"+string(A1)+"的資料,匯出的資料量跟資料庫中的相差超過5條","失敗","",0) |
|
7 |
>A3.close() |
前面已經解釋過的格子的程式碼這裡不再贅述。
B2:獲取當前系統時間,用於後面計算匯出操作的執行時長
C2:統計寫入前集檔案的記錄數
A4:執行sql查詢需要同步的昨天的資料總條數
B4:當資料追加寫入到集檔案後,再統計一遍記錄數,同時減去寫入前的數量,得到實際寫入成功的記錄條數
C4:計算整個同步過程的執行時長,其中interval()函式通過選項@ms指定返回毫秒數
A5:判斷資料庫中需要同步資料的總條數與匯出到集檔案的資料總條數,兩者之差小於5條時,認為任務是執行成功的,在日誌表中寫入成功記錄,否則認定任務執行失敗,在日誌表中寫入失敗記錄。
B5-B6:根據執行成功或失敗的判斷,log.dfx網格檔案,在日誌表中寫入相應的記錄。
A7:關閉資料庫
3.3.3查詢日誌報表
為了方便管理,我們還可以通過報表工具,做一張關於日誌資訊的查詢報表,這樣就能通過web端及時發現問題、解決問題,效果如下:
3.4定時任務
3.4.1利用 Quartz
Quartz 是 OpenSymphony開源組織在Job scheduling領域的一個開源元件,利用Quartz可以簡便地建立定時執行任務,而集算器原本就是獨立的計算引擎,兩者結合起來,再提供一些視覺化的配置和管理頁面,就能比較容易的實現輕量級ETL的功能。如下圖所示:
3.4.2使用作業系統工具建立計劃任務
windows作業系統下,可以利用自帶的任務計劃程式實現定時任務,比如可以先新建一個bat檔案,寫入需要執行的命令:
@echo off
"D:/esProc/bin/esprocx.exe" C:/20180713/synclastday.dfx
再配置一個計劃任務定時執行即可,如下圖所示:
而在Linux作業系統下,可以藉助crontab實現定時任務,命令如下: /raqsoft/esProc/bin/esprocx.sh /esproc/synclastday.dfx
3.5資料查詢
前面是一個比較完整 ETL 資料準備過程,下面我們將在這些準備工作的基礎上,完成“各區域銷售員每日銷售額日增長率報表”的製作,通過集算器利用檔案實現資料外接,從而提升報表查詢效率。
3.5.1查詢一個月內資料
我們先通過傳入開始日期、結束日期,只查詢一個月內的資料,也就是訪問某個月的集檔案即可。(值得一提的是:集算器不僅能夠降低複雜業務運算的實現難度,同時,對於單檔案的運算還提供了“簡單SQL”方式,讓懂SQL的使用者對檔案的操作更容易上手。簡單SQL的特性不是本文的重點,有興趣的讀者可以參考相關文件,這裡不再贅述。)
第一步,分組彙總;根據起止日期過濾後,按照區域ID、銷售、日期分組,並彙總銷售金額(銷售數量*單價),同時區域ID,需要顯示成區域名稱。編寫集算器指令碼如下:
A |
|
1 |
=connect("demo") |
2 |
[email protected]("SELECT id,city FROM area") |
3 |
=file("D:/進銷存/商品銷售明細/ "+string(year(Bfiledate))+string(month(Bfiledate),”00”))[email protected]() |
4 |
=A3.select(filedate>=Bfiledate && filedate<Efiledate) |
5 |
>A4.switch(areaid,A2:id) |
6 |
=A4.groups(areaid,account,date(filedate):filedate;sum(salqty*salamt):subtotal) |
7 |
=A6.new(areaid.city:areaname,account,filedate,subtotal) |
8 |
return A7 |
A1:連線資料庫
A2:通過SQL查詢外來鍵表area,共兩個欄位id,city,其中函式query()使用了@x選項,代表查詢結束時自動關閉資料庫連線,執行結果如下圖:
A3:開啟集檔案物件,根據檔案建立遊標返回,其中cursor()函式使用@b選項代表從集檔案中讀取。我們事先在指令碼設定中定義了2個引數,開始日期、結束日期,如下圖:
這裡根據傳入的開始日期引數Bfiledate,就能夠準確的找到指定的集檔案物件,比如:當Bfiledate的引數值為2017-09-01時,分別獲取年、月,拼在一起就是集檔案的名稱,全路徑為:D:/進銷存/商品銷售明細/201709
A4:通過起止日期過濾出符合條件的記錄
A5:通過switch()函式在A4表的areaid欄位上建立指向A2表中id欄位的指標引用記錄,實現關聯,如下圖:
A6:按區域ID、銷售、日期分組,並彙總銷售金額(銷售數量*單價)
A7:計算欄位值,生成新序表;其中利用A5建立的關聯關係通過“外來鍵欄位.維表字段”的方式進行引用,用 “areaid.city”生成新的欄位areaname,(將維表記錄看做外來鍵的的屬性,這便是外來鍵屬性化的由來),返回關聯後的結果集如下圖:
第二步,計算銷售日增長率;在第一步的基礎上,計算出每個區域下每個銷售員每天銷售額的日增長率; 修改後的指令碼如下:
A |
|
1 |
=connect("demo") |
2 |
[email protected]("SELECT id,city FROM area") |
3 |
=file("D:/進銷存/商品銷售明細/ "+string(year(Bfiledate))+string(month(Bfiledate),”00”))[email protected]() |
4 |
=A3.select(filedate>=Bfiledate && filedate<Efiledate) |
5 |
>A4.switch(areaid,A2:id) |
6 |
=A4.groups(areaid,account,date(filedate):filedate;sum(salqty*salamt):subtotal,sum(0):rate) |
7 |
=A6.run(if(areaid==areaid[-1]&&account==account[-1],rate=(subtotal-subtotal[-1])/subtotal[-1])) |
8 |
=A7.new(areaid.city:areaname,account,filedate,subtotal,rate) |
9 |
return A8 |
前面已經解釋過的格子程式碼這裡不再贅述。
A6:按區域ID、銷售、日期分組,並彙總銷售金額(銷售數量*單價),同時構造一個空的列叫rate,結果如下圖:
A7:在A6分組後的基礎上,針對每一行記錄,判斷相鄰行的areaid、account是否相等,相等的情況下,計算銷售員每天的銷售額的日增長率,演算法為“(當日銷售額-上一日的銷售額)/上一日的銷售額”。可以看到,集算器用subtotal[-1]來表示上一日的銷售額,可以輕鬆進行相對位置的計算。
A8:返回關聯後結果集如下圖:
A9:返回結果集給報表工具
3.5.2查詢跨月 / 跨年資料
上一步已經實現了計算每個銷售員銷售額的日增長率,不過只能在一個集檔案中查詢,也就是隻能查詢一個月的資料。那如何跨多個集檔案,從而實現跨月、跨年,適用於大資料量的報表查詢呢?
首先,我們需要寫一個工具指令碼,主要功能是能夠根據傳入的開始日期、結束日期,過濾出需要查詢跨月度範圍的多個集檔案路徑,同時判斷路徑下的集檔案物件是否存在。指令碼如下:
A |
|
1 |
[email protected](startDate,endDate,1) |
2 |
=A1.(path+string(year(~))+string(month(~),”00”)) |
3 |
=A2.id() |
4 |
=A3.select(file(~).exists()) |
5 |
return A4 |
指令碼接收3個引數,起止日期,集檔案的儲存路徑,如下圖:
A1:根據起止日期,按月間隔獲取日期,其中periods()函式的選項@m代表按月間隔計算,比如,開始日期:2017-01-03,結束日期:2017-11-23,執行結果如下圖:
A2:迴圈A1,通過集檔案的儲存路徑與該日期段內的年月進行拼接。月份要始終保持兩位,利用string()函式進行格式化,結果如下圖:
A3:去重,執行結果如下圖:
A4:判斷路徑下的檔案是否真實存在,由A5返回實際存在的檔案路徑,結果如下圖:
然後,我們需要對上面資料查詢的指令碼做一些改造,值得注意的是這裡將採用多路遊標的概念,將多個遊標合併成一個遊標使用,改造後的指令碼如下:
A |
B |
C |
D |
|
1 |
=connect("demo") |
=[] |
||
2 |
[email protected]("SELECT id,city FROM area") |
|||
3 |
=call("D:/進銷存/商品銷售明細/判斷讀取檔案的範圍.dfx",Bfiledate,Efiledate,"D:/進銷存/商品銷售明細/") |
|||
4 |
for A3 |
=file(A4) |
>B1=B1|C4 |
|
5 |
=B1.mcursor() |
|||
6 |
=A5.select(filedate>=Bfiledate && filedate<Efiledate) |
|||
7 |
>A6.switch(areaid,A2:id) |
|||
8 |
=A6.groups(areaid,account,date(filedate):filedate;sum(salqty*salamt):subtotal,sum(0):rate) |
|||
9 |
=A8.run(if(areaid==areaid[-1] && account==account[-1],rate=(subtotal-subtotal[-1])/subtotal[-1])) |
|||
10 |
=A9.new(areaid.city:areaname,account,filedate,subtotal,rate) |
|||
11 |
return A10 |
前面已經解釋過的格子程式碼這裡不再贅述。
A3:呼叫”判斷讀取檔案的範圍.dfx”,傳入指令碼引數開始日期、結束日期的值,獲得起止日期內的所有集檔案的集合
A4-C4:迴圈A3,分別開啟每個集檔案物件,根據檔案建立遊標,其中cursor()函式使用@b選項代表從集檔案中讀取。
D4:將多個遊標物件儲存到B1預留的序列中
A5:利用集算器提供的多路遊標概念,把資料結構相同的多個遊標合併成一個遊標使用。使用時,多路遊標採用平行計算來處理各個遊標的資料,可以通過設定cs.mcursor(n) 函式中的n來決定並行數,當n空缺時,將按預設自動設定並行數。
A11:最後返回結果集給報表工具使用,而結果集的計算過程A6到A10與前面一個集檔案時完全一樣。
3.6作為報表資料來源
利用集算器完成了資料查詢工作後,可以在報表中直接將集算器設定為資料來源,和使用資料庫一樣簡單地完成報表呈現,具體做法包括:
1、在報表中定義引數(Bfiledate、Efiledate),
2、設定集算器資料集,並傳遞報表引數,
3、設計報表表樣
如下圖所示。隨後,輸入引數計算,即可得到希望的報表了。
如果再結合文章<<秒級展現的百萬級大清單報表怎麼做>>,那麼基本上就可以輕鬆應對專案中遇到的各類大資料集報表、大清單列表了。
3.7總結
1、簡易版、輕量級ETL
集算器是獨立的計算引擎,搭配上定時執行程式,很容易就能實現簡單、輕量級的ETL功能。
2、高性價比、高效能
無需構建數倉,很好的解決關係型資料庫中資料量大而導致的報表慢的難題。
3、不影響原有系統構架、實現簡單、易維護
使用潤乾集算器的集檔案儲存大表資料,獨立於原有系統構架,將原有資料水平切割,顯著提高查詢效率,不影響業務操作。
4、降低應用耦合度
集算器指令碼、集檔案、報表模板等可以隨應用一起管理和維護,完全和資料庫解耦合,資料管理因此變得簡單清晰。