云社区 博客 博客详情 《三次给你聊清楚Redis》之简单聊聊单机

举报
兔老大 发表于 2021/04/20 23:40:58 2021/04/20
【摘要】 2.6对象刚写了redis主要的数据结构:动态字符串、双端链表、字典、压缩列表、整数集合、跳表等redis肯定不能直接使用这些数据结构来实现数据库,它用这些数据库建立了一个对象系统,包含:字符串对象、列表对象、哈希对象、集合对象、有序集合对象我们可以针对不同的使用场景,为对象设置多种分不同的数据结构实现,从而优化对象在不同场景下的效率。1)键值对对于redis的键值对来说:key只有字符串类...

2.6对象

刚写了redis主要的数据结构:

动态字符串、双端链表、字典、压缩列表、整数集合、跳表等

redis肯定不能直接使用这些数据结构来实现数据库,它用这些数据库建立了一个对象系统,包含:

字符串对象、列表对象、哈希对象、集合对象、有序集合对象

我们可以针对不同的使用场景,为对象设置多种分不同的数据结构实现,从而优化对象在不同场景下的效率。

1)键值对

对于redis的键值对来说:key只有字符串类型,而v可以是各种类型,

我们习惯把“这个键所对应的值是一个列表”表达为这是一个“列表键。

TYPE 命令的实现方式也与此类似, 当我们对一个数据库键执行 TYPE 命令时, 命令返回的结果为数据库键对应的值对象的类型, 而不是键对象的类型:

# 键为字符串对象,值为列表对象

redis> RPUSH numbers 1 3 5
(integer) 6

redis> TYPE numbers
list

2)对象

我们看一下redis对象的组成:

typedef struct redisObject {
    // 类型
    unsigned type:4;
    // 编码
    unsigned encoding:4;
    // 指向底层实现数据结构的指针
    void *ptr;
    // ...
} robj;

通过 encoding 属性来设定对象所使用的编码, 而不是为特定类型的对象关联一种固定的编码, 极大地提升了 Redis 的灵活性和效率, 因为 Redis 可以根据不同的使用场景来为一个对象设置不同的编码, 从而优化对象在某一场景下的效率。

字符串对象

字符串对象的编码可以是 int 、 raw 或者 embstr 。

如果一个字符串对象保存的是整数值, 并且这个整数值可以用 long 类型来表示, 那么字符串对象会将整数值保存在字符串对象结构的 ptr属性里面(将 void* 转换成 long ), 并将字符串对象的编码设置为 int 。

如果字符串对象保存的是一个字符串值, 并且这个字符串值的长度大于 39 字节, 那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值, 并将对象的编码设置为 raw 。

如果字符串对象保存的是一个字符串值, 并且这个字符串值的长度小于等于 39 字节, 那么字符串对象将使用 embstr 编码的方式来保存这个字符串值。

embstr 编码是专门用于保存短字符串的一种优化编码方式, 这种编码和 raw 编码一样, 都使用 redisObject 结构和 sdshdr 结构来表示字符串对象,但 raw 编码会调用两次内存分配函数来分别创建 redisObject 结构和 sdshdr 结构,而 embstr 编码则通过调用一次内存分配函数来分配一块连续的空间, 空间中依次包含 redisObject 和 sdshdr 两个结构。

 embstr 编码有以下好处:

  1. embstr 编码创建删除字符串对象只需操作一次内存
  2. 因为数据都保存在一块连续的内存, 所以这种编码的字符串对象比 raw 编码字符串对象能更好地利用缓存带来的优势。

3)列表对象

列表对象的编码可以是 ziplist 或者 linkedlist 。

当列表对象可以同时满足以下两个条件时, 列表对象使用 ziplist 编码:

  1. 列表对象保存的所有字符串元素的长度都小于 64 字节;
  2. 列表对象保存的元素数量小于 512 个;

不能满足这两个条件的列表对象需要使用 linkedlist 编码。

4)哈希对象

哈希对象的编码可以是 ziplist 或者 hashtable 。

当哈希对象可以同时满足以下两个条件时, 哈希对象使用 ziplist 编码:

  1. 哈希对象保存的所有键值对的键和值的字符串长度都小于 64 字节;
  2. 哈希对象保存的键值对数量小于 512 个;

