Redis设计与实现笔记---第二部分:单机数据库的实现

第九章 数据库

本章介绍:

  1. Redis服务器的数据库的实现
  2. 服务器保存数据方法
  3. 客户端切换数据库方法
  4. 数据库保存键值对的方法
  5. 数据库增查删改的方法
  6. 服务器保存键过期时间的方法及自动删除过期键的方法

9.1 服务器中的数据库

Redis服务器将所有数据库都保存在服务器状态redis.h/redisServer结构体的db数组中,db数组的每个项都是一个redis.h/redisDb结构体,每个redisDb结构体代表一个数据库。在初始化服务器时,程序会根据服务器状态的dbnum属性来决定应该创建多少个数据库,dbnum属性由服务器配置的database选项决定,默认情况下,该选项的值为16。

struct redisServer {
    ……
    //一个数组,保存着服务器中所有数据库
    redisDb *db;
    //服务器的数据库数量
    int dbnum;   
    ……
};

Redis设计与实现笔记---第二部分:单机数据库的实现_第1张图片9.2 切换数据库
每个Redis客户端都有自己的目标数据库,每当客户端执行数据库写命令或者数据库读命令的时候,目标数据库就会成为这些命令的操作对象。默认情况下,Redis客户端的目标数据库为0号数据库,但客户端可以通过执行SELECT命令来切换目标数据库。

在服务器内部,客户端状态redisClient结构的db属性记录了客户端当前的目标数据库,这个属性是一个指向redisDb结构的指针:

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

redisClient.db指针指向redisServer.db数组的其中一个元素,而被指向的元素就是客户端你的目标数据库。
Redis设计与实现笔记---第二部分:单机数据库的实现_第2张图片通过修改redisClient.db指针,让它指向服务器中的不同数据库,从而实现切换目标数据库的功能,这就是SELECT命令的实现原理

谨慎处理多数据库:在操作数据库先执行select命令选择相应的数据库,避免切换数据库后忘记在哪个数据库(Redis没有返回当前数据库的函数)。

9.3 数据库键空间

Redis是一个键值对数据库服务器,服务器中的每个数据库都由redis.h/redisDb结构体表示,其中,redisDb结构体的dict字典保存了数据库中的所有键值对,我们将这个字典称为键空间。

typedef struct redisDb {
    //数据库键空间,保存着数据库中所有键值对
    dict *dict;               
    ……
} redisDb;

键空间和用户所见的数据库是直接对应的:

  • 键空间的键也就是数据库的键,每个键都是一个字符串对象
  • 键空间的值也就是数据库的值,每个值可以是字符串对象、列表对象、哈希表对象、集合对象和有序集合对象中任意一种Redis对象、集合对象和有序集合对象中的任意一种Redis对象

举个例子

127.0.0.1:6379> SET message "hello world"
OK
127.0.0.1:6379> RPUSH alphabet "a" "b" "c"
(integer) 3
127.0.0.1:6379> HSET book name "Redis in Action"
(integer) 1
127.0.0.1:6379> HSET book author "Josiah L. Carlson"
(integer) 1
127.0.0.1:6379> HSET book publisher "Manning"
(integer) 1

Redis设计与实现笔记---第二部分:单机数据库的实现_第3张图片

  1. 添加新键:添加一个新的键值对到数据库,实际上就是将一个新的键值对添加到键空间字典中,其中键为字符串对象,而值可以是任意一种类型的Redis对象。
  2. 删除键:删除数据库中的一个键,实际上就是在键空间删除一个键值对对象。
  3. 更新键:对一个数据库键进行更新,实际上就是对键空间里面键所对应的值对象进行更新,根据值对象的类型不同,更新的具体方法也会有所不同。
  4. 对键取值:对一个数据库键进行取值,实际上就是在键空间中取出键所对应的值对象,根据值对象的类型不同,具体的取值方法也会有所不同。
  5. 其他键空间操作:用于清空整个数据库的FLUSHDB命令,通过删除键空间中的所有键值对来实现的。用于随机返回数据库中某个键的RANDOMKEY命令,通过在键空间中随机返回一个键来实现的。返回数据库键数量的DBSIZE命令,通过返回键空间中包含的键值对的数量来实现的。类似还有EXISTS、RENAME、KEYS等,这些命令都是通过对键空间进行操作来实现的

读写键空间的维护操作

  • 在读取一个键之后(读操作和写操作都要对键进行读取),服务器会根据键是否存在来更新服务器的键空间命中次数或键空间不命中次数,这两个值可以在INFO stats命令的keyspace_hits属性和keyspace_misses属性中查看
  • 在读取一个键之后,服务器会更新键的LRU(最后一次使用)时间,这个值可以用于计算键的闲置时间,使用OBJECT idletime命令可以查看键key的闲置时间
  • 如果服务器在读取一个键时发现该键已过期,那么服务器会先删除这个过期键,然后才执行余下的其他操作
  • 如果客户端使用WATCH命令监视某个键,那么服务器在对被监视的键进行修改之后,会将这个键标记为脏,从而让事物程序注意到这个键已经被修改了
  • 服务器每次修改一个键之后,都会对脏键计数器的值加1,这个计数器会触发服务器的持久化以及复制操作
  • 如果服务器开启了数据库通知功能,那么在对键进行修改之后,服务器将按配置发送相应的数据库通知

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

通过EXPIRE或PEXPIRE命令,客户端可以以秒或者毫秒精度为数据库中的某个键设置生存时间(Time To Live,TTL),在经过指定的秒数或者毫秒数之后,服务器就会自动删除生存时间为0的键

127.0.0.1:6379> SET key value
OK
127.0.0.1:6379> EXPIRE key 5
(integer) 1
# 5秒之内
127.0.0.1:6379> GET key
"value"
# 5秒之后
127.0.0.1:6379> GET key
(nil)

客户端可以通过EXPIREAT命令和PEXPIREAT命令,以秒或者毫秒精度给数据库中已存在的某个键设置过期时间。过期时间是一个Unix时间戳,当键的过期时间来临时,服务器就会自动从数据库删除这个键。

TTL命令和PTTL命令接受个带有生存时间或过期时间的键,返回这个键的剩余生存时间,也就是,返回距离这个键被服务器自动删除还有多长时间

9.4.1 设置过期时间

  • EXPIRE 命令用于将键key的生存时间设置为ttl秒
  • PEXPIRE 命令用于将键key的生存时间设置为ttl毫秒
  • EXPIREAT 命令用于将键key的过期时间设置为timestamp所指定的秒数时间戳
  • PEXPIREAT 命令用于将键key的过期时间设置为timestamp所指定的毫秒数时间戳

实际上EXPIRE、PEXPIRE 、EXPIREAT三个命令都是使用PEXPIREAT 命令来实现的

EXPIRE命令可以转换成PEXPIRE命令伪码(看个过程)

def EXPIRE(key, ttl_in_sec):
    #将TTL从秒转换成毫秒
    ttl_in_ms = sec_to_ms(ttl_in_sec)
    PEXPlRE(key, ttl_in_ms)
def PEXPIRE(key, ttl_in_ms):
    #获取以毫秒计算的当前UNIX 时间戳
    now_ms = get_current_unix_timestamp_in_ms()
    #当前时间加上TTL,得出毫秒格式的键过期时间
    PEXPlREAT(key, now_ms+ttl_in_ms)    
def EXPIREAT (key, expire_time_in_ sec):
    #将过期时间从秒转换为毫秒
    expire_time_ in_ms = sec_to_ms (expire_time_in_sec)
    PEXPlREAT(key, expire_time_in_ms)

Redis设计与实现笔记---第二部分:单机数据库的实现_第4张图片
9.4.2 保存过期时间

redisDb结构体的expires字典保存了数据库中所有键的过期时间,我们称这个字典为过期字典:

  • 过期字典的键是一个指针,这个指针指向键空间中的某个键对象(也即是某个数据库键)
  • 过期字典的值是一个long long类型的整数,这个整数保存了键所指定的数据库键的过期时间——一个毫秒精度的Unix时间戳 (所以所有命令都转成PEXPIREAT)

以下是PEXPIREAT命令的伪代码定义:

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

9.4.3 移除过期时间

PERSIST命令可以移除一个键的过期时间

127.0.0.1:6379> SET message "hello world"
OK
127.0.0.1:6379> PEXPIREAT message 1538531750000
(integer) 1
127.0.0.1:6379> TTL message
(integer) 302
127.0.0.1:6379> PERSIST message
(integer) 1
127.0.0.1:6379> TTL message
(integer) -1

PERSIST命令就是PEXPIREAT命令的反操作:PERSIST命令在过期字典中查找给定的键,并解除键和值(过期时间)在过期字典中的关联。

PERSIST命令的伪代码:

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

9.4.4 计算并返回剩余生存时间

TTL命令以秒为单位返回键的剩余生存时间,而PTTL命令而以毫秒为单位返回键的剩余生存时间。

def PTTL(key):
    #键不存在数据库
    if key not in redisDb.dict:
        return -2
    #尝试取得键的过期时间
    #如果键没有设置过期时间,那么expire_time_in_ms将为None
    expire_time_in_ms = redisDb.expires.get(key)
    #键没有设置过期时间
    if expire_time_in_ms is None:
        return -1
    #获得当前时间
    now_ms = get_current_unix_timestamp_in_ms()
    #过期时间减去当前时间,得出的差就是键的剩余生存时间
    return (expire_time_in_ms - now_ms)
 
def TTL(key):
    #获取以毫秒为单位的剩余生存时间
    ttl_in_ms = PTTL(key)
    if ttl_in_ms < 0:
        #处理返回值为-2和-1的情况
        return ttl_in_ms
    else:
        #将毫秒转换成秒
        return ms_to_sec(ttl_in_ms)

9.4.5 过期键的判定

通过过期字典,程序可以用以下步骤检查一个给定键是否过期:

  • 检查给定键是否存在于过期字典:如果存在,那么取得键的过期时间
  • 检查当前Unix时间戳是否大于键的过期时间:如果是的话,那么键已过期,否则键未过期
def is_expired(key):
    #取得键的过期时间
    expire_time_in_ms = redisDb.expires.get(key)
    #键没有设置过期时间
    if expire_time_in_ms is None:
        retrurn False
    #取得当前时间的Unix时间戳
    now_ms = get_current_unix_timestamp_in_ms()
    #检查当前时间是否大于键的过期时间
    if now_ms > expire_time_in_ms:
        #键已过期
        return True
    else:
        #键未过期
        return False

9.5 过期键的删除策略

问题:如果一个键过期了,那么它什么时候会被删除呢?有三种策略

  • 定时删除:在设置键的过期时间的同时,创建一个定时器,让定时器在键的过期时间来临时,立即执行对键的删除操作
  • 惰性删除:放任键过期不管,但是每次从键空间中获取键时,都检查取得的键是否过期。如果过期的话,就删除该键;如果没有过期,就返回该键
  • 定期删除:每隔一段时间,程序就对数据库进行一次检查,删除里面的过期键。至于要删除多少过期键,以及要检查多少个数据库,则由算法决定

9.5.1 定时删除

  • 优点:对内存友好,通过使用定时器,定时删除策略可以保证过期键会尽可能快地被删除,并释放过期键所占用的内存。
  • 缺点:它对CPU时间是不友好的:在过期键比较多的情况下,删除过期键这一行为可能会占用相当一部分CPU时间,在内存不紧张但是CPU时间非常紧张的情况下,将CPU时间用在删除和当前任务无关的过期键上,无疑会对服务器的响应时间和吞吐量造成影响

现阶段来说并不现实:创建一个定时器需要用到Redis服务器中的时间事件,而当前时间事件的实现方式——无序链表,查找一个事件的时间复杂度为O(N)——并不能高效地处理大量时间事件。

9.5.2 惰性删除

  • 优点:CPU时间来说是最友好的:程序只会在取出键时才对键进行过期检查,这可以保证删除过期键的操作只会在非做不可的情况下进行,并且删除的目标仅限于当前处理的键,这个策略不会在删除其他无关的过期键上花费任何CPU时间。
  • 惰性删除策略的缺点是,它对内存是不友好的:如果一个键已经过期,而这个键又仍然保留在数据库中,那么只要这个过期键不被删除,它所占用的内存就不会释放

在使用惰性删除策略时,如果数据库中有非常多的过期键,而这些过期键又恰好没有被访问到的话,那么它们也许永远不会被删除(除非用户手动执行FLUSHDB),我们甚至可以将这种情况看作一种内存泄露——无用的垃圾数据占用了大量内存,而服务器却不会自己去释放它们,这对于运行状态非常依赖内存的Redis服务器来说,肯定不是一个好消息

9.5.3 定期删除

  • 定期删除占用太多CPU时间,影响服务器响应时间和吞吐量
  • 惰性删除浪费太多内存,有内存泄露的危险

定期删除策略是两种策略的一种整合和折中:

  • 定期删除策略每隔一段时间执行一次删除过期键操作,并通过限制删除操作执行的时长和频率来减少删除操作对CPU时间的影响
  • 除此之外,通过定期删除过期键,定期删除策略有效地减少了因为过期键而带来的内存浪费

定期删除策略的难点是确定删除操作执行的时长和效率!

  • 如果删除操作执行得太频繁,或者执行的时间太长,定期删除策略就会退化成定时删除策略,以至于将CPU时间过多地消耗在删除过期键上面
  • 如果删除操作执行得太少,或者执行的时间太短,定期 删除策略又会和惰性删除策略一样,出现浪费内存的情况

9.6 Redis过期键删除策略

Redis服务器实际使用的是惰性删除和定期删除两种策略

9.6.1 惰性删除策略的实现

过期键的惰性删除策略由db.c/expireIfNeeded函数实现,所有读写数据库的Redis命令在执行之前都会调用expireIfNeeded函数对输入键进行检查:

  • 如果输入键已经过期,那么expireIfNeeded函数将输入键从数据库中删除
  • 如果输入键未过期,那么expireIfNeeded函数不做任何动作

9.6.2 定期删除策略的实现
过期键的定期删除策略由redis.c/activeExpireCycle函数实现,每当Redis的服务器周期性操作redis.c/serverCron函数执行时,activeExpireCycle函数就会被调用,它在规定的时间内,分多次遍历服务器中的各个数据库,从数据库的expires字典中随机检查一部分键的过期时间,并删除其中的过期键

