【Redis技术进阶之路】「原理分析系列开篇」揭秘分析客户端和服务端网络通信交互实现(客户端篇)

334 阅读20分钟

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!


【专栏简介】

随着数据需求的迅猛增长,持久化和数据查询技术的重要性日益凸显。关系型数据库已不再是唯一选择,数据的处理方式正变得日益多样化。在众多新兴的解决方案与工具中,Redis凭借其独特的优势脱颖而出。

【技术大纲】

为何Redis备受瞩目?原因在于其学习曲线平缓,短时间内便能对Redis有初步了解。同时,Redis在处理特定问题时展现出卓越的通用性,专注于其擅长的领域。深入了解Redis后,您将能够明确哪些任务适合由Redis承担,哪些则不适宜。这一经验对开发人员来说是一笔宝贵的财富。 在这里插入图片描述

在这个专栏中,我们将专注于Redis的6.2版本进行深入分析和介绍。Redis 6.2不仅是我个人特别偏爱的一个版本,而且在实际应用中也被广泛认为是稳定性和性能表现都相当出色的版本

【专栏目标】

本专栏深入浅出地传授Redis的基础知识,旨在助力读者掌握其核心概念与技能。深入剖析了Redis的大多数功能以及全部多机功能的实现原理,详细展示了这些功能的核心数据结构和关键算法思想。读者将能够快速且有效地理解Redis的内部构造和运作机制,这些知识将助力读者更好地运用Redis,提升其使用效率。

将聚焦于Redis的五大数据结构,深入剖析各种数据建模方法,并分享关键的管理细节与调试技巧。

【目标人群】

Redis技术进阶之路专栏:目标人群与受众对象,对于希望深入了解Redis实现原理底层细节的人群

1. Redis爱好者与社区成员

Redis技术有浓厚兴趣,经常参与社区讨论,希望深入研究Redis内部机制、性能优化和扩展性的读者。

2. 后端开发和系统架构师

在日常工作中经常使用Redis作为数据存储和缓存工具,他们在项目中需要利用Redis进行数据存储、缓存、消息队列等操作时,此专栏将为他们提供有力的技术支撑。

3. 计算机专业的本科生及研究生

对于学习计算机科学、软件工程、数据分析等相关专业的在校学生,以及对Redis技术感兴趣的教育工作者,此专栏可以作为他们的学习资料和教学参考。

无论是初学者还是资深专家,无论是从业者还是学生,只要对Redis技术感兴趣并希望深入了解其原理和实践,都是此专栏的目标人群和受众对象

让我们携手踏上学习Redis的旅程,探索其无尽的可能性!


客户端和服务器

Redis服务器

Redis服务器构成了一个典型的一对多服务模型:

在这里插入图片描述

在此模型中,单个服务器能够同时与多个客户端建立网络连接。每个客户端均具备向服务器发送命令请求的能力,而服务器则负责接收并妥善处理这些来自客户端的命令请求,随后向相应的客户端返回命令执行的结果。

IO多路复用

采纳了基于I/O多路复用技术的文件事件处理器,以此实现单线程单进程模式下的命令请求处理,同时维持与众多客户端的网络通信。

针对每一个与服务器建立连接的客户端,服务器都会为其创建对应的RedisClient结构(即客户端状态)。此结构全面记录了客户端的当前状态信息。

在这里插入图片描述

结构体模型如下所示:

struct redisServer{
//...
//一个链表,保存了所有客户端状态
list *clients:
//...
};

通过遍历clients链表,Redis服务器能够灵活应对各种场景。例如,当需要执行一项影响所有客户端的操作时,服务器可以简单地遍历链表,对每个客户端的状态结构执行相应的操作逻辑。

在这里插入图片描述

同样,当需要查找并操作某个特定客户端时,服务器也可以利用链表提供的遍历机制,结合客户端的唯一标识符或其他属性,高效地定位到目标客户端的状态结构。

Redis的AE事件驱动

"AE",作为"A Simple Event-Driven Programming Library"的缩略形式,这一库可以被形象地视作对高效I/O多路复用机制——epoll的精心封装与抽象化呈现。

AE不仅简化了复杂的事件处理逻辑,还通过封装epoll,使得开发者能够以更加直观和便捷的方式,在Linux平台上实现高效的事件驱动编程,从而优化应用程序的性能与响应速度。

多路复用选择aeApiPoll

