单机数据库的实现

数据库

服务器状态结构

redis服务器将所有的数据库都保存在服务器状态 redis.h/redisServer结构的db数组中,db数组的每一个项都是一个redis.h/redisDb结构,代表一个数据库。

struct redisServer {
	//...
  //一个数组,保存着服务器中的所有数据库.
	redisDb *db;
  
  //服务器的数据库数量,初始化服务器时,会根据该属性决定有多少数据库。
  int dbnum;
  //...
};

默认创建数据库

客户端状态结构

客户端可以通过执行select来切换目标数据库,默认是0。

typedef struct redisClient {
  // ...
  //记录客户端当前正在使用的数据库
  redisDb *db;
  // ...
} redisC1ient; 

redisClient.db指针指向redisServer.db数组中的一个元素,被指向的元素就是客户端的目标数据库。

客户端数据库

数据库键空间

键空间

redis是一个键值对数据库服务器,服务器中的每个数据库都由一个redis.h/redisDb结构表示,其中redisDb结构的dict保存了数据库中的所有键值对。

typedef struct redisDb {
  //...

  //数据库键空间,保存着数据库中的所有键值对
  dict *dict;

  //...

} redisDb;

  • 键空间的键:数据库的键,每一个键都是一个字符串对象。
  • 键空间的值:数据库的值,每个值可以是string、list、hash、set、zset中的任意一种redis对象。

键空间

读写键空间时的维护操作

  • 服务器根据键是否存在来更新服务器的hit和miss次数,通过info stats命令查看。
  • 更新LRU时间。
  • 服务器在读取一个键时,发现键已经过期会先删除这个过期键。
  • 如果有客户端使用watch监视了某个键,服务器在被监视的键进行修改之后会将键标记为,从而让事务程序注意到这个键已经被修改。
  • 服务器每次修改一个键之后,都会对脏键计数器的值增1,计数器会触发服务器的持久化及复制操作。
  • 如果服务器开启了数据库通知功能,在对键进行修改之后,服务器按配置发送相应的数据库通知。

设置键的生存时间或过期时间

设置过期时间

  • 通过expirepexpire命令,客户端可以以秒或者毫秒为精度为数据库中的某个键设过期时间。到达指定时间之后,服务器就会自动删除TTL为0的键。

  • 使用expireatpexpireat给键设置一个UNIX时间戳,当到达该时间服务器就会自动从数据库中删除这个键。

  • 使用TTLPTTL返回键剩余的存活时间。

过期时间命令的转换图

保存过期时间

​ 在redisDb结构的expires字典(过期字典)保存了数据库中所有键的过期时间。

​ 过期字典的键:是一个指针,指向键空间中的某个键对象。

​ 过期字典的值:long long类型的整数,一个UNIX时间戳。

typedef struct redisDb {
  //...

  //过期字典,保存着键的过期时间
  dict *expires;

  // ...
} redisDb;

​ 键空间的键对象和过期字典的键对象指向同一个string对象,不会出现重复对象。

带有过期字典的数据库

设置过期时间伪代码

def PEXPIREAT {key, expire_ time_ in_ ms):
  #如果给定的键不存在于键空间,那么不能设置过期时间
  if key not in redisDb.dict:
    return 0
  #在过期字典中关联键和过期时间
  redisDb.expires[key] = expire_ time_ in_ ms
  #过期时间设置成功
  return 1

移除过期时间

​ 使用命令persist <key>

移除过期时间伪代码

def PERSIST (key) :
  #如果键不存在,或者键没有设置过期时间,那么直接返回
  if key not in redisDb.expires:
    return0
  #移除过期字典中给定键的键值对关联
  redisDb.expires.remove (key)
  #键的过期时间移除成功
    return 1

redis的过期键删除策略

redis使用惰性删除和定期删除两种策略。

过期键的删除策略

  • 定时删除

    对内存友好:通过使用定时器,保证过期键会尽可能快地被删除,并释放过期键所占用的内存。

    对CPU不友好:过期键较多,删除过期键可能会占用相当一部分CPU时间。CPU时间花费在删除和当前任务无关的过期键上。

    例如,如果正有大量的命令请求在等待服务器处理,并且服务器当前不缺少内存,那么服务器应该优先将CPU时间用在处理客户端的命令请求上面,而不是用在删除过期键上面。
    除此之外,创建一个定时器需要用到Redis服务器中的时间事件,而当前时间事件的实
    现方式是无序链表, 查找一个事件的时间复杂度为0(N),并不能高效地处理大量时间事件。

  • 惰性删除

    对CPU友好:程序只会在取出键是才对键进行过期检查,保证删除过期键的操作只会在非做不可的情况下进行,并且只处理当前键。

    对内存不友好:过期的键不会被及时删除,占用内存。

  • 定期删除

    每隔一段时间执行一次删除过期键的操作,并通过限制删除操作执行的时长和频率来减少删除操作对CPU的影响。

惰性删除策略的实现

所有读写数据库的Redis命令在执行之前都会调用expireIfNeeded函数对输人键进行检查:

  • 如果键已经过期,expireIfNeeded函数将键从数据库中删除。
  • 如果键未过期,不做操作。

惰性删除策略实现