不能满足这两个条件的哈希对象需要使用 hashtable 编码。

5)集合对象

集合对象的编码可以是 intset 或者 hashtable 。

当集合对象可以同时满足以下两个条件时, 对象使用 intset 编码:

  1. 集合对象保存的所有元素都是整数值;
  2. 集合对象保存的元素数量不超过 512 个;

不能满足这两个条件的集合对象需要使用 hashtable 编码。

6)有序集合对象

有序集合的编码可以是 ziplist 或者 skiplist 。

当有序集合对象可以同时满足以下两个条件时, 对象使用 ziplist 编码:

  1. 有序集合保存的元素数量小于 128 个;
  2. 有序集合保存的所有元素成员的长度都小于 64 字节;

不能满足以上两个条件的有序集合对象将使用 skiplist 编码。

这里多说两句,各个语言的对象其实都差不多,底层实现也就那几个,比如java中的容器,c++的STL。java的hashset就是一个哈希而已,hashmap就是k带了一个v,而”有序的“Treemap使用了红黑树这种有平衡性的搜索二叉树。

redis的有序集合并没有再采取hash+红黑树的操作,而是把平衡树换成了跳表,实际上性能真的没差多少,甚至有时比红黑树有优势,比如跳表的性能较为平均,红黑树攒了很多次不平衡要调整可能会带来资源需求的一个高峰,再加上跳表实现简单的优点,红黑树真的没什么优势。

并且就算是真的想用一种带平衡性的搜索树,现在竞赛也是用的华人之光发明的SB树。

有序集合的优点就是它的有序操作,比如拿最大最小值,红黑树时间o(logN),而哈希表只能一个一个遍历。缺点在于插入一个值的时间也是o(logN),跳表也是。而哈希表插入数是o(1).

要了解底层和这些优缺点



《三天给你聊清楚redis》第2天看看redis怎么被搞出来的(22036字)

三、单机实现

3.1、数据库概述

redis服务器将所有数据库都保存在redis/redisServer中,数组db存放所有数据库,每一项是一个redisdb结构。dbnum代表数据库数量。

客户端有一个指针指向当前数据库,可以切换,也就是移动指针。

3.1.1键空间

现在稍微介绍一下redisdb结构,它的字典保存了所有键值对

键空间的键也就是数据库的键, 每个键都是一个字符串对象。

键空间的值也就是数据库的值, 每个值可以是字符串对象、列表对象、哈希表对象、集合对象、有序集合对象

所有数据库的操作,添加一个键值对, 删除一个键值对, 获取某个键值对, 等等,都是通过对键空间字典进行操作来实现的。

3.1.2维护

读写键空间的时候,服务器会执行一些额外操作,比如:

  • 读一个键后(读操作写操作都要对键读取), 会根据键是否存在, 更新键空间命中(hit)次数或不命中(miss)次数。
  • 读取一个键后, 服务器会更新键的 LRU (最后一次使用)时间, 这个值可以用于计算键的闲置时间。
  • 如果服务器在读一个键时, 该键已经过期, 服务器会删除这个键, 然后执行其他操作。
  • 如果客户使用 WATCH 监视某个键,在对这个键进行修改之后, 会将这个键记为脏(dirty),让事务程序知到这个键被修改
  • 服务器每次修改一个键之后, 都会对脏(dirty)键计数器的值增一, 这个计数器会触发服务器的持久化以及复制操作执行
  • 如果服务器开启了数据库通知功能, 那么在对键进行修改之后, 服务器将按配置发送相应的数据库通知。

3.1.3时间

用户可以给某个键设置生存时间,过期时间是一个UNIX时间戳,到时间自动删除这个键。

redisdb结构的expires字典保存了所有的键的过期时间,我们称这个字典为过期字典。

3.1.4三种过期键删除策略

1)定时删除:创建一个定时器,到时间立即执行删除操作(对内存友好,因为能保证过期了立马删除,但是对cpu不友好)

2)惰性删除:键过期不管,每次获取键时检查是否过期,过期就删除(对cpu友好,但是只有在使用的时候才可能删除,对内存不友好)

