Redis底层实现
- 1、事件
- 1.1 文件事件
- 1.2 时间事件
- 1.3 事件调度
- 2、Redis客户端
- 2.1 客户端的相关属性
- 2.2 客户端的创建与关闭
- 2.2.1 普通客户端的创建
- 2.2.2 普通客户端的关闭
- 2.2.3 AOF的伪客户端
- 2.2.4 Lua脚本的伪客户端
- 3、Redis服务端
如有侵权,请联系~
如有错误,也欢迎批评指正~
本篇文章大部分是来自学习《Redis设计与实现》的笔记
1、事件
在Redis设计与实现-数据持久化中提到了处理文件事件和时间时间,这里进行详细说明。
Redis服务器就是一个事件驱动程序,服务器主要处理两种事件:
- 文件事件:Redis服务器通过socket与客户端进行连接,而文件事件就是服务器对socket操作的抽象。服务器与客户端通信产生相应的文件事件,服务器通过监听并处理这些事件完成一系列网络通信。我理解文件通信主要是负责客户端的请求。
- 时间时间:Redis存在一些操作,如定时操作serverCron函数,需要在指定时间点执行某些操作。而时间时间就是这类定时操作的抽象。
1.1 文件事件
Redis是基于reactor模式开发的自己的网络事件处理器。
IO多路复用模块同时监听多个客户端,并且将请求socket存储到请求队列中,等待文件事件分发器进行分发。
文件事件分派器根据事件的类型,调用相应的连接处理器进行处理。这些处理器其实就是一个个函数,执行不同的动作、逻辑。
1.2 时间事件
Redis的时间事件分为两种:
- 定时事件:程序在指定的时间后执行。
- 周期性事件:程序每隔一段时间执行一次。
一个时间事件有三个属性:
- id:一个时间事件的全局唯一标识,这个是递增的。
- when:事件的到达时间,毫秒精度的unix时间戳。
- timeProc:时间事件处理器。当时间到达的时候调用相应的处理器进行处理事件【一个事件是定时还是周期性的根据时间事件处理器的返回结果判断。如果返回AE_NOMORE则就是定时的,如果是返回其他的整数就是周期性的。例如返回30,则表示30ms之后再次执行】。
在redis中时间事件是以无序【是指的不按照执行时间的大小排序】列表的形式存在的。
时间事件执行器遍历所有的时间事件,如果当天事件大于等于某个事件的when,则就会调用相应的处理器。如果处理器返回的是AE_NOMORE(说明这个事件是定时的),则就会将这个时间事件从列表中删除;否则就会更新这个事件的when属性。
目前正常的redis只有一个serverCron定期时间事件【Redis2.6执行频率100ms,Redis2.8版本可以通过hz参数进行设置】。serverCron为了保证redis服务器能够正常、稳定的运行,需要对服务器状态进行检查和调整,工作主要包括:
- 定期删除过期的键值对
- 进行RDB或者AOF持久化
- 更新服务器的统计信息,如内存占用、数据库占用等
- 关闭和清理连接失效的客户端
- 如果是主服务器,需要定期对从服务器进行同步
- 如果是集群模式,需要定期对集群进行同步和连接测试
1.3 事件调度
获取最近的时间事件的执行时间,根据当前时间判断还有多少时间remaindTime,然后阻塞remaindTime毫秒等待文件事件。如果文件事件到达,则先执行相应的文件事件,再执行到时间的时间事件;否则直接执行到时间的时间事件。
文件事件和时间事件都是同步、有序、原子的方式执行的,所以尽量减少阻塞时间,防止出现饥饿时间。因此耗时比较久的就会放到子进程或者子线程中执行,例如AOF重写;或者是像命令回复处理器将数据写入到套接字中,如果超过阈值就会等待下次写入。
注意:因为阻塞时间是最近的时间事件,先执行文件事件再执行时间事件,所以一般时间时间都会延迟,并不是准时执行。
2、Redis客户端
2.1 客户端的相关属性
针对于每个连接Redis服务器的客户端都会在连接应答处理器中创建一个Client结构对象。Client结构体定义如下:
typedef struct client {// 客户端基本信息uint64_t id; // 客户端 IDint fd; // 文件描述符sds name; // 客户端名称int flags; // 客户端状态标志// 输入缓冲区sds querybuf; // 输入缓冲区int argc; // 参数个数robj **argv; // 参数数组struct redisCommand *cmd; // 当前命令// 输出缓冲区char buf[PROTO_REPLY_CHUNK_BYTES]; // 固定大小的输出缓冲区list *reply; // 动态分配的输出缓冲区// 数据库相关redisDb *db; // 当前数据库int dictid; // 数据库索引// 复制相关int replstate; // 复制状态long long reploff; // 复制偏移量// 阻塞与超时mstime_t bpop_timeout; // 阻塞超时时间long long lastinteraction; // 上次交互时间// 统计信息long long query_start_time; // 命令开始执行时间size_t obuf_mem; // 输出缓冲区内存占用// 其他int authenticated; // 是否已认证connection *conn; // 连接对象dict *pubsub_channels; // 订阅的频道list *pubsub_patterns; // 订阅的模式
} client;
在服务端的结构体中存放了所有连接的客户端状态的链表,可以通过遍历获取想要的客户端:
typedef struct redisServer {...list *clients; // 客户端列表....
}
上述客户端的属性主要分为两部分:通用属性和特定属性【与特定的功能有关,例如订阅、事务】。这里介绍一些通用属性:
- fd:套接字描述符。这个属性值是大于等于-1的整数。当客户端为伪客户端【命令来自于AOF载入或者lua脚本】的时候,fd=-1;其他情况的客户端都是大于-1.
- name:客户端的名字。连接服务端的客户端一般是没有名字的,即这个字段为null。但是客户端可以使用client setname给自己设置一个名字,使得客户端的身份更加明确
- flags:标志。记录了客户端的角色和目前所处的状态。例如REDIS_MULTI:客户端正在执行事务、REDIS_FORCE_AOF强制服务端将当前命令写入到AOF中
- querybuf:输入缓冲区。记录客户端发送的命令请求。
- argv、argc:参数数组。将命令请求存储入输入缓冲区之后,对命令请求进行解析,得到的命令参数和命令个数分别存储到argv和argc中。例如set key value命令,argv数组分别存储:“set” “key” “value”, argc=3。argv[0]是待执行的命令。
- cmd:命令实现函数。redis中将所有的命令和命令实现函数存放到字典中。已知argv[0]即待执行的命令就可以得到该命令对应的实现函数。然后将得到的实现函数赋值给cmd属性。
- 输出缓冲区:输出缓冲区分为两部分:
- 缓冲区的大小是固定的【buf[PROTO_REPLY_CHUNK_BYTES]】,主要是存储一些长度比较小的回复。PROTO_REPLY_CHUNK_BYTES默认为16*1024,即buf数组为16KB。
- 缓冲区的大小是变化的【reply】。存储一些比较大的复杂的命令回复,他是一个字符串链表。
- authenticated:身份验证。表示客户端是否通过身份验证,通过验证authenticated=1,否则为0。如果身份验证不通过,在服务端开启身份验证的时候,客户端无法执行命令。
2.2 客户端的创建与关闭
不同类型的客户端,服务端对客户端的创建也略有不同。
2.2.1 普通客户端的创建
针对于通过网络连接的普通客户端,当客户端通过connect函数与redis服务器进行连接的时候,redis服务器就会利用文件事件分派器分配给连接应答处理器。连接应答处理器就会为这个客户端创建client对象,并且通过尾插法将这个client对象存储到redisServer的clients列表尾部。
2.2.2 普通客户端的关闭
客户端被关闭的情况比较多:
- 客户端进程退出或者被杀死,那么客户端和服务端的网络连接也就被关闭了,从而就会导致服务端会关闭客户端
- 客户端向服务端发送了不符合resp协议的命令
- 客户端成为client kill的目标
- 如果用户为服务端设置了timeout超时时间,如果客户端空转时间idle超过timeout,就会被关闭
- 客户端发送的命令超过输入缓冲区,默认1G
- 命令回复的内容超过输出缓冲区
2.2.3 AOF的伪客户端
在redis服务器启动的时候,会创建伪客户端用于加载AOF文件,执行aof中的命令。等AOF文件载入结束就会关闭伪客户端。
2.2.4 Lua脚本的伪客户端
服务器在初始化的时候就会创建用来执行lua脚本中redis命令的伪客户端。这个伪客户端的目的就是充当lua脚本和redisServer的桥梁,用来执行lua脚本中的redis命令。例如
redis.call('SET', 'key', 'value')
redis.call() 会通过 lua_client 向 Redis Server 发送 SET 命令。这个伪客户端会随着服务器的关闭而关闭。
3、Redis服务端
Redis服务端负责与多个客户端建立网络连接,处理客户端发送的命令请求,在数据库中保存命令执行产生的数据,并根据资源管理来维持自身的运行。
接下来的执行过程的前提是已经创建好连接,即连接应答处理器执行完,创建好了Client对象。
3.1 命令请求的执行过程
3.1.1 客户端发送命令请求
客户端执行redis命令,然后客户端会将redis命令转换为resp协议格式,通过网络发送给redis服务端。以Jedis为例子:
底层源码:
java">public class Jedis extends BinaryJedis implements JedisCommands, MultiKeyCommands,AdvancedJedisCommands, ScriptingCommands, BasicCommands, ClusterCommands, SentinelCommands,ModuleCommands {protected final Client client;@Override// 执行redis命令public String set(final String key, final String value) {checkIsInMultiOrPipeline();client.set(key, value);return client.getStatusCodeReply();}
}public class Client extends BinaryClient implements Commands {@Overridepublic void set(final String key, final String value) {// 将命令转换为字节数组set(SafeEncoder.encode(key), SafeEncoder.encode(value));}public void set(final byte[] key, final byte[] value) {sendCommand(SET, key, value);}public void sendCommand(final ProtocolCommand cmd, final byte[]... args) {try {// 获取连接connect();// 转换为resp协议并且发送给客户端Protocol.sendCommand(outputStream, cmd, args);} catch (JedisConnectionException ex) {try {String errorMessage = Protocol.readErrorLineIfPossible(inputStream);if (errorMessage != null && errorMessage.length() > 0) {ex = new JedisConnectionException(errorMessage, ex.getCause());}} catch (Exception e) {}broken = true;throw ex;}}
}public final class Protocol {public static void sendCommand(final RedisOutputStream os, final ProtocolCommand command,final byte[]... args) {sendCommand(os, command.getRaw(), args);}private static void sendCommand(final RedisOutputStream os, final byte[] command,final byte[]... args) {try {// 这里转换为resp协议。ASTERISK_BYTE = '*';DOLLAR_BYTE = '$';os.write(ASTERISK_BYTE);os.writeIntCrLf(args.length + 1);os.write(DOLLAR_BYTE);os.writeIntCrLf(command.length);os.write(command);os.writeCrLf();for (final byte[] arg : args) {os.write(DOLLAR_BYTE);os.writeIntCrLf(arg.length);os.write(arg);os.writeCrLf();}} catch (IOException e) {throw new JedisConnectionException(e);}}}
3.1.2 读取命令请求
当客户端向Redis服务端发送了命令请求之后,套接字就会变得可读,服务端就会根据命令请求分派器调用命令请求处理器进行处理。命令请求处理器将会做如下操作:
- 读取套接字中的符合resp协议的命令请求,并将其存储到相应client的输入缓冲区中
- 解析输入缓冲区中的命令,分别提取出命令参数和命令个数存储到argv和argc属性中
- 根据argv[0](存储的这个是命令,如set)去命令实现函数字典【命令表】中查找对应的实现函数【redisCommand对象】并赋值给Client对象的cmd属性,然后通过这个实现函数执行相应逻辑。
redisCommand结构体:
struct redisCommand {char *name; // 命令名称(如 "SET", "GET")redisCommandProc *proc; // 命令的实现函数指针int arity; // 参数数量要求(负数表示可变参数)char *sflags; // 命令标志字符串(如 "w" 表示写命令),表示这个命令的一些特征:写命令、读命令、管理命令、发布订阅命令、可能会占用大量内存命令...uint64_t flags; // 命令标志位(由 sflags 转换而来)redisGetKeysProc *getkeys_proc; // 获取键的函数指针(用于复杂命令)int firstkey; // 第一个键参数的位置int lastkey; // 最后一个键参数的位置int keystep; // 键参数之间的步长long long microseconds; // 执行该命令的平均耗时(统计用)long long calls; // 该命令被调用的次数(统计用)
};
3.1.3 执行实现函数
上述说了通过命令表根据执行的命令名查找该命令的实现函数。在获取到实现函数之后,并不会立刻执行实现函数。
命令执行器的预备操作
在此之前,redisClient已经保存了实现函数(redisClient.cmd属性)、参数(redisClient.argv属性)、参数个数(redisClient.argc属性)。 在真正执行命令执行器【实现函数】之前还会有一些预备操作。包括但不限于:
- cmd属性是不是为null,是不是没有找到相应的实现函数
- 根据cmd.arity属性检查参数的个数是不是符合要求
- 客户端是否通过了校验authenticated
- 如果服务器打开了maxmemory功能,在执行之前先检查内存占用情况,并在需要的时候进行内存回收
- …
命令执行器的执行环节
开始调用实现函数,将执行的结果存储到输出缓冲区中。
client->cmd->proc(client);
命令执行器的后续环节
执行完实现函数之后,还会存在一些后续操作:
- 如果开启了满查询,判断当天查询是不是要增加一条满查询日志
- 根据本次执行时间,更新该命令的平均耗时microseconds以及调用次数calls+1
- 如果服务端开启了AOF持久化,需要将本命令写入到AOF缓冲区中
- 如果有从服务器正在复制这个服务器,那么会将这个命令传递给从服务器
3.1.4 将命令回复发送给客户端
上述命令执行完会将执行结果存储到输出缓冲区中,命令请求处理器会将套接字的可写状态与命令回复处理器进行关联。当客户端可写时,就会执行命令回复执行器。命令回复执行器将输出缓冲区的数据发送给客户端,并且取消套接字可写状态与命令回复处理器的关联。
客户端会将服务端返回的resp协议结果转换为字符串结果。
3.2 serverCron函数
Redis的serverCron函数默认是100ms执行一次,进行管理服务器的资源。serverCron函数会执行如下操作:
struct redisServer{...// 保存了秒级精度的unix时间戳time_t unixtime;// 保存了毫秒级精度的unix时间戳long long mstime;// 当前的 LRU 时钟值,默认10s更新一次,用于计算idle空转时长unsigned int lruclock; // 使用内存峰值size_t stat_peak_memory;// 服务器关闭表示。1:服务器关闭 0:服务器正常int shutdown_asap;// aof重写是否被延迟了,1:延迟int aof_rewrite_scheduled// 记录执行bgsave命令的子进程ID。-1:服务器没有执行bgsavepid_t rbd_child_pid;// 记录执行bgrewriteaof命令的子进程ID。-1:服务器没有执行bgrewriteaofpid_t aof_child_pid;// 存储serverCron时间事件执行次数int cronloops;...
}
更新服务器的时间缓存 :每100ms更新一次unixtime和mstime属性。因为很多操作都会获取系统的时间,而每次获取都进行系统调用,比较耗费性能。所以redis定期更新时间缓存unixtime和mstime属性,使用这两个属性代替系统时间。
这种适合精度要求不高的场景,例如:打印日志、是否执行持久化等。但是针对于判断键的过期时间、满查询等高精度时间就需要每次都进行系统调用。
更新LRU时钟:serverCron函数默认10s更新一次lruclock。每个redisObject都有一个lru属性用于计算上次访问时间,这个时间就是使用的lruclock。而计算一个对象的空转时间也是使用lruclock-redisObject.lru,因此空转时间idle这是一个估算时间。
更新服务器每秒钟命令执行次数:这个只是一个估计值。redisServer中有四个ops_sec_*开头的属性和其相关。根据上次计算时间和执行次数和当前的时间和次数,计算平均一分钟执行的个数。同时还有一个数组保存了过去16次的平均执行次数,而获取的每秒命令执行次数是过去16个平均值的平均。
更新内存使用峰值记录:每次执行serverCron函数都会查看服务器使用的内存数量,然后和stat_peak_memory进行比较,较大的存储到stat_peak_memory中。
处理sigterm信号:服务器在启动的时候会为SIGTERM信号关联一个信号处理器sigtermHandler,即如果服务器接收到SIGTERM信号,sigtermHandler函数就会将shutdown_asap置为1. serverCron每次执行的时候都会检查shutdown_asap值。
管理客户端资源:serverCron函数每次执行都会调用clientsCron函数,clientsCron函数会进行如下操作:
- 检查客户端和服务端的连接是否已经超时
- 检查上次命令之后输入缓冲区是否超过一定长度【每次命令都会追加到缓冲区中】,如果超过会释放当天输入缓冲区,重新创建一个新的
管理服务端资源:serverCron函数每次执行都会调用databasesCron函数,databasesCron函数会对数据库进行检查,删除一些过期键,适当的时候还会对字典进行伸缩操作。
执行被延迟的BGREWRITEAOF: 当服务端在执行bgsave命令的时候,如果客户端发送了bgrewriteaof命令,那么aof重写会被延迟到bgsave命令执行之后,但是会设置aof_rewrite_scheduled=1。在执行serverCron函数的时候,会检查当前是不是在执行bgsave或者bgrewriteaof,如果没有并且aof_rewrite_scheduled=1,那么服务端就会执行被延迟的aof重写。
检查持久化运行状态:serverCron函数会检查rbd_child_pid和aof_child_pid是不是都为-1。是否有一个不为-1:
- 则等待一会。在等待期间如果有信号从子进程返回过来,说明rdb持久化或者aof重写完成了,则执行文件替换。将新生成的文件【rdb文件或者aof重写文件】替换原来文件
- 如果都是-1,则判断有没有延迟的aof重写,如果有则执行;没有在判断配置的自动持久化是不是满足了,如果满足则进行bgsave;否则判断AOF重写条件是不是满足。
将AOF缓存中的数据持久化到AOF文件:如果服务端开启了aof持久化,那么aof缓存中会有待写入的数据,serverCron函数会调用相应的程序进行持久化。
关闭异步客户端:关闭那些超过输出缓冲区超过硬限制的客户端。
增加cronloops次数:每执行一次serverCron函数,cronloops都会加1。
3.3 初始化服务器
上述的命令请求和serverCron运行都是在服务器已经启动的前提下,本小节就是介绍服务器启动的时候的一些流程和初始化。
redisServer_348">3.3.1 初始化服务器的状态结构redisServer
初始化服务器的第一步肯定是创建redisServer对象。然后就会对redisServer对象中的属性进行初始化。初始化第一步就是先给部分属性【除了命令表这种创建了结构对象,大部分都是整数、浮点数、字符串这种基本类型】赋默认值。
- 服务器的运行ID
- 服务器的默认运行频率
- 服务器的默认文件路径
- 服务器的运行架构
- 服务器的默认端口号
- 服务器的默认RDB、AOF持久化条件
- 服务器的LRU时钟
- 创建命令表
这些都是在initServerConfig函数中实现的默认初始化。
3.3.2 载入配置选项
在初始化默认的配置之后,然后根据配置文件中的相关配置或者命令参数修改、更新配置参数。
3.3.3 初始化服务器中的数据结构
上述只是初始化一些简单的配置,除了命令表。到这就开始初始化一些复杂的属性,通过initServer函数执行。
- 初始化server.clients链表,用来存储客户端的redisClient连接对象
- 创建server.db数据库
- 创建用来保存订阅信息和订阅模式的字典和列表
- 创建共享对象,主要是一些redis比较常用值,例如"ok"的字符串、0-10000的字符串
- 监听端口,并且为套接字关联连接应答处理器
- 为serverCron创建时间事件
- 如果打开了AOF持久化,打开已有的AOF文件,没有AOF文件就新建
- …
3.3.4 还原数据库状态
服务器初始化完成之后,就要根据RDB文件或者AOF文件还原数据库状态。如果服务器开启了AOF持久化,则优先使用AFO文件进行还原;否则使用RDB进行还原。因为AOF文件实时性更高,丢失数据的概率更小。
3.3.5 执行事件循环
开始执行事件循环,执行文件事件和时间事件。至此服务器可以接受客户端的请求。