《Redis设计与实现》笔记4—独立功能的实现

一、发布与订阅

Redis的发布与订阅功能由PUBLISH、SUBSCRIBE、PSUBSCRIBE等命令组成。通过执行SUBSCRIBE命令,客户端可以订阅一个或多个频道,从而成为这些频道的订阅者(subscriber):每当有其他客户端向被订阅的频道发送消息时,频道的订阅者都会接收到这条消息。

除了订阅频道外,客户端还可以通过执行PSUBSCRIBE命令订阅一个或多个模式,从而成为这些模式的订阅者:每当有其他客户端向某个频道发送消息时,消息不仅会发送给订阅这个频道的订阅者,还会发送给所有与这个频道相匹配的模式的订阅者。

1、频道的订阅与退订

当一个客户端执行SubScribe命令订阅某个或某些频道时,这个客户端与被订阅频道之间就建立起了一种订阅关系。Redis将所有频道的订阅关系保存在服务器状态的pubsub_channels字典里面,该字典的键是某个被订阅的频道,而键的值是一个链表,链表里面记录了所有订阅这个频道的客户端。示例如下:

1)、订阅频道

当客户端执行SubScribe命令订阅某个或某些频道时,服务器会将客户端与被订阅的频道在pubsub_channels字典中进行关联。根据频道是否已经有其他订阅者,关联操作分为两种执行情况:

  • 频道已经有其他订阅者,那么pubsub_channels字典中必然有相应的订阅者链表,程序要做的就是将客户端添加到订阅者链表的末尾;

  • 频道还未有任何订阅者,那么它必然不存在于pubsub_channels字典,程序会在pubsub_channels字典中为频道创建一个键,并将这个键的值设置为空链表,然后将客户端添加到链表,成为链表的第一个元素

2)、退订频道

UnSubScribe命令的行为和SubScribe命令的行为正好相反,当一个客户端退订某个或某些频道时,服务器将从pubsub_channels中解除客户端与被退订频道之间的关联:

  • 程序会根据被退订频道的名字,在pubsub_channels字典中找到频道对应的订阅者链表,然后从订阅者链表中删除退订客户端的信息;
  • 如果删除退订客户端后,频道的订阅者链表变为了空链表,说明频道已经没有对应的订阅者了,程序将从pubsub_channels字典中删除频道对应的键

2、模式的订阅与退订

服务器将所有频道的订阅关系保存在服务器状态的pubsub_channels属性中,与之类似,服务器将所有模式的订阅关系都保存在服务器状态的pubsub_patterns属性里面。pubsub_patterns属性是一个链表,链表中的每个节点都包含着一个pubsubPatterns结构,这个结构的pattern属性记录了被订阅的模式,而client属性记录了订阅模式的客户端。示例如下:

1)、订阅模式

每当客户端执行PSubScribe命令订阅某个或某些命令时,服务器会对每个被订阅的模式执行以下两个操作:

  • 新建一个pubsubPatterns结构,将结构的parttern属性设置为被订阅的模式,client属性设置为订阅模式的客户端;
  • 将pubsubPatterns结构添加到pubsub_patterns链表的表尾

2)、退订模式

模式的退订命令PunSubScribe时PSubScribe命令的相反操作,当一个客户端退订某个或某些模式时,服务器将在pubsub_patterns链表中查找并删除那些属性为退订模式,并且client属性为执行退订命令的客户端的pubsubPatterns结构

3、发送消息

当一个Redis客户端执行PUBLISH <channel> <message>命令将消息message发送给频道channel时,服务器需要执行以下两个动作:

  • 将message发送给channel频道的所有订阅者;
  • 如果有一个或多个模式pattern与频道channel向匹配,消息message将发送给paettern模式的订阅者;

1)、将消息发送给频道订阅者

因为服务器状态中的pubsub_channels字典记录了所有频道的订阅关系,所以为了将消息发送给channel频道的所有订阅者,PUBLISH命令要做的就是在遍历pubsub_channels字典,找到频道channel的订阅者名单(一个链表),然后将消息发送给名单上的所有客户端

2)、将消息发送给模式订阅者

因为服务器状态中的pubsub_patterns链表记录了所有模式的订阅关系,所以为了将消息发送给所有与channel频道相匹配的模式的订阅者,PUBLISH命令要做的就是在遍历pubsub_patterns链表,查找与频道channel相匹配的模式,并将消息发送给订阅了这些模式的客户端

4、查看订阅信息

PUBSUB命令可以用来查看频道或者模式的相关信息

1)、PubSub Channels

PubSub Channel [pattern]子命令用于返回服务器当前被订阅的频道,其中pattern参数是可选的:

  • 如果给定pattern参数,那么命令将返回服务器当前被订阅的频道中那些与pattern模式匹配的频道;
  • 如果不给定pattern参数,那么服务器将返回服务器当前被订阅的所有频道;

该命令是通过遍历服务器pubsub_channels字典的所有键,然后记录并返回所有符合条件的频道来实现的;

