[轉]實現鍵值對存儲(長文)



實現鍵值對存儲(0):目錄


2014年7月8日更新:這個系列的文章仍在繼續,我正在實現這個鍵值對存儲並將在其差很少完成的時候寫一篇文章。這些文章每一篇都花了我很長時間來寫。爲了在完成的時候得到更新通知,你能夠在博客右上角訂閱。html

這篇文章是我今天開始的系列文章「實現一個鍵值對存儲」(IKVS)的主條目。本文旨在之內容列表的形式總結系列中全部文章,可能在晚些時候有一些關於項目的筆記。node

本文的內容可能隨時間變化直到系列完成。特別是在目錄裏,各個部分的標題尚未寫而順序也有可能改變。隨着內容逐漸完成,一些部分也有可能被移除或者有新的部分被添加進來。linux

項目中的更多信息能夠在 「第一部分:什麼是鍵值對存儲,爲何要實現它」的1.3節中找到。c++

1 – 什麼是鍵值對存儲,爲何要實現它? (已翻成中文)git

  • 1.1 – 鍵值對存儲綜述
  • 1.2 –鍵值對存儲 vs 關係型數據庫
  • 1.3 – 爲何要實現鍵值對存儲
  • 1.4 – 計劃
  • 1.5 – 參考文獻

2 – 使用已存在的鍵值對存儲做爲模型 (已翻成中文)
github

  • 2.1 – 不要重複發明輪子
  • 2.2 – 候選模型和選擇標準
  • 2.3 – 所選的鍵值對存儲的概覽
  • 2.4 – 參考文獻

3 – Kyoto Cabinet和LevelDB結構的比較分析web

  • 3.1 – 結構分析的意圖和方法
  • 3.2 – 鍵值對存儲組件的概覽
  • 3.3 – Kyoto Cabinet和LevelDB的結構與概念分析
  • 3.4 – 代碼審查
  • 3.5 – 參考文獻

4 – API設計redis

  • 4.1 – API設計的基本原則
  • 4.2 – 定義FelixDB公共API的功能。
  • 4.3 – 比較已存在數據庫的API功能
  • 4.4 – 結論
  • 4.5 – 參考文獻

5 – 哈希表實現算法

  • 5.1 – Hash tables 哈希表
  • 5.2 – Implementations 實現
  • 5.3 – Conclusion 結論
  • 5.4 – References 引用

6 – 實現存儲在文件系統中的高存儲效率的哈希表sql

7 – 存儲管理

8 – 網絡

9 – 接口:REST, memcached等

10 – 更進一步





實現鍵值對存儲(一):什麼是鍵值對存儲,爲何要實現它


在本文中,我將會以鍵值對是什麼的一個簡短描述開始。而後我將解釋本項目以後的一些理由,最後我將說明我打算實現的鍵值對存儲的主要目標。這裏是本文中將會包含內容的列表:

  1. 鍵值對存儲的概述
  2. 鍵值對存儲 vs 關係型數據庫
  3. 爲何要實現鍵值對存儲
  4. 計劃
  5. 參考文獻

 

1. 鍵值對存儲的概述

基於不少文章已經有了不少詳細的介紹,本節只是對於鍵值對存儲的一個簡短介紹。我已經選擇了幾篇放在本文底部的引用一節中。

鍵值對存儲是數據庫最簡單的組織形式。基本上全部的編程語言都帶有應用在內存中的鍵值對存儲。C++STL的映射容器(map container)和Java的HashMap以及Python的字典類型都是鍵值對存儲。鍵值對存儲一般都有以下接口:

Get( key ): 獲取以前存儲於某標示符「key」之下的一些數據,或者「key」下沒有數據時報錯。

Set( key, value ): 將「value」存儲到存儲空間中某標示符「key」下,使得咱們能夠經過調用相同的「key」來訪問它。若是「key」下已經有了一些數據,舊的數據將被替換。

Delete( key ):  刪除存儲在「key」下的數據。

大部分低層實現都是使用哈希表或者某種自平衡樹(例如B-樹或者紅黑樹)。有時候數據太大而不裝不進內存,或者必須維持數據謹防系統由於未知緣由而崩潰。在這些狀況下,就必須使用到文件系統。

鍵值對存儲是NoSQL運動的一部分,NoSQL將全部不使用基於關係型數據庫概念的數據庫系統組合在一塊兒。維基百科上的NoSQL詞條很好的總結了這些數據庫的特徵。

  • 不使用SQL查詢語言
  • 可不全面支持ACID(原子性、一致性、隔離性、持久性)。
  • 可提供分佈式、容錯強的結構

 

2. 鍵值對存儲和關係型數據庫

不像關係型數據庫,鍵值對存儲不須要了解值中的數據,也沒有像MySQL或者PostgreSQL中那樣的任何結構。這同時表示像SQL那樣用WHERE語句或者經過任何形式的過濾來請求數據中的一部分是沒法作到的。若是你不知道去哪找,你必須遍歷全部的鍵,獲取它們對應的值,應用某種你須要的過濾,而後保留你想要的東西。這將會須要大量的運算,也即表示只有當鍵已知的時候才能體現出最佳性能,不然鍵值對存儲將沒法勝任(注意:一些鍵值對存儲可以存儲結構化的數據並有字段索引)。

所以,即便鍵值對存儲在訪問速度上常常比關係型數據庫系統性能要好數個數量級,但對鍵已知的需求也限制着其應用。

 

3. 爲何要實現鍵值對存儲

我開始這個項目主要是做爲充電的一種方式,學習和補充一些核心後端基本原理知識。讀書和維基上的文章很無聊而且沒有練習,所以我認爲着手開始作而且實際寫一寫代碼會更好。我要找的是一個可讓我複習以下內容的項目:

  • C++編程語言
  • 面向對象設計
  • 算法和數據結構
  • 內存管理
  • 多進程或或多線程的併發管理
  • 服務器/客戶端模式的網絡
  • 磁盤訪問的I/O問題和文件系統的使用

一個使用文件系統做爲永久存儲,且提供網絡接口的鍵值對存儲將會包含上面列出的所有範圍的內容。這個項目恰好可以處理後端工程的各個領域。可是讓咱們面對現實。市面上已經有了大量的鍵值對存儲,其中一些是由很聰明的人實現的,而且已經在大公司的生產環境使用了。這包括Redis, MongoDB, memcached, BerkeleyDB, Kyoto Cabinet 和LevelDB。