activeExpireCycle函数的工作模式可以总结如下:

  • 函数每次运行时,都从一定数量的数据库中取出一定数量的随机键进行检查,并删除其中的过期键
  • 全局变量current_db会记录当前activeExpireCycle函数检查的进度,并在下一次activeExpireCycle函数调用时,接着上一次的进度进行处理。比如说,如果当前activeExpireCycle函数在遍历10号数据库时返回了,那么下次activeExpireCycle函数执行时,将从11号数据库开始查找并删除过期键
  • 随着activeExpireCycle函数的不断执行,服务器中的所有数据库都会被检查一遍,这时函数将current_db变量重置为0,然后再次开始新一轮的检查工作

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

  • AOF:以日志的形式记录服务器所处理的每一个写删操作,根据日志可以恢复数据库原本状态。
  • RDB:在时间间隔内将数据集快照写入磁盘,实际为fork一个子进程将数据集写入临时文件,成功后替换原文件以二进制压缩存储。

生成RDB文件:

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

载入RDB文件:

在启动Redis服务器时,如果服务器开启了RDB功能,那么服务器将对RDB文件进行载入:

  • 如果服务器以主服务器模式运行,那么在载入RDB文件时,程序会对文件中保存的键进行检查,未过期的键会被载入到数据库中,而过期键则会被忽略,所以过期键对载入RDB文件的主服务器不会造成影响
  • 如果服务器以从服务器模式运行,那么在载入RDB文件时,文件中保存的所有键,不论是否过期,都会被载入到数据库中。不过,因为主从服务器在进行数据同步的时候,从服务器的数据库就会被清空,所以一般来讲,过期键对载入RDB文件的从服务器也不会造成影响(同步依靠主服务器见下章)

AOF文件写入

当服务器以AOF持久化模式运行时,如果数据库中的某个键已经过期,但它还没有被惰性删除或定期删除,那么AOF文件不会因为这个过期键而产生任何影响。当过期键被惰性删除或者定期删除之后,程序会向AOF文件追加(append)一条DEL命令,来显示地记录该键已被删除。

AOF重写

和生成RDB文件类似,在执行AOF重写的过程中,程序会对数据库中的键进行检查,已过期的键不会被保存到重写后的AOF文件中

复制

  • 当服务器运行在复制模式下时,从服务器的过期键删除动作由主服务器控制
  • 主服务器在删除一个过期键之后,会显示地向所有服务器发送一个DEL命令,告知从服务器删除这个过期键
  • 从服务器在执行客户端发送的读命令时,即使碰到过期键也不会将过期键删除,而是继续像未过期键一样来处理过期键
  • 从服务器只有在接收到主服务器发来的DEL命令之后,才会删除过期键

9.8 数据库通知

数据库通知是Redis2.8版本新增加的功能,这个功能可以让客户端通过订阅给定的频道或者模式,来获知数据库中键的变化,以及数据库中命令的执行情况

127.0.0.1:6379> config set notify-keyspace-events KEA
OK
127.0.0.1:6379> SUBSCRIBE __keyspace@0__:message
Reading messages... (press Ctrl-C to quit)
1) "subscribe"                      #订阅消息
2) "__keyspace@0__:message"
3) (integer) 1
1) "message"                        #执行SET命令
2) "__keyspace@0__:message"
3) "set"
1) "message"                        #执行EXPIRE命令
2) "__keyspace@0__:message"
3) "expire"
1) "message"                        #执行DEL命令
2) "__keyspace@0__:message"
3) "del"   

根据发回的通知显示,先后共有SET、EXPIRE、DEL三个命令对message进行了操作

-键空间通知:某个键执行了什么命令”的通知称为(上边)

  • 键事件通知:某个命令被什么键执行了(下边)
127.0.0.1:6379> config set notify-keyspace-events KEA
OK
127.0.0.1:6379> SUBSCRIBE __keyevent@0__:del
Reading messages... (press Ctrl-C to quit)
1) "subscribe"                  #订阅消息
2) "__keyevent@0__:del"
3) (integer) 1
1) "message"                    #键message执行了DE命令
2) "__keyevent@0__:del"
3) "message"
1) "message"                    #键numbers执行了DE命令
2) "__keyevent@0__:del"
3) "numbers"
1) "message"                    #键key执行了DE命令
2) "__keyevent@0__:del"
3) "key"

服务器配置的notify-keyspace-events选项决定了服务器所发送通知的类型:

  • 想让服务器发送所有类型的键空间通知和键事件通知,可以将选项设置为KEA
  • 想让服务器发送所有类型的键空间通知,可以将选项设置为AK
  • 想让服务器发送所有类型的键事件通知,可以将选项设置为AE
  • 让服务器只和字符串键有关的键空间通知,可以将选项设置为K$
  • 想让服务器只发送和列表键有关的键事件通知,可以将选项设置为El

9.8.1 发送通知

发送数据库通知的功能由notify.c/notifyKeyspaceEvent函数实现:

void notifyKeyspaceEvent(int type, char *event, robj *key, int dbid);

type参数是当前想要发送的通知的类型,程序会根据这个值来判断通知是否就是服务器配置notify-keyspace-events选项所选定的通知类型,从而决定是否发送通知。event、keys和dbid分别是事件的名称、产生事件的键,以及产生事件的数据库号码,函数会根据type参数以及三个参数来构建事件通知的内容,以及接收通知的频道名。

举例:
每当一个Redis命令需要发送数据库通知的时候,该命令的实现函数就会调用notifyKeyspaceEvent函数,并向函数传递该命令所引发的事件的相关信息

void saddCommand(client *c) {
    ……
    //如果至少有一个元素被成功添加,那么执行以下程序
    if (added) {
         
        //发送事件通知
        notifyKeyspaceEvent(NOTIFY_SET,"sadd",c->argv[1],c->db->id);
    }
    ……
}

9.8.2 发送通知的实现
notifyKeyspaceEvent函数执行以下操作:

def notifyKeyspaceEvent(type,event,key,dbid):
    #如果给定的通知不是服务器允许发送的通知,那么直接返回
    if not(server.notify_keyspace_events & type) :
      return
    #发送键空间通知
    if server.notify_keyspace_events & REDIS_NOTIFY_KEYSPACE:
        #将通知发送给频道__ keyspace@__ :
        #内容为键所发生的事件
        #构建频道名字
        chan="keyspace@{dbid}:{key}".format(dbid=dbid,key=key)
        #发送通知
        pubsubPublishMessage(chan,event)
    #发送键事件通知
    if server.notify_keyspace_events&REDIS_NOTIFY_KEYEVENT:
        #将通知发送给频道_keyevent@_:
        #内容为发生事件的键
        #构建频道名字
        chan="keyevent@{dbid}:{event}".format(dbid=dbid, event=event)
        #发送通知
        pubsubPublishMessage(chan, key)
  • server.notify_keyspace_events属性就是服务器配置notify-keyspace-events选项所设置的值,如果给定的通知事件type不是服务器允许发送的通知类型,那么函数会直接返回,不做任何操作
  • 如果给定的通知是服务器允许发送的通知,那么下一步函数会检测服务器是否允许发送键空间的通知,如果允许的话,程序就会构建发送事件通知
  • 最后,函数检测服务器是否允许发送事件通知,如果允许的话,程序就会构建并发送事件通知

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

第十章 RDB持久化

  1. 数据库状态:将服务器中的非空数据库以及它们的键值对统称为数据库状态
  2. 为什么要持久化:因为Redis是内存数据库,它将自己的数据库状态存储在内存里面,所以如果不想办法将存储在内存中的数据库状态保存到磁盘中,那么一旦服务器进程退出,服务器中的数据库状态也会消失。为了解决这个问题,Redis提供了RDB持久化功能,可以将Redis内存中的数据库状态保存到磁盘中,避免数据意外丢失
  3. RDB持久化功能:所生成的RDB文件是一个经压缩的二进制文件,通过该文件可以还原生成RDB文件时的数据库状态

10.1 RDB文件的创建和载入

有两个Redis命令可以用于生成RDB文件,一个是SAVE,另一个是BGSAVE。

  • SAVE命令会阻塞Redis服务器,直到RDB文件创建完毕为止,在服务器进程阻塞期间,服务器不能处理任何命令请求
  • BGSAVE命令会派生出一个子进程,然后由子进程负责创建RDB文件,服务器进程(父进程)继续处理命令请求

创建RDB文件的实际工作由rdb.c/rdbSave函数完成:

def SAVE():
    #创建RDB文件
    rdbSave()
 
def BGSAVE():
    #创建子进程
    pid = fork()
    if pid == 0:
        #子进程负责创建RDB文件
        rdbSave()
        #完成之后向父进程发出信号
        signal_parent()
    elif pid > 0:
        #父进程继续处理命令请求,并通过轮询等待子进程信号
        handle_request_and_wait_signal()
    else:
        #处理出错情况
        handle_fork_error()

RDB文件的载入工作是在服务器启动时自动执行的,Redis并没有专门用于载入RDB文件的命令。
另外因为AOF文件的更新频率通常比RDB文件的更新频率高,所以:

  • 如果服务器开启了AOF持久化功能,那么服务器就会优先使用AOF文件来还原数据库状态
  • 只有在AOF持久化功能处于关闭状态时,服务器才会使用RDB文件来还原数据库状态

载入RDB文件的实际工作由rdb.c/rdbLoad函数完成

SAVE命令执行时的服务器状态:

当SAVE命令执行时,Redis服务器会被阻塞,所以当SAVE命令正在执行时,客户端发送的所有命令请求都会被拒绝。只有在服务器完成SAVE命令、重新开始接受命令请求之后,客户端发送的命令才会被处理

BGSAVE命令执行时的服务器状态:

  1. BGSAVE命令的保存工作是由子进程执行的,所以在子进程创建RDB文件的过程中,Redis服务器仍然可以继续处理客户端的命令请求
  2. 在BGSAVE命令执行期间,客户端发送的BGSAVE命令会被服务器拒绝,因为同时执行两个BGSAVE命令也会产生竞争条件
  3. 如果BGSAVE命令正在执行,那么客户端发送的BGREWRITEAOF命令会被延迟到BGSAVE命令执行完毕之后再执行
  4. 如果BGREWRITEAOF命令正在执行,那么客户端发送的BGSAVE命令会被服务器拒绝
    因为BGSAVE和BGREWRITEAOF两个命令的实际工作都由子进程执行,所以这两个命令在操作方面并没有什么冲突的地方,不能同时执行它们只是一个性能方面的考虑——并发处两个子进程,并且两个子进程都同时执行大量的磁盘写入操作,对CPU是极大的消耗。

RDB文件载入时的服务器状态:

服务器在载入RDB文件期间,会一直处于阻塞状态,直到载入工作完成为止

10.2 自动间隔性保存

Redis允许用户通过没设置服务器配置的save选项,让服务器每隔一段时间自动执行一次BGSAVE命令。用户可以通过save选项设置多个保存条件,但只要其中一个条件被满足,服务器就会执行BGSAVE命令。举个栗子,如果我们向服务器提供以下配置:

save    900     1
save    300     10
save    60      10000

那么只要满足以下三个条件中的任意一个,BGSAVE命令就会被执行:

  • 服务器在900秒内对数据库进行了至少一次的修改
  • 服务器在300秒内对数据库进行了至少十次的修改
  • 服务器在60秒内对数据库进行了至少一万次的修改

设置保存条件:
当Redis启动时,用户可以通过指定配置文件或传入启动参数的方式设置save选项,如果用户没有主动设置save选项,那么服务器会为save选项设置如上默认条件。
接着,服务器会根据save选项所设置的保存条件,设置服务器状态redisServer结构体的saveparams属性:

struct redisServer {
    ……
    //记录了保存条件的数组
    struct saveparam *saveparams;  
    ……
};
struct saveparam {
    //秒数
    time_t seconds;
    //修改数
    int changes;
};

dirty计数器和lastsave属性

  • dirty计数器记录距离上一次成功执行SAVE命令或者BGSAVE命令之后,服务器对数据库状态(服务器中的所有数据库)进行了多少次修改(包括写入、删除、更新等操作)如果我们向一个集合键增加三个元素,那么程序会将dirty计数器的值加3。
  • lastsave属性是一个Unix时间戳,记录了服务器上一次成功执行SAVE命令或BGSAVE命令的时间
struct redisServer {
    ……
    //修改计数器
    long long dirty;              
    ……
    //上一次执行保存的时间
    time_t lastsave;              
    ……
};

检查保存条件是否满足
Redis的服务器周期性操作函数serverCron默认每隔100毫秒就会执行一次,该函数用于对正在运行的服务进行维护,它的其中一项工作就是检查save选项所设置的保存条件是否满足,如果满足,就执行BGSAVE命令

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

10.3 RDB文件结构

RDB文件组成部分:

REDIS db_version databases EOF check_sum

ps:

  • 为了方便区分变量、数据、常量,图1-10中用全大写单词标识常量,用全小写标识变量和数量
  • RDB文件保存的是二进制数据,而不是C字符串,为了简单起见,我们用"REDIS"符号代表’R’、‘E’、‘D’、‘I’、‘S’五个字符,而不是待’\0’结尾符号的C字符串’R’、‘E’、‘D’、‘I’、‘S’、’\0’

详述:

  • RDB文件的最开头是Redis部分,这个部分的长度为5字节,保存着"REDIS"五个字符。
  • db_version长度为4字节,它的值是一个字符串表示的整数,这个整数记录了RDB文件的版本号,比如"0006"就代表RDB文件的版本为第六版
  • databases部分包含着零个至任意多个数据库以及各个数据库中的键值对数据:如果服务器的数据库状态为空(所有数据库都是空的),那么这个部分也为空,长度为0字节,反之非空。
  • EOF常量的长度为1字节,这个常量标志着RDB文件正文内容结束
  • check_num是一个8字节长的无符号整数,保存着一个校验和,这个校验和是程序通过对REDIS、db_version、databases、EOF四部分的内容进行计算得出的。服务器在载入RDB文件时,会将载入数据所计算出的校验和与check_num所记录的校验和进行对比,以此来检查RDB文件是否有出错或者损坏的情况出现

10.3.1 database部分
一个RDB文件的databases部分可以保存任意多个非空数据库。例如,如果服务器的0号数据库和3号数据库非空