Redis在设计时集成了对多种I/O多路复用技术的支持,具体包括select、epoll(针对Linux)、kqueue(常见于BSD系统)以及evport(特定于Solaris),以确保其能够灵活适配不同操作系统环境下的高效网络通信需求。

多路复用选择.png

在编译Redis时,会根据当前宿主系统的能力自动选择并启用最为适宜的一种模式,以确保运行时采用的是最优解,而避免了多种机制并存可能带来的复杂性。

模式的优先级选择和桥接

在模式选择的优先顺序上,Redis遵循了高效性优先的原则:首先尝试使用evport(如果系统支持),因其专为高性能网络应用设计;若不可行,则退而求其次选择epoll,该机制在Linux下以其出色的性能表现而著称;若系统既不支持evport也不支持epoll,Redis会进一步尝试kqueue,它在BSD及其派生系统中表现优异。

1723369311783.png

作为兜底方案,select模式将作为默认选项被启用,因为几乎所有现代操作系统都支持select机制,尽管其性能相比前述几种机制可能有所不及。

选择和桥接以及流程路径主要是现在ae.c,源码如下:

/*
 * 根据系统支持的特性,选择并包含相应的事件驱动机制实现。  
 * 这些机制按照性能从高到低排序(降序)。  
 */  
  
#ifdef HAVE_EVPORT  
/*  
 * 如果系统支持evport(Solaris特有),则包含evport的实现。 
 * evport是Solaris系统下的一种高效的事件通知机制。  
 */  
#include "ae_evport.c"  
  
#else  
/*  
 * 如果系统不支持evport,则进一步检查是否支持epoll。  
 */  
    #ifdef HAVE_EPOLL  
    /*  
     * 如果系统支持epoll(Linux特有),则包含epoll的实现。  
     * epoll是Linux下用于处理大量并发连接的高效I/O事件通知机制。  
     */  
    #include "ae_epoll.c"  
  
    #else  
    /*  
     * 如果系统既不支持evport也不支持epoll,则进一步检查是否支持kqueue。  
     */  
        #ifdef HAVE_KQUEUE  
        /*  
         * 如果系统支持kqueue(BSD特有),则包含kqueue的实现。  
         * kqueue是BSD及其派生系统下的一种高效的事件通知机制。  
         */  
        #include "ae_kqueue.c"  
  
        #else  
        /*  
         * 如果系统都不支持上述三种机制,则默认使用select。  
         * select是几乎所有操作系统都支持的一种较老的事件通知机制,但性能相对较低。  
         */  
        #include "ae_select.c"  
  
        #endif  
  
    #endif  
  
#endif
判断对应的环境流转条件

config.h作为项目配置的核心文件,负责定义一系列宏,这些宏反映了编译时系统的特定能力和选项。对于HAVE_EVPORTHAVE_EPOLLHAVE_KQUEUE这三个宏而言,它们的值(通常定义为1或未定义)取决于编译时系统的实际支持情况。

 * 检查是否在Solaris系统上编译  
 * Solaris系统可能需要包含特定的头文件来启用额外的系统特性  
 */  
#ifdef __sun  
  
#include <sys/feature_tests.h>  
  
/*  
 * 检查Solaris系统是否支持DTrace(一种动态跟踪工具)  
 * 如果支持,则定义HAVE_EVPORT宏,表示系统支持evport事件通知机制  
 * 注意:这里假设如果系统支持DTrace,那么它也支持evport,但这是一个简化的假设  
 */  
#ifdef _DTRACE_VERSION  
  
#define HAVE_EVPORT 1  
  
#endif  
  
#endif  
  
/*  
 * 检查是否在Linux系统上编译  
 * 如果是,则定义HAVE_EPOLL宏,表示系统支持epoll事件通知机制  
 */  
#ifdef __linux__  
  
#define HAVE_EPOLL 1  
  
#endif  
  
/*  
 * 检查是否在macOS 10.6及以上版本、FreeBSD、OpenBSD或NetBSD系统上编译  
 * 这些系统都支持kqueue事件通知机制  
 * 注意:对于macOS,我们同时检查了__APPLE__宏和MAC_OS_X_VERSION_10_6宏,以确保版本兼容性  
 */  
#if (defined(__APPLE__) && defined(MAC_OS_X_VERSION_10_6)) || defined(__FreeBSD__) || defined(__OpenBSD__) || defined (__NetBSD__)  
  
#define HAVE_KQUEUE 1  
  