除此以外,近期出現了關於鍵值對存儲的潮流。好像每人都有一個而且想給你們看本身的鍵值對存儲系統有多麼出色和快速。這個問題在Leonard Lin博客中關於鍵值對存儲的文章中描述了。這些項目中大多數在那時還不成熟且不能應用於生產環境,但人們仍然想展現出來。在博客文章或會議幻燈片中常常能夠看到對一些晦澀鍵值對存儲系統性能的比較。這些圖表基本上毫無心義,而且只是在本身的硬件上用本身的數據和應用進行的孤立測試,能夠告訴你哪種鍵值對存儲最適用於解決你的問題。這裏是性能所依賴的條件:

  • 硬件
  • 使用的文件系統
  • 實際應用和具體哪些鍵會被訪問(引用的局部性
  • 數據集,特別是鍵和值的長度,以及使用哈希表的時候鍵碰撞的可能性。

所以,編寫一個鍵值對存儲系統並有必定的影響力是比較難的,由於其頗有可能由於其它已存在的更好的鍵值對存儲系統的存在而被忽視,或者被簡單的淹沒在半生不熟的業餘項目中而沒人關心。

爲了差別性,這個項目不能像其餘人作的那樣爲了速度,而必須瞄準於填補現有解決方案間的空隙。這裏是我發現的可以讓鍵值對項目脫穎而出的幾個方法。

  • 適應於某種特定數據類型(例如:圖片,地理數據等)
  • 適應於某種特定操做(例如讀取性能特別好或者寫入性能特別好等)
  • 適應於某種特定問題 (例如:自動參數調節,不少鍵值對存儲都有不少選項,而找到一個最好的參數設置有時候很棘手)
  • 提供更多數據訪問選項。以LevelDB爲例,數據能夠向前或者向後訪問,有迭代器,是按照鍵排序的。並非全部的鍵值對存儲都能作到這樣。
  • 使本身的實現更平易近人:如今,不多有鍵值對存儲系統有徹底的代碼。若是你須要快速搭建一個項目,而你必須爲其自定義一個鍵值對存儲。即使不是一個廣爲人知的項目,有代碼的解決方案看起來確實平易近人而且會做爲選項之一。實際上理解代碼並相信這個解決方案會彌補這些不足。
  •  明確應用。這兒有一個實際問題的例子:不少網絡爬蟲框架(網絡蜘蛛)有一個粗劣的接口來管理他們須要爬的URL,這常常使得客戶使用鍵值對存儲來實現邏輯。全部的網絡爬蟲框架都能因一個統一的URL優化的鍵值對存儲而受益。

 

4. 計劃

項目的目標是用易於理解的C++代碼開發一個輕量級鍵值對存儲。事實上,我打算在本項目中聽從Google C++ 代碼風格導引。我將會使用哈希表做爲底層數據結構,數據將會存儲在硬盤上,同時將會實現一個網絡接口。我不會項目進度而匆忙完成,而是要在設計和實現時簡潔和清晰。我一樣會盡我能力最小化硬盤上數據庫文件的空間佔用。

我不想從新發明輪子,因此我會從查看別的C或者C++的鍵值對存儲項目開始,而後從中選取比較出色的。我會逐漸學習他們的結構和代碼,從中獲取啓示。後端工程是個人核心技能之一,我已經有了這個項目所需的大部分知識,但我知道我還要學不少新東西,這使其對我來講更加有意思。我一樣樂於記錄下其中的所有東西。之前我很喜歡逛核心技術博客,例如Alexander SandlerGustavo Duarte,我也想貢獻出一些有用的,儘量好的東西。

個人研究結果和鍵值對存儲的一些工做將在這個文章系列中記錄。不要試圖用文章的日期來推測鍵值對存儲實現的時間:文章可能和實際研究或者作的事之間有至關大的延遲。

在第二部分,我將搜索頂級的鍵值對存儲項目並解釋爲何我選擇了其中的部分做爲參考,而不選另外一些。其餘的文章你能夠參考本系列的目錄

你能夠在下邊的「引用」一節中找到一些文章和書籍章節來學習更多關於鍵值對存儲的知識。在閱讀第二節以前,我強烈建議至少讀一下The NoSQL Ecosystem和 Key Value Stores: A Practical Overview

 

5. 參考文獻




實現鍵值對存儲(二):以現有鍵值對存儲爲模型


本文中,開頭我會解釋使用現有模型而非重頭開始此項目的緣由。我會闡述一系列選擇鍵值對存儲模型的標準。最後我將對一些廣爲人知的鍵值對存儲項目作一個概述,並用這些標準選擇其中一些做爲模型。本文將包含:

1. 不從新發明輪子
2. 備選模型和選擇標準
3. 選擇的鍵值對存儲的概述
4. 參考文獻

 

1. 不從新發明輪子

鍵值對存儲已經被人們唱好至少30年了[1]。最著名的一個項目是DBM,Kenneth Thompson爲Unix第七版編寫的最先的數據庫管理器並在1979年發佈[2]。工程師們遇到了和這些數據庫系統相關的一些問題,並選擇或放棄了各類設計和數據結構的想法。對實際生活中的問題進行試驗並從中學習。若是不考慮他們的工做並從頭開始是很愚蠢的,只會重複他們以前所犯過的錯誤。John Gall的系統學中的Gall定理:

任何能夠運做的複雜系統都是從能夠運做的簡單系統發展而來的。其逆命題一樣是真命題:由沒法正常運做的系統設計而來的複雜系統是不可能正常運做的。你必須重頭再來,從一個可運做的簡單系統開始。

這段引述爲個人鍵值對存儲項目開發帶來了兩個基礎思想。

1. 使用模型。我須要識別出那些存在了一段時間的鍵值對存儲,甚至更進一步,先前成功的鍵值對存儲的繼任者。這是其可靠設計的證實,並隨着時間在迭代中凝練。這些選擇過的建築的存儲應該做爲我如今正在工做的項目的模型。

2.起點小。這個項目的初版必須小且簡單,這樣它的設計就能簡單的測試並經過。若是須要的話,改進和額外功能必須在後續版本中加入。

 

2. 待選模型和選擇標準

在對鍵值對存儲和NoSQL數據庫作過一點研究後,我決定將下面的幾個做爲進一步選擇的選項:

  • DBM
  • Berkeley DB
  • Kyoto Cabinet
  • Memcached and MemcacheDB
  • LevelDB
  • MongoDB
  • Redis
  • OpenLDAP
  • SQLite

選擇標準以下:

  • 我想使用面向對象編程來建立鍵值對存儲,因此在設計上,我必須從由面嚮對象語言編寫的項目中汲取靈感。
  • 至於底層數據結構,我想要一個存在硬盤上的哈希表,因而我須要選擇一個提供讀寫信息到硬盤上的方法的項目。
  • 我一樣想讓這個數據存儲可以有網絡接入。
  • 我不須要查詢引擎或者方法來訪問結構化的數據.
  • 沒必要徹底支持ACID規範。
  • 鑑於這個項目是我本身弄的,我想使用那些由小團隊實現的項目模型,理想狀況下是一兩我的。

3. 所選鍵值對的概覽

三個獲選的模型是Berkeley DB、Kyoto Cabinet 和LevelDB。Berkeley DB和Kyoto Cabinet做爲DBM的繼任者有着相同的歷史。此外,Berkeley DB 和 Kyoto Cabinet 並不是「第一版」。這表示他倆與其餘初次實現的鍵值對存儲項目比較更加可靠。LevelDB則更加現代,並基於LSM樹的數據結構,其對於哈希表模式來講是無用的。然而其代碼是我見過最乾淨的。這三個項目都是由一兩我的開發的。下面是他們各自的詳細信息。

Berkeley DB

Berkeley DB的開發始於1986年,這表示我開始寫這篇文章的時候它已經存在了26年了。Berkeley DB是做爲DBM的繼任者而開發的,並實現了一個哈希表。初版是由Margo Seltzer [22] 和 Ozan Yigit [23] 在加州大學伯克利分校的時候編寫的。這個項目後來被Oracle得到,並由其繼續開發。

Berkeley DB最初是由C實現的,而且如今仍然是隻用C。其經過增量過程開發的,就是說在每一個主版本增長新的功能。Berkeley DB從一個簡單的鍵值對存儲,進化到管理並行訪問、事務及復原、及同步功能[4]。Berkeley DB的使用很是普遍,有着數億已部署的拷貝[5],這是能夠相信其架構及其可靠的證據。關於其設計的更多信息能夠在「Berkeley DB Programmer’s Reference Guide[6] 的介紹和「The Architecture of Open Source Applications, Volume 1」 [5]的開頭中找到。

Kyoto Cabinet

Kyoto Cabinet在2009年由Mikio Hirabayashi [24] 引進。其如今仍在積極進化中。Kyoto Cabinet是同一個做者的其它鍵值對存儲:Tokyo Cabinet (2007發佈) 和QDBM (2003發佈, 2000開始)的繼任者。QDBM打算做爲DBM的高性能繼任者[7]。Kyoto Cabinet尤爲有意思,由於它有着DBM的純正血統,而且它的做者在鍵值對存儲方向工做12年了。在浸淫三個鍵值對存儲這麼多年以後,沒有理由懷疑做者有着對結構需求的堅實理解,以及隨之的對性能瓶頸的成因的極強認識。

Kyoto Cabinet是由C++實現的,並實現了一個哈希表,一個B+樹,以及其餘一些深奧的數據結構。其一樣提供了出色的性能[16]。然而,因其內部參數的緣由,彷佛有些性能問題。的確,不少人報道說只要數據條目的數量保持在某一特定的閾值(正比於桶數組大小,其由建立數據庫文件時的參數所肯定)如下,性能就很好。一旦超過這個閾值,性能彷佛急劇降低[18][19]。Tokyo Cabinet [20] [21] 中也有相同的問題。這表示若是某項目的需求在數據庫使用的時候改變,你可能會遇到嚴重的問題。而咱們都知道,軟件中的改變是如此的頻繁。

LevelDB

LevelDB是由Google職員Jeffrey Dean [8] 和 Sanjay Ghemawat [9] 開發,他們爲Google傳說中的基礎建設項目MapReduce和BigTable工做。基於Dean和Ghemawat在在Google工做時得到的大規模問題上的經驗,他們頗有可能很瞭解他們正在作的東西。和大多數鍵值對存儲項目相比,LevelDB有一個頗有意思的不一樣點就是它不用哈希表或者B-樹做爲底層數據結構,而是基於一個日誌結構的合併樹[12]。LSM結構聽說是爲SSD硬盤優化的[13]。你能夠在這個博客High Scalability blog [17]找到成噸的關於LevelDB的信息。

LevelDB是由C++實現,2011年發佈,並設計做爲高級存儲系統的一部分[10]。IndexedDB HTML5 API在Chrome未來版本的實現將使用LevelDB [10] [11]。其性能決定於特定的工做負載,就像做者提供的基準測試中顯示的那樣[14]。然而,Andy Twigg在Acunu的另一個基於商用SSD的基準測試顯示出,若是數據的條數超過1e6(1百萬),並向1e9(10億)前進的時候,性能將會顯著降低[15]。所以彷佛LevelDB彷佛並非重工做負載或像實際後端項目需求那樣的大數據庫最好的選擇。

但這其實並不重要,對於我來講,LevelDB最好的部分不是其性能而是其架構。看它的源代碼和東西組織的方式,那是純粹的美。全部的東西都很清晰、簡單、條理分明。訪問LevelDB的源代碼並把它做爲模範是建立出色代碼的絕好機遇。

那些沒選中的鍵值對存儲是什麼狀況?

沒有選擇其餘鍵值對存儲的緣由並不表示我徹底拋棄他們。我會記得他們並可能偶爾使用他們結構中元素。可是,當前項目受到這些鍵值對項目影響不會像已選擇的這些那麼多。

4. 參考文獻

[1] http://blog.knuthaugen.no/2010/03/a-brief-history-of-nosql.html
[2] http://en.wikipedia.org/wiki/Dbm
[3] http://en.wikipedia.org/wiki/Systemantics
[4] http://en.wikipedia.org/wiki/Berkeley_DB#Origin
[5] http://www.aosabook.org/en/bdb.html
[6] http://docs.oracle.com/cd/E17076_02/html/programmer_reference/intro.html
[7] http://fallabs.com/qdbm/
[8] http://research.google.com/people/jeff/
[9] http://research.google.com/pubs/SanjayGhemawat.html
[10] http://google-opensource.blogspot.com/2011/07/leveldb-fast-persistent-key-value-store.html
[11] http://www.w3.org/TR/IndexedDB/
[12] http://www.igvita.com/2012/02/06/sstable-and-log-structured-storage-leveldb/
[13] http://www.acunu.com/2/post/2011/04/log-file-systems-and-ssds-made-for-each-other.html
[14] http://leveldb.googlecode.com/svn/trunk/doc/benchmark.html
[15] http://www.acunu.com/2/post/2011/08/benchmarking-leveldb.html
[16] http://blog.creapptives.com/post/8330476086/leveldb-vs-kyoto-cabinet-my-findings
[17] http://highscalability.com/blog/2011/8/10/leveldb-fast-and-lightweight-keyvalue-database-from-the-auth.html
[18] http://stackoverflow.com/questions/13054852/kyoto-cabinet-berkeley-db-hash-table-size-limitations
[19] https://groups.google.com/forum/#!topic/tokyocabinet-users/Bzp4fLbmcDw/discussion
[20] http://stackoverflow.com/questions/1051847/why-does-tokyo-tyrant-slow-down-exponentially-even-after-adjusting-bnum
[21] https://groups.google.com/forum/#!topic/tokyocabinet-users/1E06DFQM8mI/discussion
[22] http://www.eecs.harvard.edu/margo/
[23] http://www.cse.yorku.ca/~oz/
[24] http://fallabs.com/mikio/profile.html




實現鍵值對存儲(三):Kyoto Cabinet和LevelDB的架構比較分析


在本文中,我將會逐組件地把Kyoto Cabinet 和 LevelDB的架構過一遍。目標和本系列第二部分講的差很少,經過分析現有鍵值對存儲的架構來思考我應該如何創建我本身鍵值對存儲的架構。本文將包括:

1. 本架構分析的意圖和方法
2. 鍵值對存儲組件概覽
3. Kyoto Cabinet 和LevelDB在結構和概念上的分析
3.1 用Doxygen創建代碼地圖
3.2 總體架構
3.3 接口
3.4 參數化
3.5 字符串
3.6 錯誤管理
3.7 內存管理
3.8 數據存儲
4. 代碼審查
4.1 聲明和定義的組織
4.2 命名
4.3 代碼重複
5. 參考文獻

1. 本架構分析的意圖和方法

我曾經想過是應該寫兩篇獨立的文章,一篇寫LevelDB另外一篇寫Kyoto Cabinet,仍是應該寫一篇綜合的文章。我相信軟件架構是一門很須要決策的技藝,就如同建築師須要考慮並選擇每一個部分的設計同樣。方案不能孤立的評估,而應該與其餘方案之間進行權衡。軟件系統架構的分析只能根據其背景在評價,並與其餘架構比較。所以我將把鍵值對存儲中遇到的主要組件過一遍,並比較現有鍵值對系統的方案。我將會爲Kyoto Cabinet 和 LevelDB使用我本身的分析,但其餘項目我會使用現有的分析。這裏是我選用的其餘人的分析:

– BerkeleyDB, Chapter 4 in The Architecture of Open Source Applications, by Margo Seltzer and Keith Bostic (Seltzer being one of the two original authors of BerkeleyDB) [1]
– Memcached for dummies, by Tinou Bao [2]
– Memcached Internals [3]
– MongoDB Architecture, by Ricky Ho [4]
– Couchbase Architecture, by Ricky Ho [5]
– The Architecture of SQLite [6]
– Redis Documentation [7]

2. 鍵值對存儲組件概述

儘管鍵值對存儲的內部架構有很大不一樣,但總有類似的組件。下面列出了大部分鍵值對存儲中遇到的主要組件及其功能的簡述。

接口:鍵值對存儲暴露給用戶的一組方法和類,使用戶能夠與之互動。也叫作API。鍵值對存儲的最小API包括Get(),、Put() 和Delete()方法。

參數系統:選項設置並傳遞給整個系統的其餘組件。

數據存儲:接口是用來訪問內存中數據(也就是鍵和值)的。若是數據必須維護在持久性存儲器中,例如硬盤或閃存,那麼可能會出現同步性問題和併發性問題。

數據結構:用算法和方法來組織數據,並容許高效的存儲的檢索。一般使用哈希表或者B+樹。LevelDB中則是日誌結構合併樹。數據結構的選擇基於數據的內部結構和底層數據存儲方案。

內存管理:系統中用來管理內存的算法和技術。內存至關重要,若是數據存儲用錯誤的內存管理技術來訪問,會極大地影響性能。

遍歷:對數據庫中全部鍵和值進行枚舉和順序訪問的方法。解決方案大可能是迭代器和遊標。

字符串:數據結構是用來訪問字符串的。把字符串單獨拿出來講或許看起來有些過度詳細了,但對於鍵值對存儲來講,大量的時間都用來傳遞和處理字符串,STL的std::string可能不是最佳方案。

鎖管理:全部關係到併發訪問(帶有信號燈和互斥的)內存區鎖的機制,以及當數據存儲是文件系統時的文件鎖。同時處理關於多線程的問題。

錯誤管理:用來攔截和處理系統中遇到的錯誤的技術。

日誌:記錄系統中發生的事件的機制。

事務管理:可以確保全部操做正常執行的一系列操做的機制,而且在出現錯誤時,確保沒有操做被執行且數據庫也沒有更改。

壓縮:用來壓縮數據的算法

比較器:用來比較兩個鍵是否相同的方法。

校驗和:用了測試並確保數據的完整性。

快照:快照提供其建立時所有數據庫的只讀鏡像。

分區:也被稱爲分片,其包括將整套數據分配到多個數據存儲中,多是網絡中的多個節點。

數據備份:爲了防止系統或者硬件錯誤,確保持久性,一些鍵值對存儲容許數據(或者數據分區)有數個同時維護的拷貝,最好是在多個節點上。

測試框架:用來測試系統的框架,包括單元測試和總體測試。

3. Kyoto Cabinet和LevelDB結構和概念的分析

下述關於LevelDB和Kyoto Cabinet的分析將集中在下列組件:參數系統、數據存儲、字符串和錯誤管理。關於接口、數據結構、內存管理、日誌和測試框架這些組件將包含在IKVS系列以後的文章中。至於其餘的組件,我目前不打算講。其餘系統,例如關係型數據庫,有其餘的諸如命令處理器、請求處理器、以及計劃/優化器之類的組件,但它們已經超出了IKVS系列的內容。

在我開始分析以前,請注意我認爲Kyoto Cabinet 和 LevelDB是很出色的軟件部分,我也很尊敬它們的做者。即使我說了關於他們的設計的壞話,要記得的是他們的代碼仍然很出色,而我並無像他們那樣的才華。這就是說,下邊的文章是我對於Kyoto Cabinet 和 LevelDB代碼的一點意見。

3.1 用Doxygen創建代碼圖

爲了理解Kyoto Cabinet 和LevelDB的架構,我須要挖掘它們的代碼。可是我也用Doxygen,一個用來瀏覽應用模塊結構和類的很是強大的工具。 Doxygen是一個適用於多個編程語言的文檔系統,它能夠直接從源代碼中建立報告文檔或者HTML網站格式的文檔。然而Doxygen一樣能夠用在沒有註釋的代碼中,並建立基於系統組織方式(文件、命名空間、類和方法)的接口。

你能夠從官網上得到Doxygen [8]。在你機器上安裝好Doxygen以後,只須要打開shell界面,到包含全部你須要分析的源代碼的目錄下。而後輸入以下命令便可建立默認設置文件。

這將建立一個叫「Doxygen」的文件。打開這個文件,確認下述全部設置都設置爲「yes」:EXTRACT_ALL, EXTRACT_PRIVATE, RECURSIVE, HAVE_DOT, CALL_GRAPH, CALLER_GRAPH。這些選項會保證從代碼中抽取全部對象,包括子目錄,並建立調用圖。全部可用設置的描述能夠在Doxygen的在線文檔中找到[9]。只須要輸入下面的命令便可用已選好的設置來建立文檔。

文檔將在「html」文件夾中建立,你能夠用任何web瀏覽器打開「index.html」文件來訪問文檔。你能夠瀏覽代碼,查看類之間的繼承關係,並經過圖來查看每一個方法由其它哪一個方法調用。

3.2 總體架構

圖3.1和3.1分別是Kyoto Cabinet v1.2.76 和LevelDB 1.7.0的架構。類以UML類圖標準表示。組件以圓角矩形表示,黑箭頭表示其它實體調用了這個實體。從A到B的黑箭頭表示A使用或者訪問了B的元素。

這些圖示表示的功能架構和結構架構基本相同。以圖3.1爲例,不少組件出如今HashDB類內部,因其這些組件的代碼被定義爲HashDB類的一部分。

依據內部組件的組織方式來比較,LevelDB是大贏家。緣由是Kyoto Cabinet中,遍歷、參數設置、內存管理和錯誤管理的組件都做爲內核/接口組件的一部分,如圖3.1所示。這使得這些組件和內核之間造成了強耦合,並侷限了系統的模塊化和功能擴展性。與之相反,LevelDB是以一種很是模塊化的方法創建的,只有內存管理纔是內核組件的一部分。

 圖3.1

圖3.2

 

3.3 接口

Kyoto Cabinet 的HashDB類暴露出來至少50個方法,與之相比的是LevelDB的DBImpl類只有15個方法(其中4個仍是測試用的)。這是Kyoto Cabinet的Core/Interface組件強耦合的直接結果。

API設計將會在未來的IKVS系列中詳細討論。

3.4 參數設置

在Kyoto Cabine中,參數是經過調用HashDB類的方法來調節的。有15個以「tune_」開頭的方法來完成這個工做。

在LevelDB中,參數被定義在特定的對象中。「Options」對象中是通用參數,「ReadOptions」和「WriteOptions」中是Get()和Put()分別須要的參數,如圖3.2中所示。種子解耦提供了比較好的選項的擴展性,而沒必要像Kyoto Cabinet中調用Core中亂七八糟的公共接口。

3.5 字符串

在鍵值對存儲中,隨時都有大量的字符串處理。字符串被迭代、哈希、壓縮、傳遞和返回。所以,巧妙的實現字符串類至關重要,每一個對象節省一點,在大規模的運用上將會在全局形成引人注目的影響。

LevelDB使用一個特殊的類,稱爲「Slice」 [10]。一個Slice包含一個字節數組以及數組的長度。這能夠在O(1)的時間內獲取字符串的長度,而不是std::string所需的O(n)而不是對C的字符串調用strlen()時所需的O(n)。獨立保存字符串長度也能夠容許保存字符‘’,這表示鍵和值能夠是真正的字節數組而非由null終結的字符串。最後且最重要的是,Slice處理拷貝是經過建立一個淺拷貝,而非深拷貝。這表示它只簡單地拷貝字節數組的指針,而不像std::string那樣拷貝所有的字節數組。這避免了拷貝有可能出現的很是大的鍵或值。

像LevelDB同樣,Redis使用他本身的數據結構來處理字符串。其目標一樣是避免取字符串長度的時候避免使用O(n)操做[11]

Kyoto Cabinet使用std::string做爲字符串對象。

個人意見是,一個字符串類的實現適應於鍵值對存儲的需求是很是必要的。若是可以避免,爲何要花費時間來拷貝字符串並分配內存呢?

3.6 錯誤管理

在我看過的鍵值對存儲的全部C++源代碼中,我沒有見過一個將異常做爲全局的錯誤管理系統使用。在Kyoto Cabinet中,kcthread.cc文件中的線程組件使用了異常,但我認爲這個選擇與其說是通用架構倒不如說是隻是在處理線程而已。異常十分危險,並應該儘量的避免。

BerkeleyDB有很好的C風格的方法來處理錯誤。錯誤信息和代碼集中在一個文件中。全部返回錯誤代碼的函數都有一個叫「ret」的整型本地變量,這個變量將會在處理過程當中賦值並在最後返回。這種方法貫穿在全部的文件和模塊中:至關優雅和標準化的錯誤管理。在一些函數中使用了向前跳轉的goto語句——一種在如Linux內核那樣的純C系統中普遍使用的技巧[12]。雖然這種方法十分簡潔和乾淨,但C風格的錯誤管理方法不太適合C++應用。

Kyoto Cabinet中,錯誤對象存儲在每一個諸如HashDB的數據庫對象中。在數據庫類中,各個方法在出現錯誤的時候調用set_error()來設置錯誤對象,而後以很符合C風格的返回true或者false。不會像BerkeleyDB那樣在方法末尾返回本地變量,返回語句出如今錯誤出現的地方。

LevelDB徹底不使用異常,而是使用一個叫作Status的類。這個類有錯誤值和錯誤信息。每一個方法都返回這個對象,這樣錯誤狀態既能夠就地處理也能夠傳遞給調用棧中更高的其餘方法。這個Status類錯誤碼存儲在字符串中,也是一種很是的聰明的實現。我對於這種設計方法的理解是,在大部分時間裏,方法將會返回一個「OK」的狀態(Status)對象,以表示沒有出現任何錯誤。這樣,錯誤信息字符串是NULL,而這個Status對象的處理是至關輕量的。若是Status對象增長一個屬性來保存錯誤碼,那麼即使在「OK」狀態的Status對象中仍須要給這個屬性賦值,這即表示在每次調用方法的時候都要用更多的空間。全部的組件都使用這個Status類,而且不必像Kyoto Cabinet那樣總要調用一個方法,如圖 3.1 and 3.2所示。

錯誤管理的全部方案都在上文中講過了,我我的比較推薦LevelDB使用的方案。這個方案避免使用了異常,也不是一個我看來至關侷限的單純的C風格的錯誤管理,而且其避免了像Kyoto Cabinet那樣與核心組件任何沒必要要的耦合。

3.7 內存管理

Kyoto Cabinet 和LevelDB都在內核組件中定義了內存管理。對於Kyoto Cabinet,內存管理一來能夠跟蹤數據庫文件中臨近的空塊,二來當數據項保存的時候能夠選擇足夠大小的塊。而文件自己只是用mmap()函數映射出來的內存空間。另外MongoDB也使用內存映射文件[13]

而LevelDB使用的是一個日誌結構合併樹,其不像保存在硬盤上的哈希表那樣文件中有未使用的空間。內存空間管理也包括一旦日誌文件大小超過某值後,壓縮這些文件的功能[14]

其它如Redis之類的鍵值對存儲,用malloc()來分配內存——在Redis的例子中,內存分配算法不是操做系統提供的dlmalloc或者ptmalloc3,而是jemalloc[15] 。

3.8 數據存儲

Kyoto Cabinet, LevelDB, BerkeleyDB, MongoDB 和Redis使用文件系統來存儲數據。與之相反Memcached 則是在內存中保存數據。

4. 代碼審查

本節是對Kyoto Cabinet 和LevelDB的一個簡單的代碼審查。這個代碼審查並不全面,並只包含了我在閱讀源代碼時以爲比較出色的元素。

4.1  聲明和定義的組織

若是代碼都像LevelDB那樣正常的組織,聲明都在.h頭文件中,而定義都在.cc文件中。但我在Kyoto Cabinet中發現了一些使人震驚的事情。實際上,不少類中.cc文件並無包含任何定義,而方法都直接在.h文件中定義。在其餘文件中,一些方法在.h中定義另外一些在.cc文件中定義。雖然我理解這樣作的背後可能有一些緣由,但我仍認爲在C++應用中不遵照這些慣例根本是錯誤的。之因此說是錯的是由於一來它讓我像那樣驚訝,二來我必須在兩種不一樣的文件中找定義。

4.2 命名

首先,Kyoto Cabinet相對於Tokyo Cabinet.有了顯著的改進。總體架構和命名規則都大幅改進了。儘管如此,我仍然發現Kyoto Cabinet中的不少名字都很晦澀,譬如屬性和方法叫作embcomp、trhard、fmtver()、fpow()。這讓人以爲C++代碼中混進了一些C代碼。另外一方面,LevelDB中的命名至關清晰,除了諸如mem、imm和in的一些臨時變量。但這些不清晰的密碼至關微量而代碼可讀性至關強。

4.3 代碼重複

我在Kyoto Cabinet中確實看到了一些代碼重複。這些用來文件碎片整理的代碼至少重複了3次,而全部須要分爲Unix和Windows兩個版本的方法都顯示出大量的重複。我沒有在LevelDB看到明顯的代碼重複,我相信應該也有一些,但須要挖掘的更深才能找到。這證實LevelDB的代碼重複問題確實比Kyoto Cabinet要小。

5. 參考文獻

[1] http://www.aosabook.org/en/bdb.html
[2] http://work.tinou.com/2011/04/memcached-for-dummies.html
[3] http://code.google.com/p/memcached/wiki/NewUserInternals
[4] http://horicky.blogspot.com/2012/04/mongodb-architecture.html
[5] http://horicky.blogspot.com/2012/07/couchbase-architecture.html
[6] http://www.sqlite.org/arch.html
[7] http://redis.io/documentation
[8] http:://doxygen.org
[9] http://www.stack.nl/~dimitri/doxygen/config.html
[10] http://leveldb.googlecode.com/svn/trunk/doc/index.html
[11] http://redis.io/topics/internals-sds
[12] http://news.ycombinator.com/item?id=3883310
[13] http://www.briancarpio.com/2012/05/03/mongodb-memory-management/
[14] http://leveldb.googlecode.com/svn/trunk/doc/impl.html
[15] http://oldblog.antirez.com/post/everything-about-redis-24.html




實現鍵值對存儲(四):API設計



我終於爲這個鍵值對存儲項目肯定了一個名字,從如今開始我將叫它FelixDB KingDB。(譯註:改爲這麼土的名字也是醉了)

在本文中,我將對帶着你們看一看四個鍵值對存儲和數據庫系統的API:LevelDB, Kyoto Cabinet, BerkekeyDB 和 SQLite3。對於其API中的每一個主要功能,我將會比較他們的命名習慣和方法原型,以平衡其優缺點併爲正在開發的鍵值對存儲KingDB設計API。本文將包括:

  1. API設計的通常準則
  2. 定義KingDB公共API的功能
  3. 比較現有數據庫的API
    3.1 打開和關閉數據庫
    3.2 讀寫操做
    3.3 遍歷
    3.4 參數處理
    3.5 錯誤管理
  4. 結論
  5. 參考文獻

1.API設計的通常準則

設計一個好的API很難,至關難。但我在這說的不是什麼新東西,而只是在重複以前不少人告訴個人東西。到目前爲止我發現的最好的資料是Joshua Bloch的演講「How to Design a Good API & Why it Matters(如何設計一個好的API及爲何這很重要)」[1],及其摘要版本[2]。若是你尚未看過這個演講,我強烈建議你找時間去看一下。在這個演講中,Bloch清晰的陳述了聽衆須要記住的兩個很重要的東西。我複製了摘要版本的要點並添加了一些評論:

  1. 不肯定的時候,先放一邊。當不肯定某功能、類、方法或參數是否要添加在API中的時候,不要添加。
  2. 不要讓用戶作庫能夠作的事情。若是你的API讓客戶執行一系列函數調用的時候,須要將每一個函數的輸出塞到下一個函數的輸入裏,那你應該在API中添加一個函數來執行這一系列的函數調用。

另外一個關於API設計的好資源是Joshua Bloch寫的《Effective Java》4和Scott Meyers寫的《Effective C++》3第四章「Designs and Declarations」。

這些資源對於當前階段的這個鍵值對存儲項目來講十分重要,儘管我以爲這些資源沒有包含一個很重要的因素:用戶指望。將API從草圖上設計出來是很難的,但這個鍵值對存儲的例子來講,是有例可循的。用戶一直和他們的鍵值對存儲或數據庫系統的API打交道。所以,當面對一個新的鍵值對存儲的時候,用戶但願有一個相似的環境,而不關心這潛規則只會提升用戶對新API的學習曲線,並讓用戶不高興。

鑑於這個緣由,即使我牢記上文列出的這些資料中的全部好建議,但我仍認爲我必須儘量多的複製已有庫的API,由於這能夠在用戶使用我正建立的API時更簡單。

2.定義KingDB公共API的功能

考慮到這只是萬里長征的第一步,我打算實現一個最小且可靠的鍵值對存儲,我固然不會包含全部的,像Kyoto Cabinet 和LevelDB那樣的成熟項目提供的高級功能。我打算先讓基本功能實現,而後我將逐漸增長其餘功能。對於我來講,基本功能嚴格限制在:

  • 打開和關閉數據庫
  • 讀寫數據庫
  • 遍歷數據庫中全部的鍵值對集合
  • 提供參數調整的方法
  • 提供一個合適的錯誤通知接口

我意識到這些功能對於一些用例來講過於侷限了,但暫時應該對付的過來。我不打算添加任何事務機制、分類查詢、或原子操做。一樣,如今我不打算提供快照功能。

3.比較現有數據庫的API

爲了比較現有數據庫的C++ API,我將會比較每一個功能的示例代碼。 這些示例代碼是修改自或直接取自於官方代碼「Fundamental Specifications of Kyoto Cabinet」 [5], 「LevelDB’s Detailed Documentation」 [6], 「Getting Started with Berkeley DB」 [7], 和 「SQLite in 5 minutes or less」 [8]。 我一樣會使用不一樣的顏色來標示來自不一樣的API。

3.1 打開和關閉數據庫

下述示例代碼顯示出研究的系統是如何打開數據庫的。爲了更清晰的顯示代碼原理,選項設置和錯誤管理沒有在此顯示,而且會在下述各節中解釋更多的細節。

 

在打開數據庫部分出現了兩種清晰的模式。一方面,LevelDB 和SQLite3的API請求建立一個數據庫對象的指針(句柄)。而後調用打開函數的時候將這個指針的引用做爲參數,以定位對象的內存空間,而後設置這個數據庫對象。另外一方面,Kyoto Cabinet 和Berkeley DB的API以實例化一個數據庫對象爲開始,而後調對象的用open()方法來設置這個數據庫對象。

說到關閉數據庫部分,LevelDB只須要請求刪除指針就好了,但SQLite3必須調用關閉函數。Kyoto Cabinet 和BerkeleyDB的數據庫對象自身有一個close()方法。

我相信像LevelDB 和SQLite3那樣強制使用數據庫對象的指針,而後將指針傳遞給打開函數是很「C風格」的。另外,我認爲LevelDB處理關閉的方法—經過刪除指針—是一個設計缺陷。由於這會致使API的不對稱。在API中,函數的對稱應該儘量的對稱,由於這樣更加直觀和邏輯。「若是我調用了open() 那我就應該調用close()」的想法比「若是我調用了open() 那我就應該刪除指針」的想法合乎邏輯一萬倍。

設計決策

所以我決定使用在KingDB上的是相似於Kyoto Cabinet 和Berkeley DB的,先實例化一個數據庫對象,而後調用對象的Open() 和Close()方法。至於命名,我仍使用傳統的Open() 和Close()。

 

3.2 讀寫

在本節,我比較他們讀寫功能的API。

我不會考慮SQLite3的設計,由於其是基於SQL的,所以其讀寫是經過SQL請求進行的,而非方法調用。Berkeley DB請求Dbt類對象的建立,並在上面進行一大堆設置,所以我也不會考慮這個設計。剩下的只有LevelDB 和Kyoto Cabinet,而他們有很漂亮的getter/setter對稱接口。LevelDB 有Get() 和Put(), 而Kyoto Cabinet 有get() 和set()。Setter方法的原型——Put() 和set()十分類似:鍵名是值傳遞,而鍵值是傳遞的指針使得調用時能夠更改。鍵值並不經過調用返回,返回值是給錯誤管理使用的。

設計決策

對於KingDB,我打算使用和LevelDB 及Kyoto Cabinet類似的方法,對於setter方法使用一個類似的原型,即用值傳遞鍵值而用指針傳遞鍵值。至於命名,一開始我以爲Get() 和Set()是最好的選擇,但仔細思考以後我更傾向於LevelDB那樣,使用Get() 和Put()。其緣由是Get/Set 和Get/Put都很對稱,但「Get」 和 「Set」兩個詞太類似,只差了一個字母。所以閱讀代碼的時候使用「Get」 和「Put」會更加清晰且更易辨認,所以我會使用Get/Put。

3.3 遍歷

在上一節中,SQLite3不被考慮是由於其不知足鍵值對存儲的需求。但看看它是如何將一個SELECT請求發送到數據庫,而後在取回來的每一行上調用回調函數是比較有趣的。大多數MySQL 和 PostgreSQL的API用循環並調用一個可以填充本地變量的函數來作到,而非這樣使用一個回調函數。我發現這種回調函數比較棘手,由於這對於那些想執行合計操做或對取回來的行進行計算的用戶來講,會讓事情變得複雜。但這是另外一方面的討論,如今回到咱們的鍵值對存儲上來!

這裏有兩種方法:使用遊標或者使用遍歷器。Kyoto Cabinet 和BerkeleyDB使用遊標,一開始建立一個指向遊標對象的指針並實例化對象,而後在while循環中重複調用遊標的get()方法來獲取數據庫中全部的值。LevelDB使用遍歷器設計模式,一開始建立一個指向遍歷器對象的指針並實例化對象(這部分和遊標同樣),可是使用一個for循環來遍歷集合中的項目。注意這裏的while和for循環只是習慣:遊標可使用for循環而遍歷器也可使用while循環。其主要的不一樣是,在遊標中,鍵和值是指針傳遞而後在遊標的get()方法中填充內容,但在迭代器中,鍵和值是經過迭代器方法的返回值來訪問的。

設計決策

一樣,遊標和其while循環是至關「C風格」的。我發現迭代器的方法更加清晰並更符合「C++風格」,由於這正是C++中STL的集合的訪問方式。所以對於KingDB來講,我選擇使用LevelDB那樣的遍歷器。至於命名,我簡單的複製了LevelDB中的方法名。

3.4 參數處理

參數在IKVS系列文章中第三部分3.4節已經簡要敘述了,但我還想在這提一下。

SQLite3是經過sqlite3_config()修改全局參數,而後在全部後續鏈接創建的時候應用。Kyoto Cabinet 和Berkeley DB中,選項是在調用open()以前經過調用數據庫對象的方法來設置選項的,和SQlite3的作法比較類似。在這些方法之上,更通用的選項是經過open()方法的參數來設置的(見上文3.1節)。這表示選項被分爲兩部分,一些經過方法的調用來設置,而另外一些是經過open()的調用來設置。

LevelDB的作法不大同樣。選項是在本身的類中一塊兒定義,而參數是經過這些類的屬性來更改。以後這些設置類的對象以方法參數的形式傳遞,並老是第一個參數。例如LevelDB數據對象的open()方法的第一個參數是leveldb::Options類的對象,而Get()和Put()方法的第一個參數分別是leveldb::ReadOptions 和leveldb::WriteOptions。這種設計的一個好處是在同時建立多個數據庫的狀況下能夠很簡單的共享設置,儘管在Kyoto Cabinet 和 Berkeley DB的例子中能夠爲一組設置建立一個方法,而後經過調用這個方法來設置這組設定。像LevelDB那樣把設置放到一個特定的類中真正的優點在於,其接口更穩定,由於擴展設置只須要修改這個選項類,而不用修改數據庫對象的任何方法。

儘管我想用這種選項類,但我必須說的是LevelDB這種老是將選項做爲第一個參數在各個方法中傳遞的方式我不是很習慣。若是沒有須要修改的選項,這致使代碼中須要使用默認選項,就像這樣:

這可能致使代碼膨脹,而另外一種多是將選項做爲最後一個參數,而後爲這個參數設定一個缺省值,使得不須要設置選項的時候能夠省掉這項。而另外一種源自於C++的解決方式是函數的重載,有數個帶有原型的方法使其能夠省略掉選項的對象。把選項放到參數的最後對於我來講看上去更符合邏輯,由於其是可能省略的。但我相信LevelDB的做者把選項做爲第一個參數是有很好的緣由的。

設計決策

對於參數處理,我以爲將選項做爲類是最簡潔的方式,同時其符合面向對象設計。

對於KingDB來講,我會像LevelDB那樣使用獨立的類來處理選項,不過我會將做爲方法的最後一個參數。我或許之後能明白將選項做爲最後一個參數是真正正確的方法——或者有誰能幫我解釋下——但如今我堅持將其放到最後。最後,命名子啊這兒不是很重要,所以Options, ReadOption 和WriteOption均可以。

3.5 錯誤管理

在IKVS系列第三部分3.6節,有關於錯誤管理的一些討論,基本上是說用戶看不到的代碼是如何管理錯誤的。本節再次討論這個話題但稍有不一樣,不討論庫中錯誤的細節,而是關於錯誤發生後是怎麼報告給使用公共接口的用戶的。

Kyoto Cabinet, Berkeley DB 和SQLite3使用相同的方法處理錯誤,即其方法返回一個整型的錯誤代碼。如在IKVS系列第三部分3.6節所述,Kyoto Cabinet內部將值設置在數據庫對象中,這就是爲什麼上述示例代碼中,錯誤信息是從db.error().name()取出的。

LevelDB有個一特別的Status類,包含錯誤類型和提供了關於此錯誤更多信息的消息。LevelDB庫中的全部方法都返回了此類的一個對象,這使錯誤測試和將錯誤傳遞給系統各部分以進行進一步的檢查更加簡單。

設計決策

返回錯誤代碼而避免使用C++的異常處理機制是十分正確的,然而整形並不足以攜帶有意義的信息。Kyoto Cabinet, Berkeley DB 和SQLite3都有其本身的存儲錯誤信息的方法,然而即使是在在Kyoto Cabinet 和Berkeley例子中,建立了錯誤管理和數據庫類的強耦合,,仍然會爲取得信息添加額外的步驟。像LevelDB那樣使用一個Status類能夠避免使用C++異常處理,同時也避免了和架構其餘部分的耦合。

4.結論

API的預設比較有意思,由於去看不一樣的工程師如何解決相同的問題老是頗有意思的。這一樣讓我意識到Kyoto Cabinet 和Berkeley DB的API有多麼類似。Kyoto Cabinet 的做者Mikio Hirabayashi清楚地聲明瞭他的鍵值對存儲是基於Berkeley DB的,而在看完API類似性以後這一點更加清晰了。

LevelDB的設計至關好,但我仍是對於一些我認爲能夠以其餘方式實現的細節有些意見。例如數據庫打開和關閉以及方法原型。

我吸收了每一個系統的一點長處,而我如今對於KingDB的API設計的各個選擇感受更加自信了。

 

5.參考文獻

[1] http://www.infoq.com/presentations/effective-api-design
[2] http://www.infoq.com/articles/API-Design-Joshua-Bloch
[3] http://www.amazon.com/Effective-Specific-Improve-Programs-Designs/dp/0321334876
[4] http://www.amazon.com/Effective-Java-Edition-Joshua-Bloch/dp/0321356683
[5] http://fallabs.com/kyotocabinet/spex.html
[6] http://leveldb.googlecode.com/svn/trunk/doc/index.html
[7] http://docs.oracle.com/cd/E17076_02/html/gsg/CXX/index.html
[8] http://www.sqlite.org/quickstart.html



實現鍵值對存儲(五):哈希表實現


在本文中,我將會研究C++中哈希表的實際實現以理解其瓶頸。哈希函數是CPU密集型的而且應該維持而優化。然而,大部分哈希表的內部機制只關心內存效率和I/O訪問,這將是本文主要注意的東西。我將會研究三個不一樣的哈希表的C++實現,既有內存中的又有硬盤上的,並看看數據是怎麼組織和訪問的。本文將包括:

1.哈希表
1.1 哈希表簡介
1.2 哈希函數
2.實現
2.1 TR1的unordered_map
2.2 SparseHash的dense_hash_map
2.3 Kyoto Cabinet的HashDB
3.結論
4.參考文獻

 

1.哈希表

1.1 哈希表簡介

哈希表能夠認爲是人類所知最爲重要的數據結構 .
— 斯蒂夫 耶奇

哈希表能夠高效的訪問關聯數據。每一個條目都有一對對應的鍵名鍵值,而且能僅經過鍵名來快速的取回和賦值。爲了達到這個目的,鍵名經過哈希函數進行哈希,以將鍵名從原始形式轉換爲整數。此整數以後做爲索引來獲得要訪問的條目的值所在的bucket在bucket數組中的地址。不少鍵名能夠被哈希爲相同的值,這表示這些key在bucket數組中回出現碰撞。有數種方法解決碰撞,如使用鏈表的分離鏈表separate chaining 亦稱開鏈單獨鏈表)或自平衡二叉樹或線性或者二次探測的開放尋址

