Redis 命令处理过程

news2024/11/26 0:42:08

我们知道 Redis 是一个基于内存的高性能键值数据库, 它支持多种数据结构, 提供了丰富的命令, 可以用来实现缓存、消息队列、分布式锁等功能。
而在享受 Redis 带来的种种好处时, 是否曾好奇过 Redis 是如何处理我们发往它的命令的呢?

本文将以伪代码的形式简单分析一下 Redis 命令处理的过程, 探讨其背后的机制。

1 例子

set myKey myValue
ok

上面是一个简单的 Redis 命令执行过程:

  1. 用户借助 Redis 的客户端 (redis-cli, 或者各种第三方的客户端) 向 Redis 服务端发送了一个 set 命令
  2. Redis 服务端将其后面的 myKey 和 myValue 存储下来
  3. Redis 服务端再向客户端响应一个 ok 值, 表示处理成功。

下面我们就以这个为例子, 来分析一下 Redis 命令处理的过程。

备注:

  1. 下面的逻辑, 是按照 Redis 5.x 的版本进行分析的, 各个大版本之间可能会有出入
  2. 在伪代码分析过程中, 会将大部分无关的逻辑和异常场景进行省略
  3. 因为整个过程会涉及到大量 Redis 事件轮询的知识和以 set 为例, 会涉及 Redis String 编码的知识, 可以先看一下往期这 2 篇先了解一下

2 认识一下 redisServer 和 client

在真正进行分析前, 有 2 个对象需要先介绍一下, 可以说他们贯穿了整个命令处理的过程。

2.1 redisServer

redisServer 可以看做是整个 Redis 服务端运行时的上下文, 保存着整个 Redis 的配置和运行中产生的数据。

public class redisServer {
    
    // Tcp 连接对应的文件描述符 fd 存放的数组
    int[] ipfd = new int[16];
    
    // 所有存入到 Redis 中的数据, 都会存放到这里
    redisDb[] db = new redisDb[16];
    
    // 命令字典, 保存着 Redis 支持的所有命令
    // Redis 支持的所有命令和命令对应的执行函数, 都会存放到这里
    dict commands;
    
    // 一个双向链表, 所有连接到 Redis 的客户端, 都会存放到这里
    List<client> clients;
    
    // 向 Redis 发起请求的客户端, Redis 处理完成后, 如果需要响应客户端数据
    // 会将这个客户端先放到这里, 后面统一响应
    List<client> clients_pending_write;
    
    // 事件循环, Redis 的核心
    aeEventLoop el;
}
2.1.1 int[] ipfd
bind 127.0.0.1 

上面的配置应该很熟悉吧。
在 Redis 的配置文件中, 加上了这个配置, 就表示 Redis 只有本地可以访问, 因为他只会监听本地机器上的的连接, 当然也可以配置具体的 Ip 地址。

在 Redis 服务端启动后, 会和bind 指定的 Ip 地址 建立对应的 Tcp 连接, 同时会获取到一个文件描述符 fd (可以理解代表当前 Tcp 连接的唯一 Id, 持有这个文件描述符, 代表了持有了对应的端口的监听能力),
并将连接的 fd 存放在这个 ipfd 数组中, 最大支持 16 个连接。

2.1.2 redisDb[] db

Redis 本身默认支持 16 个数据库, 只是我们正常情况都是在使用 0 号数据库。 可以通过 select [0 到 15] 进行切换。
而这个 redisDb[] db 是一个长度为 16 的数组, 每个元素都是一个 redisDb 对象, 代表着一个 Redis 数据库。

redisDb 本身的定义很简单, 如下:

Alt 'redisDb 定义'

其中 dict 是字典的意思, 本身就是一个 key-value 的数据结构, 可以直接看做是一个 Map (JDK 1.7 的 HashMap), 本质是一个数组, 数组中的每个元素是一个 dictEntry。
当发送了 set myKey myValue 到服务端, myKey, myValue 就会以键值对的形式存储在 redisDb 中的 dict 中。

2.1.3 dict commands

首先它也是一个 dict, 也就是一个 Map, 一个 key-value 的映射属性, 具体的含义就是命令字典。

在平时中执行的 Redis 命令, 这个命令对应的执行函数就是存放在这里, 格式如: Map<命令的 key, redisCommand>。
当发送了 set myKey myValue 到服务端, 服务端就用通过 set 这个命令 key 到这里找到对应的 setCommand, 然后执行里面的函数。

2.1.4 List<client> clients

客户端双向链表。
Redis 本身是支持多个客户端的, Redis 接收到客户端的连接后, Redis 内部会将其封装为 client, 然后维护在这个双向链表。
具体的属性下面讲解。

2.1.5 List<client> clients_pending_write

待响应客户端双向链表。
服务端处理完客户端的请求后, 可能需要响应客户端结果, 也就是响应数据。
而 Redis 不是处理完就立即响应的, 而是先将响应结果放到客户端的输出缓存区, 然后再后面统一一起响应。
所以, 有数据需要响应的客户端, 会将其放到这个链表中。

2.1.6 aeEventLoop *el

事件轮询对象: 本质就是一个包装了多路复用的死循环。

大体的实现如下:
Alt 'aeEventLoop 定义'

2.2 client

client 是理解 Redis 命令处理过程的另一个重要对象, 他代表着一个客户端连接。
Redis 客户端连接到服务端时, 服务端将这个客户端的封装成这个对象。

