一、发布与订阅
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
命令将消息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环境的整个过程如下:
- 创建一个基础的Lua环境;
- 载入多个函数库到Lua环境中;
- 创建全局表格Redis,表格包含了对Redis进行操作的函数;
- 使用Redis自制的随机函数替换Lua原有的带副作用的随机函数;
- 创建排序辅助函数,Lua环境使用这个辅助函数对一部分Redis命令的结果进行排序,从而消除这些命令的不确定性;
- 创建redis.pcall函数的错误报告辅助函数,来提供更为详细的错误信息;
- 对Lua环境中的全局环境进行保护,防止用户在执行Lua脚本的过程中带入额外的全局变量;
- 将完成修改的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命令,需要完成以下步骤:
- Lua环境将要redis.call函数或redis.pcall函数要执行的命令传送给伪客户端;
- 伪客户端将脚本要执行的命令传给命令执行器;
- 命令执行器执行伪客户端传送的命令,并将执行结果返回给伪客户端;
- 伪客户端接受命令执行器返回的执行结果,并将命令的结果返回给Lua环境;
- Lua环境接受到命令结果之后,将结果返回给redis.call函数或redis.pcall函数
- 接受到结果的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)执行脚本函数
脚本保存完成后,服务器还需要进行一些设置一些钩子,传入参数等操作才能正式执行脚本,过程如下:
- 将Eval命令中传入的键名参数和脚本参数分别保存到KEYS数组和ARGV数组,并将这两个数组作为全局变量传入Lua环境中;
- 为Lua环境装载超时处理钩子,可以在脚本出现超时运行的情况下,让客户端通过Script Kill命令停止脚本,或者通过SHUTDOWM命令关闭服务器;
- 执行脚本函数;
- 移除之前载入的超时钩子;
- 将执行脚本函数得到的结果保存到客户端状态的输出缓冲区中,等待服务器将结果返回给客户端;
- 对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字典
-
判断传播EVALSHA命令是否安全的办法:主服务器使用服务器状态的repl_scriptcache_dict字典记录自己已经传播给所有从服务器的脚本信息;repl_scriptcache_dict字典的键是Lua脚本的SHA1校验和,值对应为null:①当一个校验和出现在repl_scriptcache_dict字典时,说明这个校验和对应的Lua脚本已经传播给了所有从服务器,主服务器可以直接向从服务器传播EVALSHA命令,从服务器也可以避免找不到脚本的错误情况;②而如果一个脚本的SHA1校验和存在于lua_scripts字典,但是不存在于repl_scriptcacha_dict字典,那么说明校验和对应的Lua脚本已经被主服务器载入,但是没有传播给所有从服务器,如果尝试传播这条EVALSHA命令,至少一个从服务器会出现脚本找不到的情况。
-
清空repl_scriptcacha_dict字典:每当主服务器添加一个从服务器,主服务器都会清空自己的repl_scriptcache_dict字典,这是因为新的从服务器的出现,导致repl_scriptcacha_dict字典中记录的信息将不再是被所有从服务器载入过的状态。
-
EVALSHA命令转换为EVAL命令的方法:通过EVALSHA命令指定的SHA1校验和,以及lua_scripts字典保存的Lua脚本,服务器可以将一个EVALSHA命令
EVALSHA
转换为等价的EVAL命令[key...] [arg...] EVAL