從如今開始,我默認你知道什麼是哈希表。若是你認爲本身須要溫習一下知識,Wikipedia的「Hash table」詞條[1](及其底部的擴展連接一節)和Cormen 等人寫的Introduction to Algorithms一書中Hash table一章[2]都是很好的參考文獻。

1.2 哈希函數

哈希函數的選擇至關重要。一個好哈希函數的基本需求是輸出的哈希值比較均勻。這樣可使碰撞的發生最小化,同時使得各個bucket中碰撞的條目比較平均。

可用的哈希函數有不少,除非你確切的知道數據會變成什麼樣子,最安全的方法是找一個可以將隨機數據分佈均勻的哈希函數,若是可能的話符合雪崩效應[3]。有少數人對哈希函數作過比較[4] [5] [6] [7],而他們的結論是MurmurHash3 [8]和CityHash [9] 是在寫本文的時候最好的哈希函數。

2.實現

和哈希函數的比較同樣,只有不多比較各個C++的內存哈希表庫性能的博文。我見到的最出名的是Nick Welch 的「Hash Table Benchmarks」 [10],和Jeff Preshing 的「Hash Table Performance Tests」 [11]。而其餘文章也值得一看[12] [13] [14]。從這些比較中,我發現兩個研究起來比較有意思的部分:GCC的TR1的unordered_map和SparseHash 庫(之前叫Google SparseHash)的dense_hash_map,我將會在下文中介紹他們。另外,我一樣會描述Kyoto Cabinet中HashDB的數據結構。顯然由於unordered_map 和dense_hash_map是內存哈希表,不會像HashDB那樣和個人鍵值對存儲相關。儘管如此,稍微看一下其內部數據結構的組織和其內存模式也是頗有意思的。