定期删除策略的实现

每当Redis的服务器周期性操作redi .c/ serverCron函数执行时,redis.c/activeExpireCycle函数就会被调用,在规定的时间内,分多次遍历服务器中的各个数据库检查并删除过期键。

伪代码

#默认每次检查的数据库数量
DEFAULT DB_ NUMBERS = 16
#默认每个数据库检查的键数量
DEFAULT_ KEY_ NUMBERS = 20
#全局变量,记录检查进度
current_ db = 0
def activeExpireCycle():
  #初始化要检查的数据库数量
  #如果服务器的数据库数量比DEFAULT DB_ NUMBERS 要小
  #那么以服务器的数据库数量为准
  if server.dbnum < DEFAULT_ DB_ NUMBERS:
  	db_ numbers = server.dbnum
  else:
  	db_ numbers = DEFAULT_ DB_ NUMBERS
  #遍历各个数据库
  for i in range(db_numbers):
    #如果current_ db的值等于服务器的数据库数量
    #这表示检查程序已经遍历了服务器的所有数据库一次
    #将current_ db重置为0,开始新的一轮遍历
    if current_db == server.dbnum:
    	current_ db = 0
    #获取当前要处理的数据库
    redisDb = server.db[ current_ db]
    #将数据库索引增1,指向下一个要处理的数据库
    current_ db += 1
    #检查数据库键
    for j in range (DEFAULT_ KEY_ NUMBERS) :
      #如果数据库中没有一个键带有过期时间,那么跳过这个数据库
      if redisDb.expires.size() == 0: break
      #随机获取一个带有过期时间的键
      key_ with_ ttl = redisDb. expires.get_ random_ key ()
      #检查键是否过期,如果过期就删除它
      if is_ expired (key_ with_ tt1) :
      	delete_ key (key_ with_ tt1)
      #已达到时间上限,停止处理
      if reach_ time_ limit() : return

总结过程

  • 函数每次运行时在规定的时间内循环遍历数据库,并从其中取出一定数量的随机键进行检查处理。

  • current_db会记录函数的检查进度,在下一次调用函数时会从current_db数据库开始检查。

    #获取当前要处理的数据库
    redisDb = server.db[ current_ db]
    #将数据库索引增1,指向下一个要处理的数据库
    current_ db += 1
    

AOF、RDB和复制对过期键的处理

RDB

生成RDB文件(无影响):

​ 在执行save或者bgsave命令创建一个新的RDB文件时,程序会对数据库中的键进行检查,过期的键不会保存到新创建的RDB文件中。

载入RDB文件

  • 主服务器模式运行(无影响):在载入RDB文件时会对文件中保存的键进行检查,忽略过期的键。

  • 从服务器模式运行(无影响):

    在载入RDB文件时,无论是否过期都会被载入到数据库中。但是主从服务器在进行数据同步的时候,从服务器的数据库会被清空,所以一般过期键不会对从服务器造成影响。

AOF

AOF文件写入(无影响):

​ 当服务器以AOF持久化模式运行时,如果数据库中的某个键已经过期,但它还没有被惰性删除或者定期删除,那么AOF文件不会因为这个过期键而产生任何影响。
​ 当过期键被惰性删除或者定期删除之后,程序会向AOF文件追加( append)一条DEL命令,来显式地记录该键已被删除。
举个例子,如果客户端使用GET message命令,试图访问过期的message键,那么服务器将执行以下三个动作:

  1. 从数据库中删除message键。
  2. 追加一条DEL message 命令到AOF文件。
  3. 向执行GET命令的客户端返回空回复。

AOF重写(无影响):

​ 在执行AOF重写的过程中,会对数据库中的键进行检查,已过期的键不会被保存到重写后的AOF文件中。

复制

服务器在复制模式下,从服务器的过期键删动作由主服务器控制:

  • 从服务器在执行客户端发送的读命令时,即使碰到过期键也不会将过期键删除,而是继续像处理未过期的键一样来处理过期键

    主从服务器删除过期键2

  • 主服务器在删除-一个过期键之后,会显式地向所有从服务器发送一个 DEL命令,告知从服务器删除这个过期键。

    主从服务器删除过期键3

  • 从服务器只有在接到主服务器发来的DEL命令之后,才会删除过期键。

    主从服务器删除过期键4

数据库通知

数据库通知可以让客户端通过订阅给定的频道或者模式,来获知数据库中键的变化,以及数据库中命令的执行情况。

subscribe_ _keyspace@<dbid>_ _:<key>

发送通知的实现

