什么是散列表
散列表又称hash table、哈希表,它是通过计算key而得到一个散列值,然后通过这个数值与数组的随机访问特性直接访问key对应的value。通过计算key而得到散列值的方法我们称之为散列函数或者哈希函数。为此如何设计这个散列函数非常关键,他直接关系到散列表的性能。那么如何设计散列函数呢?
1、计算量不能太复杂
2、散列函数计算得到的散列值必须为一个非负整数
3、如果key1 == key2,那么hash(key1) == hash(key2)
4、如果key1 != key2,那么hash(key1) != hash(key2)
其中1到3是显而易见的,但是最后一点,实际情况是不可能满足的,即使使用著名的CRC32、MD5等hash函数,也还是有碰撞的情况,而且根据鸽巢原理,当数组槽位的数量少于key-value的数量时,必然存在碰撞的情况。为此我们把第4点改成
4、对于不同的key,hash(key)计算出来的值应该尽可能的均衡的分布在各个槽位上
散列冲突
既然再好的散列函数都存在冲突的情况,那么有什么解决方法呢?主要有开放寻址法与链表法
开放寻址法
开放寻址法的核心思想是,当出现散列值冲突的时候,重新探测一个空闲的位置将其插入。常见的探测方法有线性探测、二次探测、双重散列
1、线性探测
当要插入元素的时候,要是出现散列冲突的时候,就从散列值开始的地方依次往下查找,当出现空闲位子的时候,则插入,当出现key相等时,停止探测;
当要查找元素的时候,与插入情况类似,当散列函数计算出来的散列值冲突的时候(key不相等),就从散列值开始的地方依次往下查找,直到找到key相等或者出现空闲位置为止;
当要删除元素的时候,从插入元素的算法中可以看出,我们要是简单的把找到的元素删除,那么下次插入或者删除时,线程探测到空闲位置就结束了,但是实际上可能散列表存在相同的元素。为此我们需要将其标记为删除。而插入元素的时候,遇到删除标记的需要继续线性探测,直到遇到空闲位置或者key相等,如果遇到空闲位置且之前有标记删除的槽位,则插入标记删除的位置,否则插入空闲位置。
2、二次探测
二次探测与线性探测的插入、删除以及查找算法类似,只是探测算法不一样,线性探测算法是hash(key)+1,hash(key)+2,hash(key)+3,hash(key)+4,而二次探测是以平方的方式其算法为hash(key)+1的平方,hash(key)+2的平方,hash(key)+3的平方,hash(key)+4的平方。
3、双重散列
双重散列是使用一组散列函数hash1(key),hash2(key),hash3(key)来完成探测。双重散列一般需要配合线性探测或者二次探测实现,因为我们一般不会设计非常多的散列函数。
不管使用哪只探测方法,当空闲槽位不多时,散列冲突发生的概率将会急剧的上升,从而导致散列表效率低下。衡量槽位剩余多少的方法是装载因子,其定义如下:
散列表装载因子=散列表元素个数/散列表槽位总数
装载因子越大说明空闲槽位越少,散列冲突的概率就越大。
链表法
链表法相比开放寻址法,要简单很多。散列表的每个槽位都有一个链表,当插入删除元素时都是直接操作槽位对应的链表,如下图所示:
那么链表法的时间复杂度是多少呢?这跟hash冲突相关,若hash冲突导致的链表长度为k,那么时间复杂度就是O(k)。通过选择良好的hash函数与合理的hash表的容量,那么可以做到O(1)的时间复杂度。
如何设计一个工业级的散列表
hash函数优化
上文中提到hash函数需要满足如下的几点:
1、计算量不能太复杂
2、散列函数计算得到的散列值必须为一个非负整数
3、如果key1 == key2,那么hash(key1) == hash(key2)
4、对于不同的key,hash(key)计算出来的值应该尽可能的均衡的分布在各个槽位上
但是如何让散列值能够均匀的分布在各个桶上呢?其实就是区分度的问题。比如,对于 11 位手机号,前 3 位接入号区分度最差,中间 4 位表示地域的数字信息量有所增强,最后 4 位个人号信息量最高。如果哈希桶只有 1 万个,那么通过 phonenum%10000,最大化保留后 4 位信息就是个不错的选择。
链表法优化
链表法的每个槽位都是使用一个单链表,但是如果hash冲突比较严重的话,链表的长度会很长。在好的hash函数,通过专门构造key-value,总是能找到大量的hash冲突的情况,要是有人专门构造这种key-value,那么散列表的时间复杂度将会退化为O(n),从而导致散列表不可用,这就是典型的拒绝服务攻击。当单链表的长度达到一定长度时(如8),我们可以把单链表改造成跳表或者红黑树;但是当长度小于一定长度时(如8),在改造回单链表。
扩容缩容
散列表的数据一般时动态变化的,我们很难预测数据量级,随着数据量的增长,散列表装载因子可能会越来越大,从而导致散列表的时间复杂度升高,特别是对于开放寻址法hash冲突将会急剧上升。
为此当转载因子答到某个阈值了,就应该需要扩容,那么阈值多少就应该扩容呢?对于链表法转载因子可以大于1,如果hash函数设计的比较好,甚至达到2或者3甚至更大都没有问题;但是对于开放寻址法,转载因子阈值不应该超过0.75。
那么应该怎么扩容呢?由于扩容时散列表的容量变大了,故hash函数计算出来的值(散列值)也变了,故扩容时散列表的所有值都得loop一遍,如果散列表非常大,那么这个过程将会十分耗时,整个扩容的过程中散列表是没法接受服务的,这对于日活百万的服务来说是没法接受的。
为此我们一般使用动态扩容,扩容过程中,新旧散列表一起工作,当访问散列表时,先访问旧的散列表,当找到时,迁移到新的散列表;若没有找到,则访问新的散列表。这样一点一点的旧迁移到新的散列表了,当全部都迁移到新的散列表了,就可以删除旧的散列表。但是你可能会说这个过程也太长了把,我们可以在开启一个服务,在系统空闲时,缓慢迁移。但是如果扩容后由于内存原因,新旧两个散列表不能再一台主机中放下,那么我们可以把新的散列表放到另外一台主机上,迁移过程与单主机类似。
亿级数据量如何保证高可用
对于大规模的散列表,如果出现故障了,那么对于系统的影响肯定很大的,故我们需要通过冗余来实现高可靠。同时主机故障恢复后如何快速的恢复呢?如果通过日志一条一条的添加进散列表,那么这个过程将会非常的长。故我们一般需要通过快照+(oplog)操作日志来快速的重构散列表。为此我们就需要定期的生成快照。但是链表法存在大量的指针,如果需要序列化生成快照将会非常麻烦。
linux提过了函数,用于实现内存映射,更多细节参见:https://juejin.im/post/6844903855235268615
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
/*
mmap()用来将某个文件内容映射到内存中,对该内存区域的存取即是直接对该文件内容的读写
start: 指向欲对应的内存起始地址,通常设为NULL,代表让系统自动选定地址,对应成功后该地址会返回
length: 代表将文件中多大的部分对应到内存
prot: 代表映射区域的保护方式,需要注意的时需要与文件打开的方式相匹配,有下列组合:
PROT_EXEC 映射区域可被执行;
PROT_READ 映射区域可被读取;
PROT_WRITE 映射区域可被写入;
PROT_NONE 映射区域不能存取
flags:会影响映射区域的各种特性:
MAP_FIXED 如果参数 start 所指的地址无法成功建立映射时,则放弃映射,不对地址做修正。通常不鼓励使用
MAP_SHARED 对应射区域的写入数据会复制回文件内,而且允许其他映射该文件的进程共享
MAP_PRIVATE 对应射区域的写入操作会产生一个映射文件的复制,即私人的"写入时复制" (copy on write)对此区域作的任何修改都不会写回原来的文件内容
MAP_ANONYMOUS 建立匿名映射,此时会忽略参数fd,不涉及文件,而且映射区域无法和其他进程共享
MAP_DENYWRITE 只允许对应射区域的写入操作,其他对文件直接写入的操作将会被拒绝
MAP_LOCKED 将映射区域锁定住,这表示该区域不会被置换(swap)
在调用mmap()时必须要指定MAP_SHARED 或MAP_PRIVATE
fd:open()返回的文件描述词,代表欲映射到内存的文件
offset:文件映射的偏移量,通常设置为0,代表从文件最前方开始对应,offset必须是分页大小的整数倍
返回值:若映射成功则返回映射区的内存起始地址,否则返回MAP_FAILED(-1),错误原因存于errno 中
错误代码:
EBADF 参数fd 不是有效的文件描述词
EACCES 存取权限有误。如果是MAP_PRIVATE 情况下文件必须可读,使用MAP_SHARED 则要有PROT_WRITE 以及该文件要能写入
EINVAL 参数start、length 或offset 有一个不合法
EAGAIN 文件被锁住,或是有太多内存被锁住
ENOMEM 内存不足
*/
void * mmap(void *start, size_t length, int prot , int flags, int fd, off_t offset);
/*
取消映射的内存区域
start:要取消映射的内存区域的起始地址
length:要取消映射的内存区域的大小
成功执行时munmap()返回0。失败时munmap返回-1
*/
int munmap(void *start, size_t length);
/*
对映射内存的内容的更改并不会立即更新到文件中,而是有一段时间的延迟,你可以调用msync()来显式同步一下, 这样你内存的更新就能立即保存到文件里
start:要进行同步的映射的内存区域的起始地址。
length:要同步的内存区域的大小
flag:flags可以为以下三个值之一:
MS_ASYNC : 请Kernel快将资料写入。
MS_SYNC : 在msync结束返回前,将资料写入。
MS_INVALIDATE : 让核心自行决定是否写入,仅在特殊状况下使用
*/
int msync(const void *start, size_t length, int flags);
那么如果散列表的内存要是连续的,那么我们可以直接使用内存映射,映射到一个普通的文件,以实现序列化快照的功能,而开放寻址法的散列表就是连续的。为此我们一般通过开放寻址法来实现。
但是开放寻址法由于装载因子不能太大,一般不超过0.75。如果每个槽位都保存元素的值,而值的大小一般都比较大,假如有几十个字节,那么空闲的槽位将浪费大量的内存空间。为此我们一般需要用额外的连续的内存保存值,散列表只保存数组的索引。假如有1亿的数量,那么每个槽位只需要31bit即可,这样就可以节省大量的内存空间,从而使得散列表可以有更低的装载因子,进而又降低了hash冲突的概率。但是如果元素的值长度不固定呢?我们可以根据值的特点使用多个长度不同的数组保存值。
还有一个问题,就是如果每次保存快照调用msync函数的时候,都全量地址,那么还是很费系统资源,我们可以每次msync一段内存空间。