#endif
  • HAVE_EVPORT:此宏用于标识系统是否支持Solaris特有的evport机制。如果系统支持,则config.h中会定义此宏(通常赋值为1),以便在编译时包含并启用evport相关的代码路径。

  • HAVE_EPOLL:此宏用于检测Linux系统是否支持epoll机制。epoll是Linux下用于处理大量并发连接的高效I/O事件通知方式。如果系统支持epoll,则config.h中会相应地定义此宏,以确保编译时能够利用epoll的优势。

  • HAVE_KQUEUE:此宏针对BSD及其派生系统,用于检测是否支持kqueue机制。kqueue是BSD系列操作系统提供的一种高效的事件通知接口。若系统支持kqueue,config.h中将定义此宏,以支持基于kqueue的事件处理逻辑。

RedisClient结构

Redis服务器中的clients属性设计为一个精心组织的链表结构,这一设计巧妙地维护了所有当前与服务器建立连接的客户端的状态信息。

// 创建一个新的客户端连接  
client *createClient(int fd) {  
    // 使用zmalloc为client结构体分配内存,zmalloc是Redis中用于内存分配的封装,会检查内存是否分配成功  
    client *c = zmalloc(sizeof(client));  
  
    // 如果Redis配置文件(redis.conf)中开启了tcp-keepalive选项,并且指定了keepalive的时间间隔(单位:秒)  
    if (server.tcpkeepalive) {  
        // 调用anetKeepAlive函数设置TCP连接的keepalive选项,以便在连接空闲一段时间后自动发送keepalive消息  
        // 这里的NULL参数通常用于某些实现中可能需要传递的额外上下文或socket结构,但在这个调用中未使用  
        anetKeepAlive(NULL, fd, server.tcpkeepalive);  
    }  
  
    // 将新的客户端文件描述符(fd)注册到事件循环(server.el)中  
    // 监听可读事件(AE_READABLE),当该fd有数据可读时,会调用readQueryFromClient函数  
    // 并将新创建的client结构体c作为参数传递给readQueryFromClient函数  
    if (aeCreateFileEvent(server.el, fd, AE_READABLE, readQueryFromClient, c) == AE_ERR) {  
        // 如果注册事件失败,则关闭文件描述符并释放client结构体占用的内存  
        close(fd);  
        zfree(c);  
        // 返回NULL表示客户端创建失败  
        return NULL;  
    }  
  
    // 默认选择第0个数据库(注意:在Redis中,数据库索引是从0开始的,所以第1个数据库实际上是索引为0的数据库)  
    // 这里的注释“默认选择第1个DB”可能是一个误导,实际上它选择的是索引为0的数据库  
    selectDb(c, 0);  
  
    // 省略了其他可能的代码...  
  
    // 返回新创建的client结构体指针  
    return c;  
}

具体而言,每当有客户端成功连接到Redis服务器时,其对应的状态结构(通常包含客户端的多种属性,如连接信息、当前命令执行状态、缓冲区状态等)就会被添加到clients链表中。这一动态维护的链表确保了服务器能够高效地遍历所有连接的客户端,无论是为了执行诸如广播消息、资源回收等批量操作,还是为了快速响应针对特定客户端的查询或管理需求。

在这里插入图片描述

  • 客户端套接字标识符(Socket Descriptor):唯一标识客户端与服务器之间建立的连接通道,确保数据传输的准确性与安全性。

  • 客户端唯一名称标识(Client Identifier):用于在服务器内部或日志中唯一区分不同客户端,便于监控与管理。

  • 客户端标志位集合(Flag Values):用于表示客户端的当前状态、权限等属性,如是否已认证、是否处于订阅模式等。

  • 数据库引用与编号(Database Pointer & Index):客户端当前操作所指向的数据库实例及其索引编号,确保数据操作的正确性与隔离性。

  • 命令执行上下文(Command Execution Context):包含待执行命令详情(如命令类型)、命令参数列表、参数数量统计,以及直接指向实现该命令函数的指针,确保命令的精确执行与快速调度。

  • 缓冲区机制(Input & Output Buffers):客户端的输入缓冲区用于暂存来自客户端的数据,而输出缓冲区则用于存储服务器准备发送给客户端的响应数据,优化数据传输效率。