def notifyKeyspaceEvent (type, event, key, dbid) :
	#如果给定的通知不是服务器允许发送的通知,那么直接返回
	if not (server.notify_keyspace_events & type) :
		return
	#发送键空间通知
	if server.notify_keyspace_events & REDIS_NOTIFY_KEYSPACE :
		#将通知发送给频道 _keyspace@<dbid>_:<key>
		#内容为键所发生的事件<event>

		#构建频道名字
		chan = " keyspace@ {dbid}__:{key}".format(dbid=dbid, key=key)
		#发送通知
		pubsubPublishMessage (chan, event)
	#发送键事件通知
	if server.notify_ keyspace_events & REDIS_ NOTIFY_ KEYEVENT :
		#将通知发送给频道keyevent@ <dbid>__ : <event>
		#内容为发生事件的键<key>
		#构建频道名字
		chan = "_ keyevent@{dbid}__ : {event}".format(dbid=dbid, event=event )
		#发送通知
		pubsubPublishMessage(chan, key)

  1. server . notify_ keyspace_ events属性就是服务器配置notify- keyspace-events选项所设置的值,如果给定的通知类型type不是服务器允许发送的通知类型,那
    么函数会直接返回,不做任何动作。
  2. 如果给定的通知是服务器允许发送的通知,那么下一步函数会检测服务器是否允许发送键空间通知,如果允许的话,程序就会构建并发送事件通知。
  3. 最后,函数检测服务器是否允许发送键事件通知,如果允许的话,程序就会构建并
    发送事件通知。

另外,pubsubPubli shMessage函数是PUBLISH命令的实现函数,执行这个函数等同于执行PUBLISH命令,订阅数据库通知的客户端收到的信息就是由这个函数发出的。

RDB持久化

RDB持久化功能所生曾的RDB文件是一个经过压缩的二进制文件,通过该文件可以还原生成RDB文件时的数据库状态。

RDB文件的创建与载入

RDB文件创建

可以生成RDB文件的命令:savebgsave

  • save:直接阻塞服务器进程,直到RDB文件创建完毕为止。

    def SAVE () :
      #创建RDB文件
      rdbSave ()
    
  • bgsave:fork出一个子进程,然后由子进程负责创建RDB文件,服务器进程继续处理命令请求。当子进程完成rdbSave之后,会向父进程发送信号,父进程轮询等待信号。

    但是对 save、bgsave、bgwriteaof三个命令处理的方式和平时不同。

    1. 如果bgsave命令正在被执行,save、bgsave命令会被服务器拒绝,避免服务器进程和子进程同时进行rdbSave调用,防止发生竞争条件。
    2. 如果BGSAVE命令正在执行,那么客户端发送的BGREWRITEAOF命令会被延迟到BGSAVE命令执行完毕之后执行。
    3. 如果BGREWRITEAOF命令正在执行,那么客户端发送的BGSAVE命令会被服务器拒绝。虽然这两个命令不会冲突,但是对性能的考虑:两个子进程、大量的磁盘写入操作。
    def BGSAVE() :
      #创建子进程
      pid = fork()
        
      if pid == 0:
      #子进程负责创建RDB文件
      rdbSave ()
        
      #完成之后向父进程发送信号
      signal_parent ()
        
      elif pid > 0:
        #父进程继续处理命令请求,并通过轮询等待子进程的信号
          				    handle_request_and_wait_signal ()
      else :
        #处理出错情况
        handle_ fork error ()
    

RDB文件载入

如果在启动服务器时存在RDB文件,它就会自动载入RDB文件。在载入的过程中一直处于阻塞状态

注意:如果服务器开启了AOF,服务器会优先使用AOF文件来还原数据库(比RDB文件可靠性更高)。

自动间隔性保存

设置save

通过配置save选项设置多个保存条件,只要有一个条件被满足,服务器就会自动执行bgsave命令。

服务器程序根据save选项所设置的保存条件,设置服务器状态redisServer的saveparams属性:

struct redisServer {
  //...

  //记录了保存条件的数组
  struct savepa ram *saveparams ;

  //...
};
struct saveparam {
  //秒数
  time_ t seconds;
  //修改数
  int changes;
};

服务器状态中的保持条件

dirty计数器和lastsave属性

struct redisServer {
//...
  
  //修改计数器
  1ong long dirty;
  //上一次执行保存的时间
  time t lastsave ;
  
//...
};

  • dirty计数器:记录距离上一次成功执行save或bgsave命令之后,所有数据库进行修改的次数(写入、删除、更新等操作)。
  • lastsave:记录了上一次成功执行save或bgsave命令的UNIX时间戳。

检查save条件是否满足

redis的周期性操作函数serverCron默认每100ms执行一次,这个函数用于对正在运行的服务器进行维护,比如:定期删除过期键、检查save选项的条件是否满足。

def serverCron():
  #...
  普遍历所有保存条件
  for saveparam in server.saveparams :
    #计算距离上次执行保存操作有多少秒
    save_interval = unixtime_now() - server.lastsave
    #如果数据库状态的修改次数超过条件所设置的次数
    #并且距离上次保存的时间超过条件所设置的时间
    #那么执行保存操作
    if server.dirty >= saveparam.changes and save_interval > saveparam.seconds:
      BGSAVE ()
	#...

AOF持久化

AOF持久化通过保存redis服务器所执行的写命令来记录数据库的状态。