2)、PubSub NumSub

PubSub Number [channel-1 channel-2 ... channel-n]子命令接受任意多个频道作为输入参数,并返回这些频道的订阅者数量。这个命令是通过在pubsub_channels字典中找到频道对应的订阅者链表,然后返回订阅者链表的长度(即频道订阅数量)来实现的。

3)、PubSub Numpat

PubSub Numpat子命令用于返回服务器当前被订阅模式的数量。该命令是通过返回pubSub_patterns链表的长度来实现的,该链表的长度就是服务器被订阅模式的数量

二、事务

Redis通过Multi、Exec、Watch等命令来实现事务功能。事务提供一种将多个命令请求打包、然后一次性顺序执行的机制,并且在事务执行期间,服务器不会中断事务去执行其他客户端的命令请求,它会将命令执行完毕后,再入处理其他客户端的命令请求。

1、事务的实现

事务从开始到结束通常会经历以下三个阶段:①事务开始;②命令入队;③事务执行

1)、事务开始

Multi命令的执行标志着事务的开始,该命令可以将执行该命令的客户端从非事务状态切换为事务状态,该命令是通过在客户端的flags属性中打开Redis_Multi标识来完成的

2)、命令入队

当一个客户端处于非事务状态时,这个客户端发送的命令会被服务器立即执行;而当客户端切换到事务状态后,服务器会根据这个客户端发来的不同命令执行不同的操作:

  • 如果客户端发送的命令为Exec、Discard、Watch、Muti命令中的一个,那么服务器会立即执行这个命令;
  • 如果客户端发送的命令不属于上述四个命令,那么服务器并不会立即执行这个命令,而是将这个命令放入一个事务队列里面,然后向客户端返回Queue回复;

3)、事务队列

每个Redis客户端都有自己的事务状态,这个事务状态保存在客户端状态的mstate属性里面。事务状态包含一个事务队列,以及一个已入队命令的计数器。

事务队列是一个multiCmd类型的数组,数组中的每个multiCmd结构都保存了一个已入队命令的相关信息,包含指向命令实现函数的指针、命令参数以及参数的数量。

事务队列以先进先出的方式保存入队的命令,较先入队的命令会被存放到数组的前面,较后入队的命令会放到数组的后面。

4)、执行事务

当一个处于事务状态的客户端向服务器发送Exec命令时,这个Exec命令将立即被服务器执行。服务器会遍历这个客户端的事务队列,执行队列中保存的所有命令,最后将执行命令所得的结果全部返回给客户端。

2、Watch命令的实现

Watch命令是一个乐观锁,它可以在Exec命令执行前,监视任意数量的数据库键,并在Exec命令执行时,检查被监视的键是否至少有一个已经被修改,如果被修改,服务器将拒接执行事务,并向客户端返回代表事务执行失败的空回复

1)、使用Watch命令监视数据库键

每个Redis数据库都保存着一个watched_keys字典,这个字典的键是某个被watch命令监视的数据库键,而字典的值则是一个链表,链表中记录了所有监视相应数据库键的客户端。通过watched_keys字典,服务器可以清楚的知道哪些数据库键正在被监视,以及哪些客户端正在监视这些数据库键。执行完Watch命令之后,客户端将与watched_keys字典中被监视的键相关联。

2)、监视机制的触发

所有对数据库进行修改的命令,在执行之后都会调用multi.c/touchWatchKey函数对watched_keys字典进行检查,查看是否有客户端正在监视刚刚被命令修改过的数据库键。如果有,touchWatchKey函数会将监视某个键的客户端的Redis_Dirty_Cas标识打开,表示该客户端的事务安全性已经被破坏。

3)、判断事务是否安全

当服务器接受到一个客户端发送的Exec命令时,服务器会根据这个客户端是否打开了Redis_Dirty_Cas标识来决定是否执行事务:

  • 如果客户端的Redis_Dirty_Cas标识已经被打开,那么说明客户端所监视的键当中至少存在一个键已经被修改,这意味着事务不再安全,所以服务器会拒绝执行客户端提交的事务;
  • 如果客户端的Redis_Dirty_Cas标识没有被打开,那么说明客户端监视的键没有被修改过,事务是安全的,服务器将执行这个事务;

3、事务的ACID性质

在Redis中,事务总是原子性、一致性和隔离性,并且当Redis运行在某种特定的持久化模式下时,事务也具有耐久性

1)原子性

事务的原子性是指,数据库将事务中的多个操作当作一个整体来执行,要么执行事务中的所有操作,要么一个都不执行。

Redis的事务和传统的关系型数据库事务的最大区别在于,Redis不支持事务回滚机制,即使事务队列中的某个命令在执行期间出现了错误,整个事务也会继续执行下去,直到事务队列中的所有命令都执行完毕为止。

2)一致性

事务的一致性是指,如果数据库在执行事务之前是一致的,那么在事务执行之后,无论事务是否成功,数据库也应该是一致的。一致指的是数据符合数据库本身的定义和要求,没有包含非法或无效的错误数据。

