2021-09-04

hash table 简述

简单地说,hash table是以key => value的方式实现O(1)时间复杂度的查询,发生冲突的时候往往采用拉链法的方式来处理。而当hash table的装载因子较高时,发生冲突的概率也会变大,这时候通常会使用扩大容量以及重哈希的方式来处理。

但基于特定场景,实现方法略有不同,下面详细讲讲Redis和PHP在hash table的实现上有何特点。

Redis

Redis对外暴露的数据结构包含字符串、哈希、列表、集合、有序集合,其中我们日常使用的哈希,在底层储存的时候,采用的结构可能是ziplist,也可能是dict,这里我们要讨论Redis的hash table,实际上讲的是dict的结构。先来看看Redis(代码版本更新至2021-08-22)中跟hash table相关的几个关键数据结构。

struct dict {
    dictType *type;

    dictEntry **ht_table[2];
    unsigned long ht_used[2];

    long rehashidx; /* rehashing not in progress if rehashidx == -1 */

    /* Keep small vars at end for optimal (minimal) struct padding */
    int16_t pauserehash; /* If >0 rehashing is paused (<0 indicates coding error) */
    signed char ht_size_exp[2]; /* exponent of size. (size = 1<<exp) */
};

typedef struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;
} dictEntry;

从数据结构上可以看出,Redis的hash table实现还是比较常规的,比较特别的点是dict结构中有一个dictEntry结构的数组,包含了2个元素,其中一个是用来承载数据,另一个是用来重哈希的。

下面我们通过hash table的查找算法来看看是如何使用的。

dictEntry *dictFind(dict *d, const void *key)
{
    dictEntry *he;
    uint64_t h, idx, table;

    if (dictSize(d) == 0) return NULL; /* dict is empty */
    if (dictIsRehashing(d)) _dictRehashStep(d); //如果正在进行重哈希,则往前推进
    h = dictHashKey(d, key);
    for (table = 0; table <= 1; table++) {
        idx = h & DICTHT_SIZE_MASK(d->ht_size_exp[table]);
        he = d->ht_table[table][idx];
        while(he) {
            if (key==he->key || dictCompareKeys(d, key, he->key))
                return he;
            he = he->next;
        }
        if (!dictIsRehashing(d)) return NULL;
    }
    return NULL;
}

#define dictIsRehashing(d) ((d)->rehashidx != -1)

看for循环的代码可以发现,Redis先是在d->ht_table[0]里面去搜索,根据对key做hash以及对拉链上的key做dictCompareKeys操作,如果找到了就返回结果。
注意,当在d->ht_table[0]找不到目标值的时候,Redis就会通过dictIsRehashing去检查(d)->rehashidx != -1,也就是看该hash table是否处于重哈希阶段,是的话就从d->ht_table[1]里用相同的方式去搜索值。

我们进一步观察_dictRehashStep方法,看Redis的重哈希过程是如何进行的。

static void _dictRehashStep(dict *d) {
    if (d->pauserehash == 0) dictRehash(d,1);
}


int dictRehash(dict *d, int n) {
    int empty_visits = n*10; /* Max number of empty buckets to visit. */
    if (!dictIsRehashing(d)) return 0;

    while(n-- && d->ht_used[0] != 0) {
        dictEntry *de, *nextde;

        /* Note that rehashidx can't overflow as we are sure there are more
         * elements because ht[0].used != 0 */
        assert(DICTHT_SIZE(d->ht_size_exp[0]) > (unsigned long)d->rehashidx);
        while(d->ht_table[0][d->rehashidx] == NULL) {
            d->rehashidx++;
            if (--empty_visits == 0) return 1;
        }
        de = d->ht_table[0][d->rehashidx];
        /* Move all the keys in this bucket from the old to the new hash HT */
        while(de) {
            uint64_t h;

            nextde = de->next;
            /* Get the index in the new hash table */
            h = dictHashKey(d, de->key) & DICTHT_SIZE_MASK(d->ht_size_exp[1]);
            de->next = d->ht_table[1][h];
            d->ht_table[1][h] = de;
            d->ht_used[0]--;
            d->ht_used[1]++;
            de = nextde;
        }
        d->ht_table[0][d->rehashidx] = NULL;
        d->rehashidx++;
    }

    /* Check if we already rehashed the whole table... */
    if (d->ht_used[0] == 0) {
        zfree(d->ht_table[0]);
        /* Copy the new ht onto the old one */
        d->ht_table[0] = d->ht_table[1];
        d->ht_used[0] = d->ht_used[1];
        d->ht_size_exp[0] = d->ht_size_exp[1];
        _dictReset(d, 1);
        d->rehashidx = -1;
        return 0;
    }

    /* More to rehash... */
    return 1;
}

