MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Redis 解决字典键冲突的创新方法

2022-04-233.6k 阅读

Redis 字典结构概述

Redis 作为一款高性能的键值对存储数据库,其内部的数据结构设计十分精妙。在 Redis 中,字典(dict)是一种非常基础且重要的数据结构,用于实现数据库的键空间以及哈希对象等功能。

Redis 的字典采用了哈希表的实现方式,哈希表能够在平均情况下以接近常数的时间复杂度进行插入、查找和删除操作。一个哈希表由一个数组组成,数组中的每个元素称为哈希桶(bucket),每个哈希桶存储一个指向 dictEntry 结构的指针。

// Redis 哈希表结构定义
typedef struct dictht {
    dictEntry **table;  // 哈希表数组
    unsigned long size; // 哈希表大小
    unsigned long sizemask; // 用于计算索引的掩码
    unsigned long used; // 已使用的哈希桶数量
} dictht;
// 哈希表节点结构定义
typedef struct dictEntry {
    void *key;  // 键
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;  // 值
    struct dictEntry *next;  // 指向下一个哈希表节点,解决冲突用
} dictEntry;

传统哈希表键冲突问题

在哈希表的使用过程中,键冲突是一个不可避免的问题。所谓键冲突,是指不同的键通过哈希函数计算得到了相同的哈希值,从而导致它们需要被存储到同一个哈希桶中。

传统哈希表解决键冲突的方法主要有两种:开放地址法和链地址法。

开放地址法

开放地址法是当发生键冲突时,在哈希表中寻找下一个空闲的位置来存储新的键值对。常见的开放地址法有线性探测、二次探测和双重哈希等。以线性探测为例,当计算出的哈希桶位置已被占用时,就顺序往后查找下一个空闲位置。

# 简单的线性探测开放地址法示例
class OpenAddressingHashTable:
    def __init__(self, size):
        self.size = size
        self.table = [None] * size

    def hash_function(self, key):
        return hash(key) % self.size

    def insert(self, key, value):
        index = self.hash_function(key)
        while self.table[index] is not None:
            index = (index + 1) % self.size
        self.table[index] = (key, value)

    def search(self, key):
        index = self.hash_function(key)
        while self.table[index] is not None:
            if self.table[index][0] == key:
                return self.table[index][1]
            index = (index + 1) % self.size
        return None

然而,开放地址法存在一些问题。比如线性探测容易出现聚集现象,即连续的哈希桶被占用,导致后续查找和插入操作的性能下降。

链地址法

链地址法是将所有哈希值相同的键值对用链表连接起来,存储在同一个哈希桶中。当查找某个键时,先通过哈希函数找到对应的哈希桶,然后在链表中遍历查找。

# 简单的链地址法示例
class Node:
    def __init__(self, key, value):
        self.key = key
        self.value = value
        self.next = None

class ChainingHashTable:
    def __init__(self, size):
        self.size = size
        self.table = [None] * size

    def hash_function(self, key):
        return hash(key) % self.size

    def insert(self, key, value):
        index = self.hash_function(key)
        new_node = Node(key, value)
        if self.table[index] is None:
            self.table[index] = new_node
        else:
            current = self.table[index]
            while current.next is not None:
                current = current.next
            current.next = new_node

    def search(self, key):
        index = self.hash_function(key)
        current = self.table[index]
        while current is not None:
            if current.key == key:
                return current.value
            current = current.next
        return None

链地址法虽然避免了聚集现象,但当链表过长时,查找、插入和删除操作的时间复杂度会退化为 O(n),n 为链表长度。

Redis 解决键冲突的方法

Redis 采用了链地址法来解决键冲突,但在此基础上进行了一些创新和优化。

双哈希表结构

Redis 的字典结构采用了双哈希表,即 ht[0] 和 ht[1]。正常情况下,数据存储在 ht[0] 中。当 ht[0] 的负载因子(used / size)超过一定阈值(默认为 1)时,会触发 rehash 操作,将 ht[0] 中的数据重新哈希到 ht[1] 中。

// Redis 字典结构定义
typedef struct dict {
    dictType *type;  // 字典类型特定函数
    void *privdata;  // 字典类型特定参数
    dictht ht[2];  // 两个哈希表
    int rehashidx;  // rehash 索引,-1 表示未进行 rehash
    unsigned long iterators;  // 正在使用的迭代器数量
} dict;

渐进式 rehash

在进行 rehash 操作时,Redis 并没有一次性将 ht[0] 中的所有数据迁移到 ht[1] 中,而是采用了渐进式 rehash 的方式。在每次执行字典的插入、删除、查找或迭代操作时,Redis 会顺便将 ht[0] 中的一小部分数据迁移到 ht[1] 中。

