<audio id="audio" title="03 | 索引：如何用哈希表管理亿级对象？" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/66/18/66f9041f6fc31d73e3d6c5783a852718.mp3"></audio>

你好，我是陶辉。

上一讲我们谈到，Ptmalloc2为子线程预分配了64MB内存池，虽然增大了内存消耗，但却加快了分配速度，这就是**以空间换时间**的思想。

在内存有限的单片机上运行嵌入式程序时，我们会压缩数据的空间占用，**以时间换空间**；但在面向海量用户的分布式服务中，**使用更多的空间建立索引，换取更短的查询时间**，则是我们管理大数据的常用手段。

比如现在需要管理数亿条数据，每条数据上有许多状态，有些请求在查询这些状态，有些请求则会根据业务规则有条件地更新状态，有些请求会新增数据，每条数据几十到几百字节。如果需要提供微秒级的访问速度，该怎么实现？（注意，以上非功能性约束并不苛刻，对于低ARPU，即每用户平均收入低的应用，使用更少的资源实现同等功能非常重要。）

这种情况你会面对大量数据，显然，遍历全部数据去匹配查询关键字，会非常耗时。如果使用额外的空间为这些数据创建索引，就可以基于索引实现快速查找，这是常用的解决方案。比如，我们用标准库里提供的字典类容器存放对象，就是在数据前增加了索引，其本质就是以空间换时间。

当然，索引有很多，哈希表、红黑树、B树都可以在内存中使用，如果我们需要数据规模上亿后还能提供微秒级的访问速度，**那么作为最快的索引，哈希表是第一选择。**

## 为什么选择哈希表？

为什么说哈希表是最快的索引呢？我们怎么**定量评价**索引快慢呢？

实地运行程序统计时间不是个好主意，因为它不只受数据特性、数据规模的影响，而且难以跨环境比较。巴菲特说过：“**近似的正确好过精确的错误。**”用**近似的时间复杂度**描述运行时间，好过实地运行得出的精确时间。

“时间复杂度”经过了详细的数学运算，它的运算过程我就不详细展开讲了。时间复杂度可以很好地反映运行时间随数据规模的变化趋势，就如下图中，横轴是数据规模，纵轴是运行时间，随着数据规模的增长，水平直线1不随之变化，也就是说，运行时间不变，是最好的曲线。用大O表示法描述时间复杂度，哈希表就是常量级的O(1)，数据规模增长不影响它的运行时间，所以Memcached、Redis都在用哈希表管理数据。

<img src="https://static001.geekbang.org/resource/image/e5/c8/e5e07bd2abe9f0f15df1b43fdf25f9c8.jpg" alt="" title="图片来自英文wiki">

为什么哈希表能做到O(1)时间复杂度呢？

首先，哈希表基于数组实现，而数组可以根据下标随机访问任意元素。数组之所以可以随机访问，是因为它**由连续内存承载**，且**每个数组元素的大小都相等**。于是，当我们知道下标后，把下标乘以元素大小，再加上数组的首地址，就可以获得目标访问地址，直接获取数据。

其次，哈希函数直接把查询关键字转换为数组下标，再通过数组的随机访问特性获取数据。比如，如果关键字是字符串，我们使用BKDR哈希算法将其转换为自然数，再以哈希数组大小为除数，对它进行求余，就得到了数组下标。如下图所示，字符串abc经过哈希函数的运算，得到了下标39，于是数据就存放在数组的第39个元素上。（注意，这是个**很糟糕**的哈希函数，它使用的基数是256，即2的8次方，下文我们会解释它为什么糟糕。）

<img src="https://static001.geekbang.org/resource/image/41/59/419bc11f032ebcefaa6a3eb5c1a39759.jpg" alt="">

这样，**哈希函数的执行时间是常量，数组的随机访问也是常量，时间复杂度就是O(1)。**