Redis通过错误检测和简单的设计来保证事务的一致性:

  • 入队错误:如果一个事务在入队过程中,出现了命令不存在,或者命令的格式不正确的情况,那么Redis将拒绝执行这个事务;
  • 执行错误:执行过程中发生的错误都是一些不能子啊入队时被服务器发现的错误,这些错误只会在命令实际执行时被触发;即使在事务的执行过程中发生了错误,服务器也不会中断事务的执行,它会继续执行事务中余下的其他命令,已执行的命令也不受错误命令的影响;
  • 服务器停机:如果服务器在执行事务的过程中停机,那么根据服务器所使用的持久化模式,可能出现以下情况:①服务器运行在无持久化的内存模式下,重启后数据库将是空白的,因为数据总是一致的;②服务器运行在RDB模式下,那么事务中途停机也一样不会造成数据的不一致,因为服务器可以根据现有的RDB文件来恢复数据,从而将数据库还原到一个一致的状态;③服务器运行在AOF模式下,情况同RDB模式

3)隔离性

事务的隔离性是指,即使数据库中有多个事务并发执行,各个事务之间也不会互相影响,并发状态下的执行结果与串行执行事务的结果完全一致。因为Redis使用单线程的方式来执行事务,并且服务器保证执行期间不会对事务进行中断,因为Redis事务总是以串行的模式运行

4)耐久性

事务的耐久性是指,当一个事务执行完毕后,执行这个事务的结果会被保存到永久性存储介质中,即使服务器在执行完事务之后停机,执行事务所得的结果也不会丢失。

Redis没有为事务提供额外的持久化操作,所以Redis事务的耐久性由Redis所使用的持久化模式决定:

  • 服务器在无持久化的内存模式下运作时,事务不具有耐久性,一旦服务器停机,所有数据都将丢失;
  • 服务器在RDB持久化模式下运行时,服务器只有在特定的条件下才会对数据库进行保存操作,而且保存的数据并不能保证事务数据是最新的,所以RDB持久化模式下事务不具有耐久性;
  • 服务器在AOF持久化模式下运作时,并且appendfsync选项的值为always时,程序总会在执行命令之后调用同步函数将数据保存到本地硬盘中,因而这种模式下务具有耐久性;
  • 服务器在AOF持久化模式下运作时,并且appendfsync选项的值为everysec时,程序每秒同步一次,但若停机恰好在等待同步的一秒内,可能造成数据的丢失,所以这种模式下事务不具有耐久性;
  • 服务器在AOF持久化模式下运作时,并且appendfsync选项的值为no时,程序会交由操作系统决定何时将命令数据保存到本地,因而同样会有数据丢失的可能,所以这种模式下事务不具有耐久性;

注意:no-appendfsync-on-rewrite配置打开时,在执行BGSAVE或BGREWRITEAOF命令时服务器会暂停对AOF文件的同步,所以该选项会对事务的耐久性造成影响;在事务Exec提交前使用SAVE命令可以保证事务的耐久性,但效率太低,一般不使用

三、Lua脚本

Redis从2.6开始引入对Lua脚本的支持,通过在服务器种嵌入Lua环境,Redis客户端可以使用Lua脚本,直接在服务器端原子的执行多个Redis命令。本章将对脚本管理命令SCRIPT FLUSH、SCRIPT EXISTS、SCRIPT LOAD、SCRIPT KILL命令进行介绍。

1、创建并修改Lua环境

为了在Redis服务器种执行Lua脚本,Redis在服务器内嵌了一个Lua环境,并对这个Lua环境进行了一系列修改,确保这个Lua环境可以满足Redis服务器的需要。Redis创建并修改Lua环境的整个过程如下:

  1. 创建一个基础的Lua环境;
  2. 载入多个函数库到Lua环境中;
  3. 创建全局表格Redis,表格包含了对Redis进行操作的函数;
  4. 使用Redis自制的随机函数替换Lua原有的带副作用的随机函数;
  5. 创建排序辅助函数,Lua环境使用这个辅助函数对一部分Redis命令的结果进行排序,从而消除这些命令的不确定性;
  6. 创建redis.pcall函数的错误报告辅助函数,来提供更为详细的错误信息;
  7. 对Lua环境中的全局环境进行保护,防止用户在执行Lua脚本的过程中带入额外的全局变量;
  8. 将完成修改的Lua环境保存到服务器状态的Lua属性中,等待执行服务器传递的Lua脚本

1)创建Lua环境

服务器调用Lua的C API函数lua_open,创建一个新的Lua环境,该环境只是一个基础的环境,下面将对这个环境进行一系列修改

2)载入函数库

服务器会将以下函数库载入到Lua环境中,供其使用:

3)创建Redis全局表格

服务器将在Lua环境中创建一个Redis表格,并将它设为全局变量,这个表格包含以下函数。其中最重要的是redis.call函数和redis.pcall函数,通过这两个函数,用户可以直接在Lua脚本中执行Redis命令