可以看出,每次调用dictRehash方法,hash的重哈希过程最多会往前走十步,并且每次都将d->ht_table[0]的值以新的索引重定向到d->ht_table[1]上。
当d->ht_table[0]的数据为空,即d->ht_used[0] == 0 的时候,将d->ht_table[1]的值赋值给d->ht_table[0],重置d->ht_table[1]并将d->rehashidx 赋值 -1,也就是结束重哈希。

同样的,在插入,更新,删除操作中同样也会触发dictRehash来促进重哈希的过程(当然只有在重哈希过程的dict才会受影响),Redis用这种将重哈希的过程散步到多个请求中去,这种设计是为了保证稳定的快速响应,防止某个请求因为过大的hash table重哈希而迟迟没有响应。

PHP

PHP的hash table就更有意思了,因为在PHP,hash table不仅仅是Map,它同时还是数组,是栈,是队列。。是一个非常通用的设计。注意,PHP5和PHP7的hash table差异很大,这里我们只讨论PHP7的版本。(后续可能会写PHP5和PHP7之间hash table的设计差异)我们先来看看PHP7的hash table结构。(代码取自PHP-8.0分支,版本更新至2021-05-16)

struct _zend_array {
	zend_refcounted_h gc;
	union {
		struct {
			ZEND_ENDIAN_LOHI_4(
				zend_uchar    flags,
				zend_uchar    _unused,
				zend_uchar    nIteratorsCount,
				zend_uchar    _unused2)
		} v;
		uint32_t flags;
	} u;
	uint32_t          nTableMask; //掩码
	Bucket           *arData; //数据存储的地方
	uint32_t          nNumUsed; //已使用的桶的数量
	uint32_t          nNumOfElements; //有效桶数量
	uint32_t          nTableSize; //哈希表总大小,为2的n次方
	uint32_t          nInternalPointer; 
	zend_long         nNextFreeElement;
	dtor_func_t       pDestructor;
};

在上述的数据结构中,我们目前只需要关注做了中文注释的几个字段。
nTableMask是一个掩码,用来跟hash后的key做或运算(注意,这里用的是或运算),他的取值是nTableSize的2倍取反,代码如下

uint32_t nSize = ht->nTableSize;
ht->nTableMask = HT_SIZE_TO_MASK(nSize);

#define HT_SIZE_TO_MASK(nTableSize) \
	((uint32_t)(-((nTableSize) + (nTableSize))))

常规的hash table在做key映射的时候,常见的用法是

index = hash(key) & mask

而PHP的设计却用的是或运算,这个我们在后面会详细讨论。

至于nNumUsed和nNumOfElements两个字段,区别点在于hash table在对桶做删除操作的时候,并不是直接把桶删掉,而是对桶做一个删除标识,所以nNumUsed>=nNumOfElements,当nNumUsed - nNumOfElements的差值达到阈值ht->nNumUsed > ht->nNumOfElements + (ht->nNumOfElements >> 5)之后,会引发重哈希,这个过程会将删除了的桶移除掉。

最关键的字段,*arData,是一个指针,指向数据的头部,也就是第一个桶的地址。这里我们有必要看看桶的数据结构长什么样子。

typedef struct _Bucket {
	zval              val;
	zend_ulong        h; /* hash value (or numeric index)   */
	zend_string      *key; /* string key or NULL for numerics */
} Bucket;

zval是PHP存储数据的结构,可以是任意类型,PHP的弱类型就是用他实现的,这里不是我们今天讨论的重点,不详细展开。
剩下两个字段的含义,代码本身的备注已经很清晰了。

接下来我们借用鸟哥博客的一张图来帮我们理解。

2021-09-04

可以看到,arData指向了一个桶的数组,桶的结构我们已经了解了,细心的同学应该发现了诡异的地方,在Bucket 0 之前居然还有多个idx值,这里是怎么回事呢?

我们来看看hash table的一个初始化方法。

static zend_always_inline void zend_hash_real_init_packed_ex(HashTable *ht)
{
	void *data;

	if (UNEXPECTED(GC_FLAGS(ht) & IS_ARRAY_PERSISTENT)) {
		data = pemalloc(HT_SIZE_EX(ht->nTableSize, HT_MIN_MASK), 1);
	} else {
		data = emalloc(HT_SIZE_EX(ht->nTableSize, HT_MIN_MASK));
	}
	HT_SET_DATA_ADDR(ht, data);
	/* Don't overwrite iterator count. */
	ht->u.v.flags = HASH_FLAG_PACKED | HASH_FLAG_STATIC_KEYS;
	HT_HASH_RESET_PACKED(ht);
}