REDIS db_version databases[0] databases[3] EOF check_sum

每个非空数据库在RDB文件中都可以保存为SELECTDB、db_number、key_value_pairs三个部分

SELECTDB db_number key_value_pairs
  • SELECTDB常量的长度为1字节,当读入程序遇到这个值的时候,它知道接下来要读入的将是一个数据库号码
  • db_number保存着一个数据库号码,根据号码的大小不同,这个部分的长度可以是1字节、2字节或者5字节。当程序读入db_number部分之后,服务器会调用SELECT命令,根据读入的数据库号码进行数据库切换,使得之后读入的键值对可以载入到正确的数据库中
  • key_value_pairs部分保存了数据库中的所有键值对数据,如果键值对带有过期时间,那么过期时间也会和键值对保存在一起。根据键值对的数量、类型、内容以及是否有过期时间等条件不同,key_value_pairs部分的长度也会有所不同。

一个完成的RDB文件,文件包含了0号数据库和3号数据:

REDIS db_version SELECTDB 0 key_value_pairs SELECTDB 3 key_value_pairs EOF check_sum

10.3.2 key_value_pairs部分

RDB文件中的每个key_value_pairs部分都保存了一个以上的键值对,如果键值对带过期时间的话,那么键值对的过期时间也会被保存在内。不带过期时间的键值对在RDB文件中由TYPE、key、value三部分组成

TYPE key value

带有过期时间的键值对在RDB文件中的结构:

EXPIRETIME_MS ms TYPE key value

TYPE记录了value的类型,长度为1字节,值可以是以下常量的其中一个:

  • REDIS_RDB_TYPE_STRING
  • REDIS_RDB_TYPE_LIST
  • REDIS_RDB_TYPE_SET
  • REDIS_RDB_TYPE_ZSET
  • REDIS_RDB_TYPE_HASH
  • REDIS_RDB_TYPE_LIST_ZIPLIST
  • REDIS_RDB_TYPE_SET_INTSET
  • REDIS_RDB_TYPE_ZSET_ZIPLIST
  • REDIS_RDB_TYPE_HASH_ZIPLIST

每个TYPE常量都代表了一种对象类型或底层编码,当服务器读入RDB文件中的键值对数据时,程序会根据TYPE的值来决定如何读入和解释value的数据。key和value分别保存了键值对的键对象和值对象:

  • 其中key总是一个字符串对象,它的编码方式和REDIS_RDB_TYPE_STRING类型的value一样。根据内容长度的不同,key的长度也会有所不同
  • 根据TYPE类型的不同,以及保存内容长度的不同,保存value的结构和长度也会有所不同,稍后还会详细说明每种TYPE类型的value结构保存方式

EXPIRETIME_MS常量的长度为1字节,它告知读入程序,接下来要读入的将是一个以毫秒为单位的过期时间
ms是一个8字节长的带符号整数,记录着一个以毫秒为单位的Unix时间戳,这个时间戳就是键值对的过期时间

10.3.3 value编码

RDB文件中的每个中的每个value部分都保存了一个值对象,每个值对象的类型都由与之对应的TYPE记录,根据类型的不同,value部分的结构,长度也会有所不同。

字符串对象

TYPE的值REDIS_RDB_TYPE_STRING,value保存的就是一个字符串对象,字符串对象的编码可以是REDIS_ENCODING_INT或REDIS_ENCODING_RAW。字符串对象的为REDIS_ENCODING_INT,说明对象中保存的是长度不超过32位的整数

ENCODING integer

ENCODING的值可以是REDIS_ENCODING_INT8、REDIS_ENCODING_INT16或者REDIS_ENCODING_INT32三个常量的其中一个,它们分别代表RDB文件使用8位(bit)、16位、32位来保存整数值integer

字符串对象的编码为REDIS_ENCODING_RAW,那么说明对象所保存的是一个字符串值,根据字符串长度的不同,有压缩和不压缩两种方法来保存这个字符串:

  • 如果字符串的长度小于等于20字节,那么这个字符串会直接被原样保存
  • 如果字符串的长度大于20字节,那么这个字符串会被压缩之后再保存

没有被压缩的字符串:

len string

对于压缩后的字符串:

RDDIS_RDB_ENC_LZF compressed_len origin_len compressed_string

RDDIS_RDB_ENC_LZF常量标识着字符串已经被LZF算法压缩过,读入程序在碰到这个常量时,会根据之后的compressed_len、origin_len和compressed_string三部分,对字符串进行解压缩:其中compressed_len记录的是字符串被压缩后的长度,而origin_len记录的是字符串原来的长度,compressed_string记录的是被压缩后的字符串

列表对象

TYPE的值为REDIS_RDB_TYPE_LIST,那么value保存的就是一个REDIS_ENCODING_LINKEDLIST(双端链表)编码的列表对象

list_length item1 item2 itemN

list_length记录了列表的长度,它记录列表保存了多少个项(item),读入程序可以通过这个长度知道自己应该读入多少个列表项。图中以item开头的部分代表列表的项,因为每个列表项都是一个字符串对象,所以程序会以处理字符串对象的方式来保存和读入列表项

集合对象

如果TYPE的值为REDIS_RDB_TYPE_SET,那么value保存的就是一个REDIS_ENCODING_HT(字典)编码的集合对象

set_size elem1 elem2 elemN

set_size是集合的大小,它记录了集合保存了多少个元素,读入程序可以通过这个大小知道自己应该读入多少个集合元素。图中以elem开头的部分代表集合的元素,因为每个集合元素都是一个字符串对象,所以程序会以处理字符串对象的方式来保存和读入集合元素

哈希表对象

TYPE的值为REDIS_RDB_TYPE_HASH,那么value保存的就是一个REDIS_ENCODING_HT编码的集合对象

  • hash_size记录了哈希表的大小,也即是这个哈希表保存了多少键值对,读入程序可以通过这个大小知道自己应该读入多少个键值对
  • 有key_value_pair开头的部分代表哈希表的键值对,键值对的键和值都是字符串对象,所以程序会以处理字符串对象的方式来保存和读入键值对
hash_size key_value_pair1 key_value_pair2 key_value_pairN

有序集合对象

TYPE的值为REDIS_RDB_TYPE_ZSET,那么value保存的就是一个REDIS_ENCODING_SKIPLIST(字典和有序表)编码的有序集合对象

sorted_set_size elem1 elem2 elemN

sorted_set_size记录了有序集合的大小,也即是这个有序集合保存了多少个元素,读入程序需要根据这个值来决定应该读入多少个有序集合元素。以element开头的部分代表有序集合中的元素,每个元素又分为成员(member)和分值(score)两部分,成员是一个字符串对象,分值则是一个double类型的浮点数,程序在保存RDB文件时会先将分值转换成字符串对象,然后再用保存字符串对象的方法将分值保存起来

INTSET编码的集合

TYPE的值为REDIS_RDB_TYPE_SET_INTSET,那么value保存的就是一个整数集合对象,RDB文件保存这种对象的方法是,先将整数集合转换成字符串对象,然后将这个字符串对象保存到RDB文件里面。如果程序在读入RDB文件的过程中,碰到由整数集合对象转换成的字符串对象,那么程序会根据TYPE值的指示,先读入字符串对象,再将这个字符串对象转换成原来的整数集合对象

ZIPLIST编码的列表、哈希表或有序集合

如果TYPE的值为REDIS_RDB_TYPE_LIST_ZIPLIST、REDIS_RDB_TYPE_HASH_ZIPLIST或REDIS_RDB_TYPE_ZSET_ZIPLIST,那么value保存的就是一个压缩列表对象,RDB文件保存这种对象的方法是:

  1. 将压缩列表转换成一个字符串对象
  2. 将转换所得得字符串对象保存到RDB文件中

如果程序在读入RDB文件的过程中,碰到由压缩列表对象转换成的字符串对象,那么程序会根据TYPE值的指示,执行以下操作:

  1. 读入字符串对象,并将它转换成原来的压缩列表对象
  2. 根据TYPE的值,设置压缩列表对象的类型:如果TYPE的值为REDIS_RDB_TYPE_LIST_ZIPLIST,那么压缩列表对象的类型为列表;如果TYPE的值为REDIS_RDB_TYPE_HASH_ZIPLIST,那么压缩列表对象的类型为哈希表;如果TYPE的值为REDIS_RDB_TYPE_ZSET_ZIPLIST,那么压缩列表对象的类型为有序集合

10.4 分析RDB文件

od命令来分析Redis服务器产生的RDB文件,该命令可以用给定的格式转存(dump)并输入文件。比如说,给定-c参数可以以ASCII编码的方式打印输入文件,给定-x参数可以以十六进制的方式打印输出文件

不包含任何键值对的RDB文件

127.0.0.1:6379> FLUSHALL
OK
127.0.0.1:6379> SAVE
OK
# od -c dump.rdb
0000000   R E D I S 0 0 0 6 377 334 263 C 360 Z 334
0000020 362 V
0000022

开头的是"REDIS"字符串,之后的0006是版本号,再之后的一个字节377代表EOF常量,最后的334 263 C 360 Z 334 362 V八字节则代表RDB文件的校验和

  • 五个字节的"REDIS"字符串
  • 四字节的版本号(db_version)
  • 一字节的EOF常量
  • 八字节的校验和(check_num)

包含字符串键的RDB文件

127.0.0.1:6379> FLUSHALL
OK
127.0.0.1:6379> SET MSG "HELLO"
OK
127.0.0.1:6379> SAVE
OK
# od -c dump.rdb
0000000   R   E  D  I  S  0   0   0  6 376  \0 \0 003  M   S  G
0000020 005   H  E  L  L  O 377 207  z  =  304  f   T  L 343
0000037

根据之前学习的数据库结构知识,当一个数据库被保存到RDB文件时,这个数据库将由以下三部分组成:

  • 一个一字节长的特殊值SELECTDB
    
  • 一个长度可能为一字节、两字节或者五字节的数据库号码(db_number)
  • 一个或以上数量的键值对(key_value_pairs)

RDB文件的最开始仍然是REDIS和版本号0006,之后出现的376代表SELECTDB常量,再之后的\0代表整数0,表示被保存的数据库为0号数据库。在数据库号码之后,直到代表EOF常量的377为止,RDB文件包含有以下内容:

\0 003 M S G 005 H E L L O

在RDB文件中,没有过期时间的键值对由类型(TYPE)、键(key)、值(value)三部分组成:其中类型的长度为一字节,键和值都是字符串对象,并且字符串在未被压缩前,都是以字符串长度为前缀,后跟字符串内容本身的方式来储存的。根据这些特征,我们可以确定\0就是字符串类型的TYPE值REDIS_RDB_TYPE_STRING(这个常量的实际值为整数0),之后的003是键MSG的长度值,再之后的005则是值HELLO的长度

包含带有过期时间的字符串键的RDB文件

127.0.0.1:6379> FLUSHALL
OK
127.0.0.1:6379> SETEX MSG 10086 "HELLO"
OK
127.0.0.1:6379> SAVE
OK
# od -c dump.rdb
0000000   R   E  D   I   S   0   0   0  6 376 \0 374  \  2 365 336
0000020   @ 001 \0  \0  \0 003   M   S  G 005  H   E  L  L   O 377
0000040 212 231  x 247 252   } 021 306
0000050

  

一个带有过期时间的键值对将由以下部分组成:

  • 一个一字节长的EXPIRETIME_MS特殊值
  • 一个八字节长的过期时间(ms)
  • 一个一字节长的类型(TYPE)
  • 一个键(key)和一个值(value)

据此:

  • REDIS0006:RDB文件标志和版本号
  • 376 \0:切换到0号数据库
  • 374:代表特殊值EXPIRETIME_MS
  • \ 2 365 336 @ 001 \0 \0:代表八字节长的过期时间
  • \ 0 003 M S G:\0表示这是一个字符串键,003是键的长度,MSG是键
  • 005 H E L L O:005是值的长度,HELLO是值
  • 377:代表EOF常量
  • 212 231 x 247 252 } 021 306:代表八字节长的校验和

包含一个集合键的RDB文件

127.0.0.1:6379> FLUSHALL
OK
127.0.0.1:6379> SADD LANG "C" "JAVA" "RUBY"
(integer) 3
127.0.0.1:6379> SAVE
OK
# od -c dump.rdb
0000000   R   E   D   I   S  0   0   0 6 376 \0 002 004 L   A   N
0000020   G 003 004   R   U  B   Y 004 J   A  V   A 001 C 377 202
0000040 312   r 352 346 305  * 023
0000047
  • REDIS0006:RDB文件标志和版本
  • 376 \0:切换到0号数据库
  • 002 004 L A N G:002是常量REDIS_RDB_TYPE_SET(这个常量的实际值为整数2),表示这是一个哈希表编码的集合键,004表示键的长度,LANG是键的名字
  • 003:集合的大小,说明这个集合包含三个元素
  • 004 R U B Y:集合的第一个元素
  • 004 J A V A:集合的第二个元素
  • 001 C:集合的第三个元素
  • 377:代表常量EOF
  • 202 312 r 352 346 305*023:代表校验和

关于分析RDB文件的说明

Redis本身带有RDB文件检查工具redis-check-dump,网上也能找到很多处理RDB文件的工具,所以人工分析RDB文件的内容并不是学习Redis所必须掌握的技能。

前面我们一直用od命令配合-c参数来打印RDB文件,因为使用ASCII编码打印RDB文件可以很容易地发现文件中的字符串内容。但是,对于RDB文件中的数字值,比如校验和来说,通过ASCII编码来打印它并不容易看出它的真实值,更好的办法是使用-cx参数调用od命令,同时以ASCII编码和十六进制格式打印RDB文件:

# od -cx dump.rdb
0000000   R  E D  I S  0 0  0  6 377  334 263  C 360  Z 334
          4552 4944 3053 3030   ff36     b3dc   f043   dc5a
0000020 362  V
          56f2
0000022

RDB文件的校验和为0x 56f2 dc5a f043 b3dc(校验和以小端方式保存)

第11章 AOF持久化

Redis还提供了AOF(Append Only File)持久化功能,与RDB持久化通过保存数据库中的键值对来记录数据库状态不同,AOF持久化是通过保存Redis服务器所执行的命令来记录数据库状态
Redis设计与实现笔记---第二部分:单机数据库的实现_第5张图片举个栗子