client 本身的属性如下:

public class client {
    
    // 同 redisServer 的 ipfd
    // 当 Redis 接收到客户端的连接后, 会获取到一个代表这个客户端 Tcp 连接的文件描述符 fd, 然后存放到这个属性中
    int fd;
    
    // 当前客户端的是否已经经过了密码认证, 0 代表未认证, 1 代表已认证
    int authenticated;

    // 输入缓存区, 客户端发送过来的数据会先存放在这里
    sds querybuf;
    
    // 命令参数的个数, 一个客户端发送过来的命令, 会被 Redis 拆分成多个参数
    // 比如 set myKey myValue, 总共 3 个参数
    int argc;
    
    // 命令参数, 一个客户端发送过来的命令, 会被 Redis 拆分成多个参数
    // 比如 set myKey myValue, 就会被拆分成 3 个参数, 3 个参数会存放在这个数组中
    robj[] argv;
    
    // 一个数组, 固定输出缓冲区, 用来存放响应客户端的数据
    char[] buf = new char[16 * 1024];
    
    // 一个链表, 动态输出缓冲区, 同样是用来存放响应客户端的数据
    List<clientReplyBlock> reply;
}

下面介绍一下几个重要的属性。

2.2.1 sds querybuf

输入缓冲区。
客户端发送到服务端的数据, Redis 服务端收到了, 会先存放到这里。实现结构是一个 sds。 大体的定义如下:
Alt 'sds 定义'

2.2.2 robj[] argv

querybuf 中的数据进行解析后的数据存放的地方, 具体的属性是一个 redisObject 的数组。
而一个 sds 类型 redisObject 的结构如下:
Alt ‘sds 类型的 redisObject'

2.2.3 char[] buf

一个可以存放 16 * 1024 个字符的数组。 客户端发送的命令, Redis 服务端处理完成后, 需要进行响应, 而响应的内容会先存放到这里。
因为是一个长度固定的数组, 所以叫做固定输出缓冲区, 最多可以存放 16kb 的响应数据。

2.2.4 List<clientReplyBlock> reply

动态输出缓冲区
当 Redis 服务端响应客户端数据大于上面的 char[] buf 的容量时, 就先放到这里 (双向链表理论上没有大小限制)。

本质是一个 clientReplyBlock 的双向链表。
clientReplyBlock 的定义也很简单。如下, 可以简单的看做是一个 char[] 的封装。
Alt 'clientReplyBlock 定义'

可以看出来, Redis 的响应缓存区是由一个固定大小的 char 数组加一个动态变化的 char 数组链表共同构成的。
这么组织的好处是: 16kb 的固定 buffer, 基本满足大部分的情况的使用, 提前声明好可以避免频繁分配、回收内存。
动态的响应链表则是起到一个兜底的作用, 保证大数据量时的响应。而本身在需要时进行再分配内存, 使用后释放, 可以起到节省内存的作用。

到此, Redis 命令处理过程中的 2 个重要对象: redisServer 和 client 就介绍完了, 只需要大体知道 2 个对象里面有哪些属性, 大体是干什么的即可,
怎么实现等都可以不用深入, 在开始前先介绍这 2 个对象, 只是是为了后面的分析更加清晰。

3 Redis 服务端启动流程

./redis-server ./redis.conf --port 6666 --dbfilename dump.rdb

在服务器上可以通过上面的命令启动一个 Redis 服务端。
启动脚本 redis-server 后面紧跟的是 Redis 的配置文件, 再后面是用户想要指定的参数 (这里将端口修改为 6666)。

整个启动的过程如下:
Alt 'Redis 服务端启动流程'

  1. 通过脚本启动 Redis 服务端
  2. 创建一个 redisServer 对象, 这时 redisServer 里面所有的配置都是默认值, 比如监听的端口, 连接超时等
  3. 读取配置文件和命令行参数并覆盖掉 redisServer 里面的默认配置, 比如这里的端口, 默认为 6379, 通过命令行参数覆盖为 6666, 在这个过程, 还会将 server.c 里面写好的命令和命令对应的函数从一个静态数组中加载到 redisServer 的 commands 字典中
  4. 将 redisServer 里面的事件轮询 aeEventLoop 创建出来
  5. 和配置文件里面的 bind 地址 + 启动端口建立起 Tcp 连接, 可以得到对应连接的文件描述 fd, 可以理解为一个 Id
  6. 为每一个文件描述符, 也就是 Tcp 连接, 在事件轮询中注册一个可读的文件事件, 执行函数为 acceptTcpHandler (可以理解为告诉多路复用, 关心对应的 Tcp 连接的连接事件, 触发了执行 acceptTcpHandler 函数)
  7. 从磁盘中将 Redis 上次运行的数据加载到 redisServer 的 16 个 redisDb 中 (如果有的话)
  8. 设置事件轮询的阻塞前和阻塞后执行的函数
  9. 启动事件轮询, 进入一个死循环, 整个 Redis 服务端启动成功

大体的伪代码逻辑如下:

// server.c 
int main(int argc, char **argv) {
    
    // 1. redisServer 各个属性进行默认值设置
    initServerConfig();

    // 2. 解析命令行参数
    // 启动脚本的参数会通过 main 方法的 argv 传递进来, 这里会对这些参数进行解析处理
    parsingCommandLineArguments();

    // 3. 根据配置文件和命令行参数的配置覆盖掉 redisServer 的默认值
    // 内部会执行一个函数 populateCommandTable(), 将 Reids 所以支持的命令和对应的函数放到 redisServer.commands 中
    loadServerConfig()

    // 4. 初始化服务端
    // 4.1 创建事件轮询对象
    // 4.2 对所有绑定的 Ip 对应的 6666 端口(默认为 6379, 上面启动命令修改为了 6666) 开启 TCP 监听, 并得到对应的 Ip 文件描述符 fd, 存放到 redisServer 的 ipfd 中
    // 4.3 对 Redis 的 16 个数据库进行初始化
    // 4.4 向事件轮询注册 1 个时间事件: 1 毫秒执行一次, 执行函数 serverCron
    // 4.5 对每个 ipfd 向事件轮询注册 1 个可读的文件事件: 执行函数 acceptTcpHandler
    // 其他无法的省略
    initServer();

    // 5. 从磁盘中加载数据到 redisServer 的 redisDB 中 (AOF, RDB)
    loadDataFromDisk();
    
    // 6. 向事件轮询注册 阻塞前调用函数 beforeSleep
    aeSetBeforeSleepProc(server.el,beforeSleep);
    
    // 7. 向事件轮询注册 阻塞后调用函数 afterSleep
    aeSetAfterSleepProc(server.el,afterSleep);

    // 8. 启动事件轮询, 这时进入了死循环, 整个服务端启动
    aeMain(server.el);
    
    // 9. 从事件轮询中退出来,表示程序需要退出了, 删除事件轮询
    aeDeleteEventLoop(server.el);
    return 0;
}

启动后的 redisServer 的状态如下:
Alt 'Redis 服务端启动后 redisServer 的状态'

4 Redis 客户端连接服务端

Redis 服务端端启动后, 整个 Redis 就进入到事件轮询里面的死循环, 一直在执行下面的逻辑

Alt 'Redis 服务端启动后事件轮询中执行的死循环逻辑'

这时有个客户端通过 Ip + 端口连接到 Redis 服务端, 多路复用观察到有上游关心的可读事件, 会保留下这个连接请求事件。
这时 redisServer 的事件轮询执行到了 从多路复用中获取事件, 获取到了客户端的连接事件, 接着触发了 acceptTcpHandler 函数。

Alt 'Redis 服务端接收到客户端的连接'

触发的 acceptTcpHandler 函数的逻辑如下:

Alt 'Redis acceptTcpHandler 函数逻辑'

  1. 将连接到 Redis 服务端的客户端封装为 client, 代表当前的客户端
  2. 将封装后的 client, 放到 redisServer 的客户端双写链表 List<client> clients 中
  3. 向事件轮询为这个客户端注册一个可读的文件事件, 触发执行的函数为 readQueryFromClient

大体的伪代码逻辑如下:

// networking.c
void acceptTcpHandler(aeEventLoop *el, int fd, void *privdata, int mask){

    // 1. 获取客户端 Tcp 连接对应的文件描述符
    int cfd = anetTcpAccept();

    // 2. 创建 client 
    createClient();
    
    // 3. 向事件轮询注册一个当前客户端的可读的文件事件, 执行函数为: readQueryFromClient
    registerClientFdReadFilesEvent();

    // 4. 初始化 client 的属性, 比如选中的数据库默认为第一个, 输入和输出缓存区创建
    initClient();

    // 5. 将 client 添加到 redisServer 的 client 双向链表中
    linkClient();

    // 6. 最大客户端数量检查, 如果超过了, 就关闭这个连接 (默认为 10000) 
    maxClientNumCheck();

    // 7. 保护模式检查, 默认开启 (protected-mode yes)
    // 开启保护模式时, 没有配置 bind Ip 和密码, 同时客户端的 Ip 地址不是 127.0.0.1 或 ::1, 就关闭这个连接
    protectedModeCheck();
}

接受了客户端的连接后的 redisServer 的状态如下:
Alt 'Redis 接收到客户端的连接后 redisServer 的状态'

5 客户端发送命令到服务端

Redis 的客户端和服务端之间的数据的传输, 都是遵循内部自定义的一套协议: RESP

5.1 RESP 协议

当用户在客户端输入对应的请求命令时, 比如 set myKey myValue, 客户端会将这个命令转换为 RESP 协议的格式, 然后发送到服务端。

RESP 介绍的具体介绍, 可以看一下这篇文章

所有的内容通过 \r\n 进行分割, 然后定义了几个标识符, 如下:
+ 标识后面是一个简单的字符串
$ 表示后面的内容是一个二进制安全的字符串, 后面会紧跟着一个数字, 表示字符串的长度
* 表示后面的内容是一个数组, 后面同样紧跟一个数字, 表示数组的长度
… 后面省略

比如:
set myKey myValue

  1. 三个字符串 (set + myKey + myValue), 那么转换后就是 3 个二进制安全的字符串, 所以开头就是 *3
  2. 跟后面的内容用 \r\n 分隔, 所以就是 *3\r\n
  3. 第一个字符串是 set, 长度 3, 所以就是 *3\r\n$3\r\nset\r\n
  4. 后面的 myKey 和 myValue 类似, 最终转换后的内容如下 *3\r\n$3\r\nset\r\n$5\r\nmyKey\r\n$7\r\nmyValue\r\n

