影響資料檢索效率的幾個因素

發表於2015-06-22

資料檢索有兩種主要形態。第一種是純資料庫型的。典型的結構是一個關係型資料,比如 mysql。使用者通過 SQL 表達出所需要的資料,mysql 把 SQL 翻譯成物理的資料檢索動作返回結果。第二種形態是現在越來越流行的大資料玩家的玩法。典型的結構是有一個分割槽的資料儲存,最初這種儲存就是原始的 HDFS,後來開逐步有人在 HDFS 上加上索引的支援,或者乾脆用 Elasticsearc 這樣的資料儲存。然後在儲存之上有一個分散式的實時計算層,比如 Hive 或者 Spark SQL。使用者用 Hive SQL 提交給計算層,計算層從儲存里拉取出資料,進行計算之後返回給使用者。這種大資料的玩法起初是因為 SQL 有很多 ad-hoc 查詢是滿足不了的,乾脆讓使用者自己寫 map/reduce 想怎麼算都可以了。但是後來玩大了之後,越來越多的人覺得這些 Hive 之類的方案查詢效率怎麼那麼低下啊。於是一個又一個專案開始去優化這些大資料計算框架的查詢效能。這些優化手段和經典的資料庫優化到今天的手段是沒有什麼兩樣的,很多公司打著搞計算引擎的旗號幹著重新發明資料庫的活。所以,迴歸本質,影響資料檢索效率的就那麼幾個因素。我們不妨來看一看。

資料檢索乾的是什麼事情

定位 => 載入 => 變換

找到所需要的資料,把資料從遠端或者磁碟載入到記憶體中。按照規則進行變換,比如按某個欄位group by,取另外一個欄位的sum之類的計算。

影響效率的四個因素

  • 讀取更少的資料
  • 資料本地化,充分遵循底層硬體的限制設計架構
  • 更多的機器
  • 更高效率的計算和計算的物理實現

原則上的四點描述是非常抽象的。我們具體來看這些點對映到實際的資料庫中都是一些什麼樣的優化措施。

讀取更少的資料

資料越少,檢索需要的時間當然越少了。在考慮所有技術手段之前,最有效果的恐怕是從業務的角度審視一下我們是否需要從那麼多的資料中檢索出結果來。有沒有可能用更少的資料達到同樣的效果。減少的資料量的兩個手段,聚合和抽樣。如果在入庫之前把資料就做了聚合或者抽樣,是不是可以極大地減少查詢所需要的時間,同時效果上並無多少差異呢?極端情況下,如果需要的是一天的總訪問量,比如有1個億。查詢的時候去數1億行肯定快不了。但是如果統計好了一天的總訪問量,查詢的時候只需要取得一條記錄就可以知道今天有1個億的人訪問了。

索引是一種非常常見的減少資料讀取量的策略了。一般的按行儲存的關係型資料庫都會有一個主鍵。用這個主鍵可以非常快速的查詢到對應的行。KV儲存也是這樣,按照Key可以快速地找到對應的Value。可以理解為一個Hashmap。但是一旦查詢的時候不是用主鍵,而是另外一個欄位。那麼最糟糕的情況就是進行一次全表的掃描了,也就是把所有的資料都讀取出來,然後看要的資料到底在哪裡,這就不可能快了。減少資料讀取量的最佳方案就是,建立一個類似字典一樣的查詢表,當我們找 username=wentao 的時候,可以列舉出所有有 wentao 作為使用者名稱的行的主鍵。然後拿這些主鍵去行儲存(就是那個hashmap)裡撈資料,就一撈一個準了。

談到索引就不得不談一下一個查詢使用了兩個欄位,如何使用兩個索引的問題。mysql的行為可以代表大部分主流資料庫的處理方式:
https://www.percona.com/blog/2012/12/14/the-optimization-that-often-is…
https://www.percona.com/blog/2014/01/03/multiple-column-index-vs-multi…
基本上來說,經驗表明有多個單欄位的索引,最後資料庫會選一最優的來使用。其餘欄位的過濾仍然是通過資料讀取到記憶體之後,用predicate去判斷的。也就是無法減少資料的讀取量。
在這個方面基於inverted index的資料就非常有特點。一個是Elasticsearch為代表的lucene系的資料庫。另外一個是新銳的druid資料庫。
https://www.found.no/foundation/elasticsearch-from-the-bottom-up/
http://druid.io/blog/2012/09/21/druid-bitmap-compression.html
效果就是,這些資料庫可以把單欄位的filter結果快取起來。多個欄位的查詢可以把之前快取的結果直接拿過來做 AND 或者 OR 操作。

