[转]实现键值对存储(长文)



实现键值对存储(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和大小,以下:

相关文章
相关标签/搜索