4)使用Redis自制的随机函数替换Lua原有的随机函数

之前载入函数库的math函数库中,用于生成随机数的math.random和math.randomseed函数都是带有副作用的,因而Redis使用自带的随机函数来替换它们,替换后的两个函数有如下特征:

  • 对于相同的Seed,math.Random总产生相同的随机数序列,且函数为纯函数;
  • 除非在脚本中使用math.randomseed显式的修改seed,否则每次运行脚本,Lua环境都使用固定的math.randomseed(0)初始化seed

5)创建排序辅助函数

对于一个集合键来说,因为集合元素的排列是无序的,所以即使集合内的元素完全相同,它们的输出结果也可能不同。为了消除这种不确定性,服务器会为Lua环境创建一个排序辅助函数_redis_compare_helper,当Lua脚本执行完一个带有不确定性的命令后,程序会使用这个函数作为对比函数,自动调用redis.Sort函数对命令的返回值进行排序后输出

6)创建redis.pcall函数的错误报告辅助函数

服务器将为Lua环境创建一个名为_redis_err_handler的错误处理函数,当Lua脚本调用redis.pcall函数执行redis命令且出现错误时,错误处理函数将会打印出错代码的来源和行数

7)保护Lua的全局环境

服务器将对Lua环境中的全局环境进行保护,确保传入服务器的脚本不会因忘记使用local关键字而将额外的全局变量添加到Lua环境中。需要注意的是,执行Lua脚本时,要避免错误修改了已存在的全局变量,因为Redis并未禁止用户修改已存在的全局变量。

8)将Lua环境保存到服务器状态的lua属性

在执行完上述步骤后,Redis服务器就已经完成了对Lua环境的修改操作,最后一步就是将Lua环境和服务器状态的lua属性关联起来。因为Redis使用串行化的方式执行Redis命令,所以最多只会有一个脚本能够被放进Lua环境中运行,因此整个Redis服务器只需要创建一个Lua环境即可。

2、Lua环境协作组件

除了创建并修改Lua环境外,Redis服务器还创建了两个用于与Lua环境进行协作的组件,分别是负责执行Lua脚本中的Redis命令的伪客户端,以及用于保存Lua脚本的lua_scripts字典

1)伪客户端

为了执行Lua脚本中包含的Redis命令,Redis服务器为Lua环境创建了一个伪客户端,由这个伪客户端负责处理Lua脚本中包含的所有Redis命令。Lua脚本使用redis.call函数或redis.pcall函数执行一个Redis命令,需要完成以下步骤:

  1. Lua环境将要redis.call函数或redis.pcall函数要执行的命令传送给伪客户端;
  2. 伪客户端将脚本要执行的命令传给命令执行器;
  3. 命令执行器执行伪客户端传送的命令,并将执行结果返回给伪客户端;
  4. 伪客户端接受命令执行器返回的执行结果,并将命令的结果返回给Lua环境;
  5. Lua环境接受到命令结果之后,将结果返回给redis.call函数或redis.pcall函数
  6. 接受到结果的redis.call函数或redis.pcall函数将命令结果作为函数返回值返回给脚本的调用者;

2)lua_scripts字典

lua_scripts字典的键是为某个Lua脚本的SHA1校验和,字典的值是SHA1校验和对应的Lua脚本。Redis服务器将所有被EVAL命令执行过的Lua脚本,以及所有被SCRIPT LOAD命令载入过的Lua脚本保存到lua_scripts字典中。

lua_scripts字典有两个作用,一是实现SCRIPT EXISTS命令,二是实现脚本复制功能。

3、EVAL命令的实现

EVAL命令的执行过程分为以下三步:①根据客户端给定的Lua脚本,在Lua环境中定义一个Lua函数;②将客户端给定的脚本保存到lua_scripts字典中,等待将来使用;③执行刚刚在Lua环境中定义的函数,以此来执行客户端给定的Lua脚本

1)定义脚本函数

当客户端向服务器发送EVAL命令,要求执行某个Lua脚本时,服务器首先要做的就是在Lua环境中,为传入的脚本定义一个和这个脚本对应的Lua函数,函数的名字有f_为前缀加上脚本的SHA1校验和(四十个字符)组成,函数体则时脚本本身。

使用函数保存客户端传入的脚本有以下好处:

  • 执行脚本的步骤非常简单,只需要直接调用函数就可以了;
  • 通过函数的局部性让Lua环境保持清洁,减少了垃圾回收的工作量,避免使用全局变量的情况;
  • 若某个脚本对应的函数在Lua环境中被定义过一次,那么只需要使用脚本的SHA1校验和,就可以在不知道脚本本身的情况下,直接调用Lua函数来执行脚本,这也是EVALSHA命令的实现原理

2)将脚本保存到lua_scripts字典

服务器将在lua_scripts字典中新增一个键值对,其中键位Lua脚本的SHA1校验和,值为Lua脚本本身。

3)执行脚本函数

