多層科目任意組合彙總報表的效能優化 (上)
一 問題背景
我們先來看一張資產負債表:
這是一個典型的中國式複雜報表格式,其複雜並不在於佈局,而在於其中“期末餘額”的每個單元格都是一個需要獨立計算的指標,互相之間幾乎沒有關係,事實上就是一個各種指標的彙總清單,而這些指標往往會有上百個之多。
在源資料表結構中,有一個欄位稱為科目,其長度總是固定的 10 位,如:1234567890,如下圖:
科目欄位的值實際上是一個分層的程式碼,而前面表裡上百個指標就是根據需求對不同層次科目資料的統計結果,具體的做法是通過擷取科目的前幾位來確定層次,然後按需求自由組合,作為條件進行過濾,最後對金額欄位進行累計彙總。
比如計算指標 A 對應的科目列表是 [1001,1002],代表累計所有前 4 位是 1001、1002 的科目,用 SQL 寫出來就是:select sum(金額 )from T1 where concat( 年, 月)<=? and (left( 科目,4)="1001" or left(科目,4)="1002")
其中年、月是公共過濾條件,代表統計的時間範圍。
類似的,如果另一個指標 B 對應的科目為 [2702,153102,12310105], 那就代表對前 4 位是 2702、前 6 位是 153102、前 8 位是 12310105 的所有科目值進行累計,用 SQL 寫出來就是:select sum(金額 )from T1 where concat( 年, 月)<=? and (left( 科目,4)="2702" or left(科目,6)="153102" or left(科目,8)="12310105");
實際業務中,每個指標對應的科目數量不定,可能多達 10 個以上,而且就像指標 B 這樣,各個科目的層次也不盡相同。
在有了報表工具之後 (固定報表),原則上這類格式複雜、指標引數任意組合的報表需求並不難實現,只是原始資料量一大,查詢響應就會非常慢,使用者體驗變差,當多併發請求時,還會對正常業務產生影響。
二 開發和優化過程
2.1 多次遍歷方案
最常見的開發思路,就是按前面說的計算方式,對報表的每個指標都寫一句完整的 SQL 來計算,有 100 個指標,就寫 100 個 SQL。
有些報表工具提供了函式,可以直接在單元格中執行 SQL (比如 query/call 等),單元格的表示式大概會是這樣:
= query("select sum(金額 )from T1 where concat( 年, 月)<=? and (left( 科目,4)='2702' or left(科目,6)='153102' or left(科目,8)='12310105')", concat(year,month))
如果是非多源報表工具,則可以藉助外部程式資料來源來實現,比如可以直接用集算器編寫以下指令碼:
A |
|
1 |
=connect("demo") |
2 |
[email protected]("select sum(金額 )from T1 where concat( 年, 月)<=? and (left( 科目,4)='1001' or left(科目,4)='1002')",concat(year,month)) |
3 |
[email protected]("select sum(金額 )from T1 where concat( 年, 月)<=? and (left( 科目,4)='2702' or left(科目,6)='153102' or left(科目,8)='12310105')",concat(year,month)) |
… |
… |
102 |
>A1.close() |
103 |
return [A2:A101] |
簡單說明一下:
A1:連線資料庫 demo
A2-A3:執行指標 A 的查詢 SQL 和指標 B 的查詢 SQL;其中 query() 函式中 @1 選項代表查詢符合條件的第一條記錄, 返回成單值或序列(一個欄位是單值,多個欄位是序列);這個例子是對金額彙總求和,所以返回單值。
A4-A101:假定有剩餘的 98 個指標,每個指標的查詢 SQL 都類似於 A2、A3 的寫法
A102:關閉資料庫連線
A103:合併 A2-A101 每個格子的計算結果 (共計 100 個指標值),返回一個單列資料集,供報表工具使用。
不過,在這種思路下,無論直接在格中使用 SQL 還是在程式資料來源中計算,實際上每計算一個指標就得遍歷一次源資料;而每個指標還對應多個需要 AND 的條件,這些都會嚴重降低效能。
這種思路的優點是簡單直接,看上去確實能夠實現需求,開發過程也並不太難。在資料量不大的情況下,查詢也不會很慢,勉強還能接受。不過,隨著資料量越來越大,效能瓶頸就會隨之而來,到了一定程度後,就可能出現在關鍵時刻使用者無法及時獲得自己想要指標的問題,最終只能放棄。
2.2 一次遍歷方案
上面“多次遍歷方案”的問題在於,無論如何,對源資料遍歷 100 次實在是太低效了,那麼,我們有沒有辦法能少遍歷幾次呢? 能不能只做一次遍歷就把所有指標都計算出來呢?
這種一次遍歷的思路確實是可以的,我們只需要把 SQL 中的 WHERE 條件拼到 SELECT 中就行了,比如前面說到的指標 A 和 B 可以寫成:
SELECT SUM(CASE WHEN (LEFT(科目,4)='1001' OR LEFT( 科目,4)='1002')THEN 金額 ELSE 0 END) 指標 A,
SUM(CASE WHEN (LEFT(科目,4)="2702" OR LEFT( 科目,6)="153102" OR LEFT(科目,8)="12310105")THEN 金額 ELSE 0 END) 指標 B,
…
FROM T1 WHERE CONCAT(年, 月 )<=?
但是,真要用這個思路來處理 100 個指標,可以想見這個 SQL 會有多長,維護難度會有多大。為此,我們可以利用集算器的遊標來實現這個邏輯,適當降低維護難度。
另外,這種方案下的遍歷,還是需要把整表資料讀出資料庫,而 JDBC 太慢,IO 時間很可能成為瓶頸。對於這個問題,我們注意到其中處理的都是不再變化的歷史資料,那麼我們就可以把資料先搬出資料庫存成檔案,然後用檔案作為資料來源,從而加快 IO 訪問。具體實現如下:
1、把資料搬出資料庫儲存成檔案,集算器的 SPL 指令碼如下:
A |
|
1 |
=connect("demo") |
2 |
=A1.cursor("select 科目, 年, 月, 金額 from T1") |
3 |
=file("總賬憑證 -pre.btx") |
4 |
>[email protected](A2) |
5 |
>A1.close() |
A1:連線資料庫 demo
A2:根據 sql 建立資料庫遊標返回
A3:集檔案儲存的位置
A4:匯出整表資料並儲存到檔案中,其中 export()函式的 @b 選項代表寫入到集檔案中,即總賬憑證 -pre.btx
A5:關閉資料庫連線
2、遍歷一次源資料,計算 100 個指標,集算器的 SPL 指令碼如下:
A |
B |
C |
|
1 |
=file("總賬憑證 -pre.btx") |
/指標引數列 |
|
2 |
|||
3 |
=A2.select(concat(年, 月 )<=concat(year,month)) |
||
4 |
for A3,10000 |
||
5 |
[email protected]+A4.select(C5.contain(科目 \1000000)).sum( 金額) |
[1001,1002] |
|
6 |
[email protected]+A4.select(C6.contain(科目 \1000000)||C6.contain( 科目 \10000)||C6.contain(科目 \100)).sum(金額) |
[2702,153102,12310105] |
|
… |
… |
… |
|
105 |
return [B5:B104] |
值得注意的是:這個例子引入了一個新的寫法,100 個指標引數可以統一寫到 C 列上,當 B 列每計算一個指標時,直接引用 C 列當前行的所對應的引數即可。比如:
C5:指標 A 的引數條件 (按科目號前 4 位擷取的多個值形成的集合)
C6:指標 B 的引數條件 (按科目號前 4 位 / 前 6 位 / 前 8 位擷取的多個值,形成的引數集合)
剩餘的 98 個指標,計算的寫法類似 B6,引數的寫法類似 C6,依次類推到 100。
顯然,這種計算邏輯和引數分離的寫法,能夠極大地提高可維護性。
下面我們完整地分析一下這段指令碼:
A1:開啟預處理前的原始資料表的集檔案物件
A2:根據檔案建立遊標返回,其中 cursor() 函式使用 @b 選項代表從集檔案中讀取。
A3:在 A2 的基礎上,先按公共條件年、月過濾出結果集中符合條件的記錄,其中 year,month 是 SPL 指令碼中定義的引數,接收來自報表前端傳入的查詢條件,比如查詢 2017 年 01 月,日期範圍是截止到某個時間點,所以需要利用 concat 函式對 year、month 連線起來再去做條件比較。
A4:循環遊標,每次從遊標讀取 10000 條記錄返回。
B5:代表指標 A 的金額累計彙總值;每次 for 迴圈,根據 C5 的引數選出符合條件的記錄,用 contain()函式來判斷引數是否在結果集中 ( 其中引數都是 4 位,所以需要對原記錄中科目 \1000000 後保留科目的前 4 位,才能與引數進行比較),然後對金額進行累計彙總。其中的 @符號代表當前格的值,初始值為空,每次迴圈時將上次的值與本次符合條件的資料值相加,作為新值寫入格中,最終可計算出某個指標的金額累計彙總。
B6:代表指標 B 的金額累計彙總值;與指標 A 不同的是,多個引數由不同的位陣列成,所以需要在 contain()函式中分別擷取不同的位數,與 C6 列的引數進行多次比較。
A105:合併 B5-B104 每個格子的值 (從上往下,100 個指標的計算結果),返回一個單列資料集,可以供報表工具使用。
2.3 預先彙總方案
現在我們已經做到了只需要遍歷一次資料,但需要遍歷的整體資料量仍然比較大,還有什麼辦法能進一步減少資料量呢?
如果能夠把資料事先按科目彙總,那麼我們就可以不必重複累加科目相等的記錄了,而且儲存量也會變少,IO 也會更快。
2.3.1 分組計算彙總值
首先,按照科目、年、月分組,金額進行彙總,彙總結果的資料結構應當是:科目、年、月、本科目下當月的金額彙總值。
集算器 SPL 指令碼實現分組、彙總計算的樣例如下:
A |
|
1 |
=file("總賬憑證 -pre.btx") |
2 |
=file("總賬憑證 -mid.btx") |
3 |
|
4 |
=A3.groupx(科目, 年, 月;sum(金額): 彙總金額 ) |
5 |
>[email protected](A4) |
A1:開啟預處理前的原始資料表的集檔案物件
A2:計算後中間結果資料的集檔案儲存的位置
A3:根據檔案建立遊標返回,其中 cursor() 函式的 @b 選項代表從集檔案中讀取
A4:先按照科目、年、月分組,金額彙總
A5:執行 A4 的計算結果寫入到集檔案中,其中 export() 函式使用了 @b 的選項,@b 代表寫成集檔案格式,即總賬憑證 -mid.btx
2.3.2 利用跨行組計算累計值
我們這個問題最終是要計算指標的期末值,也就是截止某個日期的金額累計值;上一步計算的是當月的金額彙總值,那金額的累計值該如何計算呢?
集算器提供了跨行引用的語法,可以用A[-1]代表上一行的 A,這樣就可以計算:累計值 = 上一行的累計值 + 當前行值。
指令碼中,接著上一步作如下修改即可計算累計值:
A |
B |
|
1 |
=file("總賬憑證 -pre.btx") |
|
2 |
=file("總賬憑證 -mid.btx") |
|
3 |
||
4 |
=A3.groupx(科目, 年, 月;sum(金額): 金額 ) |
|
5 |
for A4;科目 |
=A5.run(金額 = 金額 [-1]+ 金額 ) |
6 |
>[email protected](B5,#1:科目,#2: 年,#3: 月,#4: 累計金額 ) |
其他格子的程式碼,在上面已經解釋過了,這裡不再贅述。
A5:利用 for 循環遊標 A4,其中分號的引數“科目”表示每次從遊標讀取一組科目值相同的記錄返回。我們先單步執行一下,返回某一個科目的記錄:
再接著執行一次 for 迴圈,返回下一組科目的記錄:
B5:針對取出的同一科目的記錄,對金額累計;其中表達式:金額 = 金額 [-1]+ 金額,金額代表當前行金額,金額[-1] 代表上一行累計金額值,相加計算好後再重新賦值給金額欄位。如下圖是接著 A5 格子執行後的結果變化:
B6:執行計算後的結果寫入到集檔案中。其中 export() 函式使用了 @ab 的選項,@b 代表寫成集檔案格式,由於在 for 迴圈裡面,需要執行多次,所以用 @a 以追加的方式把結果逐步儲存到檔案中,保證檔案的完整性;即總賬憑證 -mid.btx。部分執行結果如下圖:
2.3.3 構造多層科目彙總值
現在計算出了明細科目的累計值,我們還需要計算高層次科目(擷取前 N 位)對應的彙總值。
從需求可以看到,每個計算指標都是按照科目擷取前 4 位、前 6 位、前 8 位等作為引數集合,那麼在構造不同層次的科目號時,也需要和這種規則匹配,從而計算出不同層次的聚合值。
比如:對於科目是 1234567890,那麼就需要新增科目號 1234、123456、12345678 對應的彙總金額。也就是對於每個 1234567890 這樣的 10 位科目號,還需要分別增加 4、6、8 位的科目 1234、123456、12345678。其中科目 1234 會把所有 1234 開頭的科目的金額值進行累計彙總,依次類推。其實,這就是 CUBE 的常用手段。
需要注意的是,基於上一步計算結果,資料量大小又需要分兩種情況討論:
1、 結果已經可以全部直接讀入記憶體參與下一步計算;
2、 結果依然很大,需要採用外存計算 (遊標技術可以邊讀邊算,多次計算還需要管道技術來配合)
2.3.3.1 記憶體計算
如果結果集可以全部裝入記憶體,集算器 SPL 指令碼構造多層次科目彙總值的樣例如下:
A |
|
1 |
=file("總賬憑證 -mid.btx") |
2 |
=file("總賬憑證 -later.btx") |
3 |
|
4 |
=A3.groups((科目 \100): 科目, 年, 月;sum( 累計金額): 累計金額彙總 ) |
5 |
=A4.groups((科目 \100): 科目, 年, 月;sum( 累計金額彙總): 累計金額彙總 ) |
6 |
=A5.groups((科目 \100): 科目, 年, 月;sum( 累計金額彙總): 累計金額彙總 ) |
7 |
=[A6,A5,A4].conj() |
8 |
>[email protected](A7) |
A1:開啟中間計算結果的集檔案物件
A2:計算後的結果集檔案儲存的位置
A3:從檔案物件 A1 中讀出內容作為記錄形成結果集返回;其中 @b 代表從集檔案中讀出。
A4:按科目擷取前 8 位 (科目 \100)、年、月進行分組,累計金額進行彙總,如果擷取前 7 位,就需要寫成:(科目 \1000);具體按多少位擷取由需求場景決定。執行結果如下圖:
A5:在 A4 的結果集的基礎上,按科目 \100 得到科目前 6 位、年、月進行分組,累計金額進行彙總;執行結果如下圖:
A6:同理,在 A5 的基礎上,按科目 \100 得到科目前 4 位、年、月進行分組,累計金額進行彙總;執行結果如下圖:
A7:多個結果集合併成一個結果集
A8:計算後的結果集匯出並儲存到檔案中,其中 export()函式使用了 @z 的選項,代表分段寫入到集檔案中,即總賬憑證 -later.btx
2.3.3.2 外存計算(遊標 + 管道)
在前面的例子中,我們已經使用了遊標,需要特別強調的是遊標只能從前向後單向移動,執行一次遍歷計算,只有最終生成的遊標中的 cs.fetch() 函式才能夠有效取得資料。遍歷結束後,計算過程中產生的其它遊標都將不能再次讀取資料。
不過有時候,在一次讀取資料的過程中,我們需要同時計算出多個結果,那麼此時就需要使用與遊標類似的管道,用 channel(cs) 建立管道將遊標 cs 的資料在遍歷同時壓入管道以便實施其它運算。
和記憶體相比,外存速度慢很多,因此要儘量減少硬碟訪問,所以,我們採用遊標 + 管道的機制一次遍歷獲得需要的彙總結果:
A |
|
1 |
=file("總賬憑證 -mid.btx") |
2 |
=file("總賬憑證 -later.btx") |
3 |
|
4 |
=channel(A3).groupx((科目 \100): 科目, 年, 月;sum( 累計金額): 累計金額彙總 ) |
5 |
=channel(A3).groupx((科目 \10000): 科目, 年, 月;sum( 累計金額): 累計金額彙總 ) |
6 |
=A3.groupx((科目 \1000000): 科目, 年, 月;sum( 累計金額): 累計金額彙總 ) |
7 |
=[A6,A5.result(),A4.result()].conjx() |
8 |
>[email protected](A7) |
A1-A3:前面已經解釋過了,這裡不再贅述。
A4:建立管道,將遊標 A3 中的資料推送到管道,其中 ch.groupx() 函式針對管道中的有序記錄分組並返回管道;按科目擷取前 8 位、年、月進行分組,累計金額進行彙總
A5:同理於 A4 返回管道,按科目擷取前 6 位、年、月進行分組,累計金額進行彙總
A6:返回遊標,按科目擷取前 4 位、年、月進行分組,累計金額進行彙總
A7:多個遊標運算結果合併成一個結果集;其中 ch.result() 代表管道的運算結果
A8:計算後的結果集匯出並儲存到集檔案,即總賬憑證 -later.btx
2.3.4 優化“一次遍歷”的方案
經過上面兩步資料預處理,結果資料可以直接作為報表的資料來源,每個指標的計算條件只要相等比較就可以,而不再需要擷取、計算前幾位了。
所以在前述“一次遍歷“方案的基礎上,我們來做一些優化;集算器的 SPL 指令碼樣例如下:
A |
B |
C |
|
1 |
=file("總賬憑證 -later.btx") |
/指標引數列 |
|
2 |
|||
3 |
=A2.select(concat(年, 月 )<=concat(year,month)) |
||
4 |
for A3,10000 |
||
5 |
[email protected]+A4.select(C5.contain(科目 )).sum(累計金額彙總) |
[1001,1002] |
|
6 |
[email protected]+A4.select(C6.contain(科目 )).sum(累計金額彙總) |
[2702,153102,12310105,1122,12310101,12310401,12319001,12310201,12310301,12310501,12310601,12310701,12310801,12319101] |
|
… |
… |
… |
|
105 |
return [B5:B104] |
A1-A4:前面已經解釋過了,這裡不再贅述。
B5:代表指標 A 的累計金額的彙總值求和;每次 for 迴圈,根據 C5 的引數選出符合條件的記錄,用 contain() 函式來判斷引數是否在結果集中,然後對累計金額彙總進行求和。其中的 @符號代表當前格的值,初始值為空,每次迴圈時將上次的值與本次符合條件的資料值相加,作為新值寫入格中,最終可計算出某個指標的累計金額彙總的求和值。
B6:同 B5 的寫法,代表指標 B 的累計金額彙總值求和,通常集合元素個數超過 13 個時,如果事先能對常數集合排序,那麼可以選擇 contain() 函式的 @b 選項,利用二分查詢會明顯快於順序查詢。
A105:合併 B5-B104 每個格子的值 (從上往下,100 個指標的計算結果),返回一個單列資料集,供報表工具使用。
至此,我們可以看到,按照預先彙總的思路,事先根據資料特徵對資料進行預處理,可以讓總的資料量變小,同時減少遍歷量,從而避免前述方案中總是從最底層再去累加的模式。經過實測:從報表取數到報表展現整個環節比“常規”方案足足提高了6-8倍左右,這樣的體驗已經可以很好地滿足使用者要求了。
那麼,是否有更好的優化方案呢?答案是肯定的!請看:多層科目任意組合彙總報表的效能優化 (下)
著作權歸作者所有。商業轉載請聯絡作者獲得授權,非商業轉載請註明出處。