在下述三個哈希表庫的描述中,個人通用示例是把一組城市名做爲鍵名其各自的GPS座標做爲鍵值。unordered_map的源代碼能夠在GCC代碼中做爲libstdc++-v3的一部分找到。我將會着眼於GCC v4.8.0的libstdc++-v3 release 6.0.18[15],SparseHash v2.0.2中的dense_hash_map[16],和Kyoto Cabinet v1.2.76中的HashDB[17]

Matthew Austern的「A Proposal to Add Hash Tables to the Standard Library (revision 4)」一文[18]和SparseHash的「Implementation notes」頁面[19]也有頗有意思的關於哈希表實現的討論。

2.1 TR1中的unordered_map

TR1的unordered_map提供了一個用鏈表(分離鏈)解決碰撞的哈希表。Bucket數組位於堆中,而且基於哈希表的負載係數自動調整大小。而bucket的鏈表則是用叫作_Hash_node的節點結構體建立。

若是鍵和值都是整型,其能夠直接存儲在_M_v結構體中。不然將會存儲指針,同時須要額外的內存。Bucket數組是在堆中一次性分配的,但並不分配節點的空間,節點的空間是經過各自調用C++內存分配器來分配的。

由於這些節點是各自分配的,分配過程當中可能浪費大量的內存。這取決於編譯器和操做系統使用的內存分配過程。我甚至還沒說每次分配中系統執行的調用。SGI哈希表的原始實現爲這些節點作了一些資源預分配工做,但這個方法沒有保留在TR1的 unordered_map實現中。