127.0.0.1:6379> SET msg "hello"
OK
127.0.0.1:6379> SADD fruits "apple" "banana" "cherry"
(integer) 3
127.0.0.1:6379> RPUSH numbers 128 256 512
(integer) 3

RDB持久化保存数据库状态的方法是将msg、fruits、numbers三个键的键值对保存到RDB文件中,而AOF持久化保存数据库状态的方法是将服务器执行的SET、SADD、RPUSH三个命令保存到AOF文件中。被写入AOF文件的所有命令都是以Redis的命令请求协议格式保存的,因为Redis的命令请求协议是纯文本格式,所以我们可以直接打开一个AOF文件,观察里面的内容。例如,对于之前执行的三个写命令来说,服务器将产生包含以下内容的AOF文件:

*2\r\n$6\r\nSELECT\r\n$1\r\n0\r\n
*3\r\n$3\r\nSET\r\n$3\r\nmsg\r\n$5\r\nhello\r\n
*5\r\n$4\r\nSADD\r\n$6\r\nfruits\r\n$5\r\napple\r\n$6\r\nbanana\r\n$6\r\ncherry\r\n
*5\r\n$5\r\nRPUSH\r\n$7\r\nnumbers\r\n$3\r\n128\r\n$3\r\n256\r\n$3\r\n512\r\n

在这个AOF文件里面,除了用于指定数据库的SELECT命令是服务器自动添加的之外,其他都是我们之前通过客户端发送的命令。服务器在启动时,可以通过载入和执行AOF文件中保存的命令来还原服务器关闭之前的数据库状态

11.1 AOF持久化的实现

AOF持久化功能的实现可以分为命令追加(append)、文件写入、文件同步(sync)三个步骤(往下读就知道啥是写入啥是同步别急)

命令追加
当AOF持久化功能处于打开状态时,服务器在执行完一个写命令后,会以协议格式将被执行的写命令追加到服务器状态的aof_buf缓冲区的末尾

struct redisServer {
    ……
    //AOF缓冲区
    sds aof_buf;     
    ……
};

AOF文件的写入与同步

Redis的服务器进程就是一个事件循环,这个循环中的文件事件(啥是文件事件见下章)负责接收客户端的命令请求,以及向客户端发送命令回复,而时间事件(同下章)则负责执行像serverCron函数这样需要定时运行的函数。因为服务器在处理文件事件时可能会执行写命令,使得一些内容被追加到aof_buf缓冲区里面,所以在服务器每次结束一个事件循环之前,它都会调用flushAppendOnlyFile函数,考虑是否需要将aof_buf缓冲区中的内容写入和保存到AOF文件里面,这个过程可以用以下伪代码表示:

def eventLoop():
    while True:
    #处理文件时间,接受命令请求以及发送命令回复
    #处理命令请求时可能会有新内容被追加到aof_buf缓冲区中
    processFileEvents()
    #处理时间时间
    processTimeEvents()
    #考虑是否要将aof_buf中的内容写入和保存到AOF文件里面
    flushAppendOnlyFile()

flushAppendOnlyFile函数的行为由服务器配置的appendsync选项的值来决定

appendfsync选项的值 flushAppendOnlyFile函数的行为
always 将aof_buf缓冲区中的所有内容写入并同步到AOF文件
everysec 将aof_buf缓冲区中的所有内容写入到AOF文件,如果上次同步AOF文件的时间距离现在超过一秒钟,那么再次对AOF文件进行同步,并且这个同步操作是由一个线程专门负责执行的
no 将aof_buf缓冲区中的所有内容写入到AOF文件,但并不对AOF文件进行同步,何时同步由操作系统来决定

如果用户没有主动为appendsync选项设置值,那么appendsync选项的默认值为everysec

文件的写入和同步: 为了提高文件的写入效率,在现代操作系统中,当用户调用write函数,将一些数据写入到文件的时候,操作系统通常会将写入数据暂时保存在一个内存缓冲里面,等到缓冲区的空间被填满、或者超过了指定的时限之后,才真正地将缓冲区中的数据写入到磁盘中。这种做法虽然提高了效率,但也为写入数据带来安全问题,如果计算机发生停机,那么保存在内存缓冲的数据也会丢失。为此,系统提供了fsync和fdatasync两个同步函数,它们可以强制让操作系统立即将缓冲区中的数据写入到磁盘中,从而确保写入数据的安全性

服务器配置appendfsync选项的值直接决定AOF持久化功能的效率和安全性:

  • 当appendfsync的值为always时,服务器在每个事件循环都要将aof_buf缓冲区中的所有内容写入到AOF文件,并且同步AOF文件,所以always的效率是appendfsync选项三个值中最慢的一个,但从安全性来说,always也是最安全的,因为即使因为故障停机,AOF持久化也只会丢失一个事件循环中所产生的命令数据
  • 当appendfsync的值为everysec时,服务器在每个事件循环都要将aof_buf缓冲区的所有内容写入到AOF文件,并且每隔一秒就要在子线程中对AOF文件进行一次同步。从效率上来讲,everysec模式足够快,并且就算出现故障停机,数据库也只丢失一秒钟的命令数据
  • 当appendfsync的值为no时,服务器在每个事件循环都要将aof_buf缓冲区中的所有内容写入到AOF文件,至于何时对AOF文件进行同步,则由操作系统控制。因为处于no模式下的flushAppendOnlyFile调用无须执行同步操作,所以该模式下的AOF文件写入速度总是最快的,不过因为这种模式会在系统缓存中积累一段时间的写入数据,所以该模式的单次同步时长通常是三种模式中时间最长的。从平摊操作的角度来看,no模式和everysec模式的效率类似,当出现故障停机时,使用no模式的服务器将丢失上次同步AOF文件之后的所有写命令数据

AOF文件的载入与数据还原

服务器只要读入并重新执行一遍AOF文件里面保存的写命令,就可以还原服务器关闭之前的数据库状态。Redis读取AOF文件并还原数据库状态的详细步骤如下:

  1. 创建一个不带网络连接的伪客户端(fake client):因为Redis的命令只能在客户端上下文执行,而载入AOF文件时所使用的命令直接来源于AOF文件而不是网络连接,所以服务器使用了一个没有网络连接的伪客户端来执行AOF文件保存的写命令,伪客户端执行命令的效果和网络连接的客户端执行命令的效果一样
  2. 从AOF文件中分析并读取出一条写命令
  3. 使用伪客户端执行被读取的写命令

一直执行步骤2和3,直到AOF文件中的所有写命令都被处理完毕为止

AOF重写

为啥重写:对一个键操作半天又改又删实在麻烦,只需要记录最后的状态即可避免这些冗余操作,提高效率

AOF文件重写的实现

虽然Redis将生成新AOF文件替换旧AOF文件的功能命名为“AOF文件重写”,但实际上,AOF文件重写并不需要对现有的AOF文件进行任何读取、分析或者写入操作,这个功能是通过读取服务器当前的数据库状态来实现的

整个重写过程可以用以下伪代码表示

def aof_rewrite(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);
            else if  key.type == List:
                rewrite_list(key);
            else if  key.type == Hash:
                rewrite_hash(key);
            else if  key.type == Set:
                rewrite_set(key);
            else if  key.type == SortedSet:
                rewrite_sorted_set(key);
 
            # 如果键带有过期时间,那么过期时间也要被重写
            if key.have_expire_time():
                rewrite_expire_time(key);
        # 写入完毕,关闭文件
        f.close();
 
def rewrite_string(key):
    # 使用GET命令获取字符串键的值
    value = GET(key);
    # 使用SET命令重写字符串键
    f.write_command(SET, key, value);
def rewrite_list(key):
    # 使用LRANGE命令获取列表键的所有元素
    item1, item2,..., itemN = LANGE(key, 0, -1);
     
    # 使用RPUSH命令重写列表键
    f.write_command(RPUSH, key, item1, item2,..., itemN);
 
def rewrite_hash(key):
    # 使用HGETALL命令获取哈希键包含的所有键值对
    field1, value1, field2, value2,..., fieldN, valueN = HGETALL(key);
     
    # 使用HMSET命令重写哈希键键
    f.write_command(HMSET, key, field1, value1, field2, value2,..., fieldN, valueN);
def rewrite_set(key):
    # 使用SMEMBERS命令获取集合键的所有元素
    elem1, elem2,..., elemN = SMEMBERS(key);
     
    # 使用SADD命令重写集合键
    f.write_command(SADD, key, elem1, elem2,..., elemN);
def rewrite_sorted_set(key):
    # 使用ZRANGE命令获取有序集合键的所有元素
    member1, score1,member2, score2,...,memberN, scoreN  = ZRANGE(key, 0, -1, "WITHSCORES");
     
    # 使用ZADD命令重写有序集合键
    f.write_command(ZADD, key, member1, score1,member2, score2,...,memberN, scoreN);
 
def rewrite_expire_time(key):
    # 获取毫秒精度的键过期时间戳
    timestamp = get_expire_time_in_unixstamp(key);
     
    # 使用PEXPIREAT命令重写键的过期时间
    f.write_command(PEXPIREAT, key, timestamp);   

因为aof_rewrite函数生成的新AOF文件只包含还原当前数据库状态所必须的命令,所以新AOF不会浪费任何硬盘空间

为了避免在执行命令时造成客户端输入缓冲区溢出,重写程序在处理列表、哈希表、集合、有序集合这四种可能会带有多个元素的键时,会先检查键所包含的元素数量,如果元素的数量超过了redis.h/REDIS_AOF_REWRITE_ITEMS_PER_CMD常量的值,那么重写程序将使用多条命令来记录键的值,而不单单使用一条命令

在目前版本中,REDIS_AOF_REWRITE_ITEMS_PER_CMD常量的值为64,这也就是说,如果一个集合键包含了超过64个元素,那么重写程序会用多条SADD命令来记录这个集合,并且每条命令设里的元素数量也为64个。

AOF后台重写

AOF重写程序aof_rewrite函数可以很好地完成创建一个新AOF文件的任务但是,因为这个函数会进行大量的写入操作,所以调用这个函数的线程将被长时间阻塞,因为Redis服务器使用单线程来处理命令请求,所以如果由服务器直接调用aof_rewrite函数的话,那么在重写AOF文件期间,服务器将无法处理客户端发送的命令请求

Redis决定将AOF重写程序放到子进程里执行,使子进程进行AOF重写期间,服务器进程(父进程)可以继续处理命令请求,子进程带有服务器进程的数据副本,使用子进程而不是线程,可以在避免使用锁的情况下,保证数据的安全性。(进程是分配资源的基本单位,线程共享资源)

使用子进程也有一个问题需要解决,因为子进程在进行AOF重写期间,服务器进程还需要处理命令请求,而新的命令可能会对现有的数据库状态进行修改,从而使得服务器当前的数据库状态和重写后的AOF文件所保存的数据库状态不一致

为了解决这种数据不一致问题,Redis服务器设置了一个AOF重写缓冲区,这个缓冲区在服务器创建子进程之后开始使用,当Redis服务器执行完一个写命令之后,它会同时将这个写命令发送给AOF缓冲区和AOF重写缓冲区。

在子进程执行AOF重写期间,服务器进程需要执行以下三个工作:

  • 执行客户端发来的命令
  • 将执行后的写命令追加到AOF缓冲区
  • 将执行后的写命令追加到AOF重写缓冲区

这样一来可以保证:

  • AOF缓冲区的内容会定期被写入和同步到AOF文件,对现有AOF文件的处理工作会如常进行
  • 从创建子进程开始,服务器执行的所有写命令都会被记录到AOF重写缓冲区里面

当子进程完成AOF重写工作之后,它会向父进程发送一个信号,父进程在接到该信号之后,就会调用一个信号处理函数,并执行以下工作:

  • 将AOF重写缓冲区中所有内容写入到新AOF文件中,这时新AOF文件所保存的数据库状态将和服务器当前的数据库状态一致
  • 对新AOF文件进行改名,原子地覆盖现有的AOF文件,完成新旧两个AOF文件的替换

这个信号处理函数执行完毕后,父进程就可以继续像往常一样接受命令请求了

在整个AOF后台重写过程中,只有信号处理函数执行时会对服务器进程(父进程)造成阻塞,在其他时候,AOF后台重写都不会阻塞父进程,这将AOF重写对服务器性能造成的影响降到了最低。

第12章 事件

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

  • 文件事件(file event):Redis服务器通过套接字与客户端(或者其他Redis服务器)进行连接,而文件事件就是服务器对套接字操作的抽象。服务器与客户端(或者其他服务器)的通信产生相应的文件事件,服务器监听并处理这些事件完成一系列网络通信操作。
  • 时间事件(time event):Redis服务器中的一些操作(如serverCron函数)需要在给定时间执行,而时间事件就是服务器对这类定时操作的抽象。

12.1 文件事件
Redis基于Reactor模式开发了自己的网络事件处理器:这个处理器被称为文件事件处理器:

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

文件事件处理器以单线程方式运行,通过使用I/O多路复用程序监听多个套接字,文件事件处理器实现了高性能的网络通信模型,同时也可以和Redis服务器中其他同样以单线程方式运行的模块进行对接,保持了Redis内部单线程的简单性

12.1.1 文件事件处理器的构成
文件事件处理器的四个组成部分,它们分别是套接字、I/O多路复用程序、文件事件分派器,以及事件处理器
Redis设计与实现笔记---第二部分:单机数据库的实现_第6张图片文件事件是对套接字操作的抽象,每当一个套接字准备好执行连接应答(accept)、写入、读取、关闭等操作时,就会产生一个文件事件。因为一个服务器通常会连接多个套接字,所以多个文件事件有可能会并发地出现。I/O多路复用程序负责监听多个套接字,并向文件事件分派器传送那些产生了事件的套接字

尽管多个文件事件可能会并发地出现,但I/O多路复用程序总是会将所有产生事件的套接字都放到一个队列中,然后通过这个队列,以有序、同步、每次一个套接字的方式向文件事件分派器传送套接字。当上一个套接字产生的实践被处理完毕之后(该套接字为事件所关联的事件处理器执行完毕),I/O多路复用程序才会继续向文件事件分派器传送下一个套接字
在这里插入图片描述文件事件分派器接收I/O多路复用程序传来的套接字,并根据套接字产生的事件的类型,调用相应的事件处理器。服务器会为执行不同任务的套接字关联不同的事件处理器,这些处理器是一个个函数,它们定义了某个事件发生时,服务器应该执行的动作

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