3)定期删除:隔一段时间检查一次(具体算法决定检查多少删多少,需要合理设置)

3.1.5淘汰策略

当Redis占用内存超出最大限制 (maxmemory) 时,可采用如下策略 (maxmemory-policy) ,让Redis淘汰一些数据,以腾出空间继续提供读写服务 :

noeviction: 对可能导致增大内存的命令返回错误 (大多数写命令,DEL除外) ;

volatile-ttl: 在设置了过期时间的key中,选择剩余寿命 (TTL) 最短的key,将其淘汰;

volatile-lru: 在设置了过期时间的key中,选择最少使用的key (RU) ,将其淘汰;

volatile-random: 在设置了过期时间的key中,随机选择一些key,将其淘汰;

allkeys-1Lru: 在所有的key中,选择最少使用的key (LRU) ,将其淘汰;

allkeys-random: 在所有的key中,随机选择一些key,将其淘汰;


3.2、持久化

因为redis是内存数据库,他把数据都存在内存里,所以要想办法实现持久化功能。

3.2.1、RDB

RDB持久化可以手动执行,也可以配置定期执行,可以把某个时间的数据状态保存到RDB文件中,反之,我们可以用RDB文件还原数据库状态。

    生成

有两个命令可以生成RDB文件:

  • SAVE 命令由服务器进程直接执行保存操作,所以该命令会阻塞服务器,服务器不能接受其他指令。
  • BGSAVE 命令由子进程执行保存操作,所以该命令不会阻塞服务器,服务器可以接受其他指令。。

禁止BGSAVE和SAVE同时执行,也就是说执行其中一个就会拒绝另一个,这是为了避免父进程和子进程同时执行两个rdbsave,防止产生竞争条件。

    载入

    RDB载入工作是服务器启动时自动执行的。

    自动保存

用户可以通过save选项设置多个保存条件,服务器状态中会保存所有用 save 选项设置的保存条件,当任意一个保存条件被满足时,服务器会自动执行 BGSAVE 命令。

比如

save 900 1

save 300 10

满足:服务器在900秒之内被修改至少一次或者300秒内修改至少十次。就会执行BGSAVE。


当服务器启动时,用户可以通过指定配置文件或者传入启动参数来设置save选项,服务器会把条件放到一个结构体里,结构体有一个数组,保存了所有条件。


serverCron函数默认100毫秒检查一次,他会遍历数组依次检查,符合条件就会执行BGSAVE。

    RDB文件结构

一个完整 RDB 文件所包含的各个部分:

REDIS,长度5字节, 保存着 "REDIS" 五个字符。 通过这五个字符, 可以在载入文件时, 快速检查载入文件是否 RDB 文件。

db_version ,长度 4 字节, 它的值是一个字符串表示的整数, 这个整数记录了 RDB 文件的版本号

databases 部分包含着零个或任意多个数据库, 以及各个数据库中的键值对数据

EOF 常量的长度为 1 字节, 这个常量标志着 RDB 文件正文内容的结束

check_sum 是一个 8 字节长的无符号整数, 保存着一个校验和,以此来检查 RDB 文件是否出错或损坏

我并不想深入探究databases的组成。就是知道

  • RDB 文件是一个经过压缩的二进制文件,由多个部分组成。
  • 对于不同类型的键值对, RDB 文件会使用不同的方式来保存它们即可。

3.2.2、AOF

AOF持久化是通过保存服务器执行的命令来记录状态的。还原的时候再执行一遍即可。

功能的实现可以分为命令追加、文件写入、文件同步三个步骤。


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

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

Redis 服务器进程就是一个事件循环

循环中的文件事件负责接收客户端的命令请求, 以及向客户端发送命令回复,

而时间事件则负责执行像 serverCron 函数这样需要定时运行的函数。

因为服务器在处理文件事件时可能会执行写命令, 使得一些内容被追加到 aof_buf 缓冲区里面, 所以在服务器每次结束一个事件循环之前, 它都会调用 flushAppendOnlyFile 函数, 考虑是否需要将 aof_buf 缓冲区中的内容写入和保存到 AOF 文件里面, 这个过程可以用伪代码表示:

def eventLoop():

    while True:

        # 处理文件事件,接收命令请求以及发送命令回复
        # 处理命令请求时可能会有新内容被追加到 aof_buf 缓冲区中
        processFileEvents()

        # 处理时间事件
        processTimeEvents()

        # 考虑是否要将 aof_buf 中的内容写入和保存到 AOF 文件里面
        flushAppendOnlyFile()

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

值为 always 时, 服务器在每个事件循环都要将 aof_buf 缓冲区中的所有内容写入到 AOF 文件并且同步 AOF 文件, 所以 always 的效率最慢的一个, 但从安全性来说, always 是最安全的, 因为即使出现故障停机, AOF 持久化也只会丢失一个事件循环中所产生的命令数据。

值为 everysec 时, 服务器在每个事件循环都要将 aof_buf 缓冲区中的所有内容写入到 AOF 文件, 每隔超过一秒就要在子线程中对 AOF 文件进行一次同步: 从效率上来讲, everysec 模式足够快, 并且就算出现故障停机, 数据库也只丢失一秒钟的命令数据。

值为 no 时, 服务器在每个事件循环都要将 aof_buf 缓冲区中的所有内容写入到 AOF 文件, 至于何时对 AOF 文件进行同步, 则由操作系统控制。

因为处于 no 模式下的 flushAppendOnlyFile 调用无须执行同步操作, 所以该模式下的 AOF 文件写入速度总是最快的, 不过因为这种模式会在系统缓存中积累一段时间的写入数据, 所以该模式的单次同步时长通常是三种模式中时间最长的: 从平摊操作的角度来看,no 模式和 everysec 模式的效率类似, 当出现故障停机时, 使用 no 模式的服务器将丢失上次同步 AOF 文件之后的所有写命令数据。

    重写

AOF持久化是保存了一堆命令来恢复数据库,随着时间流逝,存的会越来越多,如果不加以控制,文件过大可能影响服务器甚至计算机。而且文件过大,恢复时需要时间也太长。

所以redis提供了重写功能,写出的新文件不会包含任何浪费时间的冗余命令。

接下来,我们就介绍重写的原理。

其实重写不会对现有的AOF文件进行读取分析等操作,而是通过当前服务器的状态来实现。

 # 假设服务器对键list执行了以下命令s;
127.0.0.1:6379> RPUSH list "A" "B"
(integer) 2
127.0.0.1:6379> RPUSH list "C"
(integer) 3
127.0.0.1:6379> RPUSH list "D" "E"
(integer) 5
127.0.0.1:6379> LPOP list
"A"
127.0.0.1:6379> LPOP list
"B"
127.0.0.1:6379> RPUSH list "F" "G"
(integer) 5
127.0.0.1:6379> LRANGE list 0 -1
1) "C"
2) "D"
3) "E"
4) "F"
5) "G"
127.0.0.1:6379> 

当前列表键list在数据库中的值就为["C", "D", "E", "F", "G"]。要使用尽量少的命令来记录list键的状态,最简单的方式不是去读取和分析现有AOF文件的内容,,而是直接读取list键在数据库中的当前值,然后用一条RPUSH list "C" "D" "E" "F" "G"代替前面的6条命令。

  • 伪代码表示如下