#define HT_SIZE_EX(nTableSize, nTableMask) \
	(HT_DATA_SIZE((nTableSize)) + HT_HASH_SIZE((nTableMask)))
    
#define HT_SET_DATA_ADDR(ht, ptr) do { \
		(ht)->arData = (Bucket*)(((char*)(ptr)) + HT_HASH_SIZE((ht)->nTableMask)); \
	} while (0)
    
#define HT_HASH_SIZE(nTableMask) \
	(((size_t)(uint32_t)-(int32_t)(nTableMask)) * sizeof(uint32_t))

原来PHP在给arData申请内存的时候是分成了两部分的,
data = emalloc(HT_SIZE_EX(ht->nTableSize, HT_MIN_MASK)); 即数据+索引两个部分,并且通过调用宏HT_SET_DATA_ADDR将arData指向了第一个桶,也就是说arData的左边是索引部分,右边是数据,这种设计还是比较有意思的。常规的哈希设计索引和数据是在一起的,对key做哈希之后做一个截断操作,得出的结果作为索引,直接数据存放于索引对应的位置。那么PHP7的这种设计,索引跟数据是怎么关联起来的呢?
我们通过PHP的hash table的查找方法来解释这个问题。

static zend_always_inline Bucket *zend_hash_find_bucket(const HashTable *ht, zend_string *key, zend_bool known_hash)
{
	zend_ulong h;
	uint32_t nIndex;
	uint32_t idx;
	Bucket *p, *arData;

	if (known_hash) {
		h = ZSTR_H(key);
	} else {
		h = zend_string_hash_val(key);
	}
	arData = ht->arData;
	nIndex = h | ht->nTableMask;
	idx = HT_HASH_EX(arData, nIndex);

	if (UNEXPECTED(idx == HT_INVALID_IDX)) {
		return NULL;
	}
	p = HT_HASH_TO_BUCKET_EX(arData, idx);
	if (EXPECTED(p->key == key)) { /* check for the same interned string */
		return p;
	}

	while (1) {
		if (p->h == ZSTR_H(key) &&
		    EXPECTED(p->key) &&
		    zend_string_equal_content(p->key, key)) {
			return p;
		}
		idx = Z_NEXT(p->val);
		if (idx == HT_INVALID_IDX) {
			return NULL;
		}
		p = HT_HASH_TO_BUCKET_EX(arData, idx);
		if (p->key == key) { /* check for the same interned string */
			return p;
		}
	}
}
  1. 在对key做hash之后可以得到一个值,赋值给变量h。
  2. 通过 nIndex = h | ht->nTableMask 操作,得出索引的下标。
  3. 根据第二步得出的下标,利用宏HT_HASH_EX计算出索引的值。
  4. 最终再用索引值和宏HT_HASH_TO_BUCKET_EX就可以找到我们要的结果。
  5. 当然这里有可能会存在哈希冲突的情况,我们在做第四步的时候,同时也要对比一下原始key,这个值就存放在Bucket结构的key字段里,如果相同,则返回值,否则会利用zval结构来做一个拉链的遍历。

为了避免篇幅过长,插入跟更新的代码就不贴了,这里面有3个点需要补充讲一下,

  1. 在插入值的时候,索引的下标通过计算得出,而数据的下标则是顺延的,比如上一个数据存放的位置是arData[2],那么下一个插入的位置是arData[3],这里衍生了一个经典的PHP问题,foreach和for谁更快的问题,答案是foreach快,因为foreach读数据就是直接顺着下标顺序去读就好了,不需要对key做任何的hash操作。而for的性能好坏则要分情况讨论了,因为PHP的数据并不一定总是需要索引的部分。细心的同学会发现,这里其实也解释了foreach的读取顺序问题。
  2. PHP7的哈希冲突时也是用拉链法解决的,其中拉链中的各个元素通过zval结构相连,hash table的索引指向链表的头部,这里的头部指的是hash(key)的值相同且最后一个插入hash table的数据。
  3. 在求索引下标的时候,使用的方法是或运算,nIndex = h | ht->nTableMask,这里与arData将索引和数据分开的设计是紧密关联的。
    前面我们说过nTableMask的取值是nTableSize的2倍取反,而nTableMask的取值是2的n次方,假设nTableSize为0000 1000,那么nTableMask的值为1111 0000(补码),跟nTableSize对应的4位全是0,这样就能保证结果落在数组索引的范围之内。
上一篇:5G时代下,打造安全高效的智慧矿山|3D选矿工艺


下一篇:Redis字典