關於Kylin結果緩存的思考

由來

Apache Kylin定位是大數據量的秒級SQL查詢引擎,原理是通過預計算所有可能的維度組合存儲在Hbase中,查詢時解析SQL獲取維度和度量信息,然後再從hbase中掃描獲取數據返回,個人認爲Kylin最強大的地方在於實現了SQL引擎,如果使用自定義的格式化查詢語言也可以完成相應的數據訪問操作,無非是指定查詢的維度、度量、聚合函數、過濾條件,排序列等等。

但是這種描述較之於SQL太弱了,SQL很靈活的將一些複雜的語義轉換,例如kylin中不支持select xxx where xx in (selext xxx)的語句,但是可以通過子查詢join的方式實現,這樣的侷限導致了大量複雜的SQL。除此之外,由於Kylin中可能存在某幾個維度的cardinality比較大,當使用該列進行group by的時候會導致需要從hbase中讀取大量的記錄進行聚合運算甚至排序。Kylin中SQL引擎使用的是Calcite進行SQL解析、優化和部分算子的運算,Calcite的計算是完全基於內存的,所以當Kylin中一個查詢需要從hbase中獲取大量記錄的情況下,內存逐漸會成爲瓶頸。

OLAP查詢往往是基於歷史數據的,歷史數據最重要的特性是不可變的,即便偶爾由於程序BUG導致數據需要修復,對這種不經常會變化的數據的查詢,並且每一個查詢可能消耗大量資源的情況下,緩存是最常用也是最有效的提升性能的辦法。

現狀

Kylin並不是不對查詢結果進行緩存的,對於每一個查詢會根據該查詢掃描的記錄總數是否超過閥值(以此判斷是否值的緩存)判斷是否緩存結果。但是這部分緩存是基於本機內存的,並且是實例間不可共享的,而一般Kylin查詢服務器的架構是多個獨立的服務器通過前面的負載均衡器進行請求轉發,如下圖,所以這部分緩存是無法共享的。由此目前Kylin的緩存機制存在一下幾種弊端:

  • 緩存在本機內存,對查詢最需要的內存資源就是一種消耗。
  • 機器間無法共享,導致查詢可能時快時慢,並且造成不必要的資源浪費。
  • 無法自動過期,build完成之後需要手動清理,否則結果可能出現錯誤。例如查詢select count(1) from xxx;當一個新的segment build完成之前和之後兩次查詢的結果是相同的,明顯第二次是使用緩存的,並且build成功之後並沒有將緩存清理,需要手動清除。

Kylin原生緩存

改進

瞭解了Kylin當前緩存的情況,針對以上前兩點進行改進,最直接的方案便是將緩存移至外部緩存,首選的key-value緩存當然是redis,如下圖,根據現在緩存的設計,可以將查詢的SQL和所在的project作爲key,查詢結果以及一些查詢中的統計信息作爲value緩存。但是第三點需要針對kylin的實現設計出具體的自動過期方案。

使用Redis作爲外部緩存

Kylin數據計算和查詢流程

Kylin是基於預計算的,計算的是所有定義的維度組合的聚合結果(SUM、COUNT等),既然需要聚合肯定需要一段數據量的積累,Kylin通過在創建Cube時定義一個或者兩個(新版本,支持分鐘級別粒度)分區字段,根據這個字段來獲取每次預計算的輸入數據區間,Kylin中將每一個區間計算的結果稱之爲一個Segment,預計算的結果存儲在hbase的一個表中。通常情況下這個分區字段對應hive中的分區字段,以天爲例子,每次預計算一天的數據。這個過程稱之爲build。

除了build這種每個時間區間向前或者向後的新數據計算,還存在兩種對已完成計算數據的處理方式。第一種稱之爲Refresh,當某個數據區間的原始數據(hive中)發生變化時,預計算的結果就會出現不一致,因此需要對這個區間的segment進行刷新,即重新計算。第二種稱之爲Merge,由於每一個輸入區間對應着一個Segment,結果存儲在一個htable中,久而久之就會出現大量的htable,如果一次查詢涉及的時間跨度比較久會導致對很多表的掃描,性能下降,因此可以通過將多個segment合併成一個大的segment優化。但是merge不會對現有數據進行任何改變。

說句題外話,在kylin中可以設置merge的時間區間,默認是7、28,表示每當build了前一天的數據就會自動進行一個merge,將這7天的數據放到一個segment中,當最近28天的數據計算完成之後再次出發merge,以減小掃描的htable數量。但是對於經常需要refresh的數據就不能這樣設置了,因爲一旦合併之後,刷新就需要將整個合併之後的segment進行刷新,這無疑是浪費的。

說完了數據計算,接下來講一下這部分預計算的數據是如何被使用的,Calcite完成SQL的解析並回調kylin的回調函數來完成每一個算子參數的記錄,這裏我們只需要關心查詢是如何定位到掃描的htable的。當一個查詢中如果涉及到建cube中使用的分區字段,由於分區字段一般是維度字段,否則每次掃描都需要掃描全部的分區。那麼這裏涉及表示該字段出現在where子句或者group by子句中,下面分幾種情況分別討論(假設分區字段爲dt):

  • SQL中沒有分區字段,例如select country, count(1) from table group by country,這種查詢需要統計的數據設計全部預計算的時間區間和將來計算的時間區間,所以它需要掃描所有的htable才能完成。這種情況下build和refresh計算都會使得結果發生變化。
  • SQL中where子句使用了分區字段,例如select country, count(1) from table where dt >= ‘2016-01-01’ and dt < ‘2016-02-01’ 這種情況下可以根據時間區間確定只掃描部分htable。這種情況只有這段時間區間的refresh操作會改變查詢結果,如果過濾的時間區間包含一個未來的時間,build操作會導致結果發生改變。
  • SQL中group by中使用了分區字段,這種情況下類似於1,需要掃描全部的htable以獲取結果。
  • SQL中group by和where字段中都使用了分區字段,這種情況類似於2,畢竟SQL執行時首先執行where再進行group by的。