实际上并非只有哈希表的时间复杂度是O(1)，另一种索引“位图”，它的时间复杂度也是O(1)。不过本质上，它是哈希表的变种，限制每个哈希桶只有1个比特位，所以，虽然它消耗的空间更少，但仅用于辅助数据的主索引，快速判断对象是否存在。

位图常用于解决缓存穿透的问题，也常用于查找数组中的可用对象，比如下图中通过批量判断位图数组的比特位（对CPU缓存也很友好），找到数据数组中的对应元素。

<img src="https://static001.geekbang.org/resource/image/bf/f5/bf2e4f574be8af06c285b3fc78d7b0f5.jpg" alt="">

当然，logN也是不错的曲线，随着数据规模的增长，运行时间的增长是急剧放缓的。红黑树的时间复杂度就是O(logN)。如果需求中需要做范围查询、遍历，由于哈希表没办法找到关键字相邻的下一个元素，所以哈希表不支持这类操作，我们可以选择红黑树作为索引。采用二分法的红黑树，检索1万条数据需要做14次运算，1亿条也只需要27次而已。

如果红黑树过大，内存中放不下时，可以改用B树，将部分索引存放在磁盘上。磁盘访问速度要比内存慢很多，但B树充分考虑了机械磁盘寻址慢、顺序读写快的特点，通过多分支降低了树高，减少了磁盘读写次数。

综合来看，不考虑范围查询与遍历操作，在追求最快速度的条件下，哈希表是最好的选择。

然而，在生产环境用哈希表管理如此多的数据，必然面临以下问题：

- 首先，面对上亿条数据，为了保证可靠性，需要做灾备恢复，我们可以结合快照+oplog方式恢复数据，但内存中的哈希表如何快速地序列化为快照文件？
- 其次，简单的使用标准库提供的哈希表处理如此规模的数据，会导致内存消耗过大，因为每多使用一个8字节的指针（或者叫引用）都会被放大亿万倍，此时该如何实现更节约内存的个性化哈希表？
- 再次，哈希表频繁发生冲突时，速度会急剧降低，我们该通过哪些手段减少冲突概率？

接下来，我们就来看看，如何解决以上问题，用哈希表有效地管理亿级数据。

## 内存结构与序列化方案

事实上**对于动态（元素是变化的）哈希表，我们无法避免哈希冲突。**比如上例中，“abc”与“cba”这两个字符串哈希后都会落到下标39中，这就产生了冲突。有两种方法解决哈希冲突：

1. **链接法**，落到数组同一个位置中的多个数据，通过链表串在一起。使用哈希函数查找到这个位置后，再使用链表遍历的方式查找数据。Java标准库中的哈希表就使用链接法解决冲突。
1. **开放寻址法**，插入时若发现对应的位置已经占用，或者查询时发现该位置上的数据与查询关键字不同，开放寻址法会按既定规则变换哈希函数（例如哈希函数设为H(key,i)，顺序地把参数i加1），计算出下一个数组下标，继续在哈希表中探查正确的位置。

我们该选择哪种方法呢？

由于生产级存放大量对象的哈希表是需要容灾的，比如每隔一天把哈希表数据定期备份到另一台服务器上。当服务器宕机而启动备用服务器时，首先可以用备份数据把哈希表恢复到1天前的状态，再通过操作日志oplog把1天内的数据载入哈希表，这样就可以最快速的恢复哈希表。所以，为了能够传输，首先必须把哈希表序列化。

链接法虽然实现简单，还允许**存放元素个数大于数组的大小**（也叫装载因子大于1），但链接法序列化数据的代价很大，因为使用了指针后，内存是不连续的。

**开放寻址法**确保所有对象都在数组里，就可以把数组用到的这段连续内存原地映射到文件中（参考Linux中的mmap，Java等语言都有类似的封装），再通过备份文件的方式备份哈希表。虽然操作系统会自动同步内存中变更的数据至文件，但备份前还是需要主动刷新内存（参考Linux中的msync，它可以按地址及长度来分段刷新，以减少msync的耗时），以确定备份数据的精确时间点。而新的进程启动时，可以通过映射磁盘中的文件到内存，快速重建哈希表提供服务。