def AOF_REWRITE(tmp_tile_name):

  f = create(tmp_tile_name)

  # 遍历所有数据库
  for db in redisServer.db:

    # 如果数据库为空,那么跳过这个数据库
    if db.is_empty(): continue

    # 写入 SELECT 命令,用于切换数据库
    f.write_command("SELECT " + db.number)

    # 遍历所有键
    for key in db:

      # 如果键带有过期时间,并且已经过期,那么跳过这个键
      if key.have_expire_time() and key.is_expired(): continue

      if key.type == String:

        # 用 SET key value 命令来保存字符串键

        value = get_value_from_string(key)

        f.write_command("SET " + key + value)

      elif key.type == List:

        # 用 RPUSH key item1 item2 ... itemN 命令来保存列表键

        item1, item2, ..., itemN = get_item_from_list(key)

        f.write_command("RPUSH " + key + item1 + item2 + ... + itemN)

      elif key.type == Set:

        # 用 SADD key member1 member2 ... memberN 命令来保存集合键

        member1, member2, ..., memberN = get_member_from_set(key)

        f.write_command("SADD " + key + member1 + member2 + ... + memberN)

      elif key.type == Hash:

        # 用 HMSET key field1 value1 field2 value2 ... fieldN valueN 命令来保存哈希键

        field1, value1, field2, value2, ..., fieldN, valueN =\
        get_field_and_value_from_hash(key)

        f.write_command("HMSET " + key + field1 + value1 + field2 + value2 +\
                        ... + fieldN + valueN)

      elif key.type == SortedSet:

        # 用 ZADD key score1 member1 score2 member2 ... scoreN memberN
        # 命令来保存有序集键

        score1, member1, score2, member2, ..., scoreN, memberN = \
        get_score_and_member_from_sorted_set(key)

        f.write_command("ZADD " + key + score1 + member1 + score2 + member2 +\
                        ... + scoreN + memberN)

      else:

        raise_type_error()

      # 如果键带有过期时间,那么用 EXPIREAT key time 命令来保存键的过期时间
      if key.have_expire_time():
        f.write_command("EXPIREAT " + key + key.expire_time_in_unix_timestamp())

    # 关闭文件
    f.close()

    AOF后台重写


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


Redis不希望AOF重写会造成服务器无法处理请求,所以将AOF重写程序放到子进程(后台)里执行。这样处理的好处是: 
1)子进程进行AOF重写期间,主进程可以继续处理命令请求;
2)子进程带有主进程的数据副本,使用子进程而不是线程,可以避免在锁的情况下,保证数据的安全性。

还有一个问题,可能重写的时候又有新的命令过来,造成信息不对等,所以redis设置了一个缓冲区,重写期间把命令放到重写缓冲区。

  总结
AOF重写的目的是为了解决AOF文件体积膨胀的问题,使用更小的体积来保存数据库状态,整个重写过程基本上不影响Redis主进程处理命令请求;
AOF重写其实是一个有歧义的名字,实际上重写工作是针对数据库的当前状态来进行的,重写过程中不会读写、也不适用原来的AOF文件;
AOF可以由用户手动触发,也可以由服务器自动触发。


3.3、事件

redis服务器是一个事件驱动程序。

需要处理两类事件:

1)文件事件:redis是通过套接字与客户端或者其他服务器连接的,而文件事件就是服务器对套接字操作的抽象。

2)时间事件:服务器对一些定时操作的抽象。

3.3.1、文件事件

redis基于reactor模式开发了自己的网络事件处理器,这个处理器被称作文件事件处理器,它使用IO多路复用程序来同时监听多个套接字, 并根据套接字目前执行的任务来为套接字关联不同的事件处理器,当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关闭(close)等操作时, 与操作相对应的文件事件就会产生, 这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。

虽然文件事件处理器以单线程方式运行, 但通过使用 I/O 多路复用程序来监听多个套接字, 文件事件处理器既实现了高性能的网络通信模型, 又可以很好地与 Redis 服务器中其他同样以单线程方式运行的模块进行对接, 这保持了 Redis 内部单线程设计的简单性。

文件事件处理器的构成:

I/O 多路复用程序负责监听多个套接字, 并向文件事件分派器传送那些产生了事件的套接字。

 I/O 多路复用程序会把所有产生事件的套接字放到一个队列, 以有序(sequentially)、同步(synchronously)、每次一个套接字的方式,向文件事件分派器传送套接字。

I/O 多路复用程序可以监听多个套接字的 ae.h/AE_READABLE 事件和 ae.h/AE_WRITABLE 事件

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

2)当套接字变得可写时(客户端对套接字执行 read 操作), 套接字产生 AE_WRITABLE 事件。

如果一个套接字又可读又可写的话, 那么服务器将先读套接字, 后写套接字。

下面介绍各种处理器:

1)连接应答处理器:服务器进行初始化时, 程序会将连接应答处理器和服务器监听套接字的 AE_READABLE 事件关联, 当有客户端连接(connect)服务器监听套接字的时候, 套接字就会产生 AE_READABLE 事件, 引发连接应答处理器执行, 并执行相应的套接字应答操作。