索引存在的必要是因為主儲存沒有提供直接的快速定位的能力。如果訪問的就是資料庫的主鍵,那麼需要讀取的資料也就非常少了。另外一個變種就是支援遍歷的主鍵,比如hbase的rowkey。如果查詢的是一個基於rowkey的範圍,那麼像hbase這樣的資料庫就可以支援只讀取到這個範圍內的資料,而不用讀取不再這個範圍內的額外資料,從而提高速度。這種加速的方式就是利用了主儲存自身的物理分佈的特性。另外一個更常見的場景就是 partition。比如 mysql 或者 postgresql 都支援分割槽表的概念。當我們建立了分割槽表之後,查詢的條件如果可以過濾出分割槽,那麼可以大幅減少需要讀取的資料量。比 partition 更細粒度一些的是 clustered index。它其實不是一個索引(二級索引),它是改變了資料在主儲存內的排列方式,讓相同clustered key的資料彼此緊挨著放在一起,從而在查詢的時候避免掃描到無關的資料。比 partition 更粗一些的是分庫分表分檔案。比如我們可以一天建立一張表,查詢的時候先定位到表,再執行 SQL。比如 graphite 給每個 metric 建立一個檔案存放採集來的 data point,查詢的時候給定metric 就可以定位到一個檔案,然後只讀取這個檔案的資料。

另外還有一點就是按行儲存和按列儲存的區別。按列儲存的時候,每個列是一個獨立的檔案。查詢用到了哪幾個列就開啟哪幾個列的檔案,沒有用到的列的資料碰都不會碰到。反觀按行儲存,一張中的所有欄位是彼此緊挨在磁碟上的。一個表如果有100個欄位,哪怕只選取其中的一個欄位,在掃描磁碟的時候其餘99個欄位的資料仍然會被掃描到的。

考慮一個具體的案例,時間序列資料。如何使用讀取更少的資料的策略來提高檢索的效率呢?首先,我們可以保證入庫的時間粒度,維度粒度是正好是查詢所需要的。如果查詢需要的是5分鐘資料,但是入庫的是1分鐘的,那麼就可以先聚合成5分鐘的再存入資料庫。對於主儲存的物理佈局選擇,如果查詢總是針對一個時間範圍的。那麼把 timestamp 做為 hbase 的 rowkey,或者 mysql 的 clustered index 是合適。這樣我們按時間過濾的時候,選擇到的是一堆連續的資料,不用讀取之後再過濾掉不符合條件的資料。但是如果在一個時間範圍內有很多中資料,比如1萬個IP,那麼即便是查1個IP的資料也需要把1萬個IP的資料都讀取出來。所以可以把 IP 維度也編碼到 rowkey 或者 clustered index 中。但是假如另外還有一個維度是 OS,那麼查詢的時候 IP 維度的 rowkey 是沒有幫助的,仍然是要把所有的資料都查出來。這就是僅依靠主儲存是無法滿足各種查詢條件下都能夠讀取更少的資料的原因。所以,二級索引是必要的。我們可以把時間序列中的所有維度都拿出來建立索引,然後查詢的時候如果指定了維度,就可以用二級索引把真正需要讀取的資料過濾出來。但是實踐中,很多資料庫並不因為使用了索引使得查詢變快了,有的時候反而變得更慢了。對於 mysql 來說,儲存時間序列的最佳方式是按時間做 partition,不對維度建立任何索引。查詢的時候只過濾出對應的 partition,然後進行全 partition 掃描,這樣會快過於使用二級索引定位到行之後再去讀取主儲存的查詢方式。究其原因,就是資料本地化的問題了。

資料本地化

資料本地化的實質是軟體工程師們要充分尊重和理解底層硬體的限制,並且用各種手段規避問題最大化利用手裡的硬體資源。本地化有很多種形態

  • 最常見的最好理解的本地化問題是網路問題。我們都知道網路頻寬不是無限的,比本地磁碟慢多了。如果可能儘量不要通過網路去訪問資料。即便要訪問,也應該一次抓取多一些資料,而不是一次搞一點,然後搞很多次。因為網路連線和來回的開銷是非常高的。這就是 data locality 的問題。我們要把計算儘可能的靠近資料,減少網路上傳輸的資料量。
  • 這種頻寬引起的本地化問題,還有很多。網路比硬碟慢,硬碟比記憶體慢,記憶體比L2快取慢。做到極致的資料庫可以讓計算完全發生在 L2 快取內,儘可能地避免頻繁地在記憶體和L2之間倒騰資料。
  • 另外一種形態的問題化問題是磁碟的順序讀和隨機讀的問題。當資料彼此靠近地物理存放在磁碟上的時候,順序讀取一批是非常快的。如果需要隨機讀取多個不連續的硬碟位置,磁頭就要來回移動從而使得讀取速度快速下降。即便是 SSD 硬碟,順序讀也是要比隨機讀快的。

基於儘可能讓資料讀取本地化的原則,檢索應該儘可能地使用順序讀而不是隨機讀。如果可以的話,把主儲存的row key或者clustered index設計為和查詢提交一樣的。時間序列如果都是按時間查,那麼按時間做的row key可以非常高效地以順序讀的方式把資料拉取出來。類似地,按列儲存的資料如果要把一個列的資料都取出來加和的話,可以非常快地用順序讀的方式載入出來。