分析

既然數據計算和查詢結果有了這種關係,那麼就可以利用這種關係解決緩存中最困難的一個問題——緩存過期,即現狀中的問題3,但是查詢通常是非常複雜的,例如多個子查詢join、多個子查詢union之類的,並不能輕易地獲取group by和where子句的內容,幸運的是,在Kylin每一個查詢過程中會將本次查詢或者每一個子查詢設計的信息保存在OLAPContext對象中,一次查詢可能生成多個OLAPContext對象,它們被保存在一個stack中,並且這個stack是線程局部變量,因此可以通過遍歷這個stack中內容獲取本次查詢所有信息,包括使用的cube、group by哪些列、所有的過濾條件等。

緩存設計

  • 緩存的key:查詢請求,包括SQL、project、limit、offset、isPartial參數,將該信息
  • 緩存的value:查詢返回結果,包括結果數據和元數據,以及其他統計信息,包括使用了哪些cube,每一個cube的時間分區過濾條件是什麼。以及緩存添加的時間。
  • 添加緩存:查詢獲取結果之後將key-value對加入到緩存中,Kylin原生的緩存會緩存異常,這裏不進行緩存,主要是由於Kylin內部的異常主要分爲三類:AccessDeny、語法錯誤和其它數據訪問異常,第一種原生的緩存也不會進行存儲,第二種異常查詢一下元數據就可以判斷也沒必要緩存;第三種則是系統異常,緩存可能會導致修復之後的的查詢繼續出現錯誤。因此緩存中只存儲執行成功的查詢。
  • 緩存過期時間:可配置,其實這個值設置無所謂,只是爲了將長時間不查詢的key刪除罷了,可以設置較久。
  • 緩存失效:根據上面的分析,數據計算中build和refresh都可能對緩存中的結果產生變化,因此這部分緩存需要自動失效,檢查緩存失效的方式由兩種,一種主動式,在每次數據計算任務結束之後遍歷全部的緩存值判斷是否失效,另外一種是被動式,真正讀取緩存的時候再去判斷它是否已經失效,不被讀取的已失效緩存會隨着過期時間的到達而自動刪除。前者需要redis的keys命令的支持,後者只是在讀取value之後根據上一次執行的統計信息執行單個記錄的判斷,很明顯這種情況下被動的方式更加合適。判斷的邏輯如下:當每一個數據計算任務完成之後(無論是build、refresh還是merge)都會記錄這個segment的最後更新時間(更新cube元數據的時候記錄),在查找緩存對的時候首先根據key獲取緩存內容,即緩存的value,然後查看每一個使用的cube的分區字段過濾區間,查看該區間的segment是否存在最後更新時間大於緩存添加時間的。如果存在說明這段數據已經被更新,則釋放該條緩存,否則說明數據沒有被更新,可以繼續使用當前緩存作爲結果。

條件和說明

  • 一個相同的SQL兩次查詢使用相同的cube。
  • 每一個segment的最後更新時間總是有效的,不會出現錯誤的時間。 
    * 每一個查詢服務器的元數據是相同的,實際情況下不同機器之間可能存在短時間的誤差。
  • 只統計第一層時間分區(天)的過濾週期,如果where條件中沒有出現過濾(情況1,3)則說明過濾區間爲[0, Long.MAX_VALUE]
  • 雖然merge不會導致數據的修改,但是merge之前可能出現其中某一個segment被build或者refresh,而這部分信息無從獲取,所以merge需要和refresh相同對待。

優化

  • 緩存作爲鎖,相同的查詢可以通過外部緩存順序化,如果緩存可以命中則直接從緩存中獲取結果,否則首先將該查詢作爲key加入,value設置爲isRunning表示加鎖,後面相同的查詢等待之前查詢結果的返回,可以通過訂閱-發佈模式或者輪詢isRunning標識的方式判斷第一次查詢是否執行完成,放置同時執行多個相同的查詢。
  • SQL標準化,查詢的SQL可能存在只多一個空格的情況,雖然語義是完全一樣的,但是作爲key則是不相同的,可以通過修剪SQL中多餘的空格(字符串中的除外)完成SQL寫法的標準化,更充分的利用緩存。
  • 只緩存執行成功的查詢結果。不緩存異常。
  • 手動清除異常接口。

僞代碼

//首先檢查key是否存在
if exists key
     value = get key
     //如果key存在可能存在兩種情況:已經有結果或者正在查詢
     if value is running
          waiting for finish(loop and check)  //輪詢是個更好的辦法,效率稍低
     else 
          //即使有結果也可能存在結果已經失效的情況
          if value is valid
               return value
          else 
               //爲了保證互斥,使用setnx語句設置
               set if exist the value is running
               if return true  run the query
               else  wait for finish(loop and check)
else
     //不存在則直接表示running
     set if exist the value is running
     if return true run the query
     eles waiting for finish(loop and check)

總結

本文基於分析Kylin中現有的緩存策略,提出一種使用外部緩存的方案,接下來可以基於此進行編碼和測試,希望能夠取得較好的效果。


發佈了82 篇原創文章 · 獲贊 57 · 訪問量 33萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章