脚本保存完成后,服务器还需要进行一些设置一些钩子,传入参数等操作才能正式执行脚本,过程如下:

  1. 将Eval命令中传入的键名参数和脚本参数分别保存到KEYS数组和ARGV数组,并将这两个数组作为全局变量传入Lua环境中;
  2. 为Lua环境装载超时处理钩子,可以在脚本出现超时运行的情况下,让客户端通过Script Kill命令停止脚本,或者通过SHUTDOWM命令关闭服务器;
  3. 执行脚本函数;
  4. 移除之前载入的超时钩子;
  5. 将执行脚本函数得到的结果保存到客户端状态的输出缓冲区中,等待服务器将结果返回给客户端;
  6. 对Lua环境进行垃圾回收操作;

4、EVALSHA命令的实现

同本章3.1介绍的内容,服务器会根据客户端输入的SHA1校验和,检查函数是否存在于Lua环境中,若存在则直接调用执行,将结果返回

5、脚本管理命令的实现

除了Eval命令和EVALSHA命令外,Redis中与Lua脚本有关的命令还有4个,分别是SCRIPT FLUSH、SCRIPT EXISTS、SCRIPT LOAD、SCRIPT KILL,下面将对它们进行介绍

1)SCRIPT FLUSH

SCRIPT FLUSH命令用于清除服务器中所有和Lua脚本相关的信息,这个命令会释放并重建lua_scripts字典,关闭现有的Lua环境并重新创建一个新的Lua环境

2)SCRIPT EXISTS

SCRIPT EXISTS命令根据输入的SHA1校验和,检查校验和对应的脚本是否存在于服务器中,即是否存在于lua_scripts字典中来实现的。存在用1表示,不存在用0表示(这里只用到了lua_scripts字典的键,它的值实际上是为了实现脚本复制功能而存在的,后续介绍)

3)SCRIPT LOAD

SCRIPT LOAD命令做的事和EVAL命令执行脚本的前两步一样,即在Lua环境中为脚本创建对应的函数,再将脚本保存到lua_scripts字典中。完成这些后,客户端可以使用EVALSHA命令来执行前面保存的脚本。

4)SCRIPT KILL

如果服务器设置了lua_time_limit配置选项,那么每次执行Lua脚本前,服务器都会再Lua环境中设置一个超时处理钩子。它会在脚本运行期间,检查脚本的运行事件,一旦超出了lua_time_limit选项设置的时长,钩子将定期再脚本运行的间隙,查看是否有SCRIPT KILL命令或SHUTDOWN命令到达服务器。

  • 如果超时处理的脚本未执行过写操作,那么客户端可以通过SCRIPT KILL命令让服务器停止执行该脚本,并返回一个错误回复。处理完SCRIPT KILL命令后,服务器将继续运行。
  • 如果超时处理的脚本执行过写操作,那么客户端只能使用SHUTDOWN nosave命令来停止服务器,防止不合法的数据写入数据库。

6、脚本复制

与其他普通的Redis命令一样,当服务器运行在复制模式下时,具有写性质的脚本命令也会被复制到从服务器,这些命令包括EVAL命令、EVALSHA命令、SCRIPT FLUSH命令以及SCRIPT LOAD命令

1)复制EVAL命令、SCRIPT FLUSH命令以及SCRIPT LOAD命令

Redis复制复制EVAL命令、SCRIPT FLUSH命令以及SCRIPT LOAD命令的方法和复制其他普通Redis命令的方法一样,当主服务器执行完以上三个命令中的一个时,主服务器架构直接将被执行的命令传播给从服务器:

  • 对于EVAL命令来说,在主服务器执行的Lua脚本同样会在从服务器中执行;
  • 对于SCRIPT FLUSH命令来说,从服务器接受到该命令后和主服务器一样会重置Lua环境;
  • 对于SCRIPT LOAD命令来说,从服务器会载入和主服务器一样的Lua脚本

2)复制EVALSHA命令

EVALSHA命令是所有与Lua脚本有关的命令中操作最复杂的,因为主从服务器载入Lua脚本的情况可能有所差异,所以主服务器没有像上述的三个命令一样直接将EVALSHA命令传播给从服务器。主服务上成功执行的EVALSHA命令在从服务执行时可能会出现脚本未找到的情况。另外不同的从服务器载入Lua脚本的情况也可能不同,因而不能直接传播EVALSHA命令给从服务器。