下文的圖5.1展現了TR1中unordered_map的內存和訪問模式。讓咱們來看看當咱們訪問和鍵名「Johannesburg」相關的GPS座標的時候會發生什麼。這個鍵名被哈希並映射到了bucket #0。在那咱們跳到了此bucket的鏈表的第一個節點(bucket #0左邊的橙色箭頭),咱們能夠訪問堆中存儲了鍵「Johannesburg」所屬數據的內存區域(節點右側的黑色箭頭)。若是鍵名所指向的第一個節點不可用,就必須遍歷其餘的節點來訪問。

至於CPU性能,不能期望全部的數據都在處理器的同一個緩存行中。實際上,基於bucket數組的大小,初始bucket和初始節點不會在同一個緩存行中,而和節點相關的外部數據一樣不太可能在同一個緩存行中。而隨後的節點機器相關數據一樣不會在同一個緩存行中而且須要從RAM中取回。若是你不熟悉CPU優化和緩存行,維基上的「CPU Cache」文章是一個很好的介紹[20]

圖5.1

2.2 SparseHash的dense_hash_map

SparseHash庫提供了兩個哈希表實現,sparse_hash_map和dense_hash_map。sparse_hash_map在低成本下提供了出色的內存佔用,並使用一個特定的數據結構sparsetable來打到這個目的。在SparseHash的「Implementation notes」頁面19能夠找到更多關於sparsetables 和sparse_hash_map的信息。在此我只討論dense_hash_map。