注意,Redis输人缓冲区记录了客户端发送的命令请求,这个缓冲区的大小不能超过1GB。客户端在数据传输过程中,可灵活利用两种类型的缓冲区:固定大小缓冲区和动态调整大小缓冲区。固定大小缓冲区被设计为具有固定的容量上限,即最大可达16KB

  • 复制状态与数据结构(Replication Status & Structures):存储客户端参与复制过程的状态信息及相关数据结构,支持主从同步、故障转移等高级功能。

  • 阻塞命令支持结构(Blocking Command Structures):特别针对如BRPOP、BLPOP等列表阻塞命令,设计专用数据结构以管理等待列表项可用的客户端,实现高效的事件驱动机制。

  • 事务与WATCH机制(Transaction & WATCH Structures):记录客户端事务执行的当前状态,包括已入队命令、WATCH监控的键等,确保事务的原子性与一致性。

  • 发布/订阅机制的数据结构(Pub/Sub Structures):支持客户端参与发布与订阅消息的功能,包含订阅的频道列表、消息队列等,实现实时消息传递与广播。

  • 认证状态标志(Authentication Status Flag):标记客户端是否已通过身份验证,控制对敏感操作与数据的访问权限。

  • 时间戳与缓冲区监控(Timestamps & Buffer Size Monitoring):记录客户端的创建时间、最后一次与服务器通信的时间点,以及输出缓冲区大小超出预设软性限制的具体时刻,用于性能监控与资源调配。

客户端属性分析

客户端状态所蕴含的属性可划分为两大范畴:

  • 普遍适用的属性:跨越了不同功能场景,构成了客户端操作的基本框架,无论客户端执行何种任务,这些属性均不可或缺;
  • 功能特定的属性:紧密关联于特定的操作或命令执行,如数据库操作涉及的dbdictid属性、事务处理中的mstate属性,以及WATCH命令执行期间所需的watched keys属性等,这些属性为特定功能提供了必要的支持与保障。
套接字描述符

客户端状态的 fd(File )属性扮演着至关重要的角色,它精确无误地记录了当前客户端所使用的套接字描述符。

typedef struct redisclient
	int fd;
} redisclient;

描述符不仅是客户端与服务器之间通信桥梁的核心标识,也是系统层面进行数据传输、连接管理以及资源分配的关键依据。通过fd属性,Redis服务器能够高效地识别并操作与特定客户端相关联的网络连接,确保数据流的稳定传输与命令响应的及时性。

客户端的分类

根据客户端类型的不同,fd属性的值可以是-1或者是大于-1的整数,主要分为伪客户端和普通客户端。

伪客户端(fake client)

伪客户端其fd(文件描述符)属性被特别设定为-1,这一设计巧妙地反映了伪客户端的独特性质与用途。

伪客户端.png

Lua脚本的伪客户端

在Redis服务器的初始化阶段,一个精心设计的伪客户端(pseudo-client)会被创建出来,其职责专注于执行Lua脚本内部嵌入的Redis命令。

struct redisserver
    redisclient *lua_client;
}

此伪客户端被巧妙地关联到服务器状态结构体的一个特定属性上,我们可以称之为lua_client(假设原描述中的lua_client是笔误,应为lua_client或类似名称)。

注意,lua_clent伪客户端在服务器运行的整个生命期中会-一直存在,只有服务器被关闭

AOF文件的伪客户端

在Redis服务器加载AOF(Append Only File)文件的过程中,一个精心设计的机制被引入以高效地执行文件中记录的Redis命令。这一机制的核心在于创建一个临时的、专门用于此目的的伪客户端(pseudo-client)。

伪客户端的创建旨在模拟真实客户端的行为,但仅用于内部处理AOF文件中的命令,不参与正常的客户端-服务器交互流程。

注意,伪客户端不直接参与网络通信,其处理的命令请求源自于AOF(Append Only File)持久化文件的重放过程或Lua脚本的执行环境,因此无需建立和维护套接字连接,自然也就无需分配和记录标准的套接字描述符。

两大关键场景中运用伪客户端
  1. AOF文件恢复过程中,伪客户端作为中介,负责读取AOF文件中的命令并逐一执行,从而恢复数据库至特定时间点的状态,确保了数据的持久性与一致性。

  2. Lua脚本执行期间,伪客户端被用于执行脚本内嵌的Redis命令,实现了脚本对数据库的原子性操作,避免了脚本执行期间数据状态的不一致性。

普通客户端(normal client)