AOF持久化的实现

  • 命令追加

    AOF功能打开时,服务器执行完一个写命令之后,会以协议格式将被执行的命令追加到redisServer的aof_buf缓冲区(SDS)的末尾:

    struct redisServer (
      //...
      
      //AOF缓冲区
      sds aof_buf;
      
      //...
    };
    
    
  • 文件写入和文件同步

    redis服务器的进程就是一个事件循环,循环中的文件时间负责接收客户端的命令请求以及回复,时间事件负责像serverCron需要定时运行的函数

    在每次结束事件循环之前,调用flushAppendOnlyFile函数,考虑是否将aof_buf中的内容写入和保存到AOF文件:

    def eventLoop() :
      while True:
        #处理文件事件,接收命令请求以及发送命令回复
        #处理命令请求时可能会有新内容被追加到aof_buf缓冲区中
        processFileEvents ()
        #处理时间事件
        processTimeEvents ()
          
        #考虑是否要将aof_buf中的内容写入和保存到AOF文件里面
        flushAppendOn1yFile()
    
appendfsync选项值flushAppendOn1yFile函数行为
always将aof_buf中的所有内容写入并同步到AOF文件
everysec(默认)将aof_ buf中的所有内容写人到AOF文件,如果上次同步AOF文件的时间距离现在超过一秒钟,那么再次对AOF文件进行同步,并且这个同步操作是由一个线程专门负责执行的
no将aof_buf中的所有内容写入到AOF文件,但是不对AOF文件进行同步,何时同步由操作系统决定

文件的写入和同步

​ 为了提高文件的写入效率,当用户调用write函数将一些数据写入到文件的时候,os将写入数据暂时保存在一个内存缓冲区中。等到缓冲区满、或者超过了指定的时限之后,才会将缓冲区中的数据写入到磁盘中去。

​ 如果计算机发生停机,那么内存缓冲区中额数据会丢失。系统提供了fsync和fdatasync两个同步函数,强制让那个操作系统将缓冲区中的数据写入到磁盘里面。

AOF文件的载入和数据还原

  1. 创建一个不带有网络连接的伪客户端来执行AOF文件保存的写命令。
  2. 从AOF文件中分析并读取一条写命令。
  3. 使用伪客户端执行被分析出的写命令。
  4. 一直执行2、3直到所有的写命令被处理完。

AOF重写

通过AOF重写,解决AOF体积膨胀的问题。AOF重写不需要对现有的AOF文件进行任何读取、分析或写入操作,而是通过读服务器当前的数据库状态来实现的。

aof_rewrite函数伪代码

def aof_rewrit (new_aof_file_name) :
  #创建新AOF文件
  f = create_file(new_aof_file_name)
  #遍历数据库
  for db in redisServer.db:
    #忽略空数据库
    if db.is_empty(): continue
    #写入SELECT 命令,指定数据库号码
    f.write_ command ("SELECT" + db.id)
    #遍历数据库中的所有键
    for key in db:
      #忽略巳过期的键
      if key.is_expired() : continue
      #根据键的类型对键进行重写
      if key.type == String :
      	rewrite_string (key)
      elif key.type = = List:
     	 rewrite_list (key)
      elif key.type = Hash:
      	rewrite_hash (key)
      elif key.type == Set:
      	rewrite_set (key)
      elif key.type == SortedSet:
      	rewrite sorted_set (key)
      #如果键带有过期时间,那么过期时间也要被
      if key.have_expire_time() :
      	rewrite_ expire_time(key)
  专写入完毕,关闭文件
  f. close ()

AOF后台重写