为了防止上述的情况,Redis要求主服务器在传播EVALSHA命令时,必须要确保EVALSHA命令要执行的脚本已经被所有从服务器载入过,否则主服务器会将EVALSHA命令转换成等价的EVAL命令,然后传播EVAL命令来代替EVALSHA命令。传播EVALSHA命令或将EVALHA命令转换为EVAL命令,都需要用到服务器状态的lua_script字典和repl_scriptcache_dict字典

  1. 判断传播EVALSHA命令是否安全的办法:主服务器使用服务器状态的repl_scriptcache_dict字典记录自己已经传播给所有从服务器的脚本信息;repl_scriptcache_dict字典的键是Lua脚本的SHA1校验和,值对应为null:①当一个校验和出现在repl_scriptcache_dict字典时,说明这个校验和对应的Lua脚本已经传播给了所有从服务器,主服务器可以直接向从服务器传播EVALSHA命令,从服务器也可以避免找不到脚本的错误情况;②而如果一个脚本的SHA1校验和存在于lua_scripts字典,但是不存在于repl_scriptcacha_dict字典,那么说明校验和对应的Lua脚本已经被主服务器载入,但是没有传播给所有从服务器,如果尝试传播这条EVALSHA命令,至少一个从服务器会出现脚本找不到的情况。

  2. 清空repl_scriptcacha_dict字典:每当主服务器添加一个从服务器,主服务器都会清空自己的repl_scriptcache_dict字典,这是因为新的从服务器的出现,导致repl_scriptcacha_dict字典中记录的信息将不再是被所有从服务器载入过的状态。

  3. EVALSHA命令转换为EVAL命令的方法:通过EVALSHA命令指定的SHA1校验和,以及lua_scripts字典保存的Lua脚本,服务器可以将一个EVALSHA命令EVALSHA <sha1> <numKeys> [key...] [arg...]转换为等价的EVAL命令EVAL <script> <numkeys> [key...] [arg...];步骤如下:①根据SHA1校验和在lus_scripts字典中查找校验和对应的Lua脚本②将原来的EVALSHA命令请求改写成EVAL命令请求,并且将校验和改成脚本script,其他numkeys、key、arg参数不变。转换传播给所有从服务器后,系统会将被传播脚本的SHA1校验和(原本的EVALSHA命令校验和)添加到repl_scriptcache_dict字典中,后面再传播时就不需要进行转换。

  4. 传播EVALSHA命令的方法:当主服务器执行完一个EVALSHA命令后,它将根据EVALSHA命令指定的校验和来确认是否存在于repl_scriptcacha_dict字典中,若存在则传播EVALSHA命令,不存在则转换后传播EVAL命令

四、排序

Redis的Sort命令可以对列表键、集合键、或者有序集合键进行排序

1、Sort 命令的实现

Sort命令最简单的执行形式为Sort ,它可以对包含数字值的key进行排序。如sort numbers命令的详细执行步骤如下:

  1. 创建一个和numbers列表长度相同的数组,数组的每一项都是redis.h/redisSortObject结构;
  2. 遍历数组,将各个数组项的obj指针指向numbers列表的各个项,构成一对一的关系;
  3. 遍历数组,将各个obj指针指向的列表项准换为一个double类型浮点数,并将这个浮点数保存在相应数组项的u.score属性中;
  4. 根据数组项u.score属性的值,对数组进行数字值排序,排序后的数组按照u.score属性的值从小到大排列;
  5. 遍历数组,将各个数组项的obj指针所指向的列表项作为排序结果返回给客户端,通过依次访问索引返回u.score的值

SORT命令为每个被排序键都创建一个与键长度相同的数组,数组的每个项都是一个redisSortObject结构,根据SORT命令使用的选项不同,程序使用redisSortObject结构的方式也有所不同

2、ALPHA选项的实现

通过使用ALPHA选项,SORT命令可以对包含字符串值的键进行排序:SORT <key> ALPHA。如执行Sort fruits ALPHA命令,步骤如下:

  1. 创建一个和fruits集合大小相同的redisSortObject结构数组;
  2. 遍历数组,将各个数组项的obj指针指向fruits集合的各个元素;
  3. 根据obj指针所指向的集合元素,对数组进行字符串排序,排序后的数组按照集合元素字符串的值从小到大排列;
  4. 遍历数组,将各个数组项的obj指针所指向的元素返回给客户端;

3、ASC选项和DESC选项的实现

默认情况下,SORT命令执行升序排列,排列后的值按从小到大排序,所以命令Sort <key>Sort <key> ASC是等价的;而使用DESC后,排序会按值从大到小进行排列。

升序排列和降序排列都由相同的排序算法执行,不同之处在于对比函数产生的是升序对比结果还是降序对比结果

4、BY选项的实现

默认情况下,SORT命令使用被排序键包含的元素作为排序的权重,元素本身决定了元素在排序后的位置。但通过BY选项,可以指定某些字符串键、或者哈希键所包含的某些域(field)来作为元素的权重,对一个键进行排序。如下指令会根据*-price对应的权重进行排序

5、带有ALPHA选项的BY选项的实现

BY选项默认假设权重键保存的值为数字值,但如果权重键保存的是字符串值,那么就需要使用By选项的同时,配合使用ALPHA选项。示例如下:

6、LIMIT选项的实现

在默认情况下,SORT命令会将排序后的所有元素返回给客户端;但是通过LIMIT选项可以只返回一部分已排序的元素,命令格式如下:LIMIT <offset> <count>,offset参数表示要跳过已排序元素的数量,count参数表示跳过已排序数量后需要返回的已排序数量

7、GET选项的实现