2)命令请求处理器:客户端连接到服务器后, 服务器会将客户端套接字的 AE_READABLE 事件和命令请求处理器关联起来, 当客户端发送命令请求时, 套接字就会产生 AE_READABLE 事件, 引发命令请求处理器执行, 并执行相应的套接字读入操作

3)命令回复处理器:服务器有命令回复需要传送给客户端, 服务器会将客户端套接字的 AE_WRITABLE 事件和命令回复处理器关联起来, 当客户端准备好接收服务器传回的命令回复时, 就会产生 AE_WRITABLE 事件, 引发命令回复处理器执行, 并执行相应的套接字写入操作。

一次完整的连接事件实例:

3.3.2、时间事件

redis时间事件可以分为两类:定时事件、周期性事件,他们的特点就像他们的名字一样。

而一个时间事件主要有三部分:

id:服务器为时间事件创建的全局唯一id,按时间递增,越新的越大

when:unix时间戳,记录到达时间

timeProc:时间事件处理器,是一个函数,时间事件到达时,服务器就会调用处理器来处理事件。

目前版本的redis只使用周期性事件

来看看实现:

服务器把所有时间事件放在一个链表中,每当时间事件执行器执行时,它就遍历链表,调用相应的事件处理器。

但是注意:链表是无序的,不按when属性来排序,当时间事件执行器运行时,必须遍历整个链表。但是,无序链表并不影响时间事件处理器的性能,因为在目前版本中,redis服务器只使用serverCron一个时间事件,就算在benchmark模式下也只有两个事件,服务器几乎是把链表退化成指针使用了。


3.3.3、事件的调度和执行


文件事件和时间事件之间是合作关系, 服务器会轮流处理这两种事件,对两种事件的处理都是同步、有序、原子地进行的,处理事件的过程中也不会进行抢占,所以时间事件的实际处理时间通常会比设定的到达时间晚一些

大概流程为:

是否关闭服务器?---->等待文件事件产生---->处理已经产生的文件事件---->处理已经达到的时间事件---->是否关闭服务器?........


3.4、客户端

redis服务器是典型的一对多服务器,通过使用由IO多路复用技术实现的文件事件处理器,redis服务器使用了单线程单进程的方式来处理请求。

3.4.1客户端的属性

  • 描述符

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

typedef struct redisClient {
    // ...

    int fd;

    // ...
} redisClient;
  • 伪客户端fd 值为 -1 : 伪客户端处理的命令请求来源于 AOF 文件或者 Lua 脚本, 而不是网络, 所以这种客户端不需要套接字连接。
  • 普通客户端 fd 值为大于 -1 的整数: 普通客户端使用套接字来与服务器进行通讯, 所以服务器会用 fd 属性来记录客户端套接字的描述符。 


  • 标志

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

typedef struct redisClient {
    // ...

    int flags;

    // ...

} redisClient;

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

flags = <flag>

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

flags = <flag1> | <flag2> | ...

每个标志使用一个常量表示, 一部分标志记录了客户端的角色:

  • 在主从服务器进行复制操作时, 主服务器会成为从服务器的客户端, 而从服务器也会成为主服务器的客户端。 REDIS_MASTER 标志表示客户端代表的是一个主服务器, 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 标志。

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

以上提到的所有标志都定义在 redis.h 文件里面。

PUBSUB 命令和 SCRIPT LOAD 命令的特殊性

通常情况下, Redis 只会将那些对数据库进行了修改的命令写入到 AOF 文件, 并复制到各个从服务器: 如果一个命令没有对数据库进行任何修改, 那么它就会被认为是只读命令, 这个命令不会被写入到 AOF 文件, 也不会被复制到从服务器。

以上规则适用于绝大部分 Redis 命令, 但 PUBSUB 命令和 SCRIPT_LOAD 命令是其中的例外。

PUBSUB 命令虽然没有修改数据库, 但 PUBSUB 命令向频道的所有订阅者发送消息这一行为带有副作用, 接收到消息的所有客户端的状态都会因为这个命令而改变。 因此, 服务器需要使用 REDIS_FORCE_AOF 标志, 强制将这个命令写入 AOF 文件, 这样在将来载入 AOF 文件时, 服务器就可以再次执行相同的 PUBSUB 命令, 并产生相同的副作用。

