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

Redis数据库键空间的数据组织方式

2021-06-027.6k 阅读

Redis 键空间概述

Redis 是一个基于内存的高性能键值对存储数据库,它的数据存储结构围绕着键空间(Keyspace)展开。键空间本质上是一个字典结构,类似于许多编程语言中的哈希表(Hash Table)。在这个字典中,键(Key)是唯一标识数据的字符串,而值(Value)则可以是 Redis 支持的各种数据类型,如字符串(String)、哈希表(Hash)、列表(List)、集合(Set)以及有序集合(Sorted Set)。

例如,当我们在 Redis 中执行 SET mykey "Hello, Redis!" 命令时,实际上就是在键空间中创建了一个键为 mykey,值为字符串 "Hello, Redis!" 的键值对。键空间为 Redis 提供了一种高效的方式来索引和访问数据,使得 Redis 能够快速地定位并操作特定的数据项。

键空间的底层数据结构

在 Redis 内部,键空间的实现依赖于字典数据结构。Redis 的字典结构是一个经过优化的哈希表,它由 dict 结构体表示,包含两个哈希表 ht[0]ht[1],这种设计主要是为了应对哈希表的扩容和缩容操作。

下面是 Redis 字典结构体的简化表示:

typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    int iterators; /* number of iterators currently running */
} dict;

其中,dictType 结构体定义了针对不同数据类型的操作函数,如哈希函数、比较函数等。privdata 是一个指针,用于传递特定于 dictType 的私有数据。ht 数组包含两个哈希表,rehashidx 用于记录当前是否正在进行 rehash 操作。

哈希表 dictht 的结构如下:

typedef struct dictht {
    dictEntry **table;
    unsigned long size;
    unsigned long sizemask;
    unsigned long used;
} dictht;

table 是一个指向 dictEntry 指针数组的指针,size 表示哈希表的大小,sizemask 用于计算哈希值的索引,used 记录了哈希表中已使用的节点数。

dictEntry 结构体则表示哈希表中的一个节点:

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

key 是键,v 是值,next 用于解决哈希冲突,采用链地址法(Separate Chaining)来处理多个键映射到相同哈希值的情况。

键空间的操作

  1. 添加键值对 当执行类似 SET key value 的命令时,Redis 会在键空间中添加一个新的键值对。首先,Redis 计算键的哈希值,然后通过哈希值确定在哈希表中的索引位置。如果该位置为空,则直接将新的 dictEntry 插入;如果该位置已有节点,则通过链表将新节点链接到该位置的链表尾部。

以下是简单的 Python 代码示例,通过 redis - py 库在 Redis 中添加键值对:

import redis

r = redis.Redis(host='localhost', port=6379, db = 0)
r.set('name', 'John')
  1. 获取键值对 执行 GET key 命令时,Redis 同样计算键的哈希值,找到对应的哈希表索引位置。然后遍历该位置的链表,通过比较键来查找匹配的 dictEntry。如果找到,则返回对应的值;否则返回 nil

Python 代码示例:

value = r.get('name')
print(value.decode('utf - 8'))
  1. 删除键值对 执行 DEL key 命令时,Redis 计算键的哈希值,定位到哈希表中的位置,然后在链表中查找并删除匹配的 dictEntry。如果删除的节点位于链表中间,需要调整链表指针以保持链表的连续性。

Python 代码示例:

r.delete('name')

键空间的 rehash 机制

随着键值对的不断添加和删除,哈希表的负载因子(load factor,即 used / size)会发生变化。当负载因子过高(超过一定阈值,如 1.0)时,哈希表的性能会下降,因为哈希冲突的概率增加。为了维持哈希表的高效性能,Redis 采用 rehash 机制,将哈希表的大小扩大一倍。

  1. 渐进式 rehash Redis 采用渐进式 rehash 方式,避免在一次操作中完成大量数据的迁移,从而影响 Redis 的性能。在渐进式 rehash 过程中,rehashidx 记录了当前 rehash 的进度。每次对键空间进行操作(如添加、获取、删除键值对)时,Redis 会顺带迁移一小部分哈希表中的数据,直到所有数据都从 ht[0] 迁移到 ht[1],然后将 ht[1] 设为当前哈希表,释放 ht[0],并将 rehashidx 设为 -1,表示 rehash 操作完成。

以下是简化的 rehash 代码逻辑(用 C 语言表示):