5.2 请求类型

在 Redis 解析客户端的请求内容前, 还需要确定当前的请求的方式, 判断的逻辑如下:

// 请求内容以 * 开头, 那么请求类型为 mult bulk 请求, 否则是 inline 请求
if (c->querybuf[c->qb_pos] == '*') {
    c->reqtype = PROTO_REQ_MULTIBULK;
} else {
    c->reqtype = PROTO_REQ_INLINE;
}

可以看到 Redis 支持 2 种请求的类型 mult bulk 请求, 还是 inline 请求
2 者的区别也很简单, 以请求内容的开头是否为 * 开头进行区分。
以 * 开头的内容, 可以看出就是遵循 REST 协议的请求, 而其他的请求就是 inline 请求。

之所以有 inline 请求, 其实是为了兼容一下特殊的客户端, 比如 Linux 的 telnet 等。

在 Linux 可以通过 telnet Ip 端口 连接到服务端, 然后直接发送请求到服务端, 而这些请求是直接发送到服务端的, 没有中间转为 RESP 协议的。
所以 Redis 选择了兼容这些特殊的情况, 并将这些请求称为 inline 请求。

所以客户端发送命令到服务端的过程如下
Alt 'Redis 客户端按照 RESP 协议转换发送请求'

  1. Redis 客户端接收用户的输入请求
  2. 将这些请求按照 RESP 协议进行转换 (inline 请求, 不会有这一步)
  3. 将转换后的请求内容发送给 Redis 服务端

6 服务端接收到客户端发送的命令

在上面客户端连接时, 向事件轮询中为当前的客户端注册了一个可读的文件事件, 触发函数为 readQueryFromClient
而在客户端将请求发送到服务端后, 事件轮询从多路复用中获取到了这个文件事件后, 会执行里面的函数 readQueryFromClient 函数。

整个 redisQueryFromClient 可以拆分为 2 部分

  1. 请求参数处理
  2. 具体请求命令的执行

6.1 请求参数处理

在上面我们知道, 客户端向服务端发送了一段 RESP 格式的请求 *3\r\n$3\r\nset\r\n$5\r\nmyKey\r\n$7\r\nmyValue\r\n, 服务端会

  1. 将客户端发送过来的请求 *3\r\n$3\r\nset\r\n$5\r\nmyKey\r\n$7\r\nmyValue\r\n, 原封不动的存储到对应 client 的输入缓冲区 queryBuf
    Alt 'Redis redaQueryFromCLient - 存储请求'
  2. 存储在 client querybuf 的内容 *3\r\n$3\r\nset\r\n$5\r\nmyKey\r\n$7\r\nmyValue\r\n, 按照 RESP 协议解析为 3 个 embstr 编码的 redisObject (String 的三种编码有讲解), 然后存储到 client 的 argv 数组中。
    Alt 'Redis redaQueryFromCLient - 命令解析'
  3. 根据 client 的参数数组 argv 的第一个参数 (第一个参数一定是命令参数) 到 redisServer 的命令字典 commands 查找当前的命令
    Alt 'Redis redaQueryFromCLient  执行命令查询'
  4. 找到命令后, 当然是执行对应的命令里面的函数了

上面是 redisQueryFromClient 第一部分, 忽略请求命令的逻辑后的简化过程, 想要继续深入了解里面的其他判断可以看一下下面的伪代码

// networking.c
void readQueryFromClient(aeEventLoop *el, int fd, void *privdata, int mask) {

    // 1. 先将客户端的请求数据读取到 client 的 querybuf 中
    putRequestContentIntoClientQueryBuffer();

    // 2. 如果 querybuf 中的数据超过了 1GB, 就关闭这个连接
    checkClientQueryBufferMoreThanOneGb();

    // 3. 临时暂停这次请求, 等待后面触发, 对应的状态有
    // 3.1 当前的 client 的为阻塞状态 (如果 querybuf 中的数据超过了 256MB, 就将这个 client 的状态设置为 REDIS_BLOCKED)
    // 3.2 当前有一个 lua 脚本在执行中
    // 3.3 当前的客户端是准备关闭状态
    // 3.4 客户端被暂停了
    temporaryPaurseThisRequest();

    // 4. 根据请求参数的第一个字符是否为 *, 确定当前的请求是 mult bulk 请求还是 inline 请求
    confirmThisRequestType();
    
    // 5. 根据请求类型, 对 querybuf 的参数进行解析, 然后存放到 argv 
    parseRequestContentIntoClientArgvByRequestType();

    // 6. 命令处理
    processCommand();
}