dense_hash_map用二次內部探測處理碰撞。和unordered_map同樣,bucket數組也是在堆中一次分配,並基於哈希表的負載因子調整大小。bucket數組的元素是std::pair的實例,其中KeyT分別是鍵名和鍵值的模版參數。在64位架構下儲存字符串的時候,pair的實例大小是16字節。

下文的圖5.2是dense_hash_map內存和訪問模式的展現。若是咱們要尋找「Johannesburg」的座標,咱們一開始會進入bucket #0,其中有「Paris」(譯註:圖上實際應爲「Dubai」)的數據(bucket #0右側的黑色箭頭)。所以必須探測而後跳轉到bucket (i + 1) = (0 + 1) = 1(bucket #0左側的橙色箭頭),而後就能在bucket #1中找到「Johannesburg」的數據。這看上去和unordered_map中作的事情差很少,但其實徹底不一樣。固然,和unordered_map同樣,鍵名和鍵值都必須存儲在分配於堆中的內存,這將致使對鍵名和鍵值的尋找會使緩存行無效化。但爲碰撞的條目尋找一個bucket相對較快一些。實際上既然每一個pair都是16字節而大多數處理器上的緩存行都是64字節,每次探測就像是在同一個緩存行上。這將急劇提升運算速度,與之相反的是unordered_map中的鏈表須要在RAM中跳轉以尋找餘下的節點。