Redis的I/O多路复用程序的所有功能都是通过包装常见的select、epoll、evport和kqueue这些I/O多路复用函数库来实现的,每个I/O多路复用函数库在Redis源码中都对应一个单独的文件,比如ae_select.c、ae_epoll.c、ae_kqueue.c,诸如此类。因为Redis为每个I/O多路复用函数库都实现了相同的API,所以I/O多路复用程序的底层实现时可以互换的
Redis设计与实现笔记---第二部分:单机数据库的实现_第7张图片
Redis在I/O多路复用程序的实现源码中用#include宏定义了相应的规则,程序会在编译时自动选择系统中性能最高的I/O多路复用函数库来作为Redis的I/O多路复用程序的底层实现

事件的类型

I/O多路复用程序可以监听多个套接字的ae.h/AE_READABLE事件和ae.h/AE_WRITABLE事件,这两类事件和套接字操作之间的对应关系如下:

  • 当套接字变得可读时(客户端对套接字执行write操作,或执行close操作),或有新的可应答(acceptable)套接字出现时(客户端对服务器的监听套接字执行connect操作),套接字产生AE_READABLE事件
  • 当套接字变得可写时(客户端对套接字执行read操作),套接字产生AE_WRITABLE事件
    ps:套接字的可读和可写是相对于服务器来说吧(我猜的)。

I/O多路复用程序允许服务器同时监听套接字的AE_READABLE事件和AE_WRITABLE事件,如果一个套接字同时产生了这两种事件,那么文件分派器会优先处理AE_READABLE事件之后,再处理AE_WRITABLE事件。也就是说,如果一个套接字可读又可写的话,那么服务器会先读取套接字,后写套接字

API

  • ae.c/aeCreateFileEvent函数接受一个套接字描述符、一个事件类型、以及一个事件处理器作为参数,将给定套接字的事件加入到I/O多路复用程序的监听范围内,并对事件和事件处理器进行关联

  • ae.c/aeDeleteFileEvent函数接受一个套接字描述符和一个监听事件类型作为参数,让I/O多路复用程序取消对给定套接字的给定事件的监听,并取消事件和事件处理器之间的关联

  • ae.c/aeGetFileEvents函数接受一个套接字描述符,返回该套接字正在被监听的事件类型:
    如果套接字没有任何事件被监听,那么函数返回AE_NONE
    如果套接字的读事件正在被监听,那么函数返回AE_READABLE
    如果套接字的写事件正在被监听,那么函数返回AE_WRITABLE
    如果套接字的读事件和写事件正在被监听,那么函数返回AE_READABLE|AE_WRITABLE

  • ae.c/aeWait函数接受一个套接字描述符、一个事件类型和一个毫秒数为参数,在给定的时间内阻塞并等待套接字的给定类型事件产生,当事件成功产生,或者等待超时之后,函数返回

  • ae.c/aeApiPoll函数接受一个sys/time.h/struct timeval结构为参数,并在指定的时间內,阻塞并等待所有被aeCreateFileEvent函数设置为监听状态的套接字产生文件事件,当有至少一个事件产生,或者等待超时后,函数返回

  • ae.c/aeProcessEvents函数是文件事件分派器,它先调用aeApiPoll函数来等待事件产生,然后遍历所有已产生的事件,并调用相应的事件处理器来处理这些事件

  • ae.c/aeGetApiName函数返回I/O多路复用程序底层所使用的I/O多路复用函数库的名称:返回"select"表示底层为select函数库,诸如此类

文件事件的处理器

Redis为文件事件编写了多个处理器,这些事件处理器分别用于实现不同的网络通信需求

  • 为了对连接服务器的各个客户端进行应答,服务器要为监听套接字关联连接应答处理器
  • 为了接收客户端传来的命令请求,服务器要为客户端套接字关联命令请求处理器
  • 为了向客户端返回命令的执行结果,服务器要为客户端套接字关联命令回复处理器
  • 当主服务器和从服务器进行复制操作时,主从服务器都需要关联特别为复制功能编写的复制处理器

连接应答处理器

networking.c/acceptTcpHandler函数是Redis的连接应答处理器,这个处理器用于对连接服务器监听套接字的客户端进行应答,具体实现为为sys/socket.h/accept函数的包装。当Redis服务器进行初始化的时候,程序会将这个连接应答处理器和服务监听套接字的AE_READABLE事件关联起来,当有客户端用sys/socket.h/connect函数连接服务端监听套接字时,套接字就会产生AE_READABLE事件,引发连接应答处理器执行,并执行相应的套接字应答操作
Redis设计与实现笔记---第二部分:单机数据库的实现_第8张图片命令请求处理器

networking.c/readQueryFromClient函数是Redis的命令请求处理器,这个处理器负责从套接字中读入客户端发送的命令请求内容,具体实现为unistd.h/read函数的包装。当一个客户端通过连接应答处理器成功连接到服务器之后,服务器会将客户端套接字的AE_READABLE事件和命令请求处理器关联起来,当客户端向服务器发送命令请求的时候,套接字就会产生AE_READABLE事件,引发命令请求处理器执行,并执行相应的套接字读入操作
Redis设计与实现笔记---第二部分:单机数据库的实现_第9张图片命令回复处理器

networking.c/sendReplyToClient函数是Redis的命令回复处理器,这个处理器负责将服务器执行命令后得到的命令回复通过套接字返回给客户端,具体实现为unistd.h/write函数的包装。当服务器有命令回复需要传送给客户端的时候,服务器会将客户端套接字的AE_WRITABLE事件和命令回复处理器关联起来,当客户端准备好接收服务器传回的命令回复时,就会产生AE_WRITABLE事件,引发命令回复处理器执行,并执行相应的套接字写入操作,如图1-6所示。当命令回复发送完毕之后,服务器就会解除命令回复处理器与客户端套接字的AE_WRITABLE事件之间的关联

Redis设计与实现笔记---第二部分:单机数据库的实现_第10张图片一次完整的客户端与服务器连接事件示例

假设一个Redis服务器正在运作,那么这个服务器的监听套接字的AE_READABLE事件应该正处于监听状态下,而该事件所对应的处理器为连接应答处理器。如果这时有一个Redis客户端向服务器发起连接,那么监听套接字将产生AE_READABLE事件,触发连接应答处理器执行。处理器会对客户端的连接请求进行应答,然后创建客户端套接字,以及客户端状态,并向客户端套接字的AE_READABLE事件与命令请求处理器进行关联,使得客户端可以向主服务器发送命令请求

之后,假设客户端向主服务器发送一个命令请求,那么客户端套接字将产生AE_READABLE事件,引发命令请求处理器执行,处理器读取客户端命令内容,然后传给相关程序去执行。执行命令将产生相应的命令回复,为了将这些命令回复传送给客户端,服务器会将客户端套接字的AE_WRITABLE事件与命令回复处理器进行关联。当客户端尝试读取命令回复时,客户端套接字将产生AE_WRITABLE事件,触发命令回复处理器执行,当命令回复处理器将命令回复全部写入到套接字之后,服务器就会解除客户端套接字的AE_WRITABLE事件与命令回复处理器之间的关联

Redis设计与实现笔记---第二部分:单机数据库的实现_第11张图片12.2 时间事件

Redis的时间事件分为以下两类:

  • 定时事件:让一段程序在指定的时间之后执行一次。比如说,让程序X在当前时间的30毫秒之后执行一次
  • 周期性事件:让一段程序每隔指定时间就执行一次。比如说,让程序Y每隔30毫秒就执行一次

一个时间事件主要由以下三个属性组成:

  • id:服务器为时间事件创建的全局唯一ID(标识号)。ID号按从小到大的顺序递增,新事件的ID号比旧事件的ID号要大
  • when:毫秒精度的UNIX时间戳,记录了时间事件的到达(arrive)时间
  • timeProc:时间事件处理器,一个函数。当时间事件到达时,服务器就会调用相应的处理器来处理事件

一个时间事件是定时事件还是周期性事件取决于时间事件处理器的返回值:

  • 如果事件处理器返回ae.h/AE_NOMORE,那么这个事件为定时事件:该事件在达到一次之后就会被删除,之后不再到达
  • 如果事件处理器返回一个非AE_NOMORE的整数值,那么这个事件为周期性时间:当一个时间事件到达之后,服务器会根据事件处理器返回的值,对时间事件的when属性进行更新,让这个事件在一段时间之后再次到达,并以这种方式一直更新并运行下去。比如说,如果一个时间事件的处理器返回整数值30,那么服务器应该对这个时间事件进行更新,让这个事件在30毫秒之后再次到达

实现

服务器将所有时间事件都放在一个无序链表中,每当时间事件执行器运行时,它就遍历整个链表,查找所有已达的时间事件,并调用相应的事件处理器。下图展示了一个保存时间事件的链表的例子,链表中包含了三个不同的时间事件:因为新的时间事件总是插入到链表的表头,所以三个时间事件分别按ID逆序排序,表头事件的ID为3,中间事件的ID为2,表尾事件的ID为1
Redis设计与实现笔记---第二部分:单机数据库的实现_第12张图片我们说保存时间事件的链表为无序链表,指的不是链表不按ID排序,而是说,该链表不按when属性的大小排序。正因为链表没有按when属性进行排序,所以当时间事件执行器运行的时候,它必须遍历链表中的所有时间事件,这样才能确保服务器中所有已达到时间事件都会被处理

API

  • ae.c/aeCreateTimeEvent函数接受一个毫秒数milliseconds和一个时间事件处理器proc作为参数,将一个新的时间事件添加到服务器,这个新的时间事件将在当前时间的milliseconds毫秒之后到达,而事件的处理器为proc。
  • ae.c/aeDeleteFileEvent函数接受一个时间事件ID作为参数,然后从服务器中删除该ID所对应的时间事件
  • ae.c/aeSearchNearestTimer函数返回到达时间距离当前时间最接近的那个时间事件
  • ae.c/processTimeEvents函数是时间事件的执行器,这个函数会遍历所有已到达的时间事件,并调用这些事件的处理器。已到达指的是,时间事件的when属性记录的Unix时间戳等于或小于当前时间的Unix时间戳
    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函数

持续运行的Redis服务器需要定期对自身的资源和状态进行检查和调整,从而确保服务器可以长期、稳定地运行,这些定期操作由redis.c/serverCron函数负责执行,它的主要工作包括:

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

Redis服务器以周期性事件的方式来运行serverCron函数,在服务器运行期间,每隔一段时间, serverCron就会执行一次,直到服务器关闭为止。在Redis2.6版本,服务器默认规定serverCron每秒运行10次,平均每间隔100毫秒运行一次。从Redis2.8开始,用户可以通过修改hz选项来调整serverCron的每秒执行次数

事件的调度与执行

因为服务器同时存在文件事件和时间事件两种事件类型,所以服务器必须对两种事件进行调度,决定何时处理文件事件,何时处理时间事件,以及花多少时间来处理它们等等。事件的调度和执行由ae.c/aeProcessEvents函数负责。

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,那么aeApiPoll调用之后马上返回,不阻塞
    aeApiPoll(timeval)
    #处理所有已产生的文件事件(其实并没有这个函数)
    processFileEvents()
    #处理所有已到达的时间事件
    processTimeEvents()

Redis服务器主函数

def main():
    # 初始化服务器
    init_server()

    # 一直处理事件,知道服务器关闭为止
    while server_is_not_shutdown():
        aeProcessEvents()

    # 服务器关闭,执行清理操作
    clean_server()

Redis设计与实现笔记---第二部分:单机数据库的实现_第13张图片事件的调度和执行规则:

  • aeApiPoll函数的最大阻塞时间由到达时间最接近当前时间的时间事件决定,这个方法既可以避免对时间事件进行频繁的轮询(忙等待),也可以确保aeApiPoll函数不会阻塞过长时间。
  • 因为文件事件是随机出现的,如果等待并处理完一次文件事件之后,仍未有任何时间事件到达,那么服务器将再次等待并处理文件事件。
  • 对文件事件和时间事件的处理都是同步,有序,原子地执行的,服务器不会中途中断事件处理,也不会对事件进行抢占。所以,文件事件的处理器,时间事件的处理器都会尽可减少程序的阻塞时间,并在有需要时主动让出执行权,降低事件饥饿的可能性。比如说,在命令回复处理器将一个命令回复写入客户端套接字时,如果写入字节数超过了一个预设常量的话,命令回复处理器就会主动用break跳出写入循环,将余下的数据留到下次再写。另外,时间事件也会将非常耗时的持久化操作放到子线程或者子进程执行
  • 因为时间事件在文件事件之后执行,并且事件之间不会出现抢占,所以时间事件的实际处理时间,通常会比时间事件设定的到达时间稍晚一些。

第13章 客户端

Redis服务器是典型的一对多服务器程序,通过使用I/O多路复用技术实现的文件事件处理器,Redis服务器使用单线程单进程的方式来处理命令请求,并与多个客户端进行网络通信。
服务器都为这些客户端建立了相应的redis.h/redisClient结构(客户端状态),这个结构保存了客户端当前的状态信息,以及执行相关功能时需要用到的数据结构,其中包括:

  • 客户端的套接字描述符
  • 客户端的名字
  • 客户端的标志值(flag)
  • 指向客户端正在使用的数据库指针,以及该数据库的号码
  • 客户端当前要执行的命令、命令的参数、命令参数的个数,以及指向命令实现函数的指针
  • 客户端的输入缓冲区和输出缓冲区
  • 客户端的复制状态信息,以及进行复制所需的数据结构
  • 客户端执行BRPOP、BLPOP等列表阻塞命令时使用的数据结构
  • 客户端的事物状态,以及执行WATCH命令时用到的数据结构
  • 客户端执行发布与订阅功能时用到的数据结构
  • 客户端的身份验证标识
  • 客户端的创建时间,客户端和服务器最后一次通信时间,以及客户端的输出华冲区大小超出软性限制的时间

Redis服务器状态结构的clients属性是一个链表,这个链表保存了所有与服务器连接的客户端的状态结构,对客户端执行批量操作,或者查找某个指定的客户端,都可以通过遍历clients链表来完成:

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