// server.c
int processCommand(client *c) {

    // 1. 模块过滤器, 前置处理
    // https://redis.io/resources/modules/
    moduleCallCommandFilters(c);

    // 2. argv[0] 为 quit (断开连接)
    // 将客户端的标识设置为 client_close_after_reply, 等待后面的处理, 然后返回
    ifQuitCommandHandle(c);

    // 3. 根据 argv[0], 也就是 key, 从 redisServer 的 commands 中找到对应的 redisCommand, 也就是执行命令
    c->cmd = c->lastcmd = lookupCommand(c->argv[0]->ptr);

    // 4. 命令 null 检查和命令需要的参数格个数和实际参数个数检查, 不符合就返回错误
    commandNullAndArgumentsNumberCheck(c->cmd, c->argc);

    // 5. 服务端需要密码认证, 同时当前的客户端未认证, 并且执行的命令不是 auth 命令, 返回错误
    requirePassCheckWhenCmdNotAuth(c->authenticated, c->cmd);

    // 6. 开启了集群模式, 根据 key 计算出对应的执行服务端节点, 如果当前的服务端不是执行的服务端节点, 通知客户端重定向
    redirectClientIfNeedByKeyWhenClusterEnabled();

    // 7. 如果设置了最大内存同时当前没有正在执行的 lua 脚本, 就尝试释放内存
    tryToReleaseMemoryWhenSetMaxMemoryAndNoLuaScriptTimeout();

    // 8. 当前是主节点, 磁盘检测失败, 执行的命令具备变更属性(新增, 修改, 删除等)或者是 ping 命令,  返回错误
    // 磁盘检测失败的场景
    // 8.1 开启了 RDB, 上一次 RDB 失败了,  同时配置了 RDB 失败停止写操作 (stop_writes_on_bgsave_error yes)
    // 8.2 开启了 AOF, 上一次 AOF 失败了
    pingAndWriteCommandsDeniedByDiskErrorByMaster();

    // 9. 主从复制配置检查
    // 配置了 repl_min_slaves_to_write 和 repl_min_slaves_max_lag
    // 当前需要有多少个心跳正常的从节点存活, 否则变更属性的命令不执行, 返回错误
    writeCommandsDeniedByMinSlavesNumberReply();

    // 10. 当前的客户端是从节点, 并且配置了 slave_read_only, 并且执行的命令具备变更属性, 返回错误
    writeCommandDeniedBySlaveReadOnly();

    // 11. 当前的客户端是一个订阅客户端 (subscribe), 执行的命令不是 subscribe, unsubscribe, psubscribe, punsubscribe, ping, 返回错误
    subscribeClientCanHandleCommandCheck();

    // 12. 从节点和主节点失去了联系或者正在执行复制中, 同时 slave-serve-stale-data 设置为了 no, 执行的命令不是 stale 属性(info, slaveof), 返回错误
    slaveSynchronizingOrConnectStateUnusualCheck();

    // 13. 服务端正在从磁盘中加载数据, 执行的命令不是 loading 属性(info, subscribe, unsubscribe, psubscribe, punsubscribe, publish) , 返回错误
    loadingFromDiskCheck();

    // 14. 当前正在执行 lua 脚本, 执行的命令不是 auth, replconf, shutdown, script, 返回错误
    luaScribtBusyCheck();

    // 15. 开启了事务, 执行的命令不是 exec, discard, multi, watch, 返回错误
    if (openTranscation() && commandIsNotExecAndDiscardAndMultiAndWatch()) {
        // 15.1 命令入队列
        queueMultiCommand()
        return C_OK;
    }

    // 17. 将要执行的命令, 发送给监控器
    // Redis 客户端可以成为服务端的监控器, 服务端执行的命令会同步传输给客户端
    sendCommandToMonitors();

    // 18. 对应 key 的命令函数执行, 后面会以 setCommand 为例进行分析
    c->cmd->proc(c);

    // 19. 如果需要,进行统计记录
    latencyAddSampleIfNeeded();

    // 20. 如果需要, 慢日志记录
    slowlogPushEntryIfNeeded();

    // 21. 命令传播, 如果有必要进行命令替换
    // aof 和 主从复制需要当前执行的命令进行数据处理
    // 一些随机性的命令, 不能直接就传播出去, 需要根据当前的执行结果进行替换, 比如 SPOP key [count], 从 set 中随机弹出若干个元素
    propagateCommand();
}

6.2 具体请求命令的执行

在 redisQueryFromClient 的逻辑中, 有一段代码

int processCommand(client *c) {
    ......

    // 这一步就是具体的命令执行的地方, 以 set 命令为例, 了解一下 set 命令的执行过程
    c->cmd->proc(c);

    ......
}

就是具体的请求命令的执行时机, 这里以 setCommand 为了, 这次直接看伪代码先