在默认情况下,SORT命令在对键进行排序后,总是返回被排序键本身所包含的元素。通过Get选项,可以让SORT命令对键进行排序后,根据被排序的元素,以及Get选项所指定的模式,查找并返回某些键的值,示例如下:

一个SORT命令可以带有多个GET选项,所以随着GET选项的增多,命令要执行的查找操作也会增多,示例如下:

8、STORE选项的实现

默认情况下,SORT命令只返回排序结果,而不保存排序结果;通过使用STORE选项,我们可以将排序结果保存在指定的键里面,并在有需要时重用这个排序结果。

如命令SORT Students ALPHA STORE sorted_students的存储步骤如下:通过SORT排序后,会首先检查sorted_students这个键是否存在,若存在会删除该键;删除后将该键设置为空白的列表键;再将排序后的元素依次插入到sorted_students列表的末尾

9、多个选项的执行顺序

通常情况下,一个SORT命令请求通常会用到多个选项,而这些选项的执行顺序是有先后之分的

1)选项的执行顺序

按照选项划分,执行过程可以分为以下四步:

  1. 排序:命令使用ALPHA、ASC、DESC、BY选项,对输入键进行排序,并得到一个排序结果集;
  2. 限制排序结果集长度:通过LIMIT选项,对排序结果集的长度进行限制,只有LIMIT选项指定的那部分元素会被保存到结果集中;
  3. 获取外部键:命令使用GET选项及指定的模式,根据排序结果集中的元素,查找并获取指定键的值,并将这些值作为新的结果集;
  4. 保存结果集:命令使用STORE选项,将排序结果集保存到指定的键上去;
  5. 向客户端返回排序结果集:遍历结果集,依次向客户端返回排序结果集中的元素;

2)选项的摆放顺序

调用SORT命令时,除了GET选项外,改变选项的拜访位置不会影响SORT命令执行这些选项的顺序

五、二进制位数组

Redis提供了SETBIT、GETBIT、BITCOUNT、BITOP四个命令用于处理二进制数组:

  • SETBIT命令用于为位数组指定偏移量上的二进制位设置值,位数组的偏移量从0开始计数,二进制的值可以是1或0;
  • GETBIT命令用于获取位数组指定偏移量上的二进制位的值;
  • BITCOUNT命令用于统计数组里面,值为1的二进制位的数量;
  • BITTOP命令可以对多个位数组进行按位与、按位或、按位异或或按位取反操作;

1、位数组的表示

Redis使用字符串对象来表示位数组,因为字符串对象使用的SDS数据结构是二进制安全,所以程序可以直接使用SDS结构来保存位数组,并使用SDS结构的操作函数来处理位数组。需要注意的是为了简化SETBIT命令的实现,系统使用逆序来保存位数组。示例如下:

2、GETBIT命令的实现

GETBIT命令用于返回位数组在offset偏移量上的二进制位的值;命令GETBIT <bitarray> <offset>的执行过程如下:

  1. 计算byte=offset除以8后向下取整,byte则可以记录偏移量指定的二进制位保存在位数组的哪个字节;
  2. 计算bit=offset除以8的余数+1,bit值记录了offset偏移量指定的二进制位是byte字节的第几个二进制位;
  3. 根据byte值和bit值,在位数组bitarray中定位offset偏移量指定的二进制位,并返回这个位的值

3、SETBIT命令的实现

SETBIT用于将位数组bitarray在offset偏移量上的二进制位的值设置位value,并向客户端返回二进制位被设置前的旧值,步骤如下:

  1. 计算len=offset除以8向下取整后+1,len值记录了保存offset偏移量指定的二进制位至少需要多少字节;
  2. 检查bitarray键保存的位数组(SDS)的长度是否小于len,若小于则扩展长度至len,并将新扩展空间的二进制位的值设备为0;
  3. 计算byte=offset除以8向下取整,byte可以记录偏移量指定的二进制位保存在位数组的哪个字节;
  4. 计算bit=offset除以8的余数+1,bit值记录了offset偏移量指定的二进制位是byte字节的第几个二进制位;
  5. 根据byte值和bit值,在位数组bitarray中定位offset偏移量指定的二进制位,首先将指定二进制位的值保存在oldValue变量,再将新增value设置为这个二进制位的值;
  6. 向客户端返回这个二进制位的值;

上面提到若位数组长度小于所需字节数会自动扩展,但SDS的空间预分配策略会额外分配空间;另外逆序保存数buf数组的优势在这里体现,写入操作可以在新扩展的二进制位中完成,而不变改动位数组原有的二进制位,否则需要将位数组已有的位进行移动后才能执行写操作,这会带来一定的CPU消耗

4、BITCOUNT命令的实现

BITCOUNT命令用于统计给定数组中,值为1的二进制位的数量。功能看上去很简单,但要高效的实现这个命令需要用到一些算法

1)遍历算法

遍历算法最为简单直接,但每次循环只能检查一个二进制位的值,若位数组长度过大的情况下重复执行二进制位的检查显然不是很好的方法,所以程序必须尽可能的增加每次检查所能处理的二进制位的数量,减少执行次数