二次內部探測提供的緩存行優化使得dense_hash_map成爲全部內存哈希性能測試中的贏家(至少是在我目前讀過的這些中)。你應該花點時間來看看Nick Welch的文章「Hash Table Benchmarks」 [10]

圖5.2

2.3 Kyoto Cabinet的HashDB

Kyoto Cabinet實現了不少數據結構,其中就有哈希表。這個哈希表HashDB雖然有一個選項能夠用來把他用做代替std::map的內存哈希表,但其是設計用於在硬盤上持久化的。哈希表的元數據和用戶數據一塊兒用文件系統依次存儲在硬盤上惟一的文件中。
Kyoto Cabinet使用每一個bucket中獨立的二叉樹處理碰撞。Bucket數組長度固定且不改變大小,無視負載因子的狀態。這是Kyoto Cabinet的哈希表實現的主要缺陷。實際上,若是數據庫建立的時候定義的bucket數組的長度低於實際需求,當條目開始碰撞的時候性能會急劇降低。

容許硬盤上的哈希表實現改變bucket數組大小是很難的。首先,其須要bucket數組和條目存儲到兩個不一樣的文件中,其大小會各自獨立的增加。第二,由於調整bucket數組大小須要將鍵名從新哈希到新bucket數組的新位置,這須要從硬盤中讀取全部條目的鍵名,這對於至關大的數據庫來講代價過高以致於幾乎不可能。避免這種從新哈希過程的一種方法是,存儲哈希後鍵名的時候每一個條目預留4或8個字節(取決於哈希是長度32仍是64 bit)。由於這些麻煩事,固定長度的bucket數組更簡單,而Kyoto Cabinet中採用了這個方法。