// t_string.c
void setCommand(client *c) {
    // 上面的 c->cmd->proc(c), 最终执行到的函数就是这个

    // SET key value [NX] [XX] [EX <seconds>] [PX <milliseconds>] 

    // 1. 根据参数计算超时时间
    robj *expire = calExpireTime(c->argv, c->argc);

    // 2. 尝试对 value 进行字符串的编码优化

    // 2.1 编码不是 embstr 和 raw, 就直接返回原数据, 不是字符串类型, 没必要优化

    // 2.2 value 长度小于 20, 同时可以转为整数
    // 2.2.1 没有配置最大内存, 同时内存回收策略不是 MAXMEMORY_FLAG_NO_SHARED_INTEGERS (涉及 lru/lfu 这 2 种模式的回收策略), 
    // 转换的数字大于等于 0, 小于 10000, 返回共享整数池中返回这个数字, 都不满足, 新建一个整数
    // 2.2.2 原本的 reidsObject 的编码为 raw, 将入参的 redisObject 转为 int 编码, *ptr 修改为转换后的整数值
    // 2.2.3 原本的 reidsObject 的编码为 embstr, 重新创建一个新的 int 编码的 redisObject
    // 2.2 逻辑结束 下面为 2.2 不满足情况

    // 2.3 入参的 redisObject 内容长度小于等于 44, 重新创建一个 embstr 的字符串, 将入参的 redisObject 转为 embstr 编码,  *ptr 修改为转换后的整数值
    // 2.3 逻辑结束 下面为 2.3 不满足情况
    
    // 2.4 到了这里, 说明客户端传过来的 value 大于 44, 只能用 raw 编码, 但是 raw 编码还可以 尝试进行 trim 优化, 也就是去空格
    c->argv[2] = tryObjectEncoding(c->argv[2]);

    // 3. 将 key 和 value 存放到 当前客户端选择的 redisDb[] db 中
    putTheKeyAndValueToDb(c->db, c->argv[1], c->argv[2]);

    // 4. 如果设置了参数时间, 将更新 redisObject 的 expireTime
    setExpireTimeIfNeed(c->db, c->argv[1], expire);

    // 5. 如果需要, 将当前的客户端放到 redisServer 的 pending_write_clients 中, 表明当前的客户端有数据需要响应
    putCurrentClientToClientsPendingWriteIfNeed();

    // 6. 将响应内容 +OK\r\n (响应结果也遵循 RESP 协议) 写入到客户端的 buf 中, 无法写入就写入到客户端的 reply
    tryWriteResponseToBufOrReply();

    // 7. 当写入的内容是写入到 reply 中时, 需要检查当前客户端待响应的内容的大小是否超过了限制, 是的话, 关闭当前客户端
    checkClientOutputBufferLimitsWhenWriteToReply();
}

逻辑概括如下:

  1. 根据参数计算超时时间, Redis 的 set 命令支持很多种参数格式, 需要根据这些参数计算出一个当前 String 的过期时间 (如果有设置的话)
  2. 参数数组 argv[2], 一定是要存入到 Redis 的 value, 当前的 value 虽然已经是 redisObject 了, 但如果它是 embstr 和 raw, 尝试寻找更合适的编码 (这一部分都是 Redis String 编码的内容)
    Alt 'Redis setCommand  重编码尝试过程'
  3. 将处理好的 myKey 和 myValue 存到 redisServer 的 redisDb 数组中的第一个 (如果使用前, 通过 select 修改了使用的数据库, 那么存在对应的数据库, 默认为 0, 第一个)
    Alt 'Redis setCommand  保存对象过程'
  4. 如果有必要, 对 redisObject 的过期时间的进行更新
  5. 数据处理完了, 当前的命令如果有数据需要响应客户端时, 需要将当前客户端放到 redisServer 的待响应客户端双向链表 clients_pending_write 中, set 命令处理完需要响应一个 ok, 所以当前 client 需要加入这个链表
    Alt 'Redis setCommand  客户端加入待响应客户端双向链表'
  6. 如果有数据需要响应, 将响应的数据放到 client 的固定输出缓冲区 char buf[] 中, 如果无法直接存放进去, 则存放到动态输出缓冲区 List reply 中, set 回应的是 ok, 经过 RESP 协议后假设可以直接放到固定输出缓冲区
    Alt 'Redis setCommand  响应结果写入缓冲区'

服务端接收到客户端发送的命令并处理后, redisServer 的状态如下:
Alt 'Redis 服务端接收到客户端发送的命令后 redisServer 状态'

此时 client 的状态如下:
Alt 'Redis 服务端接收到客户端发送的命令后 client 状态'

7 服务端响应客户端

存放在 client 的输出缓冲区的数据, 是什么时候发送给客户端的呢?
在 Redis 里面是经过 2 个步骤实现的

  1. 为每一个待发送的客户端注册一个可写的文件事件, 执行函数为 sendReplyToClient
  2. 事件轮询获取这个可写事件并触发 sendReplyToClient 函数

7.1 为待发送的客户端注册发送数据的文件事件

Redis 服务端端启动后, 整个 Redis 就进入到事件轮询里面的死循环, 一直在执行下面的逻辑

!Alt 'Redis 服务端启动后事件轮询中执行的死循环逻辑'

而这次在阻塞前 beforesleep 函数执行 时, 在 beforesleep 函数中会:
遍历 redisServer 的待响应客户端双向链表 clients_pending_write 中的所有客户端,

  1. 将对应的客户端从双向链表删除
  2. 删除的客户端如果有数据要发送, 为他在多路复用各注册一个可写的文件事件, 触发函数 sendReplyToClient

Alt 'Redis 服务端注册发送数据文件事件'

对应的地方为 beforeSleep 函数逻辑如下:

// server.c
void beforeSleep(struct aeEventLoop *eventLoop) {

    ......

    // 处理带有输出缓冲区的客户端
    handleClientsWithPendingWrites();

    ......
}

int handleClientsWithPendingWrites(void) {

    client *c

    // 1. 遍历 redisServer 的 clients_pending_write 
    while(c = getNextNodeFromLinkList(server.clients_pending_write)) {

        // 将当前的 client 从 clients_pending_write 链表中删除
        removeTheClientFromeClientsPendingWrite(c);

        // 当前的客户端有数据需要发送 (client->buf 或 client->reply 不为空),
        // 向多路复用注册一个可写的文件事件, 执行函数为 sendReplyToClient
        registFileEventForClientWhenClientHaveDataToWrite(c);
    }
}

7.2 触发发送数据的文件事件