13.1 客户端属性

客户端状态包含的属性可以分为两类:

  • 一类是比较通用的属性,这类属性很少与特定功能相关,无论客户端执行的是什么工作,它们都要用到这些属性
  • 另一类是和特定功能相关的属性,比如操作数据库时需要用到的db属性和dictid属性,执行事务时需要用到的master属性,以及执行WATCH命令时需要用到的watched_keys属性等

套接字描述符

客户端状态的fd属性记录了客户端正在使用的套接字描述符

typedef struct redisClient {
    int fd;
    ……
} redisClient;

根据客户端类型不同,fd属性的值可以是-1或者是大于-1的整数:

  • 伪客户端(fake client)的fd属性的值为-1:伪客户端处理的命令请求来源于AOF文件或者Lua脚本,而不是网络,所以这种客户端不需要套接字连接,自然也不需要记录套接字描述符。目前Redis服务器会在两个地方用到伪客户端,一个是用于载入AOF文件并还原数据库状态,另一个则是用于执行Lua脚本中包含的Redis命令
  • 普通客户端的fd属性的值为大于-1的整数:普通客户端使用套接字来与服务器进行通信,所以服务器会用fd属性来记录客户端套接字的描述符。因为合法的套接字描述符不能为-1,所以普通客户端的套接字描述符的值必然是大于-1的整数

执行CLIENT list命令可以列出目前所有连接到服务器的普通客户端,命令输出中fd域显示了服务器连接客户端所使用的套接字描述符:

127.0.0.1:6379> CLIENT list
id=3 addr=127.0.0.1:57522 fd=9 name= age=76021 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=0 qbuf-free=32768 obl=0 oll=0 omem=0 events=r cmd=client

名字
默认情况下,一个连接到服务器的客户端是没有名字的,如上name域是空白的,可以使用CLIENT SETNAME命令为客户端设置一个名字,如下:

127.0.0.1:6379> CLIENT SETNAME message_quque
OK
127.0.0.1:6379> CLIENT list
id=3 addr=127.0.0.1:57522 fd=9 name=message_quque age=76399 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=0 qbuf-free=32768 obl=0 oll=0 omem=0 events=r cmd=client

客户端的名字记录在客户端状态的name属性中:

typedef struct redisClient {
    ……
    robj *name;            
    ……
} redisClient;

如果客户端没有为自己设置名字,那么相应客户端状态的name属性指向NULL指针;相反地,如果客户端为自己设置了名字,那么name属性将指向一个字符串对象,而对象则存放着客户端的名字。

标志
客户端的标志属性flags记录了客户端的角色,以及客户端目前所处的状态

typedef struct redisClient {
    ……
    int flags;          
    ……
} redisClient;

flags属性的值可以是单个标志:flags = < flag >,也可以是多个标志的二进制:flags = < flags1 > | < flags2 > | ……
每个标志使用一个常量表示,一部分标志记录了客户端的角色:

  • 在主从服务器进行复制操作时,主从服务器会成为从服务器的客户端,而从服务器也会成为主服务器的客户端。REDIS_MASTER标志表示客户端代表的一个主服务器,REDIS_SLAVE标志表示客户端代表的是一个从服务器

  • REDIS_PRE_PSYNC标志表示客户端代表的是一个版本低于Redis2.8的从服务器,主服务器不能使用PSYNC命令与这个从服务器同步。这个标志只能在REDIS_SLAVE标志处于打开状态时使用

  • REDIS_LUA_CLIENT标识表示客户端是专门用于处理Lua脚本里面包含的Redis命令的伪客户端
    而另外一部分标志则记录了客户端目前所处的状态:

  • REDIS_MONITOR标志表示客户端正在执行MONITOR命令

  • REDIS_UNIX_SOCKET标志表示服务器使用UNIX套接字来连接客户端

  • REDIS_BLOCKED标志表示客户端正在被BRPOP、BLPOP等命令阻塞

  • REDIS_UNBLOCKED标志表示客户端已经从REDIS_BLOCKED标志所表示的阻塞状态中脱离出来,不再阻塞。REDIS_UNBLOCKED标志只能在REDIS_BLOCKED标志已经打开的情况下使用

  • REDIS_MULTI标志表示客户端正在执行事务

  • REDIS_DIRTY_CAS标志表示事务使用WATCH命令监视的数据库键已经被修改,REDIS_DIRTY_EXEC标志表示事务在命令入队时出现了错误,以上两个标志都表示事务的安全性已经被破坏,只要这两个标记中的任意一个被打开,EXEC命令必然会执行失败。这两个标志只能在客户端打开了REDIS_MULTI标志的情况下使用

  • REDIS_CLOSE_ASAP标志表示客户端的输出缓冲区大小超出了服务器允许的范围,服务器会在下一次执行serverCron函数时关闭这个客户端,以免服务器的稳定性受到这个客户端影响。积存在输出缓冲区中的所有内容会直接被释放,不会返回给客户端

  • REDIS_CLOSE_AFTER_REPLY标志表示有用户对这个客户端执行了CLIENT_KILL命令,或者客户端发送给服务器的命令请求中包含了错误的协议内容。服务器会将客户端积存在输出缓冲区中的所有内容发送给客户端,然后关闭客户端

  • REDIS_ASKING标志表示客户端向集群节点(运行在集群模式下的服务器)发送了ASKING命令

  • REDIS_FORCE_AOF标志强制服务器将当前执行的命令写入到AOF文件里面,REDIS_FORCE_REPL标志强制主服务器将当前执行的命令复制给所有从服务器。执行PUBSUB命令会使客户端打开REDIS_FORCE_AOF标志,执行SCRIPT_LOAD命令会使客户端打开REDIS_FORCE_AOF标志和REDIS_FORCE_REPL标志

  • 在主从服务器进行命令传播期间,从服务器需要向主服务器发送REPLICATIONACK命令,在发送这个命令之前,从服务器必须打开主服务器对应的客户端的REDIS_MASTER_FORCE_REPLY标志,否则发送操作会被拒绝执行

PUBSUB命令和SCRIPT LOAD命令的特殊性
通常情况下,Redis只会将那些对数据库进行了修改的命令写入到AOF文件,并复制到各个从服务器。如果一个命令没有对数据库进行任何修改,那么它就会被认为是只读命令,这个命令不会被写入到AOF文件,也不会被复制到从服务器以上规则适用于绝大部分Redis命令,但PUBSUB命令和SCRIPT LOAD命令是其中的例外。PUBSUB命令虽然没有修改数据库,但PUBSUB命令向频道的所有订阅者发送消息这一行为带有副作用,接收到消息的所有客户端的状态都会因为这个命令而改变。因此,服务器需要使用REDIS_FORCE_AOF标志,强制将这个命令写入AOF文件,这样在将来载入AOF文件时,服务器就可以再次执行相同的PUBSUB命令,并产生相同的副作用。SCRIPT LOAD命令的情况与PUBSUB命令类似:虽然SCRIPT LOAD命令没有修改数据库,但它修改了服务器状态,所以它是一个带有副作用的命令,服务器需要使用REDIS_FORCE_AOF标志,强制将这个命令写入AOF文件,使得将来在载入AOF文件时,服务器可以产生相同的副作用
另外,为了让主服务器和从服务器都可以正确地载入SCRIPT LOAD命令指定的脚本,服务器需要使用REDIS_FORCE_REPL标志,强制将SCRIPT LOAD命令复制给所有从服务器

例子

# 客户端是一个主服务器
REDIS_MASTER
# 客户端正在被列表命令阻塞
REDIS_BLOCKED
# 客户端正在执行事务,但事务的安全性已被破坏
REDIS_MULTI | REDIS_DIRTY_CAS
# 客户端是一个从服务器,并且版本低于Redis 2.8
REDIS_SLAVE | REDIS_PRE_PSYNC
# 这是专门用于执行Lua脚本包含的Redis命令的伪客户端
# 它强制服务器将当前执行的命令写入AOF文件,并复制给从服务器
REDIS_LUA_CLIENT | REDIS_FORCE_AOF| REDIS_FORCE_REPL

输入缓冲区

客户端状态的输入缓冲区用于保存客户端发送的命令请求

typedef struct redisClient {
    ……
    sds querybuf;
    ……
} redisClient;

如果客户端向服务器发送了以下命令请求:

SET key value

客户端状态的querybuf属性将是一个包含以下内容的SDS值:

*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n

命令与命令参数

在服务器将客户端发送的命令请求保存到客户端状态的querybuf属性之后,服务器将对命令请求的内容进行分析,并将得出的命令参数以及命令参数的个数分别保存到客户端状态的argv属性和argc属性:

typedef struct redisClient {
    ……
    int argc;
    robj **argv;
    ……
} redisClient;

argv属性是一个数组,数组中的每个项都是一个字符串对象,其中argv[0]是要执行的命令,而之后的其他项则是传给命令的参数。argc属性负责记录argv数组的长度
Redis设计与实现笔记---第二部分:单机数据库的实现_第14张图片argc属性的值为3,而不是2,因为命令的名字"SET"本身也是一个参数

命令的实现函数

当服务器从协议内容中分析并得出argv属性和argc属性的值之后,服务器将根据项argv[0]的值,在命令表中查找命令所对应的命令实现函数。下图展示了一个命令表示例,该表是一个字典,字典的键是一个SDS结构体,保存了命令的名字,字典的值是命令所对应的redisCommand结构体,这个结构体保存了命令的实现函数、命令的标志、命令应该给定的参数个数、命令的总执行次数和总消耗时长等统计信息
Redis设计与实现笔记---第二部分:单机数据库的实现_第15张图片当程序在命令表中成功找到argv[0]所对应的redisCommand结构体时,它会将客户端状态的cmd指针指向这个结构体:

typedef struct redisClient {
    ……
    struct redisCommand *cmd;
    ……
} redisClient;

之后,服务器就可以使用cmd属性所指向的redisCommand结构体,以及argv、argc属性中保存的命令参数信息,调用命令实现函数,执行客户端指定的命令
针对命令表的查找操作不区分输入字母的大小写,所以无论argv[0]是"SET"、"set"或者是"SeT"等等,查找结果都是相同的

输出缓冲区

执行命令所得的命令回复会被保存在客户端状态的输出缓冲区里面,每个客户端都有两个输出缓冲区可用,一个缓冲区的大小是固定的,另一个缓冲区的大小是可变的

  • 固定大小的缓冲区用于保存那些长度比较小的回复,比如OK、简短的字符串值、整数值、错误回复等等
  • 可变大小的缓冲区用于保存那些长度比较大的回复,比如一个非常长的字符串值、一个由很多项组成的列表、一个包含了很多元素的集合等等
    客户端的固定大小缓冲区由buf和bufpos两个属性组合:
typedef struct redisClient {
    ……
    int bufpos;
    char buf[REDIS_REPLY_CHUNK_BYTES];
} redisClient;

buf是一个大小为REDIS_REPLY_CHUNK_BYTES字节的字节数组,而bufpos属性则记录了buf数组目前已使用的字节数量。REDIS_REPLY_CHUNK_BYTES常量目前的默认值为16*1024,也即是说,buf数组的默认大小为16KB。
当buf数组的空间已经用完,或者回复因为太大而无法装进buf数组里面,服务器就会开始使用可变大小缓冲区。可变大小缓冲区由reply链表和一个或多个字符串对象组成:

typedef struct redisClient {
    ……
    list *reply;
    ……
} redisClient;

通过使用链表来连接多个字符串对象,服务器可以为客户端保存一个非常长的命令回复,而不必受到固定大小缓冲区16KB大小的限制。图1-9展示了一个包含三个字符串对象的reply链表
Redis设计与实现笔记---第二部分:单机数据库的实现_第16张图片身份验证
客户端状态的authenticated属性用于记录客户端是否通过了身份验证:

typedef struct redisClient {
    ……
    int authenticated;      /* when requirepass is non-NULL */
    ……
} redisClient;

如果authenticated的值为0,表示客户端尚未通过身份认证;如果authenticated的值为1,表示客户端已通过认认证。

当客户端authenticated属性的值为0时,除了AUTH命令之外,客户端发送的所有其他命令都会被服务器拒绝执行。authenticated属性仅在服务器启用了身份验证功能时使用,如果服务器没有启用身份验证功能的话,那么即使authenticated属性的值为0(默认值),服务器也不会拒绝未验证身份的客户端发送的命令请求。

时间
客户端还有几个和时间相关的属性:

typedef struct redisClient {
    ……
    time_t ctime;         
    time_t lastinteraction;
    time_t obuf_soft_limit_reached_time;
    ……
} redisClient;

ctime属性记录了创建客户端的时间,这个时间可以用来计算客户端与服务器已经连接了多少秒,CLIENT list命令的age域记录了这个秒数

127.0.0.1:6379> CLIENT list
…… age=87315 ……
…… age=535 ……

lastinteraction属性记录了客户端与服务器最后一次进行互动(interaction)的时间,这里的互动可以是客户端向服务器发送命令请求,也可以是服务器向客户端发送命令回复。lastinteraction属性可以用来计算客户端的空转时间,也即是,距离客户端与服务器最后一次进行互动以来,已经过去多少秒,CLIENT list命令的idle域记录了这个秒数

127.0.0.1:6379> CLIENT list
…… idle=10916 ……
…… idle=0 ……

obuf_soft_limit_reached_time属性记录了输出缓冲区第一次达到软性限制(soft time)的时间

13.2 客户端的创建与关闭

创建普通客户端
如果客户端是通过网络连接和服务器进行连接的普通客户端,那么在客户端使用connect函数连接到服务端时,服务器就会调用连接事件处理器,为客户端创建相应的客户端状态,并将这个客户端状态添加到服务器状态结构体中clients链表的末尾