2)查表算法

对于一个有限集合或有限长度的位数组来说,其排序方式是有限的,只需要列出所有可能的二进制位排序就可以知道当前二进制位数组包含的1的个数。对于8位长的位数组,一次表查找就可以检查8个二进制位,效率比遍历算法提升了8倍。但这种做法是典型的空间换时间,且会收到内存消耗和CPU缓存的影响

3)variable-precision SWAR算法

统计一个位数组中非0二进制位的数量,在数学上被称为Hamming Weight,一些处理器直接带有计算汉明重量的指令,对于不具备特殊指令的普通处理器,目前已知效率最好的算法是variable-precision SWAR算法,它通过一系列位移和位运算操作,可以在常数时间内计算多个字节的汉明重量,并且不需要额外的内存。实现如下:

swar函数每次执行可以计算32个二进制位的汉明重量,比遍历算法快32倍;此外它是一个常数复杂度的操作,所以我们可以按照自己的需要,在一次循环中多次执行swar,从而按倍提升效率,如一次循环调用2次swar算法会再次提升2倍效率,但是一旦处理位数组的大小超过了缓存的大小,优化效果就会消失。

4)Redis的实现

BITCOUNT命令的实现用到了表查找和Swar查找算法:

  • 表查找算法使用键长为8位的表,表中记录了从0000 0000到1111 1111在内的所有二进制位的汉明重量;
  • Swar算法,BITCOUNT命令在每次循环中载入128个二进制位,然后调用4次32位的Swar算法来计算128个二进制位的汉明重量;

在执行BITCOUNT命令时,程序会根据未处理的二进制位数量来决定使用哪种算法;如果未处理的二进制位的数量大于等于128位,程序会使用Swar算法,否则使用表查找算法。具体逻辑如下,BITCOUNT实现的算法复杂度为O(n),n为输入二进制位的数量

5、BITOP命令的实现

因为C语言支持逻辑与&、逻辑或|、逻辑异或^和逻辑非~的操作,所以BITOP命令的AND、OR、XOR和NOT操作都是直接基于这里逻辑操作实现的。因为BITOP OR、BITOP XOR、BITOP XOR三个命令可以接受多个位数组输入,所以程序需要遍历输入的每个位数组的每个字节进行计算,所以命令的复杂度位O(n^2);而BITOP NOT命令只接受一个位数组输入,所以它的复杂度位O(n)

六、慢日志查询

Redis的慢日志查询功能用于记录执行时间超过给定时长的命令请求,用户可以通过这个功能产生的日志来监视和优化查询速度。服务器配置有两个与慢日志查询相关的选项:

  • slowlog-log-slower-than选项指定执行时间超过多少微秒;通过Config set slowlog-log-slower-than xxx设定;
  • slowlog-max-len选项指定服务器最多保存多少条慢查询日志;通过Config set slowlog-max-len xxx设定;当超过设定的数量后会采用先进先出原则删除旧日志

可以使用SlowLog Get命令查看服务器保存的慢查询日志

1、慢查询记录的保存

服务器状态中包含了几个和慢日志日志功能有关的属性:

  • slowlog_entry_id属性初始值为0,每创建一条慢日志,这个属性的值就会用作新日志的Id,之后程序会对这个属性的值增1;
  • slowlog链表保存了服务器中所有的慢查询日志,链表中的每个节点都保存了一个slowlogEntry结构,该结构代表一条慢查询日志;

2、慢查询日志的阅览和删除

  • 查看慢查询日志的ShowLog Get命令定义如下:

  • 查看慢查询日志数量的ShowLog Len命令定义如下:

  • 清楚所有慢查询日志的ShowLog Reset命令定义如下:

3、添加新日志

每次执行命令之前或之后,程序都以微妙格式记录UNIX时间戳,两个时间戳的相隔时间就是执行命令耗费的时长,服务器会将这个时长作为参数之一传给slowLogPushEntryIfNeeded函数,该函数复制检查是否需要为这次执行的命令创建慢查询日志。该函数会确认是否创建慢日志,若创建会将新的日志插入到slowLog链表的表头;它还会检查日志的长度是否超过slowlog_max_len选项所设置的长度,如超出则将多余的日志从slowLog链表中删除

七、监视器

通过Monitor命令,客户端可以将自己变为一个监视器,实时的接受并打印出服务器当前处理的命令请求的相关信息。每当一个客户端向服务器发送一条命令请求,服务器处理处理这条命令外,还会将这条命令请求的信息发送给所有的监视器

1、成为监视器

客户端发送Monitor命令后,会将自身的Redis_Monitor标志打开,并且这个客户端会被添加到monitors链表的表尾;

2、向监视器发送命令请求

服务器每次处理命令请求之前,都会调用replicationFeedMonitors函数,由这个函数将被处理命令的信息发送给所有的监视器

原文地址:https://www.cnblogs.com/Jscroop/p/13336735.html