事件轮询在执行完阻塞前函数后, 又进入到多路复用中获取文件事件, 这时会获取到刚刚注册的可写事件文件, 触发 sendReplyToClient 的逻辑, 过程如下:

Alt 'Redis 服务端响应客户端过程'

  1. 逐步将 client 的缓冲区推送给客户端 (单次推送数据有上限要求, 超过的需要到下次事件轮询再推送)
  2. client 推送数据完成, 将其对应的文件事件从多路复用中删除 (如果还有数据没推送, 事件不会被删除, 下次事件轮询还能触发, 推送剩下的)

具体的逻辑如下:

// networking.c
void sendReplyToClient(aeEventLoop *el, int fd, void *privdata, int mask) {
    //  client 的输出缓冲区有数据需要发送
    while(clientHasPendingReplies(c)) {

        // client 的 buf 有数据需要发送
        if (clientBufHasDataToSend(c)) {
            writeDataToClient();
        } else {
            // 如果 client 的 reply 有数据, 获取链表的第一个节点, 将里面的数据发送给客户端, 同时从双写链表中删除这个节点
            writeDataToClientIfClientReplyHasData();
        }

        // 当前已经发送的数据达到了单次发送的上线 1024*64
        if (currentHaveSendMoreThanMaxLimit()) {

            // 没有设置最大内存, 当前发送数据停止
            if(noSetMaxMemory()) {
                break;
            }

            // 设置了最大内存, 当前已经使用的内存大小小于最大内存, 当前发送数据停止
            if (haveSetMaxMemoryAndCurrentUsedMemoryLessThanMaxMemory()) {
                break;
            }
            // 设置了最大内存了, 当前使用的内存大于等于最大内存了, 继续循环, 尽量多发送一些, 释放内存
        }
    }

    // 当前 client 没有数据需要发送了
    if (!clientHasPendingReplies(c)) {
        // 从事件轮询中删除当前的发送数据事件
        delCurrentFileEventFromEventLoop();
    }
    // client 还有数据, 那么不删除事件, 继续保留, 下次事件轮询执行, 继续执行

需要留意的是执行一个 sendReplyToClient 函数, 给这个客户端推送数据

  1. 每次个客户端推送数据最大为 1024 * 64, 超过了会停止这次推送, 将剩下的留到下次再继续推送 (伪代码里面表明了一些特殊情况了)

至此

set myKey myValue
ok

一个完整的流程就结束了。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1257800.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Linux操作系统使用及C高级编程-D15D16内存管理和动态内存使用

内存分区 使用size查看内存使用 动态内存使用 不能返回局部变量的引用&#xff0c;局部变量存放在栈区&#xff0c;空间随着函数结束自动释放 动态申请内存 内存泄漏和内存溢出

【沐风老师】3DMAX快速地板屋顶墙面铺设插件使用方法详解

3DMAX快速地板屋顶墙面铺设插件使用教程 3DMAX快速地板屋顶墙面铺设插件&#xff0c;一键生成各种地板、墙面纹理模型&#xff0c;是一款非常实用的室内设计和建筑建模插件。 【适用版本】 3dMax7或更新版本 【安装方法】 该插件无需安装&#xff0c;直接在建模过程中使用&a…

互联网上门预约按摩软件新模式

随着移动互联网的发展&#xff0c;人们越来越注重生活质量。其中&#xff0c;按摩作为一种保健方式受到了广大消费者的青睐。为了满足用户的这一需求&#xff0c;我们计划开发一款按摩小程序软件&#xff0c;那么&#xff0c;这款软件需要具备哪些功能呢&#xff1f;本文将从用…

【设计模式_观察者模式/发布订阅】观察者模式_股票案列

编程中常见的技术难题有哪些&#xff1f; 编程中常见的技术难题有如同一道道难题&#xff0c;比如bug像隐藏的恶魔&#xff0c;让程序员们捉摸不透&#xff1b;性能优化就像是调整汽车引擎&#xff0c;需要精准的调校&#xff1b;还有就是跨平台兼容性&#xff0c;就像是翻译不…

基于C#实现十字链表

上一篇我们看了矩阵的顺序存储&#xff0c;这篇我们再看看一种链式存储方法“十字链表”&#xff0c;当然目的都是一样&#xff0c;压缩空间。 一、概念 既然要用链表节点来模拟矩阵中的非零元素&#xff0c;肯定需要如下 5 个元素(row,col,val,down,right)&#xff0c;其中&…

Java 8新特性Optional的使用以及判空案例

Java 8新特性&#xff1a;Optional的使用及判空案例 在Java 8中&#xff0c;引入了一个重要的类Optional&#xff0c;它是为了解决空指针异常&#xff08;NullPointerException&#xff09;而设计的。Optional可以作为一个容器&#xff0c;可以包含一个非空的值或者为空。 Opti…

1评论收藏分享抖店不要再无脑铺货了!这个方法学会,7天流量就起飞~

这2023年都马上过完了&#xff0c;你还在上一堆链接到抖店吗&#xff1f;要知道这样无脑铺货是拿不到大流量的。 哪今天我给大家分享一个&#xff0c;比较适合新手操作&#xff0c;也能快速起流量出单的方法。 。首先你的店铺拿不到流量&#xff0c;一定要先查清楚你为什么拿…

商城免费搭建之java商城 鸿鹄云商 B2B2C产品概述

【B2B2C平台】&#xff0c;以传统电商行业为基石&#xff0c;鸿鹄云商支持“商家入驻平台自营”多运营模式&#xff0c;积极打造“全新市场&#xff0c;全新 模式”企业级B2B2C电商平台&#xff0c;致力干助力各行/互联网创业腾飞并获取更多的收益。从消费者出发&#xff0c;助…

第二证券:投资股票有哪些坑?如何避免?

出资股票有哪些坑&#xff1f; 1、情绪化生意&#xff0c;追涨杀跌。许多出资者看到一只股票涨得很快&#xff0c;就想赶忙买入&#xff0c;怕失掉时机&#xff0c;或当自己持有的股票跌了就受不了只想卖出。这样的行为其实是十分不理性的&#xff0c;由于股票的价格是由商场供…

实战案例!用1行Python代码识别身份证信息,准确率超过99%,YYDS

录入身份证信息是一件繁琐的工作&#xff0c;如果可以自动识别并且录入系统&#xff0c;那可真是太好了。 今天我们就来学习一下&#xff0c;如何自动识别身份证信息并且录入系统~ 识别身份证信息 识别身份证信息的代码最简单&#xff0c;只需要1行代码&#xff0c;如下所示…

详解Python对Excel处理

Excel是一种常见的电子表格文件格式&#xff0c;广泛用于数据记录和处理。Python提供了多个第三方库&#xff0c;可以方便地对Excel文件进行读写、数据操作和处理。本文将介绍如何使用Python对Excel文件进行处理&#xff0c;并提供相应的代码示例和详细说明。 一、安装第三方库…

HT97220与HT97230耳机放大器芯片对比

HT97230有两个不同开启时间(tON)版本&#xff0c;版本A、C和E的导通时间tON为5.5ms&#xff0c;用于耳机驱动&#xff1b;B和D则具有130ms的tON&#xff0c;用于机顶盒设计&#xff08;目前仅提供A版本&#xff0c;其他版本需预定&#xff09;。内部电荷泵对输入电源反相&#…

Linux操作系统 1.初识Linux

一、Linux学习大致内容 二、操作系统概述 操作系统的作用&#xff1a; 常见操作系统&#xff1a; 1、pc&#xff08;电脑端&#xff09;&#xff1a;windows、Linux、MacOS 2、移动端&#xff1a;Android、ios、鸿蒙系统 总结 1.计算机由哪两个部分组成&#xff1f;、 硬件…

RabbitMQ工作模式2 整合springboot 和MQ高级特性

RabbitMQ工作模式 1.路由模式 创建交换机 , 连接队列 (生产者) public class MyTestExDirect {Testpublic void bbb() throws IOException, TimeoutException {ConnectionFactory connectionFactory new ConnectionFactory();//连接mqconnectionFactory.setUsername("…

Vue3中调用外部iframe链接方法

业务场景&#xff0c;点击某个按钮需要跳转到外部iframe的地址&#xff0c;但是需要在本项目内显示。以前项目中写过调用外部链接的功能&#xff0c;是有菜单的&#xff0c;但是这次是按钮&#xff0c;所以不能直接把地址配到菜单里。 实现方法&#xff1a;在本地路由文件里写个…

neo4j在Linux上安装及使用

1、简介 neo4j安装主要有两个步骤&#xff1a; 环境配置&#xff1a;Java安装工具下载&#xff1a;neo4j安装 2、java 安装 2.1 检查 安装前可以检查下&#xff0c;当前环境是否有Java 查看是否安装&#xff1a;java -version 说明当前环境没有&#xff0c;那么去下载 …

推荐你一个基于Koin, Ktor Paging等组件的KMM Compose Multiplatform项目

推荐你一个基于Koin, Ktor & Paging等组件的KMM Compose Multiplatform项目 Kotlin Multiplatform Mobile&#xff08;KMM&#xff09;已经从一个雄心勃勃的想法发展成为一个稳定而强大的框架&#xff0c;为开发人员提供了在多个平台上无缝共享代码的能力。通过最近的稳定…

2023.11.25电商项目平台建设2 -四大业务之核销主题建模

1.数仓建模步骤 自下而上 ADS-DWS-DWM-DWD 2.DWD方案(清洗转换,降维拉宽) DWD层的表 dwd_sale_store_sale_dtl_i 门店销售明细宽表 维度dim 销售sale合成成的宽表 dwd_dim_date_f 日期表 store_sale_dtl 门店销售明细表 dwd_sale_store_sale_dtl_i 门店销售明细表 …

漏洞分析 | 经典的Shiro反序列化

0x01、前言 相信大家总是面试会问到java反序列化&#xff0c;或者会问到标志性的漏洞&#xff0c;比如shiro反序列化&#xff0c;或者weblogic反序列化漏洞。 那我就这篇文章为大家讲解一下&#xff0c;不懂的哥哥直接背一下&#xff0c;理解一下就好了。 至于为什么要选择sh…

力扣(LeetCode)907. 子数组的最小值之和(C++)

枚举 请对题目有疑惑的小伙伴看枚举思想&#xff0c;有助于掌握最基本的解题思路。对于本题数据范围&#xff0c;枚举算法会超时。 请看题目描述&#xff1a;给定一个整数数组 arr&#xff0c;找到 min(b) 的总和&#xff0c;其中 b 的范围为 arr 的每个&#xff08;连续&…