关闭普通客户端

  • 如果客户端进程退出或者被杀死,那么客户端与服务器之间的网络连接将被关闭,从而造成客户端被关闭
  • 如果客户端向服务器发送了带有不符合协议格式的命令请求,那么这个客户端也会被服务器关闭
  • 如果客户端成为了CLIENTKILL命令的目标,那么它也会被关闭
  • 如果用户为服务器设置了timeout配置选项,那么当客户端的空转时间超过timeout选项设置的值时,客户端将被关闭。不过timeout选项有一些例外情况:如果客户端是主服务器(打开了REDIS_MASTER标志),从服务器(打开了REDIS_SLAVE标志),正在被BLPOP等命令阻塞(打开了REDIS_BLOCKED标志),或者正在执行SUBSCRIBE, PSUBSCRIBE等订阅命令,那么即使客户端的空转时间超过了timeout选项的值,客户端也不会被服务器关闭
  • 如果客户端发送的命令请求的大小超过了输入缓冲区的限制大小(默认为1GB),那么这个客户端会被服务器关闭
  • 如果要发送给客户端的命令回复的大小超过了输出缓冲区的限制大小,那么这个客户端会被服务器关闭

可变大小缓冲区由一个链表和任意多个字符串对象组成,理论上来说,这个缓冲区可以保存任意长度的命令回复。但为了避免客户端回复过大,占用过多服务器资源,服务器会时刻检查客户端的输出缓冲区的大小,并在缓冲区的大小超出范围时,执行相应的限制操作。服务器使用两种模式来限制客户端输出缓冲的大小:

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

使用client-output-buffer-limit 可以为普通客户端、从服务器客户端、执行发布与订阅功能的客户端分别设置不同的软性限制和硬性限制,格式为:

client-output-buffer-limit <class> <hard limit> <soft limit> <soft seconds>
//example
client-output-buffer-limit normal 0 0 0  #不限制输出缓冲区大小
client-output-buffer-limit slave 256mb 64mb 60
client-output-buffer-limit pubsub 32mb 8mb 60 #执行发布和订阅功能的客户端硬性限制为32mb,软性限制为8mb,软性限制时长为60

Lua脚本的伪客户端

服务器会在初始化时创建负责执行Lua脚本中包含的Redis命令的伪客户端,并将这个伪客户端关联在服务器结构的lua_client属性中:

struct redisServer {
    ……
    redisClient *lua_client; 
    ……
};

lua_client伪客户端在服务器运行的整个生命周期中会一直存在,只有服务器被关闭时,这个客户端才会被关闭

AOF文件的伪客户端

服务器在载入AOF文件时,会创建用于执行AOF文件包含的Redis命令的伪客户端,并在载入完成之后,关闭这个伪客户端

第 14 章 服务器

14.1 命令请求过程

客户端发送SET KEY VALUE命令到获得回复OK期间,客户端和服务端共需要执行以下操作:

  • 客户端向服务器发送命令请求SET KEY VALUE
  • 服务器接收并处理客户端的命令请求SET KEY VALUE,在数据库中进行设置操作,并产生命令回复OK
  • 服务器将命令回复OK发送给客户端
  • 客户端接收服务器返回的命令回复OK,并将这个回复打印给用户看、

发送命令请求

Redis服务器的命令请求来自Redis客户端,当用户在客户端中键入一个命令请求时,客户端会将这个命令请求转换成协议格式,然后通过连接到服务器的套接字,将协议格式的命令请求发送给服务器
在这里插入图片描述

假设用户在客户端键入命令:
SET KEY VALUE
客户端会将这个命令转换成协议:
*3\r\n$3\r\nSET\r\n$3\r\nKEY\r\n$5\r\nVALUE\r\n

读取命令请求

客户端与服务器之间的连接套接字因为客户端的写入而变得可读时,服务器将调用命令请求处理器来执行以下操作:

  • 读取套接字中协议格式的命令请求,并将其保存到客户端状态的输入缓冲区
  • 对输入缓冲区中的命令请求进行分析,提取出命令请求中包含的命令参数,以及命令参数的个数,然后分别将参数和参数个数保存到客户端状态的argv和argc属性
  • 调用命令执行器,执行客户端指定的命令

Redis设计与实现笔记---第二部分:单机数据库的实现_第17张图片Redis设计与实现笔记---第二部分:单机数据库的实现_第18张图片命令执行器(1):查找命令实现

命令执行器要做的第一件事就是根据客户端状态的argv[0]参数,在命令表中查找参数所指定的命令,并将找到的命令保存到客户端状态的cmd属性中。命令表是一个字典,字典的键是一个个命令名字,比如"set"、“get”、"del"等等;而字典的值则是一个个redisCommand结构体,每个redisCommand结构体记录了一个Redis命令的实现信息,结构体的各个主要属性的类型和作用:

属性名 类型 作用
name redisCommandProc * 函数指针,指向命令的实现函数,比如setCommand。redisCommandProc类型的定义为typedef void redisCommandProc(redisClient *c)
arity int 命令参数的个数,用于检查命令请求的格式是否正确。如果这个值为负数-N,那么表示参数的数量大于等于N。注意命令的名字本身也是一个参数,比如说SET msg “hello world 命令的参数是"SET”、“msg”、“hello world”,而不仅仅是"msg"和"hello world"
sflags char * 字符串形式的标识值,这个值记录了命令的属性,比如这个命令是写命令还是读命令, 这个命令是否允许在载入数据时使用,这个命令是否允许在 Lua 脚本中使用,等等
flags int 对sflags标识进行分析得出的二进制标识,由程序自动生成。服务器对命令标识进行检查时使用的都是flags属性而不是 sflags属性,因为对二进制标识的检查可以方便地通过&、^、~等操作来完成
calls long long 服务器总共执行了多少次这个命令
milliseconds long long 服务器执行这个命令所耗费的总时长
标识 意义 带有这个标识的命令
w 这是一个写入命令,可能会修改数据库 SET、RPUSH、DEL等等
r 这是一个只读命令,不会修改数据库 GET、STRLEN、EXISTS,等等
m 这个命令可能会占用大量内存, 执行之前需要先检查服务器的内存使用情况,如果内存紧缺的话就禁止执行这个命令 SET、APPEND、RPUSH、LPUSH、SADD、SINTERSTORE,等等
a 这是一个管理命令 SAVE、BGSAVE、SHUTDOWN ,等等
p 这是一个发布与订阅功能方面的命令 PUBLISH、SUBSCRIBE、PUBSUB,等等
s 这个命令不可以在Lua脚本中使用 BRPOP、BLPOP、BRPOPLPUSH、SPOP,等等
R 这是一个随机命令,对于相同的数据集和相同的参数,命令返回的结果可能不同 SPOP、SRANDMEMBER、SSCAN、RANDOMKEY,等等
S 当在Lua脚本中使用这个命令时,对这个命令的输出结果进行一次排序,使得命令的结果有序 SINTER、SUNION、SDIFF、SMEMBERS、KEYS,等等
l 这个命令可以在服务器载入数据的过程中使用 INFO、SHUTDOWN、PUBLISH,等等
t 这是一个允许从服务器在带有过期数据时使用的命令 SLAVEOF、PING、INFO,等等
M 这个命令在监视器(monitor)模式下不会自动被传播(propagate) EXEC

以SET命令和GET命令作为例子,展示了redisCommand结构体:
Redis设计与实现笔记---第二部分:单机数据库的实现_第19张图片客户端状态的cmd指针会指向这个redisCommand结构体:
Redis设计与实现笔记---第二部分:单机数据库的实现_第20张图片命令执行器(2):执行预备操作

服务器已经将执行命令所需的命令实现函数(保存在客户点状态的cmd属性)、参数(保存在客户端状态的argv属性)、参数个数(保存在客户端状态的argc属性)都收集齐了,但在真正执行命令前,程序还需要进行一些预备操作,从而确保命令可以正确、顺利地被执行,这些操作包括:

  • 检查客户端状态的cmd指针是否执行NULL,如果是的话,那么说明客户端输入的命令名字找不到相应的命令实现,服务器不再执行后续步骤,并向客户端返回一个错误
  • 根据客户端cmd属性指向的redisCommand结构体的arity属性,检查命令请求所给定的参数个数是否正确,当参数个数不正确时,不再执行后续步骤,直接向客户端返回一个错误。比如说,如果redisCommand结构体的arity属性的值为-3,那么用户输入的命令参数个数必须大于等于3个才行
  • 检查客户端是否已经通过了身份验证,未通过身份验证的客户端只能执行AUTH命令,如果未通过身份验证的客户端试图执行除AUTH命令之外的其他命令, 那么服务器将向客户端返回一个错误
  • 如果服务器打开了maxmemory功能,那么在执行命令之前,先检查服务器的内存占用情况,并在有需要时进行内存回收,从而使得接下来的命令可以顺利执行。如果内存回收失败,那么不再执行后续步骤,向客户端返回一个错误
  • 如果服务器上一次执行BGSAVE命令时出错,并且服务器打开了stop-writes-on-bg-save-error功能,而且服务器即将要执行的命令是一个写命令,那么服务器将拒绝执行这个命令,并向客户端返回一个错误
  • 如果客户端当前正在用SUBSCRIBE命令订阅频道,或者正在用PSUBSCRIBE命令订阅模式,那么服务器只会执行客户端发来的SUBSCRIBE、PSUBSCRIBE、UNSUBSCRIBE、PUNSUBSCRIBE四个命令,其他别的命令都会被服务器拒绝
  • 如果服务器正在进行数据载入,那么客户端发送的命令必须带有l标识(比如INFO、SHUTDOWN、PUBLISH,等等)才会被服务器执行,其他别的命令都会被服务器拒
  • 如果服务器因为执行Lua脚本而超时并进入阻塞状态,那么服务器只会执行客户端发来的SHUTDOWN nosave命令和SCRIPTKILL命令,其他别的命令都会被服务器拒绝
  • 如果客户端正在执行事务,那么服务器只会执行客户端发来的EXEC、DISCARD、MULTI、WATCH四个命令,其他命令都会被放进事务队列中
  • 如果服务器打开了监视器功能,那么服务器会将要执行的命令和参数等信息发送给监视器

命令执行器(3):调用命令的实现函数

服务器已经将要被执行的命令保存到客户端状态的cmd属性中,并将命令的参数和参数个数分别保存到客户端状态argv属性和argc 属性,当服务器决定要执行命令时,它只要执行以下语句就可以了

// client 是指向客户端状态的指针
client->cmd->proc(client);

因为执行命令所需的实际参数都已经保存到客户端状态的argv属性里面了,所以命令的实现函数只需要一个指向客户端状态的指针作为参数即可。继续以之前的SET命令为例子,图:Redis设计与实现笔记---第二部分:单机数据库的实现_第21张图片对于这个例子来说, 执行语句:client->cmd->proc(client);等于执行语句:setCommand(client)

被调用的命令实现函数会执行指定的操作,并产生相应的命令回复,这些回复会被保存在客户端状态的输出缓冲中(buf属性和reply属性),之后实现函数还会为客户端的套接字关联命令回复处理器,这个处理器将命令回复返回给客户端

对于前面SET命令的例子来说,函数调用setCommand(client)将产生一个"+OK\r\n"回复,这个回复会保存到客户端状态的buf属性中,如图:
Redis设计与实现笔记---第二部分:单机数据库的实现_第22张图片命令执行器(4):执行后续工作

在执行完实现函数之后,服务器还需要执行一些后续工作:

  • 如果服务器开启了慢查询日志功能,那么慢查询日志模块会检查是否需要为刚刚执行完的命令请求添加一条新的慢查询日志
  • 根据刚刚执行命令所耗费的时长,更新被执行命令的redisCommand结构体的milliseconds属性,并将命令的redisCommand结构体的calls计数器的值加1
  • 如果服务器开启AOF持久化功能,那么AOF持久化模块会将刚刚执行的命令请求写入到AOF缓冲区
  • 如果有其他服务器正在复制当前这个服务器,那么服务器会将刚刚执行的命令传播给所有从服务器

当以上操作都执行完之后,服务器对当前命令的执行到此告一段落,之后服务器就可以继续从文件事件处理器中取出并处理下一个命令请求

将命令回复发送给客户端
命令实现函数会将命令回复保存到客户端的输出缓冲,并为客户端的套接字关联命令回复处理器,当客户端套接字变为可写状态时,服务器就会执行命令回复处理器,将保存在客户端状态输出缓冲区的命令回复发送给客户端。当命令回复发送完毕之后,回复处理器会清空客户端状态的输出缓冲区,为处理下一个命令请求做好准备。以图1-7所示的客户端状态为例子,当客户端的套接字变为科协状态时,命令回复处理器会将协议格式的命令回复"+OK\r\n"发送给客户端

客户端接收并打印命令回复

当客户端接收到协议格式的命令回复之后,它会将这些回复转换成人们可以识别的可读模式,并打印给用户看
Redis设计与实现笔记---第二部分:单机数据库的实现_第23张图片
14.2 serverCron函数

Redis服务器中的serverCron函数默认每隔100毫秒执行一次,这个函数负责管理服务器的资源,并且保持服务器自身的良好运转。下面,我们将对serverCron函数执行的操作进行完整介绍,并介绍redisServer结构(服务器状态)中和serverCron有关的属性

更新服务器时间缓存

Redis服务器中有不少功能需要获取系统的当前时间,而每次获取系统的当前时间都需要执行一次系统调用,为了减少系统调用的次数,服务器状态中的unixtime属性和mstime属性被用作当前时间的缓存:

struct redisServer {
    ……
    //保存了秒级精度的系统当前Unix时间戳
    time_t unixtime;       
    //保存了毫秒级精度的系统当前Unix时间戳
    long long mstime;      
    ……
};

因为serverCron函数默认会以100毫秒一次的频率更新unixtime属性和mstime属性,所以这两个属性记录的时间的精确度并不高:

  • 服务器只会在打印日志、更新服务器的LRU时钟、决定是否执行持久化任务、计算服务器上线时间这类对时间精确度要求不高的功能上
  • 对于为键设置过期时间、添加慢查询日志这种需要高精度时间的功能来说,服务器还是会再次执行系统调用,从而获得最准确的系统当前时间

更新LRU时钟

服务器中的lrulock属性保存了服务器的LRU时钟,这个属性和上面介绍的unixtime属性、mstime属性一样,都是服务器时间缓存的一种

struct redisServer {
    ……
    //默认每10秒更新一次时钟缓冲
    //用于计算键的空转时长
    unsigned lruclock:22;      
    ……
};

每个Redis对象都会有一个lru属性,这个lru属性保存了对象最后一次被命令访问的时间:

typedef struct redisObject {
    ……
    unsigned lru:22;       
    ……
} robj;