​ 因为aof_rewrite有大量的写入操作,调用这个函数的线程将被长时间阻塞,所以redis使用子进程来执行AOF重写。

  • 子进程进行AOF重写期间,父进程可以继续处理命令请求。
  • 子进程带有父进程的数据副本,使用子进程而不是线程可以避免在使用锁的情况下,保证数据的安全。
  • fork(子进程使用写时复制避免了复制大量数据造成内存利用率低。

解决子进程AOF重写时,父进程继续执行写命令而导致数据不一致

​ AOF重写缓冲区,在服务器创建子进程之后开始使用。当redis执行完一个写命令之后,会同时将这两个写命令发送给AOF缓冲区和AOF重写缓冲区。

AOF缓冲区和AOF重写缓冲区

​ AOF缓冲区的内容会定期被写入和同步到AOF文件,对现有AOF文件的处理工作正常进行。

​ 当子进程完成重写工作之后,会向父进程发送一个信号,父进程在接收到该信号之后开始调用信号处理函数(阻塞):

  1. 将AOF重写缓冲区中的所有内容写入到新AOF文件中,保证新的AOF文件所保存的数据库状态和服务器当前数据库状态一致。

  2. 对新的AOF文件进行改名,原子地覆盖现有的AOF文件,完成文件的替换。

事件

redis服务器是一个事件驱动程序,服务器需要处理以下两类事件:

  • 文件事件

    redis服务器通过套接字与客户端进行连接,而文件事件就是服务器对套接字操作的抽象。服务器与客户端的通信会产生相应的文件事件,而服务器则通过监听并处理这些事件来完成一系列网络通信操作

  • 时间事件

    redis服务器中的一些操作(serverCron函数)需要在给定的时间点执行,而时间事件就是服务器对这类定时操作的抽象。

文件事件

redis基于reactor模式开发了自己的网络事件处理器:文件事务处理器。虽然文件事件处理器以单线程方式运行,通过I/O多路复用程序来监听多个套接字,文件事件处理器既实现了高性能的网络通信模型,也可以很好地与redis服务器中其他同样以单线程方式运行的模块进行对接。

  • 文件事务处理器使用I/O多路复用(multiplexing)程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事务处理器。
  • 当被监听的套接字准备好执行accept、read、write、close等操作的时候,与操作系统相对应的文件事件就会产生,这时事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。

文件事件处理器的构成

文件事件处理器的构成

文件事件分派器接收I/O多路复用程序传来的套接字,并根据套接字产生的事件的类型,调用相应的事件处理器。

因为一个服务器通常会连接多个套接字,所以多个文件事件有可能会并发地出现。I/O多路复用程序负责监听多个套接字,I/O多路复用程序将所有产生事件的套接字放到一个==队列==中,当上一个事件被管理的事件处理器处理完毕,才会通过队列向文件事件分派器发送下一个套接字

I/O多路复用程序的实现

redis的I/O多路复用程序通过包装将select、epoll、evprt和kqueue这些I/O多路复用函数包装成相同的API,在底层可以进行互换。

优先套接字读,然后套接字写

  • 当套接字变得可读时(客户端对套接字执行write操作,或者执行close操作),或者有新的可应答(acceptable)套接字出现时(客户端对服务器的监听套接字执行connect操作),套接字产生AE READABLE 事件。
  • 当套接字变得可写时(客户端对套接字执行read操作),套接字产生AE_WRI TABLE事件。

文件事件的处理器

连接应答处理器

​ networking. c/ acceptTcpHandler函数是Redis的连接应答处理器,这个处理器用于对连接服务器监听套接字的客户端进行应答,具体实现为sys/socket.h/accept函数的包装。

​ 当redis服务器进行初始化的时候,程序将连接应答处理器和服务器监听套接字的AE_READABLE事件(用来处理链接)关联起来,当客户端调用sys/socket.h/connect函数连接服务器套接字的时候,就会产生AE_READALE事件,引发连接 应答处理器执行。

命令请求处理器

​ networking.c/ readQueryFromClient函数是Redis的命令请求处理器,这个处理器负责从套接字中读客户端发送的命令请求内容,具体实现为unistd.h/read函数的包装。

​ 当客户端通过连接应答处理器连接成功后,服务器会将客户端套接字的AE_READABLE事件(用来处理客户端的请求)和命令请求处理器关联起来。进行命令请求的时候会产生该事件,然后被命令请求处理器处理。

命令回复处理器

​ networking.c/ sendReplyToClient函数是Redis的命令回复处理器,这个处理器负责将服务器执行命令后得到的命令回复通过套接字返回给客户端,具体实现为unistd. h/write函数的包装。

​ 当服务器有命令回复需要传送给客户端的时候,服务器会将客户端套接字的AE_WRITEABLE事件(相应客户端准备接受回复)和命令回复处理器关联起来,当客户端准备好接收服务器传回的命令回复时,就会产生AE_WRITEABLE事件,引发命令回复处理器执行。当处理器执行结束之后,就会解除该客户端套接字的AE_WRITEABLE事件与命令回复器之间的关联

客户端和服务器的通信过程

时间事件

时间事件分类

  • 定时事件:让一段程序在指定的时间之后执行一次。
  • 周期事件(redis目前只使用该事件):让一段程序每隔指定时间就执行一次。

时间事件的组成

  • id:服务器为时间事件创建的全局唯一ID(标示号)。新的事件的ID永远比旧的ID要大。

  • when:毫秒精度的UNIX时间戳,记录了时间事件的到达时间。

  • timeProc:时间事件处理器。

    如果事件处理器返回 ae.h/AE_NOMORE,则该事件为定时事件:事件在达到一次之后就会被删除。

    如果事件处理器返回一个非AE_NOMORE的整数值,则该事件为周期性事件:事件到达之后,根据事件处理器的返回值对when属性进行更新,让该事件一段时间之后再次到达。

实现

服务器将所有时间事件采用头插法都放在一个无序链表(id有序,但是when属性的排列无序)中,每当时间事件执行器运行就遍历链表找到满足when的时间事件并调用相应的时间事件处理器。然后对处理器返回值进行处理:如果是定时事件就讲该时间事件删除,如果是周期时间就对时间事件的when属性进行更新。

时间事件实现

processTimeEvents函数的伪代码

def processTimeEvents () :
  #遍历服务器中的所有时间事件
  for time_event in all_time_event () :
    #检查事件是否已经到达
    if time_ event.when <= unix_ ts_ now() :
    #事件已到达
    #执行事件处理器,并获取返回值
    	retval = time_event.timeProc()
      #如果这是一个定时事件
      if retval == AE_ NOMORE :
        #那么将该事件从服务器中删除
        delete_ time_ event_ from_ server (time_ event) 
      #如果这是一个周期性事件
      else:
        #那么按照事件处理器的返回值更新时间事件的when属性
        #让这个事件在指定的时间之后再次到达
        update_ when (time_ event, retval)

周期事件应用实例:serverCron函数

主要工作

  • 更新服务器的各类统计信息,比如时间、内存占用、数据库占用情况等。
  • 清理数据库中的过期键值对。
  • 关闭和清理连接失效的客户端。
  • 尝试进行AOF或RDB持久化操作。
  • 如果服务器是主服务器,那么对从服务器进行定期同步。
  • 如果处于集群模式,对集群进行定期同步和连接测试。

事件的调度与执行

ae.c/aeProcessEvents函数

用来对事件进行调度和处理。将该函数放在循环中,加上初始化函数和清理函数就构成了redis服务器的主函数

主函数伪代码

det main() :
  #初始化服务器
  init server ()
  #一直处理事件,直到服务器关闭为止
  while server_is_not_shutdown () :
  aeProcessEvents ( )
  #服务器关闭,执行清理操作
  clean_server ()

伪代码

​ 根据最接近当前时间的时间事件time_event的when计算出应该阻塞线程等待文件事件的时间的结构timeval。如果time_event.when - now()<=0,则不会进行阻塞。然后去执行已经到达的文件事件时间事件函数

def aeProcessEvents () :
  #获取到达时间离当前时间最接近的时间事件
  time_ event = aeSearchNearestTimer()
  #计算最接近的时间事件距高到达还有多少毫秒
  remaind ms = time_ event.when-unix_ts_now ()
  #如果事件巳到达,那么remaind_ms的值可能为负数,将它设定为0
  if remaind ms < 0:
  	remaind_ms = 0
      
  #根据remaind_ms的值,创建timeval结构
  timeval = create:timeval_with_ms (remaind_ms )
    
  #阻塞并等待文件事件产生,最大阻塞时间由传入的timeval结构
  #如果remaind_ ms的值为0,那么aeApiPo11调用之后马,上返回,不阻塞
  aeApiPo11 (timeval) 
    
  #处理所有已产生的文件事件,直接在该函数进行执行事实上processFileEvents不存在
  processFileEvents ()
  #处理所有已到达的时间事件
  processTimeEvents ()
  #考虑是否要将aof_buf中的内容写入和保存到AOF文件里面
  flushAppendOn1yFile()

事务处理角度下的服务器流程

事务处理角度下的服务器流程

事件的调度和执行规则

  1. aeApiPoll函数的最大阻塞时间由到达时间最接近当前时间的时间事件决定,这个方法既可以避免服务器对间事件进行频繁的轮询(忙等待),也可以确保aeApiPoll函数不会阻塞过长时间。

  2. 因为文件事件是随机出现的,如果等待并处理完一次文件事件之后, 仍未有任何时间事件到达,那么服务器将再次等待并处理文件事件。随着文件事件的不断执行,时间会逐渐向时间事件所设置的到达时间逼近,并最终来到到达时间,这时服务器就可以开始处理到达的时间事件了。

  3. 文件事件和时间事件的处理都是同步、有序、原子地执行的,服务器不会中途中断事件处理,也不会对事件进行抢占,因此,不管是文件事件的处理器,还是时间事件的处理器,它们都会尽可地减少程序的阻塞时间,并在有需要时主动让出执行权,从而降低造成事件饥饿的可能性。比如说,在命令回复处理器将-一个 命令回复写人到客户端套接字时,如果写人字节数超过了一个预设常量的话,命令回复处理器就会主动用break跳出写入循环,将余下的数据留到下次再写;另外,时间事件也会将非常耗时的持久化操作放到子线程或者子进程执行。

  4. 因为时间事件在文件事件之后执行,并且事件之间不会出现抢占,所以时间事件的实际处理时间,通常会比时间事件设定的到达时间稍晚一些

客户端

redis服务器是一对多的服务器程序,使用单线程单进程的方式来处理命令请求。

在redisServer中使用clients属性链表数据结构保存所有与服务器连接的客户端的状态。

伪代码

struct redisServer {
  //...

  //一个链表,保存了所有客户端状态
  list *clients;

  //...
};

clients链表

客户端属性

可以使用client list进行查看。

套接字描述符:int fd

​ -1:伪客户端,用来处理来源于AOF文件或者Lua脚本的命令而不是网络,所以这种客户端不需要套接字。

​ >-1:普通客户端使用套接字来与服务器进行通信,所以使用fd属性记录用来连接客户端套接字的描述符。

*名字:redisObject name

​ 没有名字:name属性指向null。

​ 有名字:name属性指向一个string对象。

标志:int flags

​ flags属性的值可以是单个标志,也可以是多个标志的二进制域。

输入缓冲区:sds querybuf

​ 保存客户端发送的命令请求,超过一定大小会被serverCron清理。为什么不在执行命令之后就将其清理??

*命令与命令参数:redisObject *argv,int argc

​ argv是一个string对象的数组,其中argv[0]是要执行的命令,而之后的其他项则是传给命令的参数。

​ argc是argv数组的长度。

**命令的实现函数:struct redisCommand cmd;list reply

​ redis使用字典保存了命令的名字(k)和命令的redisCommand结构(v)。服务器可以通过cmd属性指向的redisCommand结构,调用命令实现函数,执行客户端指定的命令。

输出缓冲区: char buf[REDIS_REPLY_CHUNK_BYTES],int bufpos

​ 每个redisClient都有两个输出缓冲区可用:

固定大小的缓冲区:保存长度较小的回复。由buf和bufpos确定。

可变大小的缓冲区:保存长度较大的回复。使用链表连接多个字符串对象。

客户端的创建与关闭

创建普通客户端

客户端使用connect函数连接到服务器,服务器调用连接事件处理器为客户端创建相应的客户端状态,并将这个新的客户端的状态添加到服务器状态结构clients链表的末尾

关闭普通客户端

被关闭的原因

  • 客户端进程退出或被杀死。

  • 客户端向服务器发送了不符合协议格式的命令请求。

  • 客户端成了client kill的目标。

  • 服务器设置了timeout配置项,当客户端的空转事件超过timeout就会被关闭。除了一些特殊情况:

    如果客户端是主服务器(打开了REDIS_ MASTER标志),从服务器(打开了REDIS_ SLAVE 标志),正在被BLPOP等命令阻塞(打开了REDIS_ BLOCKED 标志),或者正在执行SUBSCRIBE、PSUBSCRIBE 等订阅命令,那么即使客户端的空
    转时间超过了timeout选项的值,客户端也不会被服务器关闭。

  • 客户端的命令请求超过了输入缓冲区。

  • 服务器给客户端的命令回复大小超过了输出缓冲区的限制大小。

    硬性限制:如果输出缓冲区的大小超过了硬性限制所设置的大小,那么服务器立即关闭客户端。

    软性限制:如果输出缓冲区的大小超过了软性限制所设置的大小,但是没超过硬性限制,那么服务器将使用客户端状态结构的obuf_soft_limit_reached_time属性记录下客户端到达软性限制的起始时间;之后服务器会继续监视客户端,如果输出缓冲区的大小一直超出软性限制,并且持续时间超过服务器设定的时长,那么服务器将关闭客户端;相反地,如果输出缓冲区的大小在指定时间之内,不再超出软性限制,那么客户端就不会被关闭,并且obuf_ soft_ limit_reached_time也会被清零。

Lua脚本的伪客户端

服务器在初始化时创建负责执行Lua脚本中包含redis命令的伪客户端,将关联在redisServer中的lia_client属性中。这个Lua伪客户端一致存在,直到服务器关闭

伪代码

struct redi sServer {
  //...
  redisClient *lua_client;
  //...
};

AOF文件的伪客户端

服务器在载入AOF文件时创建用于执行AOF文件包含的redis命令的伪客户端,在执行结束之后关闭这个伪客户端。

服务器

命令请求的执行过程

发送命令请求

  1. 客户端将命令请求转换成协议格式
  2. 然后通过连接到服务器的套接字。请求命令触发服务器监听套接字的AE_READABLE事件,连接应答处理器工作(会在服务器生成一个新的套接字:客户端套接字。和java中ServerSocket一样)。服务器将客户端套接字和AE_READABLE事件和命令请求处理器进行绑定,然后通过I/O多路复用程序处理该事件到达。
  3. 将协议格式的命令请求发送给服务器。

读取命令请求并执行

  1. 服务器收到客户端传来的协议格式的命令,即客户端套接字的AE_READABLE事件到达,然后由命令请求处理器进行处理。
  2. 将命令请求读取并保存到客户端状态的输入缓冲区里面。对缓冲区中的命令请求进行分析,将参数和参数个数封装到redisClient的argv属性和argc属性里面。调用命令执行器。
  3. 命令执行器在命令表(字典)中根据argv找到redisCommand并保存到客户端状态的cmd属性里面。然后对参数、权限等进行预处理。然后调用cmd属性的命令实现函数,将函数产生的相应的命令回复保存在redisClient的输出缓冲区中
  4. 将客户端套接字的AE_WRITEABLE事件和命令回复处理器进行绑定。
  5. 命令执行器进行一些后续操作:
    • 是否写入到慢查询日志
    • 将命令追加到AOF缓冲区中(或AOF重写缓冲区)
    • 如果从服务器正在复制这个服务器,将执行的命令传播给所有服务器

将命令回复给客户端

  1. 当客户端能够接受回复的时候,客户端套接字的AE_WRITEABLE事件会到达,然后命令回复处理器将输出缓冲区中的命令回复给客户端。删除该事件。
  2. 清空客户端状态的输出缓冲区,为处理下一个命令做准备。

serverCron函数

是一个每100ms执行一次的周期事件。负责管理服务器的资源,并保持服务器自身的良好运转。

更新服务器时间缓存

因为每次获取当前系统的时间都要进行一次系统调用,为了减少系统调用,serverClient中的unixtime和mstime属性被当作当前时间的缓存。

但是这个时间缓存随着serverCron每100ms执行一次精度并不高:

  • 服务器只会在打印日志、更新服务器的LRU时钟、决定是否执行持久化任务、计算服务器上线时间( uptime )这类对时间精确度要求不高的功能上。
  • 对于为键设置过期时间添加慢查询日志这种需要高精确度时间的功能来说,服务器还是会再次执行系统调用,从而获得最准确的系统当前时间。
struct redisServer {
  // ...
  //保存了秒级精度的系统当前UNIX时间戳
  time_ t unixtime; 
  //保存了毫秒级精度的系统当前UNIX时间戳
  long long mst ime;
  //...
};

更新LRU时钟

用于计算redisObject的空转时长,使用服务器的lruclock属性减去对象的lru属性得到的时间就是对象的空转时长。每10s更新一次lruclock属性。

struct redisServer {
  // ...
  //默认每10秒更新一次的时钟缓存,
  //用于计算键的空转(idle)时长。
  unsigned lruc1ock:22;
  // ...
};

处理SIGTEAM信号

在启动服务器的时候,redis服务器将SIGTEAM信号关联处理器sigtermHandler函数。当服务器接收到SIGTEAM信号时,打开服务器的shutdown_asap标识。根据该标识决定是否关闭服务器,在关闭服务器之前进行RDB持久化操作,而不是直接关闭。

管理客户端资源

调用clientCron函数,该函数会对一定数量的客户端进行检查:

  • 客户端和服务器之间没有互动的时间超时,释放客户端。
  • 客户端在上一次执行命令请求之后,输入缓冲区大小超过了一定的长度,释放客户端当前的输入缓冲区,并重新创建一个默认大小的输入缓冲区。

管理数据库资源

调用databasesCron函数对服务器中的部分数据库进行检查,删除过期键,在有必要的时候对字典进行收缩操作

执行被延迟的BGREWRITEAOF

服务器使用aof_rewrite_scheduled标识记录服务器是否因为bgsave,延迟了bgrewriteaof

struct redisServer {
  // ...
  //如果值为1,那么表示有BGREWRITEAOF命令被延迟了。
  int aof_rewrite_scheduled;

};

如果bgrewriteaof没有正在执行,且标识为1就会执行被推延的bgrewriteaof

检查持久化操作的运行状态

服务器状态使用rdb_ child_ pid属性和aof_ child_ pid属性记录执行BGSAVE命令和BGREWRITEAOF命令的子进程的ID,这两个属性也可以用于检查BGSAVE命令或者BGREWRITEAOF命令是否正在执行:

struct redisServer {
	// ...
  
  //记录执行BGSAVE命令的子进程的ID:
  //如果服务器没有在执行BGSAVE,
  //那么这个属性的值为-1。
  pid_t rdb_child_pid;
  /* PID of RDB saving child */
  //记录执行BGREWRITEAOF命令的子进程的ID:
  //如果服务器没有在执行BGRENRITEAOF,
  //那么这个属性的值为-1。
  pid_t aof_child_pid;
  /* PID if rewri ting process */
  
	// ...
};

只要有一个属性的值不为-1,就会执行wait3函数,检查子进程是否有信号发来父进程:

  • 有信号到达,可能会需要父进程进行RDB文件的替换或AOF重写完成之后的操作。
  • 如果没有信号到达则不做操作。

如果属性值都为-1,那么会检查是否需要执行持久化:

判断是否需要执行持久化

  1. 检查是否有AOF重些操作被延迟了,如果有则进行AOF重写。
  2. 如果条件1不满足,检查是否达到bgsave的条件,如果达到则进行RDB持久化操作。
  3. 如果条件1,2不满足,检查是否达到AOF重写的条件,如果达到则进行AOF重写。如果达到条件但是在进行bgsave则会将AOF重写推延设置标志位。

将AOF缓冲区中的内容写入AOF文件

关闭异步客户端

关闭缓冲区大小超出限制的客户端。

初始化服务器

  1. 创建一个redisServer类型的实例变量server作为服务器的状态,并且运行initServerConfig函数。

    void initServerConfig (void) {
      //设置服务器的运行id
      getRandomHexChars (server.runid, REDIS_RUN_ID_SIZE) ;
      //为运行id加上结尾字符
      server.runid[REDIS_RUNID_SIZE] = '\0' ;
      //设置默认配置文件路径
      server.configfile = NULL;
      //设置默认服务器频率
      server.hz = REDIS_DEFAULT_HZ;
      //设置服务器的运行架构
      server.arch_bits = (sizeof (long) = 8) ? 64 : 32;
      //设置默认服务器端口号
      server.port = REDIS_SERVERPORT;
      // ...
    }
    
    
  2. 载入配置选项,可能会更新initServerConfig函数中已经赋值的参数。

  3. 初始化服务器数据结构,调用initServer函数为服务器数据结构分配内存。必须在载入服务器的配置文件设置后才能进行初始化数据结构

    除了初始化数据结构之外,initServer 还进行了一些非常重要的设置操作,其中包括:

    • 为服务器设置进程信号处理器。
    • 创建共享对象:这些对象包含Redis服务器经常用到的一些值,比如包含"OK"回复的字符串对象,包含"ER"回复的字符串对象,包含整数1到10000的字符串对象等等,服务器通过重用这些共享对象来避免反复创建相同的对象。
    • 打开服务器的监听端口,并为监听套接字关联连接应答事件处理器,等待服务器正式运行时接受客户端的连接。
    • 为serverCron函数创建时间事件,等待服务器正式运行时执行serverCron函数。
    • 如果AOF持久化功能已经打开,那么打开现有的AOF文件,如果AOF文件不存在,那么创建并打开一个新的AOF文件,为AOF写入做好准备。
    • 初始化服务器的后台I/O模块( bio),为将来的I/O操作做好准备。
  4. 打印logo

  5. 还原数据库的状态,如果开启了AOF功能就使用AOF文件,如果没有开启AOF就使用RDB文件。

  6. 执行事件循环


版权声明:本文为qq_40623110原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。