SCRIPT_LOAD 命令的与 PUBSUB 命令类似

3.4.2输入缓冲区

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

typedef struct redisClient {

    // ...

    sds querybuf;

    // ...

} redisClient;

 redisClient 实例:

3.4.3命令相关

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

typedef struct redisClient {

    // ...

    robj **argv;

    int argc;

    // ...

} redisClient;

argv 属性是一个数组, 数组中的每个项都是一个字符串对象: 其中 argv[0] 是要执行的命令, 而之后的其他项则是传给命令的参数。

argc 属性则负责记录 argv 数组的长度。

3.3.4实现函数


当服务器从协议内容中分析并得出 argv 属性和 argc 属性的值之后, 服务器将根据项 argv[0] 的值, 在命令表中查找命令所对应的命令实现函数。

(命令表是一个字典,字典的键是一个 SDS 结构, 保存了命令的名字, 字典的值是命令所对应的 redisCommand 结构, 这个结构保存了命令的实现函数、 命令的标志、 命令应该给定的参数个数、 命令的总执行次数和总消耗时长等统计信息。)

3.3.5、输出缓冲区

执行命令所得的命令回复会被保存在客户端状态的输出缓冲区里面, 每个客户端都有两个输出缓冲区:

  • 固定大小的缓冲区用于保存那些长度比较小的回复, 比如 OK 、简短的字符串值、整数值、错误回复,等等。
  • 可变大小的缓冲区用于保存那些长度比较大的回复, 比如一个非常长的字符串值, 一个由很多项组成的列表, 一个包含了很多元素的集合, 等等。

3.3.6、其它

客户端状态的 authenticated 属性用于记录客户端是否通过了身份验证,还有几个和时间有关的属性,叙述是一件挺无聊的事情,不再写。


3.4、命令的执行过程

3.4.1发送命令请求

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

3.4.2读取命令请求

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

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

3.4.3命令执行器:查找命令实现

命令执行器要做的第一件事就是根据客户端状态的 argv[0] 参数, 在命令表(command table)中查找参数所指定的命令, 并将找到的命令保存到客户端状态的 cmd 属性里面。

命令表是一个字典, 字典的键是一个个命令名字,比如 "set" 、 "get" 、 "del" ,等等; 而字典的值是一个个 redisCommand 结构, 每个 redisCommand 结构记录了一个 Redis 命令的实现信息。

命令名字的大小写不影响命令表的查找结果

因为命令表使用的是大小写无关的查找算法, 无论输入的命令名字是大写、小写或者混合大小写, 只要命令的名字是正确的, 就能找到相应的 redisCommand 结构。

比如说, 无论用户输入的命令名字是 "SET" 、 "set" 、 "SeT" 又或者 "sEt" , 命令表返回的都是同一个 redisCommand 结构。

redis> SET msg "hello world"
OK

redis> set msg "hello world"
OK

redis> SeT msg "hello world"
OK

redis> sEt msg "hello world"
OK

3.4.4命令执行器:执行预备操作

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

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

当完成了以上预备操作之后, 服务器就可以开始真正执行命令了。

3.4.5命令执行器:调用命令的实现函数

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

// client 是指向客户端状态的指针

client->cmd->proc(client);

因为执行命令所需的实际参数都已经保存到客户端状态的 argv 属性里面了, 所以命令的实现函数只需要一个指向客户端状态的指针作为参数即可。

3.4.6命令执行器:执行后续工作

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

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

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

3.4.7将命令回复发送给客户端

前面说过, 命令实现函数会将命令回复保存到客户端的输出缓冲区里面, 并为客户端的套接字关联命令回复处理器, 当客户端套接字变为可写状态时, 服务器就会执行命令回复处理器, 将保存在客户端输出缓冲区中的命令回复发送给客户端。

当命令回复发送完毕之后, 回复处理器会清空客户端状态的输出缓冲区, 为处理下一个命令请求做好准备。

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

当客户端接收到协议格式的命令回复之后, 它会将这些回复转换成人类可读的格式, 并打印给用户观看(假设使用的是 Redis 自带的 客户端)

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。