**如果能将数据完整的放进数组，那么开放寻址法已经解决了序列化问题，所以我们应该选择开放寻址法**。

但是，有两个因素使得我们必须把数据放在哈希桶之外：

1. 每条数据有上百字节；
1. 哈希表中一定会有很多空桶（没有存放数据）。空桶的比例越高（装载因子越小），冲突概率也会越低，但如果每个空桶都占用上百字节，亿级规模会轻松把浪费的内存放大许多倍。

**所以，我们要把数据从哈希表中分离出来，提升哈希表的灵活性（灵活调整装载因子）**。此时，该如何序列化哈希表以外的数据呢？最快速的序列化方案，还是像开放寻址法的散列表一样，使用定长数组存放对象，通过原地映射文件的方式序列化数据。由于数据未必是定长的，所以又分为两种情况。

**一、数据的长度是固定的。**可以用另一个数组D存放数据，其中D的大小是待存放元素的最大数量，注意，D可以远小于哈希数组的大小。如果哈希表是动态的，支持新建与删除元素操作，还需要把数组D中空闲的位置构建一个单链表，新建时从链表头取元素，删除时将元素归还至链表头部。

<img src="https://static001.geekbang.org/resource/image/7e/e8/7e0636fc6d9a70d6d4de07da678da6e8.jpg" alt="">

**二、数据的长度并不固定。**此时，可以采用有限个定长数组存放数据，用以空间换时间的思想，加快访问速度。如下图中，D1数组存放长度小于L1的数据，D2数组存放长度在L1和L2之间的数据，以此类推。而哈希表数组H中，每个桶用i位存放该数据在哪个数组中，用j位存放数组下标。查找数据时，前i位寻找数组，后j位作为数组下标直接访问数据。

<img src="https://static001.geekbang.org/resource/image/17/82/17f3f4e9e949a49a4ce7a50bbf1d4f82.jpg" alt="">

在这两种情况里，哈希桶不需要存放8字节64位的地址。因为，或许数组D的大小不到1亿，也就是说，你最多只需要寻址1亿条数据，这样30位足够使用。要知道，减少哈希桶的尺寸，就意味着同等内存下可以扩大哈希数组，从而降低装载因子。

## 降低哈希表的冲突概率

虽然哈希冲突有解决方案，但若是所有元素都发生了冲突，哈希表的时间复杂度就退化成了O(N)，即每查找一次都要遍历所有数据。所以，为了获得与数据规模无关的常量级时间，我们必须减少冲突的概率，而减少冲突概率有两个办法，**第一个办法是调优哈希函数，第二个办法就是扩容。**

我们先看调优哈希函数。什么是好的哈希函数呢？首先它的计算量不能大，其次应尽量降低冲突概率。回到开头的那个哈希函数：

<img src="https://static001.geekbang.org/resource/image/41/59/419bc11f032ebcefaa6a3eb5c1a39759.jpg" alt="">

这个哈希函数使得“abc”和“cba”两个关键字都落在了下标39上，造成了哈希冲突，是因为它**丢失了字母的位置信息**。BKDR是优秀的哈希算法，但它不能以2<sup>8</sup> 作为基数，这会导致字符串分布不均匀。事实上，我们应当找一个合适的**素数作为基数**，比如31，Java标准库的BKDR哈希算法就以它为基数，它的计算量也很小：n*31可以通过先把n左移5位，再减去n的方式替换（n*31 == n&lt;&lt;5 - n）。

一次位移加一次减法，要比一次乘法快得多。当然，图中的哈希函数之所以会丢失位置信息，是因为以2<sup>8</sup> 作为基数的同时，又把2<sup>8</sup>-1作为除数所致，数学较好的同学可以试着推导证明，这里只需要记住，**基数必须是素数**就可以了。