当服务器要计算一个数据库键的空转时间(也即是数据库键对应的值对象的空转时间),程序会用服务器的lruclock属性记录的时间减去对象的lru属性记录的时间,得出的计算结果就是这个对象的空转时间。

serverCron函数默认会以每10秒一次的频率更新lruclock属性的值,因为这个时钟不是实时,所以根据这个属性计算出来的LRU时间实际上只是一个模糊的估计值

lruclock时钟的当前值可以通过INFO server命令的lru_clock域查看:

127.0.0.1:6379> INFO server
# Server
……
lru_clock:11967088
……

更新服务器每秒执行命令次数

serverCron函数中trackOperationsPerSecond函数会以每100毫秒一次的频率执行, 这个函数的功能是以抽样计算的方式,估算并记录服务器在最近一秒钟处理的命令请求数量,这个值可以通过INFO status命令的instantaneous_ops_per_sec域查看:

127.0.0.1:6379> INFO stats
# Stats
……
instantaneous_ops_per_sec:6
……

上面的命令结果显示,在最近一秒钟内,服务器处理了大概六个命令。trackOperationsPerSecond函数和服务器状态中四个ops_sec开头的属性有关:

struct redisServer {
    ……
    //上次一进行抽样的时间
    long long ops_sec_last_sample_time;
    //上次一抽样时,服务器已执行命令的数量
    long long ops_sec_last_sample_ops;
    //REDIS_OPS_SEC_SAMPLES大小(默认值为16)的环形数组
    long long ops_sec_samples[REDIS_OPS_SEC_SAMPLES];
    //标记上边数组索引
    //每次抽样后将值加1
    //在值等于16时重置为0
    //让ops_sec_samples数组构成一个环形数组
    int ops_sec_idx;
    ……
};

trackOperationsPerSecond函数每次运行,都会根据ops_sec_last_sample_time记录的上一次抽样时间和服务器的当前时间,以及ops_sec_last_sample_time记录的上一次抽样已执行命令数量和服务器当前已执行命令数量,计算出两次trackOperationsPerSecond调用之间,服务器平均每一毫秒处理了多少个命令请求,然后将这个平均值乘以1000,这就得到了服务器在一秒钟内能处理多少个命令你请求的估计值,这个估计值会被作为一个新的数组项被放进ops_sec_samples环形数组里面

当客户端执行INFO命令时,服务器就会调用getOperationsPerSecond函数,根据ops_sec_samples环形数组中的抽样结果,计算出instantaneous_ops_per_sec属性的值,以下是getOperationsPerSecond函数的实现代码:

long long getOperationsPerSecond(void) {
    int j;
    long long sum = 0;
    //计算所有取样值的总和
    for (j = 0; j < REDIS_OPS_SEC_SAMPLES; j++)
        sum += server.ops_sec_samples[j];
    //计算取样的平均值
    return sum / REDIS_OPS_SEC_SAMPLES;
}

更新服务器内存峰值记录

服务器状态中的stat_peak_memory属性记录了服务器内存峰值大小:

struct redisServer {
    ……
    //已使用内存峰值
    size_t stat_peak_memory;       
    ……
};

每次serverCron函数执行时,程序都会查看服务器当前使用的内存数量,并与stat_peak_memory保存的数值进行比较,如果当前使用的内存数量比stat_peak_memory属性记录的值要大,那么程序就将当前使用的内存数量记录到stat_peak_memory属性里面

INFO memory命令的和used_memory_peak_human两个域分别以两种格式记录了服务器的内存峰值:

127.0.0.1:6379> INFO memory
# Memory
……
used_memory_human:848.87K
used_memory_peak:870552
……

处理SIGTERM信号

在启动服务器时,Redis会为服务器进程的SIGTERM信号关联处理器sigtermHandler函数,这个信号处理器负责在服务器接到SIGTERM信号时,打开服务器状态的shutdown_asap标识:

//SIGTERM信号的处理器
static void sigtermHandler(int sig) {
    REDIS_NOTUSED(sig);
    //打印日志
    redisLogFromHandler(REDIS_WARNING,"Received SIGTERM, scheduling shutdown...");
    //打开关闭标识
    server.shutdown_asap = 1;
}

每次serverCron函数运行时,程序都会对服务器状态的shutdown_asap属性进行检查,并根据属性的值决定是否关闭服务器:

struct redisServer {
    ……
    //关闭服务器的标识
    //值为1时,关闭服务器
    //值为0时,不做动作
    int shutdown_asap;        
    ……
};

以下代码展示了服务器在接到SIGTERM信号之后,关闭服务器并打印相关日志的过程:

[207 | signal handler] (1380450274) Received SIGTERM, scheduling shutdown...
[207] 29 Sep 18:24:34.899 * Saving the final RDB snapshot before exiting.
[207] 29 Sep 18:24:35.050 * DB saved on disk
[207] 29 Sep 18:24:35.050 * Removing the pid file.
[207] 29 Sep 18:24:35.150 # Redis is now ready to exit, bye bye...

从日志里面可以看到,服务器在关闭自身之前会进行RDB持久化操作,这也是服务器拦截SIGTERM信号的原因,如果服务器一接到SIGTERM信号就立即关闭,那么它就没办法执行持久化操作了。

管理客户端资源

serverCron函数每次执行都会调用clientsCron函数,clientsCron函数会对一定数量的客户端进行以下两个检查:

  • 如果客户端与服务器之间的连接已经超时(很长一段时间里客户端和服务器都没有互动),那么程序会释放这个客户端
  • 如果客户端在上一次执行命令请求后,输入缓冲区的大小超过一定的长度,那么程序会释放客户端当前的输入缓冲区,并重新创建一个默认大小的输入缓冲区,从而防止客户端的输入缓冲区耗费过多内存

管理数据库资源

serverCron函数每次执行都会调用databasesCron函数,这个函数会对服务器中的一部分数据库进行检查,删除其中的过期键,并在有需要时,对字典进行收缩操作

执行被延迟的BGREWRITEAOF

在服务器执行BGSAVE命令的期间,如果客户端向服务器发来BGREWRITEAOF命令,那么服务器会将BGREWRITEAOF命令的执行延迟到BGSAVE命令执行完毕之后。服务器的aof_rewrite_scheduled标识记录了服务器是否延迟了BGREWRITEAOF命令

struct redisServer {
    ……
    //如果值为1,那么表示有BGREWRITEAOF命令被延迟了
    int aof_rewrite_scheduled;      /* Rewrite once BGSAVE terminates. */
    ……
};

每次serverCron函数执行时,函数都会检查BGSAVE命令或BGREWRITEAOF命令是否正在执行,如果这两个命令都没在执行,并且aof_rewrite_scheduled属性的值为1,那么服务器就会执行之前被推延的BGREWRITEAOF命令

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

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

struct redisServer {
    ……
    //记录执行BGSAVE命令的子进程ID
    //如果服务器没有执行BGSAVE
    //那么这个属性的值为-1
    pid_t aof_child_pid;           
    ……
    //记录执行BGREWRITEAOF命令的子进程ID
    //如果服务器没有执行BGREWRITEAOF
    //那么这个属性的值为-1
    pid_t rdb_child_pid;          
    ……
};

每次serverCron函数执行时,程序都会检查rdb_child_pid和aof_child_pid两个属性的值,只要其中一个属性的值不为-1,程序就会执行一次wait3函数,检查子进程是否有信号发来服务器进程:

  • 如果有信号到达,那么表示新的RDB文件已经生成完毕(对于BGSAVE命令来说),或者AOF文件已重写完毕(对于BGREWRITEAOF命令来说),服务器需要进行相应命令的后续操作,比如用新的RDB文件替换现有的RDB文件,或者用重写后的AOF文件替换现有的AOF文件
  • 如果没有信号到达,那么表示持久化操作未完成,程序不做动作

如果rdb_child_pid和aof_child_pid两个属性的值都为-1,那么表示服务器没有在进行持久化操作,在这种情况下,程序执行以下三个检查:

  1. 查看是否有BGREWRITEAOF被延迟了,如果有的话,那么开始一次新的BGREWRITEAOF操作
  2. 检查服务器的自动保存条件是否已被满足,如果条件满足,并且服务器没有在执行其他持久化操作,那么服务器开始一次新的BGSAVE操作(因为条件1可能会引发一次BGREWRITEAOF,所以在这个检查中,程序会再次确认服务器是否已经在执行持久化操作了)
  3. 检查服务器设置的AOF重写条件是否满足,如果条件满足,并且服务器没有执行其他持久化操作,那么服务器将开始一次新的BGREWRITEAOF操作(因为条件1和条件2都可能会引发新的持久化操作,所以在这个检查中,程序会再次确认服务器是否已经在执行持久化操作了)
    Redis设计与实现笔记---第二部分:单机数据库的实现_第24张图片将AOF缓冲区中的内容写入AOF文件

如果服务器开启了AOF持久化功能,并且AOF缓冲区里面还有待写入的数据,那么serverCron函数会调用相应的程序,将AOF缓冲区中的内容写入到AOF文件中

关闭异步客户端

服务器会关闭那些输出缓冲区大小超出限制的客户端

增加cronloops计数器的值

服务器状态的cronloops属性记录了serverCron函数执行的次数:

struct redisServer {
    ……
    //serverCron函数的运行次数计数器
    //serverCron函数每执行一次,这个属性的值就加1
    int cronloops;            
    ……
};

cronloops属性目前在服务器中唯一的作用,就是在复制模块中实现“每执行serverCron函数N次就执行一次指定代码”的功能

14.3 初始化服务器
一个Redis服务器从启动到能够接受客户端的命令请求,需要经过一系列的初始化和设置过程,比如初始化服务器状态、接受用户指定的服务器配置、创建相应的数据结构和网络连接等等,本节接下来的内容将对服务器的整个初始化过程做详细介绍

初始化服务器状态结构
初始化服务器的第一步就是创建一个struct redisServer类型的实例变量server作为服务器的状态,并为结构中的各个属性设置默认值。初始化server变量的工作由redis.c/initServerConfig函数完成,以下是这个函最开头的一部分代码:

void initServerConfig(void){
    // 设置服务器的运行id
    getRandomHexChars(server.runid,REDIS_RUN_ID_SIZE);
    // 为运行id加上结尾字符
    server.runid[REDIS_RUN_ID_SIZE] = '\0';
    // 设置默认配置文件路径
    server.configfile = NULL;
    // 设置默认服务器频率
    server.hz = REDIS_DEFAULT_HZ;
    // 设置服务器的运行架构
    server.arch_bits = (sizeof(long) == 8) ? 64 : 32;
    // 设置默认服务器端口号
    server.port = REDIS_SERVERPORT;
  ...
}

以下是initServerConfig函数完成的主要工作:

  • 设置服务器的运行ID
  • 设置服务器的默认运行频率
  • 设置服务器的默认配置文件路径
  • 设置服务器的运行架构
  • 设置服务器的默认端口号
  • 设置服务器的默认RDB持久化条件和AOF持久化条件
  • 初始化服务器的LRU时钟
  • 创建命令表

initServerConfig函数设置的服务器状态属性基本都是一些整数、浮点数、或者字符串属性,除了命令表外,initServerConfig函数没有创建服务器状态的其他数据结构,数据库、慢查询日志、Lua环境、共享对象这些数据结构在之后的步骤才会被创建出来。当initServerConfig函数执行完毕之后,服务器就可以进入初始化的第二个阶段——载入配置选项

载入配置选项

在启动服务器时,用户可以通过给定配置参数或指定配置文件来修改服务器的默认配置举个栗子

# redis-server redis.conf

我们就通过给定配置参数的方式,修改了服务器的运行端口号

# redis-server redis.conf

我们就通过指定配置文件的方式修改了服务器的数据库数量,以及RDB持久化模块的压缩功能。服务器在用initServerConfig函数初始化完server变量之后,就会开始载入用户给定的配置参数和配置文件,并根据用户设定的配置,对server变量相关属性的值进行修改。

初始化服务器数据库结构

在之前执行initServerConfig函数初始化server状态时,程序只创建了命令表一个数据结构,不过除了命令表之外,服务器状态还包含其他数据结构,比如:

  • server.clients链表,这个链表记录了所有与服务器相连的客户端的状态结构,链表的每个节点都包含了一个redisClient结构实例
  • server.db数组,数组中包含了服务器的所有数据库
  • 用于保存频道订阅信息的server.pubsub_channels字典,以及用于保存模式订阅信息的server.pubsub_patterns链表
  • 用于执行Lua脚本的Lua环境server.lua
  • 用于保存慢查询日志的server.slowlog属性

当初始化服务器进行到这一步,服务器将调用initServer函数,为以上提到的数据结构分配内存,并在有需要时,为这些数据结构设置或关联初始化值。服务器到现在才初始化数据结构的原因在于,服务器必须先载入用户指定的配置选项,然后才能正确地对数据结构进行初始化。如果在执行initServerConfig函数时就对数据结构进行初始化,那么一旦用户通过配置项修改了和数据结构相关的服务器状态属性,服务器就要重新调整和修改已创建的数据结构。为了避免出现这种麻烦的情况,服务器选择了将server状态的初始化分为两步进行,initServerConfig函数主要负责初始化一般属性,而initServer函数主要负责初始化数据结构

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

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

还原数据库状态

在完成了对服务器状态server变量的初始化之后,服务器需要载入RDB文件或AOF文件,并根据文件记录的内容来还原服务器的数据库状态

根据服务器是否启用AOF持久化功能,服务器载入数据时所使用的目标文件会有所不同:

  • 如果服务器启用了AOF持久化功能,那么服务器使用AOF文件来还原数据库状态
  • 相反地,如果服务器没有启用AOF持久化功能,那么服务器使用RDB文件来还原数据库状态

当服务器完成数据库状态还原工作之后,服务器将在日志中打印出载入文件并还原数据库状态所耗费的时长:

[5244] 21 Nov 22:43:49.084 * DB loaded from disk: 0.068 seconds

在初始化最后一步,服务器将打印出日志:

[5244] 21 Nov 22:43:49.084 * The server is now ready to accept connections on port 6379

并开始执行服务器的事件循环(loop)。至此,服务器的初始化工作圆满完成,服务器现在开始可以接受客户端的连接请求,并处理客户端发来的命令请求了

你可能感兴趣的:(Redis设计与实现笔记---第二部分:单机数据库的实现)