圖5.3顯示出文件中存儲的一個HashDB的結構。我是從calc_meta()方法的代碼,和kchashdb.h尾部HashDB類中屬性的註釋中獲得的這個內部結構。此文件以以下幾個部分組織:

  • 頭部有數據庫全部的元數據
  • 包含數據區域中可用空間的空塊池
  • bucket數組
  • 記錄(數據區域)

一條記錄包含一個條目(鍵值對),以及此獨立鏈的二叉樹節點。這裏是Record結構體:

圖5.4能夠看到記錄在硬盤上的組織。我從kchashdb.h中的write_record()方法中獲得組織方法。注意其和Record結構體不一樣:保存在硬盤上的目標是最小化硬盤佔用,然而結構體的目標是使記錄在編程的時候用起來比較方便。圖5.4的全部變量都有固定長度,除了keyvalue、 和padding,其固然是取決於數據條目中數據的尺寸。變量left 和right是二叉樹節點的一部分,儲存文件中其餘記錄的offset。

圖5.3

圖5.4若是咱們要訪問鍵名」Paris」的鍵值,一開始要得到相關bucket的初始記錄,在本例中是bucket #0.。而後跳轉到此bucket二叉樹的頭節點(bucket #0右側的橙色箭頭),其保存鍵名爲」Johannesburg」.的數據。鍵名爲」Paris」的數據須要經過當前節點的右側節點來訪問(」Johannesburg」記錄右側的黑色箭頭)。二叉樹須要一個可比較的類型來對節點分類。這裏用的可比較類型是用fold_hash()方法將哈希過的鍵名縮減獲得的。

把數據條目和節點一塊兒存儲在單一記錄中,乍一看像是設計失誤,但實際上是至關聰明的。爲了存儲一個條目的數據,老是須要保持三種不一樣的數據:bucket、碰撞和條目。既然bucket數組中的bucket必須順序存儲,其須要就這樣存儲而且沒有任何該進的方法。假設咱們保存的不是整型而是不能存儲在bucket中的字符或可變長度字節數組,這使其必須訪問此bucket數組區域以外的其餘內存。這樣當添加一個新條目的時候,須要即保存衝突數據結構的數據,又要保存該條目鍵名和鍵值的數據。

若是衝突和條目數據分開保存,其須要訪問硬盤兩次,再加上必須的對bucket的訪問。若是要設置新值,其須要總計3次寫入,而且寫入的位置可能相差很遠。這表示是在硬盤上的隨機寫入,這差很少是I/O的最糟糕的狀況了。如今既然Kyoto Cabinet的HashDB中節點數據和條目數據存儲在一塊兒,其就能夠只用一次寫入寫到硬盤中。固然,仍然必須訪問bucket,但若是bucket數組足夠小,就能夠經過操做系統將其從硬盤中緩存到RAM中。如規範中」Effective Implementation of Hash Database」一節[17]聲明的,Kyoto Cabinet可能採用這種方式。

然而在硬盤上用二叉樹存儲條目須要注意的一點是,其會下降讀取速度,至少當碰撞出現的時候會是這樣。實際上,由於節點和條目存儲在一塊兒,處理一個bucket中的碰撞其實是在一個二叉樹中尋找要找的條目,這可能須要大量的對硬盤的隨機讀取。這可讓咱們理解當條目的數量超過bucket數量時Kyoto Cabinet的性能急劇降低的緣由。

最後,由於全部的東西都是存在文件中,Kyoto Cabinet是本身處理內存管理,而非像unordered_map 和dense_hash_map那樣交給操做系統處理。FreeBlock結構體保存着和文件中空閒空間的信息,其基本上是offset和大小,以下:

相關文章
相關標籤/搜索