通过套接字与Redis服务器进行交互,它们的fd属性值被赋予了一个大于-1的整数,该值代表了客户端套接字在服务器端的唯一标识符。确保了每个普通客户端都能通过其特定的套接字描述符与服务器进行独立且高效的通信。

CLIENT list

CLIENT list使得能够详尽地检索到当前所有活跃地连接到服务器的普通客户端列表。此命令的执行结果中,特别值得注意的是d域,它扮演着至关重要的角色,揭示了服务器为各客户端连接所分配的唯一套接字描述符(Socket Descriptor)。

redis>CLIENT list
addr=127,0,0,1:53428 fd=6 name= age=1242 id1e=0 ...
addr=127,0,0.1:53469 fd=7 name= age=6 id1e=4 ...

通过深入分析CLIENT list命令的输出,我们不仅能够清晰地看到哪些客户端当前正在与服务器建立连接,还能够借助套接字描述符这一关键指标,进一步探讨连接的管理与优化策略。套接字描述符作为操作系统中用于标识和追踪网络连接的唯一标识符,其值的分配与管理直接关联到服务器的连接处理效率和稳定性。

CLIENT SETNAME

通过运用CLIENT SETNAME指令,用户可以为其Redis客户端分配一个唯一且具描述性的名称,此举显著增强了客户端在服务器端的识别度与可管理性,使得每个客户端的身份变得一目了然,便于追踪与维护。

redis>CLIENT list
addr=127.0.0.1:53428 fd=6 name=message_queue age=2093 idle=0 ...
addr=127.0.0.1:53469 fd=7 name=user_relationship age=855 idle=2...

在此场景中,首先映入眼帘的是名为message_queue的客户端,其命名直观地指向了其核心功能——管理并处理消息队列。这一命名策略不仅便于理解,也预示着该客户端负责高效地调度、传递与存储消息,确保信息流通的顺畅无阻。

紧接着,我们注意到另一个精心命名的客户端:user_relationship。从这个名字中,我们可以合理推测,该客户端专注于用户关系的维护与管理,包括但不限于用户之间的连接、权限设定、以及关系数据的存储与查询。

typedef struct redisclient
    robj *name;
}redisclient;

若客户端未主动通过配置来标识自身,则其状态中的name属性将默认指向一个NULL指针,这表示该客户端当前处于未命名状态。反之,一旦客户端通过相应机制设定了自身名称,name属性则会转而指向一个专门存储该名称的字符串对象。

在这里插入图片描述

这一设计确保了每个已命名的客户端都能通过其name属性快速访问到其专属的名称信息,既提高了信息的可访问性,也增强了系统的灵活性与可扩展性。

CLIENT Flag

通过flags属性,系统能够灵活地根据客户端的不同角色和实时状态来执行相应的操作逻辑、资源分配或是访问权限控制,从而确保系统的高效运行与安全性。

typedef struct redisclient
	int flags;
} redisclient;

flags属性的值可以是单个标志:

flags = <flag>

也可以是多个标志的二进制或,比如:

flags=<flag1> | <flag2> 
  • 主从复制架构中的角色标识:在Redis的主从复制机制中,角色是动态且相互依存的。主服务器在执行复制任务时,相对于从服务器而言,扮演了客户端的角色;反之,从服务器在主服务器的视角下,亦被视为客户端。为明确区分这两种角色,我们引入了REDIS_MASTER_CLIENT和REDIS_SLAVE_CLIENT常量。

  • Lua脚本执行环境的客户端标识:为了支持在Redis环境中高效执行Lua脚本,系统引入了REDIS_LUA_SCRIPT_CLIENT这一特殊标志。此标志标识的客户端并非传统意义上的网络连接客户端,而是一种虚拟或内部使用的“伪客户端”,专门负责处理Lua脚本中嵌入的Redis命令。


扩展总结

clients对象模型链表

服务器状态结构巧妙地利用了一个名为clients的链表来维护多个客户端状态的集合。这一设计确保了服务器能够高效地追踪和管理所有活跃的客户端连接

新创建client对象的“尾插法”

每当一个新的客户端状态被创建并加入到系统中时,它会被精心地放置在clients链表的尾部。

1723369311783.png

LUA和AOF的伪客户端

  • 处理Lua脚本的伪客户端在服务器初始化时创建,这个客户端会一直存在,直到服务器关闭。
  • 载人AOF文件时使用的伪客户端在载人工作开始时动态创建,载人工作完毕之后关闭。