那些年背过的题目:Redis数据库设计与实现

283 阅读10分钟

基本架构

Redis 是一个基于内存的键值数据库,主要结构如下:

  • Server:整个 Redis 实例。
  • Database:每个 Redis 实例包含多个数据库(默认16个)。
  • Key-Value Store:每个数据库是一个键值对集合,键和值可以是多种数据类型。

1. 数据库结构

Redis 的数据库结构体 redisDb 定义如下:

typedef struct redisDb {
    dict *dict;                // 主字典,存储所有键值对
    dict *expires;             // 过期字典,存储键的过期时间
    dict *blocking_keys;       // 阻塞等待的键
    dict *ready_keys;          // 已准备好的键
    dict *watched_keys;        // 被 WATCH 命令监控的键
    int id;                    // 数据库 ID
} redisDb;
  • dict:主字典,用来存储所有的键值对。
  • expires:过期字典,记录键的过期时间。
  • blocking_keysready_keys 和 watched_keys:用于管理阻塞操作、通知和事务。

2. 全局数据库数组

Redis 服务器实例 (redisServer) 中包含了一个 redisDb 数组,用于存放所有的数据库:

struct redisServer {
    redisDb *db; // 数据库数组
    int dbnum;   // 数据库数量,默认是 16
    ...
};
  • db:指向数据库数组的指针。
  • dbnum:数据库的数量,默认是 16,可以通过配置文件修改。

3. 数据库切换

Redis 支持使用 SELECT 命令切换当前数据库:

void selectCommand(client *c) {
    long id;

    if (getLongFromObjectOrReply(c, c->argv[1], &id, NULL) != C_OK)
        return;
    if (id < 0 || id >= server.dbnum) {
        addReplyError(c,"invalid DB index");
        return;
    }
    c->db = &server.db[id];
    addReply(c,shared.ok);
}
  • 参数解析:首先从命令参数中解析出数据库 ID。
  • 合法性检查:检查数据库 ID 是否在合法范围内(0 到 server.dbnum - 1)。
  • 数据库切换:将客户端的 db 指针指向指定的数据库。

4. 命令执行中的数据库访问

在具体的命令实现中,通过客户端上下文访问相应的数据库。例如,设置键值对的 SET 命令:

void setCommand(client *c) {
    int flags = OBJ_SET_NO_FLAGS;
    robj *key = c->argv[1];
    robj *val = c->argv[2];

    setKey(c,c->db,key,val);
    notifyKeyspaceEvent(NOTIFY_STRING,"set",key,c->db->id);
    addReply(c,shared.ok);
}
  • c->db:当前客户端所选择的数据库。
  • setKey:在当前数据库里进行键值设置操作。

5. 数据存储与隔离

每个 redisDb 都有自己独立的字典 (dict),各数据库之间的数据互不影响,实现了逻辑上的隔离:

int dbAdd(redisDb *db, robj *key, robj *val) {
    sds copy = sdsdup(key->ptr);
    int retval = dictAdd(db->dict, copy, val);
    return retval == DICT_OK ? C_OK : C_ERR;
}
  • 独立字典:每个数据库有自己的 dict,管理自己的键值对。
  • 独立操作:数据库操作都是针对当前数据库的字典进行,不会影响其他数据库。

6. 数据库数量的配置

在 Redis 的配置文件中,可以调整数据库的数量:

# 默认数据库数量为 16
databases 16

启动时,Redis 会根据配置文件初始化数据库数组:

server.db = zmalloc(sizeof(redisDb)*server.dbnum);
for (int j = 0; j < server.dbnum; j++) {
    server.db[j].dict = dictCreate(&dbDictType,NULL);
    server.db[j].expires = dictCreate(&dbExpiresDictType,NULL);
    server.db[j].blocking_keys = dictCreate(&dbBlockingDictType,NULL);
    server.db[j].ready_keys = dictCreate(&dbReadyKeysDictType,NULL);
    server.db[j].watched_keys = dictCreate(&dbWatchedKeysDictType,NULL);
    server.db[j].id = j;
}

数据库切换与操作

当一个客户端连接到 Redis 服务器时,它会默认连接到数据库 0。客户端可以通过 SELECT 命令切换到其他数据库,这个过程非常轻量且高效。

切换数据库的实现:

void selectCommand(client *c) {
    long id;

    if (getLongFromObjectOrReply(c, c->argv[1], &id, NULL) != C_OK)
        return;
    if (id < 0 || id >= server.dbnum) {
        addReplyError(c,"invalid DB index");
        return;
    }
    c->db = &server.db[id];
    addReply(c,shared.ok);
}

查找操作如何定位到当前数据库,找到对应数据库中的数据

在 Redis 中,查找操作包括多个步骤,其中关键的一步是定位到当前客户端选择的数据库,并在该数据库中执行查找操作。下面将详细解析这一过程的源码实现流程。

1. 客户端请求处理

Redis 接收到客户端的请求后,会首先进行命令解析。每个客户端都有一个上下文(client 结构体),其中包含了当前所选择的数据库指针。

2. 客户端结构体

client 结构体中包含了与客户端相关的所有信息,包括当前选择的数据库:

typedef struct client {
    redisDb *db;           // 指向当前数据库的指针
    int argc;              // 命令参数数量
    robj **argv;           // 命令参数数组
    ...
} client;

3. 命令分派与处理

当 Redis 接收到一个 GET 请求时,会调用对应的处理函数 getCommand。处理函数会使用客户端上下文中的数据库指针来执行操作。

getCommand 实现:

void getCommand(client *c) {
    robj *o;

    // 从当前数据库中查找键
    o = lookupKeyReadOrReply(c,c->argv[1],shared.nullbulk);
    if (o == NULL || checkType(c,o,OBJ_STRING)) return;

    addReplyBulk(c,o);
}

在这里,lookupKeyReadOrReply 函数用于在当前客户端选择的数据库中查找键。如果未找到键,则返回特定的回复信息(如空值)。

4. 查找键的核心函数

lookupKeyReadOrReply 实现:

robj *lookupKeyReadOrReply(client *c, robj *key, robj *reply) {
    robj *o = lookupKeyRead(c->db,key);
    if (!o) addReply(c,reply);
    return o;
}

robj *lookupKeyRead(redisDb *db, robj *key) {
    return lookupKey(db,key,LOOKUP_NONE);
}
  • lookupKeyReadOrReply:尝试从当前数据库中读取键值,如果未找到则回复客户端特定信息。
  • lookupKeyRead:直接调用 lookupKey 在指定数据库中查找键值。

5. 数据库结构体

每个数据库在 Redis 中都由 redisDb 结构体表示:

typedef struct redisDb {
    dict *dict;                // 主字典,存储所有键值对
    dict *expires;             // 过期字典,存储键的过期时间
    ...
    int id;                    // 数据库 ID
} redisDb;

6. 键的查找

lookupKey 实现:

robj *lookupKey(redisDb *db, robj *key, int flags) {
    dictEntry *de = dictFind(db->dict, key->ptr);
    if (de) {
        robj *val = dictGetVal(de);

        if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
            updateLFU(val);
        } else if (server.maxmemory_policy & MAXMEMORY_FLAG_LRU) {
            updateLRU(val);
        }
        return val;
    } else {
        return NULL;
    }
}
  • dictFind:在数据库的主字典中查找键。
  • dictGetVal:获取字典条目中的值。

7. 字典查找机制

dictFind 实现:

dictEntry *dictFind(dict *d, const void *key) {
    if (d->ht[0].used + d->ht[1].used == 0) return NULL;

    unsigned int h = dictHashFunction(key);
    dictEntry *he;

    // 遍历第一个哈希表
    he = d->ht[0].table[h & d->ht[0].sizemask];
    while (he) {
        if (dictCompareKeys(d, key, he->key))
            return he;
        he = he->next;
    }

    // 如果正在进行rehash,遍历第二个哈希表
    if (d->ht[1].table) {
        he = d->ht[1].table[h & d->ht[1].sizemask];
        while (he) {
            if (dictCompareKeys(d, key, he->key))
                return he;
            he = he->next;
        }
    }

    return NULL;
}
  • 哈希计算:使用哈希函数计算键的哈希值。
  • 桶位置:根据哈希值找到对应的桶位置。
  • 链表遍历:在相应的桶中遍历链表,查找匹配的键。
  • rehash 处理:如果正在进行 rehash(重新构建哈希表),会同时检查两个哈希表。

8. 返回结果

一旦找到键,lookupKey 函数会返回找到的键值对象 (robj) 给上层调用者。对于 getCommand 来说,如果成功找到键,则将其值返回给客户端。

完整的 getCommand 逻辑:

void getCommand(client *c) {
    robj *o;

    // 从当前数据库中查找键
    o = lookupKeyReadOrReply(c,c->argv[1],shared.nullbulk);
    if (o == NULL || checkType(c,o,OBJ_STRING)) return;

    addReplyBulk(c,o);
}
  • 查找键:通过 lookupKeyReadOrReply 在当前数据库中查找键。
  • 类型检查:确保键对应的值是字符串类型。
  • 发送回复:将找到的值发送给客户端。

9. 数据库切换

在 Redis 中,每个客户端可以在连接过程中使用 SELECT 命令来切换数据库。例如:

void selectCommand(client *c) {
    long id;

    if (getLongFromObjectOrReply(c, c->argv[1], &id, NULL) != C_OK)
        return;
    if (id < 0 || id >= server.dbnum) {
        addReplyError(c,"invalid DB index");
        return;
    }
    c->db = &server.db[id];
    addReply(c,shared.ok);
}
  • 解析数据库 ID:从命令参数中获取数据库 ID。
  • 合法性检查:检查数据库 ID 是否在合法范围内。
  • 切换数据库:将客户端上下文中的数据库指针指向指定的数据库。

总结

Redis 查找操作的流程包括以下几个关键步骤:

  1. 客户端请求处理:接收并解析客户端请求。
  2. 命令分派与处理:根据请求调用相应的命令处理函数。
  3. 定位当前数据库:每个客户端都有一个指向当前数据库的指针(client->db)。
  4. 在数据库中查找键:调用 lookupKey 等函数,从数据库的主字典中查找键。
  5. 返回结果:找到键后,将其值返回给客户端。

思考题1: redis为什么设计16个数据库

Redis 默认配置了 16 个数据库,这一设计选择背后有多方面的考虑:

1. 简单有效的隔离机制

  • 逻辑隔离:通过提供多个数据库,用户可以在一个 Redis 实例内实现简单的逻辑数据隔离。
  • 轻量级:不同于 PostgreSQL 等数据库系统中的多数据库实例,Redis 的多数据库是非常轻量级的,它们共享同一个进程和资源,只是在逻辑上进行划分。

2. 避免复杂性

  • 统一管理:多个数据库共享同一个 Redis 配置和持久化机制,这简化了系统管理和运维。
  • 减少配置复杂度:如果每个用户或应用都需要启动一个独立的 Redis 实例,将会导致高昂的资源消耗和复杂的管理任务。16 个数据库的设置在一定程度上解决了这一问题,同时避免了过多配置带来的复杂性。

3. 适应多数使用场景

  • 常见需求覆盖:对于大多数应用场景来说,16 个数据库已经足够用来实现数据隔离。例如,不同模块、功能或环境的数据存储。
  • 合理限制:通过限制数据库数量,Redis 可以更好地控制资源使用,防止因滥用而造成的性能问题。

4. 操作简单

  • 切换便捷:使用 SELECT 命令即可轻松切换数据库,无需额外配置和操作。
  • 命名空间:用户可以通过不同的数据库 ID 来实现简单的命名空间,避免键冲突。

5. 历史原因与向下兼容

  • 早期设计:16 个数据库的设计源自早期版本,同时为了保持向下兼容性,这个默认值一直保留至今。
  • 灵活调整:尽管默认是 16 个数据库,用户仍可以通过修改配置文件中的 databases 参数来调整数据库数量,满足特定需求。
# redis.conf 配置文件示例
databases 16

案例分析

以下是一些具体案例,说明为什么 16 个数据库在很多情况下是恰当且实用的:

  • 开发测试环境:一个团队可能有多个开发和测试环境,可以将不同环境的数据放在不同的数据库中。
  • 模块分离:一个大型应用可以将不同模块的数据分散到不同数据库中,以减少代码层面的命名冲突。
  • 多租户应用:一些简单的多租户应用可以利用 Redis 的多个数据库来实现不同租户间的数据隔离。

限制与注意事项

尽管 Redis 提供了多达 16 个数据库,但也存在一些限制和需要注意的地方:

  • 非独立实例:不同数据库之间没有真正的物理隔离,共享同一个内存池和 CPU 资源,因此一个数据库的高负载可能影响其他数据库。
  • 集群模式不支持多数据库:在 Redis Cluster 模式下,只能使用数据库 0。这是因为集群模式主要通过分片(sharding)来管理数据,支持多数据库会增加实现复杂度。