Redis一条指令执行流程

4 阅读6分钟

阶段一:指令发送

  1. 首先客户端通过SocketRedis服务器的监听端口建立tcp连接。redis主线程处理连接请求,连接建立后,服务器为客户端创建一个redisClient对象。
  2. 客户端将命令按照Redis协议序列化成字节流,传输到服务器的接收缓冲区

阶段二:服务器I/O和多路复用

Redis 6.0之前,所有的网络I/O都在主线程中处理,在之前的版本,引入了多线程I/O,但是命令的执行依然是单线程的。

版本变动的背景:

之前版本采用完全单线程模型处理所有客户端请求,这种方法有三种优势:

  1. 因为是单线程,不需要锁机制
  2. 减少线程切换带来的性能损耗
  3. 实现简单

但是随着硬件发展,网络带宽的增长速度远超CPU单核频率,在千兆甚至万兆网络环境下,单线程处理网络I/O成为主要瓶颈。

因此Redis 6.0采用了 "单线程命令执行 + 多线程网络I/O"混合模型,其中IO线程就专门用于网络数据的读写。

为什么依然还采用单线程执行命令?

因为如果多线程执行命令,就需要对内存数据操作(redisDb, dict, skiplist等)加锁,锁竞争会抵消多线程的收益,并让逻辑变得无比复杂。Redis的设计哲学是:利用单线程的原子性避免锁,利用非阻塞I/O和多路复用处理并发连接,利用内存计算获得极速响应。

什么是非阻塞IO?

非阻塞IO是一种IO操作模式,当应用程序发起一个IO操作(如读Socket)时,如果数据未就绪,调用会立即返回一个错误(如EAGAIN) ,而不会将调用线程挂起等待。

  1. 传统阻塞IO:线程调用read(),如果对端数据没发来,内核会让线程进入“睡眠”状态,直到数据到达、内核缓冲区就绪,再唤醒线程。这期间线程什么也干不了,占着内存却浪费CPU。
  2. 非阻塞IO:通过fcntl系统调用给文件描述符(如Socket)设置O_NONBLOCK标志。此后调用read(),有数据就直接读到;没数据时,内核不会阻塞线程,而是立刻返回,并告诉线程“还没好,你等会儿再来问”。这样,线程就可以腾出手去处理其他连接的任务。

这样解放了线程,使其在等待IO时不空转,可以服务其他请求,极大提高了单线程的利用率。 但是线程如何知道哪个连接的数据“好了”呢?它只能通过轮询——不断地遍历所有连接,调用read()尝试。当连接数成千上万时,这种轮询会产生大量无用的系统调用,CPU时间主要浪费在“问状态”上,效率依然低下。

什么是多路复用?

多路复用是一种机制,它允许单个线程通过一个系统调用同时监视多个文件描述符(如多个Socket连接)的IO就绪状态。当其中任何一个或多个描述符就绪(可读、可写或出错)时,系统调用返回,并通知应用程序哪些描述符已就绪,可以进行后续IO操作。

多路复用的主流实现:

  1. select/poll:早期的多路复用实现。内部采用线性扫描的方式检查所有被监视的描述符。虽然将N次轮询系统调用合并为1次,但通知时仍需遍历整个集合(O(N)复杂度),在连接数很高时效率下降
  2. epoll:现代高性能服务器的基石。核心改进:
    • 事件驱动:内核维护一个就绪列表,只将真正发生事件的描述符通知给应用,无需遍历全部。
    • 红黑树管理:使用高效的数据结构管理海量描述符,增删改查速度快。因此epoll的效率不会随着连接数增加而线性下降
  • 监听与就绪Redis主线程(在aeMain函数中运行事件循环)通过I/O多路复用机制(在Linux上通常是epoll,在macOSkqueue)同时监听成千上万个客户端Socket

  • 接收数据

    • 6.0前(单线程I/O) :主线程自己处理所有I/O。当epoll报告某个Socket可读时,主线程会同步地从内核缓冲区将数据读入该客户端对应的client->querybuf输入缓冲区。
    • 6.0后(多线程I/O) :主线程负责将就绪的Socket分配到一个全局的读任务队列。一组I/O线程(默认不启用,需配置)会竞争从队列中取出Socket,并进行数据的读取和协议的解析,将解析好的命令放入每个客户端自己的argv(参数数组)和argc(参数个数)中。注意:I/O线程只负责读取和解析,绝不执行命令。这大大减轻了主线程在高压下的I/O负载。

阶段三:命令执行(单线程核心)

  1. 命令查找:主线程从已解析的命令参数中(argv[0],即SET),在命令表中查找,这个命令表是一个字典,key是命令名(如set),value是一个struct redisCommand结构,里面包含了该命令的函数指针等。

  2. 前置检查:执行一系列检查。

  3. 调用执行函数:检查通过后,调用真正的命令实现函数

  4. 操作内存数据结构:这是最核心的操作。以SET key0931 "hello"为例:

    • 主字典db->dict,一个哈希表)中查找或创建key0931

    • Redis的keyvalue都不是简单的字符串,而是被封裝为Redis对象redisObject)。

    • 为新值"hello"创建一个新的redisObject(类型为REDIS_STRING,编码可能是高效的embstr)。

    • key0931对应的redisObjectptr指向这个新对象,旧值(如有)的引用计数减1,若为0则释放内存。

阶段四:返回响应

  1. 将结果写入缓冲区:命令执行完毕后,返回值序列化成二进制字节流写入redisClient输出缓冲区
  2. 注册写事件:主线程会将客户端的Socketepoll中注册为可写事件
  3. 发送数据:将执行的结果放入写任务队列