void dictRehash(dict *d, int n) {
    if (d->rehashidx == -1) return;

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

        /* 找到第一个非空的桶 */
        while (d->ht[0].table[d->rehashidx] == NULL) d->rehashidx++;
        de = d->ht[0].table[d->rehashidx];

        /* 将桶中的所有节点迁移到 ht[1] */
        while (de) {
            nextde = de->next;
            unsigned int h = dictHashFunction(d->type, 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++;
    }

    /* 如果所有数据都迁移完成,清理 ht[0] */
    if (d->ht[0].used == 0) {
        zfree(d->ht[0].table);
        d->ht[0] = d->ht[1];
        _dictReset(&d->ht[1]);
        d->rehashidx = -1;
    }
}
  1. 缩容 当哈希表中的键值对数量大幅减少,负载因子过低(如小于 0.1)时,Redis 会进行缩容操作,将哈希表的大小缩小为原来的一半,同样采用渐进式的方式进行,以避免性能抖动。

键空间与数据库

Redis 支持多个逻辑数据库,默认有 16 个数据库,通过 SELECT 命令切换。每个数据库都有自己独立的键空间,这些键空间在 Redis 内部以数组的形式存储。

以下是 Redis 数据库结构体的简化表示:

typedef struct redisDb {
    dict *dict;                 /* 键空间,存储键值对 */
    dict *expires;              /* 过期时间字典,存储键的过期时间 */
    dict *blocking_keys;        /* 被阻塞的键 */
    dict *ready_keys;           /* 已准备好的键 */
    dict *watched_keys;         /* 被 WATCH 命令监视的键 */
    int id;                     /* 数据库 ID */
    long long avg_ttl;          /* 平均 TTL,用于统计 */
    unsigned long expires_cursor; /* 用于渐进式删除过期键 */
    list *defrag_later;         /* 用于延迟碎片整理的键 */
} redisDb;

其中,dict 字段就是该数据库的键空间。不同数据库之间的键空间相互隔离,这意味着在一个数据库中设置的键不会影响其他数据库中的同名键。

键空间的过期策略

  1. 过期时间的存储 Redis 通过 expires 字典来存储键的过期时间。expires 字典的键与键空间字典的键相对应,值是一个时间戳,表示键的过期时间。例如,当执行 SET key value EX 10 命令时,Redis 会在键空间中设置键值对,并在 expires 字典中记录键 key 的过期时间为当前时间加上 10 秒。

  2. 过期策略 Redis 采用惰性删除(Lazy Deletion)和定期删除(Periodic Deletion)相结合的过期策略。

  • 惰性删除:当客户端访问一个键时,Redis 首先检查该键是否过期。如果过期,则删除该键并返回 nil;否则返回键的值。这种策略不会主动扫描过期键,只有在访问键时才进行检查,减少了对 CPU 的消耗,但可能会导致过期键在内存中停留一段时间。
  • 定期删除:Redis 会定期随机抽取一些键检查是否过期,并删除过期的键。通过这种方式,Redis 可以在一定程度上控制过期键在内存中的停留时间,避免内存浪费。定期删除的频率和每次检查的键数量可以通过配置参数进行调整。

以下是简化的惰性删除代码逻辑(用 C 语言表示):

robj *lookupKeyReadWithFlags(redisDb *db, robj *key, int flags) {
    dictEntry *de = dictFind(db->dict, key->ptr);
    if (de == NULL) return NULL;

    if (dictFind(db->expires, key->ptr) != NULL &&
        time(NULL) > dictGetSignedIntegerVal(dictFind(db->expires, key->ptr))) {
        /* 键已过期,删除键 */
        dictDelete(db->dict, key->ptr);
        dictDelete(db->expires, key->ptr);
        return NULL;
    }

    return dictGetVal(de);
}

键空间的通知机制

Redis 提供了键空间通知机制,允许客户端订阅关于键空间的事件,如键的创建、删除、修改等。通过这种机制,应用程序可以实时感知 Redis 键空间的变化,从而做出相应的处理。

  1. 通知类型 Redis 支持两种类型的通知:键空间通知(Keyspace Notifications)和键事件通知(Keyevent Notifications)。键空间通知以 __keyspace@<db>__: 为前缀,后面跟着触发事件的键名,如 __keyspace@0__:mykey;键事件通知以 __keyevent@<db>__: 为前缀,后面跟着事件类型,如 __keyevent@0__:del

  2. 订阅与发布 客户端可以通过 SUBSCRIBE 命令订阅键空间通知频道。例如,要订阅数据库 0 中所有键的删除事件,可以执行 SUBSCRIBE __keyevent@0__:del。当有键被删除时,Redis 会向该频道发布消息。

以下是 Python 代码示例,通过 redis - py 库订阅键空间通知:

import redis

r = redis.Redis(host='localhost', port=6379, db = 0)
p = r.pubsub()
p.subscribe('__keyevent@0__:del')

for message in p.listen():
    if message['type'] =='message':
        print('Key deleted:', message['data'].decode('utf - 8'))

键空间的持久化

Redis 支持两种持久化方式:RDB(Redis Database)和 AOF(Append - Only File),这两种方式都与键空间的数据组织密切相关。

  1. RDB 持久化 RDB 持久化是将 Redis 在某个时间点的键空间数据以快照的形式保存到磁盘上。在生成 RDB 文件时,Redis 会遍历键空间字典,将每个键值对按照特定的格式写入文件。对于不同的数据类型,如字符串、哈希表、列表等,会有不同的编码方式进行存储。

例如,对于一个简单的字符串键值对 SET mykey "Hello",在 RDB 文件中可能会以类似于以下的格式存储:

REDIS0009...key_lenmykeyvalue_lenHello

其中,REDIS0009 是 RDB 文件的版本标识,key_lenvalue_len 分别表示键和值的长度。

RDB 持久化的优点是文件紧凑,恢复速度快;缺点是可能会丢失最近一次持久化之后的数据,因为 RDB 是定期生成快照的。

  1. AOF 持久化 AOF 持久化是将 Redis 执行的写命令以追加的方式记录到日志文件中。每次有写操作发生时,Redis 会将对应的命令追加到 AOF 文件末尾。例如,执行 SET mykey "Hello" 命令后,AOF 文件中会追加一行 SET mykey "Hello"

AOF 持久化的优点是数据安全性高,因为可以通过重放 AOF 文件中的命令来恢复到最新状态;缺点是 AOF 文件可能会变得很大,需要定期进行重写(Rewrite)操作,以压缩文件大小。

键空间的内存管理

  1. 数据结构内存占用 Redis 键空间中的不同数据类型占用的内存大小不同。以字符串为例,其内存占用包括字符串本身的内容、长度信息以及一些元数据。对于哈希表、列表、集合和有序集合等复杂数据类型,还需要考虑内部数据结构的开销,如哈希表的桶数组、链表节点等。

例如,一个简单的哈希表,假设包含 10 个键值对,每个键值对的键和值都是长度为 10 的字符串。哈希表本身需要一定的内存来存储桶数组、负载因子等元数据,每个键值对在哈希表节点中也会占用一定的内存空间。

  1. 内存回收与碎片整理 当键值对被删除时,Redis 会回收对应的内存空间。然而,由于内存分配和释放的过程中可能会产生内存碎片,Redis 提供了内存碎片整理机制。通过配置参数 activedefrag 可以开启主动碎片整理功能,Redis 会在后台对内存进行整理,合并相邻的空闲内存块,提高内存利用率。

键空间的性能优化

  1. 合理设计键名 键名应该尽量简洁且具有描述性,避免过长的键名。因为键名本身也会占用内存空间,并且在哈希计算和比较时会消耗 CPU 资源。例如,使用 user:1:name 而不是冗长且含义不明确的键名。

  2. 批量操作 尽量使用批量操作命令,如 MSETMGET 等。这样可以减少客户端与服务器之间的网络交互次数,提高性能。例如,一次性设置多个用户的信息:

r.mset({
    'user:1:name': 'John',
    'user:1:age': 30,
    'user:2:name': 'Jane',
    'user:2:age': 25
})
  1. 优化数据结构选择 根据实际应用场景选择合适的数据结构。例如,如果需要存储具有唯一性且无序的数据,使用集合(Set);如果需要存储有序的数据,使用有序集合(Sorted Set)。选择合适的数据结构可以提高数据存储和检索的效率。

键空间的常见问题与解决方法

  1. 键冲突 虽然 Redis 的哈希表设计可以有效减少键冲突,但在极端情况下仍可能发生。如果发现键冲突频繁导致性能下降,可以考虑调整哈希表的大小(通过配置参数或等待 Redis 自动 rehash),或者优化键的哈希函数,使键的分布更加均匀。

  2. 内存溢出 当键空间占用的内存超过系统分配给 Redis 的内存时,会发生内存溢出。解决方法包括优化数据结构以减少内存占用、启用持久化并合理配置 RDB 和 AOF 策略,以及根据实际需求调整 Redis 的内存分配。

  3. 过期键清理不及时 如果发现过期键清理不及时,导致内存占用过高,可以适当调整定期删除的频率和每次检查的键数量。通过配置参数 hz 可以调整 Redis 的定期任务执行频率,active - expire - cycles 可以调整每次定期删除操作检查的键数量。

键空间在分布式环境中的应用

  1. Redis 集群 在 Redis 集群中,键空间被分片存储在多个节点上。Redis 集群采用哈希槽(Hash Slot)的方式来分配键,共有 16384 个哈希槽。每个键通过 CRC16 算法计算哈希值,然后对 16384 取模,得到对应的哈希槽编号,从而确定该键应该存储在哪个节点上。

例如,假设有一个 Redis 集群包含 3 个节点,节点 A 负责 0 - 5460 号哈希槽,节点 B 负责 5461 - 10922 号哈希槽,节点 C 负责 10923 - 16383 号哈希槽。当执行 SET mykey "value" 命令时,Redis 计算 mykey 的哈希槽编号,然后将键值对存储到对应的节点上。

  1. 分布式缓存 Redis 键空间在分布式缓存中广泛应用。通过在多个应用服务器之间共享 Redis 键空间,可以实现数据的缓存和共享。例如,在一个多服务器的 Web 应用中,多个服务器可以从 Redis 键空间中读取缓存数据,减少数据库的负载。同时,当数据发生变化时,通过更新 Redis 键空间中的数据,保证各个服务器获取到的数据一致性。

键空间的安全与访问控制

  1. 认证机制 Redis 支持通过密码进行认证,只有提供正确密码的客户端才能访问键空间。可以通过配置文件中的 requirepass 参数设置密码。例如,在 redis.conf 文件中添加 requirepass mypassword,然后重启 Redis 服务,客户端在连接 Redis 时需要使用 AUTH mypassword 命令进行认证。

  2. 访问控制列表(ACL) Redis 从 6.0 版本开始支持访问控制列表(ACL),可以更加精细地控制客户端对键空间的访问权限。通过 ACL 可以定义不同的用户,为每个用户分配不同的命令权限和键空间访问权限。例如,可以创建一个只读用户,只允许其执行 GET 等读取命令,禁止执行 SET 等写命令。

键空间的监控与分析

  1. INFO 命令 通过 INFO 命令可以获取 Redis 服务器的各种信息,包括键空间的统计信息,如每个数据库的键数量、过期键数量等。执行 INFO keyspace 可以获取键空间相关的详细信息:
# Keyspace
db0:keys=10,expires=2,avg_ttl=1000

其中,keys 表示数据库 0 中的键数量,expires 表示过期键数量,avg_ttl 表示平均 TTL。

  1. 慢查询日志 Redis 提供慢查询日志功能,可以记录执行时间超过一定阈值的命令。通过分析慢查询日志,可以发现对键空间操作的性能瓶颈。可以通过配置参数 slowlog - log - slower - than 设置慢查询的阈值,slowlog - max - len 设置慢查询日志的最大长度。

键空间的未来发展趋势

  1. 数据类型扩展 随着应用场景的不断丰富,Redis 可能会进一步扩展键空间支持的数据类型。例如,增加对地理空间数据类型的更完善支持,以满足基于位置服务(LBS)等应用的需求。

  2. 与其他技术融合 Redis 键空间可能会与更多的技术进行融合,如与大数据处理框架结合,实现对海量数据的实时处理和分析。通过将 Redis 键空间作为数据的高速缓存和实时交互层,与大数据框架协同工作,提升整体系统的性能和功能。

  3. 性能优化与创新 在性能方面,Redis 可能会继续优化键空间的存储和访问机制,如进一步改进哈希表的实现,提高 rehash 操作的效率,减少内存碎片等,以满足日益增长的高性能应用需求。同时,可能会引入新的技术和算法,提升键空间在分布式环境下的可用性和一致性。