当哈希函数把高信息量的关键字压缩成更小的数组下标时，**一定会丢失信息**。我们希望只丢失一些无关紧要的信息，尽量多地保留区分度高的信息。这需要分析关键字的特点、分布规律。比如，对于11位手机号，前3位接入号区分度最差，中间4位表示地域的数字信息量有所增强，最后4位个人号信息量最高。如果哈希桶只有1万个，那么通过phonenum%10000，最大化保留后4位信息就是个不错的选择。

再比如，QQ  号似乎不像手机号的数字分布那么有特点，然而，如果静态的统计存量QQ号，就会发现最后1位为0的号码特别多（数字更讨人欢喜），区分度很低。这样，哈希函数应当主动降低最后1位的信息量，减少它对哈希表位置的影响。比如，QQ号%100就放大了最后1位的信息，增大了哈希冲突，而用QQ号%101（**101是素数，效果更好****）**作为哈希函数，就降低了最后1位的影响。

**接下来我们看看减少哈希冲突概率的第二个办法，扩容。**装载因子越接近于1，冲突概率就会越大。我们不能改变元素的数量，只能通过扩容提升哈希桶的数量，减少冲突。

由于哈希函数必须确保计算出的下标落在数组范围中，而扩容会增加数组的大小，进而影响哈希函数，因此，扩容前存放在哈希表中的所有元素，它们在扩容后的数组中位置都发生了变化。所以，扩容需要新老哈希表同时存在，通过遍历全部数据，用新的哈希函数把关键字放到合适的新哈希桶中。可见，扩容是一个极其耗时的操作，尤其在元素以亿计的情况下。

那么，在耗时以小时计的扩容过程中，如何持续提供正常服务呢？其实，只要把一次性的迁移过程，分为多次后台迁移，且提供服务时能够根据迁移情况选择新老哈希表即可。如果单机内存可以存放下新老两张哈希表，那么动态扩容不需要跨主机。反之，扩容过程将涉及新老哈希表所在的两台服务器，实现更为复杂，但原理是相同的。

## 小结

今天我们介绍了如何用哈希表管理上亿条数据。为什么选择哈希表？因为哈希表的运行时间不随着业务规模增长而变化。位图本质上是哈希表的变种，不过它常用于配合主索引，快速判断数据的状态。因为哈希表本身没办法找到关键字相邻的下一个元素，所以哈希表不支持范围查询与遍历。如果业务需要支持范围查询时，我们需要考虑红黑树、B树等索引，它们其实并不慢。当索引太大，必须将一部分从内存中移到硬盘时，B树就是一个很好的选择。

使用哈希表，你要注意几个关键问题。

1. 生产环境一定要考虑容灾，而把哈希表原地序列化为文件是一个解决方案，它能保证新进程快速恢复哈希表。解决哈希冲突有链接法和开放寻址法，而后者更擅长序列化数据，因此成为我们的首选 。
1. 亿级数据下，我们必须注重内存的节约使用。数亿条数据会放大节约下的点滴内存，再把它们用于提升哈希数组的大小，就可以通过降低装载因子来减少哈希冲突，提升速度。
1. 优化哈希函数也是降低哈希冲突的重要手段，我们需要研究关键字的特征与分布，设计出快速、使关键字均匀分布的哈希函数。在课程的第四部分，集群的负载均衡也用到了哈希函数及其设计思想，只不过，哈希桶从一段内存变成了一台服务器。

再延伸说一点，哈希表、红黑树等这些索引都使用了以空间换时间的思想。判断它们的时间消耗，我们都需要依赖时间复杂度这个工具。当然，索引在某些场景下也会降低性能。例如添加、删除元素时，更新索引消耗的时间就是新增的。但相对于整体的收益，这些消耗是微不足道的。

## 思考题

最后留给大家一个思考题，你用过哪些其他类型的索引？基于怎样的应用场景和约束，才选择使用这些索引的？欢迎你在留言区与大家一起探讨。

感谢阅读，如果你觉得这节课对你有一些启发，也欢迎把它分享给你的朋友。