// 渐进式 rehash 示例代码片段
static 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[0].used != 0) {
        dictEntry *de, *nextde;

        /* Note that rehashidx can't overflow as we are sure there are more
         * elements because ht[0].used != 0 */
        assert(d->ht[0].size > (unsigned long)d->rehashidx);
        while (d->ht[0].table[d->rehashidx] == NULL) {
            d->rehashidx++;
            if (--empty_visits == 0) return 1;
        }
        de = d->ht[0].table[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) & d->ht[1].sizemask;
            de->next = d->ht[1].table[h];
            d->ht[1].table[h] = de;
            d->ht[0].used--;
            d->ht[1].used++;
            de = nextde;
        }
        d->ht[0].table[d->rehashidx] = NULL;
        d->rehashidx++;
    }

    /* Check if we already rehashed the whole table... */
    if (d->ht[0].used == 0) {
        zfree(d->ht[0].table);
        d->ht[0] = d->ht[1];
        _dictReset(&d->ht[1]);
        d->rehashidx = -1;
        return 0;
    }

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

渐进式 rehash 的好处在于,它避免了一次性 rehash 带来的大量计算和内存开销,使得 Redis 在 rehash 过程中仍然能够保持高性能,不会因为 rehash 操作而长时间阻塞其他操作。

哈希函数优化

Redis 使用 MurmurHash2 作为哈希函数。MurmurHash2 是一种非加密型哈希函数,具有高运算速度和低碰撞率的特点。它的计算过程相对简单,能够在较短的时间内为不同的键生成较为均匀的哈希值。

// Redis 中使用的 MurmurHash2 哈希函数实现片段
static uint32_t dictGenHashFunction(const void *key, int len) {
    uint32_t h = 0;
    const uint32_t seed = 5381;
    const uint8_t *p = key;
    while (len--) {
        h = (h << 5) + h + (*p++);
    }
    return h;
}

通过使用 MurmurHash2 这样优秀的哈希函数,Redis 进一步降低了键冲突的概率,提高了哈希表的性能。

代码示例分析

下面我们通过一个简单的 Python 模拟 Redis 字典操作的示例来深入理解 Redis 解决键冲突的机制。

import random


class RedisDict:
    def __init__(self):
        self.size = 4
        self.ht = [
            {i: None for i in range(self.size)},
            {i: None for i in range(self.size)}
        ]
        self.rehashidx = -1
        self.threshold = 1

    def hash_function(self, key):
        return hash(key) % self.size

    def insert(self, key, value):
        if self.rehashidx != -1:
            self._rehash_step()
        ht_index = 0 if self.rehashidx == -1 else 1
        index = self.hash_function(key)
        if self.ht[ht_index][index] is None:
            self.ht[ht_index][index] = []
        self.ht[ht_index][index].append((key, value))
        if self._load_factor() >= self.threshold:
            self._start_rehash()

    def _load_factor(self):
        used = sum([1 for sublist in self.ht[0].values() if sublist is not None])
        return used / self.size

    def _start_rehash(self):
        if self.rehashidx != -1:
            return
        self.rehashidx = 0
        self.ht[1] = {i: None for i in range(self.size * 2)}

    def _rehash_step(self):
        while self.ht[0][self.rehashidx] is None:
            self.rehashidx += 1
            if self.rehashidx >= self.size:
                break
        if self.rehashidx >= self.size:
            self.ht[0] = self.ht[1]
            self.ht[1] = {i: None for i in range(self.size * 2)}
            self.rehashidx = -1
            self.size *= 2
            return
        bucket = self.ht[0][self.rehashidx]
        if bucket is not None:
            for key, value in bucket:
                index = self.hash_function(key) & (len(self.ht[1]) - 1)
                if self.ht[1][index] is None:
                    self.ht[1][index] = []
                self.ht[1][index].append((key, value))
            self.ht[0][self.rehashidx] = None
        self.rehashidx += 1

    def search(self, key):
        if self.rehashidx != -1:
            self._rehash_step()
        for ht_index in range(2):
            index = self.hash_function(key)
            bucket = self.ht[ht_index][index]
            if bucket is not None:
                for k, v in bucket:
                    if k == key:
                        return v
        return None


# 测试代码
rd = RedisDict()
keys = [random.randint(1, 100) for _ in range(20)]
for key in keys:
    rd.insert(key, key * 2)
for key in keys:
    value = rd.search(key)
    if value is not None:
        print(f"Key: {key}, Value: {value}")

在上述代码中,RedisDict 类模拟了 Redis 的字典结构。insert 方法在插入键值对时,会检查是否正在进行 rehash,如果是则执行一步 rehash 操作。当负载因子超过阈值时,会启动 rehash 过程。search 方法在查找键时也会处理 rehash 情况。通过这个示例,我们可以直观地看到 Redis 字典如何在实际操作中解决键冲突以及进行 rehash 操作。

总结 Redis 解决键冲突方法的优势

Redis 通过双哈希表结构、渐进式 rehash 和哈希函数优化等创新方法,有效地解决了哈希表中的键冲突问题,并在高负载情况下仍能保持高性能。

双哈希表结构和渐进式 rehash 的结合,使得 Redis 在 rehash 过程中不会因为一次性处理大量数据而导致性能急剧下降,保证了系统的持续可用性。而 MurmurHash2 哈希函数的使用,从源头上降低了键冲突的概率,进一步提升了哈希表的整体性能。这些方法的综合运用,是 Redis 能够成为高性能键值对存储数据库的重要原因之一。在实际应用中,理解和掌握 Redis 解决键冲突的机制,对于优化 Redis 应用的性能和稳定性具有重要意义。