二級索引的訪問方式典型的隨機讀。當查詢條件經過了二級索引查詢之後得到一堆的主儲存的 key,那麼就需要對每個 key 進行一次隨機讀。即便彼此僅靠的key可以用順序讀做一些優化,總體上來說仍然是隨機讀的模式。這也就是為什麼時間序列資料在 mysql 裡建立了索引反而比沒有建索引還要慢的原因。

為了儘可能的利用順序讀,人們就開始想各種辦法了。前面提到了 mysql 裡的一行資料的多個列是彼此緊靠地物理存放的。那麼如果我們把所需要的資料建成多個列,那麼一次查詢就可以批量獲得更多的資料,減少隨機讀取的次數。也就是把之前的一些行變為列的方式來存放,減少行的數量。這種做法的經典案例就是時間序列資料,比如可以一分鐘存一行資料,每一秒的值變成一個列。那麼行的數量可以變成之前的1/60。

但是這種行變列的做法在按列儲存的資料庫裡就不能直接照搬了,有些列式資料庫有column family的概念,不同的設定在物理上存放可能是在一起的也可能是分開的。對於 Elasticsearch 來說,要想減少行的數量,讓一行多pack一些資料進去,一種做法就是利用 nested document。內部 Elasticsearch 可以保證一個 document 下的所有的 nested document是物理上靠在一起放在同一個 lucene 的 segment 內。

網路的data locality就比較為人熟知了。map reduce的大資料計算模式就是利用map在資料節點的本地把資料先做一次計算,往往計算的結果可以比原資料小很多。然後再通過網路傳輸彙總後做 reduce 計算。這樣就節省了大量網路傳輸資料的時間浪費和資源消耗。現在 Elasticsearch 就支援在每個 data node 上部署 spark。由 spark 在每個 data node 上做計算。而不用把資料都查詢出來,用網路傳輸到 spark 叢集裡再去計算。這種資料庫和計算叢集的混合部署是高效能的關鍵。類似的還有 storm 和 kafka 之間的關係。

網路的data locality還有一個老大難問題就是分散式大資料下的多表join問題。如果只是查詢一個分散式表,那麼把計算用 map reduce 表達就沒有多大問題了。但是如果需要同時查詢兩個表,就意味著兩個表可能不是在物理上同樣均勻分佈的。一種最簡單的策略就是找出兩張表中最小的那張,然後把表的內容廣播到每個節點上,再做join。複雜一些的是對兩個單表做 map reduce,然後按照相同的 key 把部分計算的結果彙集在一起。第三種策略是保證資料分佈的方式,讓兩張表查詢的時候需要用到的資料總在一起。沒有完美的方案,也不大可能有完美的方案。除非有一天網路頻寬可以大到忽略不計的地步。

更多的機器

這個就沒有什麼好說的了。多一倍的機器就多一倍的 CPU,可以同時計算更多的資料。多一倍的機器就多一倍的磁頭,可以同時掃描更多的位元組數。很多大資料框架的故事就是講如何如何通過 scale out 解決無限大的問題。但是值得注意的是,叢集可以無限大,資料可以無限多,但是口袋裡的銀子不會無限多的。堆機器解決問題比升級大型機是要便宜,但是機器堆多了也是非常昂貴的。特別是 Hive 這些從一開始就是分散式多機的檢索方案,剛開始的時候效率並不高。堆機器是一個乘數,當資料庫本來單機效能不高的時候,乘數大並不能起到決定性的作用。

更高效的計算和計算實現

檢索的過程不僅僅是磁碟掃描,它還包括一個可簡單可複雜的變換過程。使用 hyperloglog,count min-sketch等有損演算法可以極大地提高統計計算的效能。資料庫的join也是一個經常有演算法創新的地方。
計算實現就是演算法是用C++實現的還是用java,還是python實現的。用java是用大Integer實現的,還是小int實現的。不同的語言的實現方式會有一些固定的開銷。不是說快就一定要C++,但是 python 寫 for 迴圈是顯然沒有指望的。任何資料檢索的環節只要包含 python/ruby 這些語言的逐條 for 迴圈就一定快不起來了。

結論

希望這四點可以被記住,成為一種指導性的優化資料檢索效率的思維框架。無論你是設計一個mysql表結構,還是優化一個spark sql的應用。從這四個角度想想,都有哪些環節是在拖後腿的,手上的工具有什麼樣的引數可以調整,讓隨機讀變成順序讀,表結構怎麼樣設計可以最小化資料讀取的量。要做到這一點,你必須非常非常瞭解工具的底層實現。而不是盲目的相信,xx資料庫是最好的資料庫,所以它一定很快之類的。如果你不瞭解你手上的資料庫或者計算引擎,當它快的時候你不知道為何快,當它慢的時候你就更加無從優化了。

相關文章