初学乍练redis:事务与脚本

目录

一、事务

1. 概述

2. 错误处理

3. watch命令

二、redis脚本

1. 脚本介绍

2. 实例:自定义incr

3. redis与Lua

(1)在脚本中调用redis命令     

(2)从脚本中返回值        

(3)脚本相关命令

(4)KEYS和ARGV

(5)沙盒与随机数

(6)原子性和执行时间


        大部分摘自Redis入门指南(第2版),书摘备查。

一、事务

1. 概述

        redis提供了一个实用的命令INCR,其作用是让当前键值递增,并返回递增后的值。用法为:

[root@hdp4~]#redis-cli set foo 4
OK
[root@hdp4~]#redis-cli incr foo
(integer) 5

        当要操作的键不存在时会默认键值为0,所以第一次递增后的结果是1。当键值不是整数时redis会提示错误。

[root@hdp4~]#redis-cli set foo lorem
OK
[root@hdp4~]#redis-cli incr foo
(error) ERR value is not an integer or out of range

        有些用户会想到可以借助GET和SET两个命令自己实现incr函数。伪代码如下:

def incr($key)
    $value = GET $key
    if not $value
       $value = 0
    $value = $value + 1
    SET $key, $value
    return $value

        如果redis只连接了一个客户端,那么上面的代码没有问题(其实还没有加入错误处理)。可当同一时间有多个客户端连接到redis时则有可能出现竞态条件(race condition)。竞态条件是指一个系统或者进程的输出,依赖于不受控制的事件的出现顺序或者出现时机。例如有两个客户端 A 和 B 都要执行我们自己实现的incr函数并准备将同一个键的键值递增。当它们恰好同时执行到代码第二行时二者读取到的键值是一样的,如“5”,而后它们各自将该值递增到“6”并使用SET命令将其赋给原键,结果虽然对键执行了两次递增操作,最终的键值却是“6”而不是预想中的“7”。

        包括INCR在内的所有redis命令都是原子操作(atomic operation),无论多少个客户端同时连接,都不会出现上述情况。下面将介绍如何利用redis事务和脚本实现自定义的原子操作的方法。原子操作取“原子”的“不可拆分”的意思,原子操作是最小的执行单位,不会在执行过程中被其它命令插入打断。

        redis中的事务是一组命令的集合。事务同命令一样都是redis的最小执行单位,一个事务中命令要么都执行,要么都不执行。与关系数据库的事务概念不同,实际上redis的事务只是保证了ACID特性中的A,即原子性。事务的原理是先将属于一个事务的命令发送给redis,然后再让redis依次执行这些命令。例如:

127.0.0.1:6379> multi
OK
127.0.0.1:6379> sadd "user:1:following" 2
QUEUED
127.0.0.1:6379> sadd "user:2:followers" 1
QUEUED
127.0.0.1:6379> exec
1) (integer) 0
2) (integer) 0

        上面的代码演示了事务的使用方式。首先使用multi命令告诉redis下面的命令属于同一个事务。redis会把下面的两个sadd命令暂时存起来,返回QUEUED表示这两条命令已经进入等待执行的事务队列中了。当把所有要在一个事务中执行的命令都发给redis后,我们使用exec命令告诉redis将等待执行的事务队列中的所有命令,即前面所有返回QUEUED的命令,按照发送顺序依次执行。exec命令的返回值就是这些命令返回值组成的列表,返回值顺序和命令的顺序相同。

        redis保证一个事务中的所有命令要么都执行,要么都不执行。如果在发送exec命令前客户端断线了,则redis会清空事务队列,事务中的所有命令都不会执行。而一旦客户端发送了exec命令,所有的命令就都会被执行,即使此后断线也没关系,因为redis中已经记录了所有要执行的命令。

        除此之外,redis的事务还能保证一个事务内的命令依次执行而不被其它命令插入。试想客户端A需要执行几条命令,同时客户端B发送了一条命令。如果不使用事务,则客户端B的命令可能插入客户端A的几条命令中执行。如果不希望发生这种情况,也可以使用事务。

2. 错误处理

        如果一个事务中的某个命令执行出错,redis会怎么处理呢?要回答这个问题,首先需要知道什么原因会导致命令执行出错。

(1)语法错误。语法错误指命令不存在或者命令参数的个数不对。比如:

127.0.0.1:6379> multi
OK
127.0.0.1:6379> set key value
QUEUED
127.0.0.1:6379> set key
(error) ERR wrong number of arguments for 'set' command
127.0.0.1:6379> errorcommand key
(error) ERR unknown command `errorcommand`, with args beginning with: `key`, 
127.0.0.1:6379> exec
(error) EXECABORT Transaction discarded because of previous errors.

        跟在multi命令后执行了三个命令:一个是正确的命令,成功地加入事务队列;其余两个命令都有语法错误。而只要有一个命令有语法错误,执行exec命令后redis就会直接返回错误,连语法正确的命令也不会执行。

(2)运行错误。运行错误指在命令执行时出现的错误,比如使用散列类型的命令操作集合类型的键。这种错误在实际执行之前redis是无法发现的,所以在事务里这样的命令是会被redis接受并执行的。如果事务里的一条命令出现运行错误,事务里其它的命令依然会继续执行,包括出错命令之后的命令,示例如下:

127.0.0.1:6379> multi
OK
127.0.0.1:6379> set key 1
QUEUED
127.0.0.1:6379> sadd key 2
QUEUED
127.0.0.1:6379> set key 3
QUEUED
127.0.0.1:6379> exec
1) OK
2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
3) OK
127.0.0.1:6379> get key
"3"

        可见虽然 sadd key 2 出现错误,但是 set key 3 依然执行了。事务回滚是指将一个事务已经完成的对数据库的修改操作撤销。redis的事务没有关系数据库事务的回滚(rollback)功能,为此开发者必须在事务执行出错后自己收拾剩下的摊子,将数据库复原回事务之前的状态。

        不过由于redis不支持回滚功能,也使得redis在事务上可以保持简洁和快速。另外回顾刚才提到的会导致事务执行失败的两种错误,其中语法错误完全可以在开发时找出并解决,另外如果能够很好地规划数据库(保证键名规范等)的使用,是不会出现命令与数据类型不匹配这样的运行错误的。

3. watch命令

        在一个redis事务中,只有当所有命令都依次执行完后才能得到每个结果的返回值。可是有些情况下需要先获得一条命令的返回值,然后再根据这个值执行下一条命令。例如本文开始介绍的伪代码,使用get和set命令自己实现incr函数会出现竞态条件。就是说在执行set命令时,之前get获得的返回值可能已经被修改了。这种情况类似于关系数据库中的丢失更新问题。关系数据库一般采用悲观锁或乐观锁解决丢失更新问题。

        但在redis中我们需要换一种思路。即在get获得键值后保证该值不被其它客户端修改,直到函数执行完成后才允许客户端修改该键值,这样也可以防止竞态条件。redis使用watch命令实现这一思路。watch命令可以监控一个或多个键,一旦其中有一个键被修改或删除,之后的事务就不会执行。监控一直持续到exec命令(事务中的命令是在exec之后才执行的,所以multi命令后可以修改watch监控的键值),如:

127.0.0.1:6379> set key 1
OK
127.0.0.1:6379> watch key
OK
127.0.0.1:6379> set key 2
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set key 3
QUEUED
127.0.0.1:6379> exec
(nil)
127.0.0.1:6379> get key
"2"

        上例中在执行watch命令后、事务执行前修改了key值(即 set key 2),所以最后事务中的命令 set key 3 没有执行,exec命令返回空结果。利用watch命令就可以通过事务自己实现incr函数了,伪代码如下:

def incr($key)
    WATCH $key
    $value = GET $key
    if not $value
       $value = 0
    $value = $value + 1
    MULTI
    SET $key, $value
    result = EXEC
    return result[0]

        因为exec命令返回值是多行字符串类型,所以代码中使用result[0]来获得其中第一个结果。watch命令的作用只是当被监控的键值被修改后阻止之后一个事务的执行,而不能保证其它客户端不修改这一键值,所以我们需要在exec执行失败后重新执行整个函数。watch使用的实际是一种乐观锁的思想。

        执行exec命令后会取消对所有键的监控,如果不想执行事务中的命令也可以使用unwatch命令来取消监控。比如,我们要实现 hsetxx 函数,作用于hsetnx命令类似,只不过是仅当字段存在时才赋值。为了避免竞态条件,使用事务来完成这一功能:

def hsetxx($key, $field, $value)
    WATCH $key
    $isFieldExists = HEXISTS $key, $field
    if $isFieldExists is 1
       MULTI
       HSET $key, $field, $value
       EXEC
    else
       UNWATCH
    return $isFieldExists

        在代码中会判断要复制的字段是否存在,如果字段不存在的话就不执行事务中的命令,但需要使用UNWATCH命令来保证下一个事务的执行不会受到影响。
 

二、redis脚本

1. 脚本介绍

        在前面使用事务实现的incr函数中,为避免出现竞态条件,用watch检测$key键的变动。但是这样做比较麻烦,而且还需要判断事务是否因为键被改动而没有执行。除此之外这段代码在不使用管道的情况下要向redis请求5条命令,在网络传输上会浪费很多时间。而此时正是redis脚本功能的用武之地。

        redis在2.6版推出了脚本功能,允许开发者使用Lua语言编写脚本传到redis中执行。在Lua脚本中可以调用大部分redis命令。使用脚本的好处如下:

  • 减少网络开销:使用脚本实现自定义incr同样的操作只需要发送一个请求即可,减少了网络往返时延。
  • 原子操作:redis会将整个脚本作为一个整体执行,中间不会被其它命令插入。换句话说在编写脚本的过程中无需担心会出现竞态条件,也就无需使用事务。事务可以完成的所有功能都可以使用脚本来实现。
  • 复用:客户端发送的脚本会永久存储在redis中,这就意味着其它客户端(可以是其它语言开发的项目)可以复用这一脚本而不需要编写代码完成同样的逻辑。

        从以上总结可以看出脚本功能特点类似于关系数据库中的存储过程。

2. 实例:自定义incr

        因为无需考虑事务,使用redis脚本实现incr非常简单。Lua代码如下:

local num = redis.call('get', KEYS[1])
local cycles = tonumber(ARGV[1])

for i = 0, cycles do
    local a = 1
end

if not num then
   num = 0
else 
   local n = tonumber(num)
   if n then
      if math.ceil(n) ~= n then
         return "Key's value is not an integer!"
      end
   else
      return "Key's value is not a number!"
   end 
end

num = num + 1
redis.call('set', KEYS[1], num)
return num

        for循环用来模拟sleep,目的是验证脚本的原子性。将这段代码保存为incr.lua文件,然后执行下面的步骤进行测试。

(1)设置一个键值作为初始值。

[root@hdp4~]#redis-cli set foo 5
OK

(2)在终端1执行如下命令行,命令将处于等待状态。

redis-cli --eval incr.lua foo , 400000000

(3)5秒之内终端2执行同样的命令行

[root@hdp4~]#redis-cli --eval incr.lua foo , 400000000

(4)几秒后待两个终端都执行完成,验证两个终端的输出结果
        第一个终端的输出为:

[root@hdp4~]#redis-cli --eval incr.lua foo , 400000000
(integer) 6

        第二个终端的输出为:

[root@hdp4~]#redis-cli --eval incr.lua foo , 400000000
(integer) 7

        最终的键值是7而不是6。lua本身没有提供sleep函数,而在redis中使用lua脚本时又不能使用全局变量(os、socket、posix等等),所以合理的等待时间只能通过测试得到,400000000就是我的环境下测试的结果,约产生5秒的等待。如果该参数太小,难以模拟并发情况,若太大,使得第一个终端的执行时间超过5秒,则第二个终端会报错:
(error) BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE.

        这个错误是由参数lua-time-limit所控制:

127.0.0.1:6379> config get lua*
1) "lua-time-limit"
2) "5000"
127.0.0.1:6379>

        命令行中的 --eval 参数是告诉redis-cli读取并运行后面的Lua脚本,后面是脚本文件名,再后面跟着的是传给Lua脚本的参数。其中“,”前面的foo是要操作的键,可以在脚本中使用KEYS[1]获取(Lua语言区分大小写)。“,”后面的400000000是其它参数,在脚本中能够使用ARGV[1]获得。注意命令行中“,”两边的空格不能省略,否则会出错。

3. redis与Lua

(1)在脚本中调用redis命令     

        在脚本中可以使用redis.call函数调用redis命令。就像这样:

redis.call('set', 'foo', 'bar')
local value = redis.call('get', 'foo')

        redis.call函数的返回值就是redis命令的执行结果。redis命令的返回值有5种类型,redis.call函数会将这5种类型的返回值转换成Lua的数据类型,具体的对应规则如表1所示(空结果比较特殊,其对应Lua的false)。

Redis返回值类型

Lua数据类型

整数

数字

字符串

字符串

多行字符串

表类型(数组形式)

状态

表类型(只有一个ok字段存储状态信息)

错误

表类型(只有一个err字段存储错误信息)

        表1 redis返回值类型和Lua数据类型转换规则

        redis还提供了redis.pcall函数,功能与redis.call相同,唯一的区别是当命令执行出错时redis.pcall会记录错误性继续执行,而redis.call会直接返回错误,不会继续执行。

(2)从脚本中返回值        

        在很多情况下都需要脚本返回值。在脚本中可以使用return语句将值返回给客户端,如果没有执行return语句则默认返回nil。因为我们可以像调用其它redis内置命令一样调用自己写的脚本,所以同样redis会自动将脚本返回值的Lua数据类型转换成redis的返回值类型。具体的转换规则见表2(其中Lua的false比较特殊,会被转换成空结果)。

Lua数据类型

Redis返回值类型

数字

整数(Lua的数字类型会被自动转换成整数)

字符串

字符串

表类型(数组形式)

多行字符串

表类型(只有一个ok字段存储状态信息)

状态

表类型(只有一个err字段存储错误信息)

错误

        表2 Lua数据类型和redis返回值类型转换规则

(3)脚本相关命令

  • EVAL

        编写完脚本后最重要的就是在程序中执行脚本。redis提供了eval命令可以使开发者像调用其它redis内置命令一样调用脚本。eval命令的格式是:eval 脚本内容 key参数的数量 [key ...] [arg ...]。可以通过key和arg这两类参数向脚本传递数据,它们的值可以在脚本中分别使用 KEYS 和 ARGV 两个表类型的全局变量访问。比如希望脚本功能实现一个set命令,脚本内容是这样的:

return redis.call('set', KEYS[1], ARGV[1])

        现在打开redis-cli执行此脚本:

[root@hdp4~]#redis-cli
127.0.0.1:6379> eval "return redis.call('set', KEYS[1], ARGV[1])" 1 foo bar
OK
127.0.0.1:6379> get foo
"bar"

        其中要读写的键名应该作为 key 参数,其它的数据都作为 arg 参数。eval命令依据第二个参数将后面的所有参数分别存入KEYS和ARGV两个表类型的全局变量。

  • EVALSHA

        考虑到在脚本比较长的情况下,如果每次调用脚本都需要将整个脚本传给redis会占用较多的带宽。为解决这个问题,redis通过了evalsha命令允许开发者通过脚本内容的sha1摘要来执行脚本。该命令的用法和eval一样,只不过是将脚本内容替换成脚本内容的sha1摘要。

        redis在执行eval命令时会计算脚本的sha1摘要并记录在脚本缓存中,执行evalsha命令时redis会根据提供的摘要从脚本缓存中查找对应的脚本内容,如果找到则执行脚本,否则会返回错误:“NOSCRIPT No matching script. Please use EVAL.”。在程序中使用evalsha命令的一般流程如下:

  1. 先计算脚本的sha1摘要,并使用evalsha命令执行脚本。
  2. 获得返回值,如果返回“NOSCRIPT”错误则使用eval命令重新执行脚本。

        虽然这一流程略显麻烦,但值得庆幸的是很多编程语言的redis客户端都会代替开发者完成这一流程。

        除eval和evalsha外,redis还提供了其它4个脚本相关的命令,一般都会被客户端封装起来,开发者很少能使用到。

  • SCRIPT LOAD

        将脚本加入缓存。每次执行命令时redis都会将脚本的sha1摘要加入脚本缓存中,以便下次客户端可以使用evalsha命令调用该脚本。如果只是希望将脚本加入脚本缓存而不执行则可以使用SCRIPT LOAD命令,返回值是脚本的sha1摘要。就像这样:

127.0.0.1:6379> script load "return 1"
"e0e1f9fabfc9d4800c877a703b823ac0578ff8db"
  • SCRIPT EXISTS

        判断脚本是否已经被缓存。SCRIPT EXISTS命令可以同时查找1个或多个脚本的sha1摘要是否被缓存,如:

127.0.0.1:6379> script exists e0e1f9fabfc9d4800c877a703b823ac0578ff8db abcdefghijklmnopqrstuvwxyzabcdefghijklmn
1) (integer) 1
2) (integer) 0
  • SCRIPT FLUSH

        清空脚本缓存。redis将脚本的sha1摘要加入到脚本缓存后会永久保留,不会删除,但可以手动使用SCRIPT FLUSH命令清空脚本缓存。

127.0.0.1:6379> script flush
OK
  • SCRIPT KILL

        强制终止当前脚本的执行。如果想终止当前正在执行的脚本使用SCRIPT KILL命令。

(4)KEYS和ARGV

        向脚本传递的参数分为 KEYS 和 ARGV 两类,前者表示要操作的键名,后者表示非键名参数。但事实上这一要求并不是强制的,比如 eval "return redis.call('get', KEYS[1])" 1 user:Bob 可以获得 user:Bob 的键值,同样也可以使用 eval "return redis.call('get', 'user:' .. ARGV[1])" 0 Bob 完成同样的功能。此时我们虽然并未按照redis的规则使用KEYS参数传递键名,但还是获得了正确的结果。

        虽然规则不是强制的,但不遵守规则依然有一定的代价。redis 3.0及以后版本带有就集群(cluster)功能,集群的作用是将数据库中的键分散到不同的节点上。这意味着在脚本执行前就需要知道脚本会操作哪些键以便找到对应的节点,所以如果脚本中的键名没有使用 KEYS 参数传递则无法兼容集群。

        有时候键名是根据脚本某部分的执行结果生成的,这时就无法在执行前将键名明确标出。比如一个集合类型键存储了用户ID列表,每个用户使用散列键存储,其中有一个字段是年龄。下面的脚本可以计算某个集合中用户的平均年龄:

local sum = 0
local users = redis.call('smembers', KEYS[1])
for _, user_id in ipairs(users) do 
    local user_age = redis.call('hget', 'user:' .. user_id, 'age')
    sum = sum + user_age
end 

return sum / #users

        这个脚本同样无法兼容集群功能,因为第4行中访问了 KEYS 变量中没有的键,但却十分实用,避免了数据往返客户端和服务器端的开销。为了兼容集群,可以在客户端获取集合中的用户ID列表,然后将用户ID组装成键名列表传给脚本并计算平均年龄。两种方案都是可行的,至于实际采用哪种就需要开发者自行权衡了。

(5)沙盒与随机数

        redis脚本禁止使用Lua标准库中与文件或系统调用相关的函数,在脚本中只允许对redis的数据进行处理。并且redis还通过禁用脚本的全局变量的方式保证每个脚本都是相对隔离的,不会互相干扰(类似于ACID中的事务隔离性)。

        使用沙盒不仅是为了保证服务器的安全性,而且还确保了脚本的执行结果只和脚本本身和执行时传递的参数有关,不依赖外界条件,如系统时间、系统中某个文件的内容、其它脚本的执行结果等。这是因为在执行复制和AOF持久化操作时记录的是脚本的内容而不是脚本调用的命令(有点类似于MySQL复制中的 binlog_format=statement),所以必须保证在脚本内容和参数一样的前提下脚本的执行结果必须是一样的。此概念与关系数据库函数定义中的deterministic选项是一致。

        除使用沙盒外,为了确保执行的结果可以重现,redis还对随机数和会产生随机结果的命令进行了特殊的处理。对于随机数而言,redis替换了math.random和math.randomseed函数,使得每次执行脚本时生成的随机数序列都相同,如果希望获得不同的随机数序列,最简单的方法是由程序生成随机数并通过参数传递给脚本。或者采用更灵活的方法,即在程序中生成随机数传给脚本作为随机数种子(通过math.randomseed(tonumber(ARGV[种子参数索引]))),这样在脚本中再调用math.random产生的随机数就不同了(由随机数种子决定)。

        对于产生随机结果的命令如smembers(因为集合类型是无序的)或hkeys(因为散列类型的字段也是无序的)等,redis会对结果按照字典顺序排序。内部是通过调用Lua标准库的table.sort函数实现的,代码与下面这段很相似:

function __redis__compare_helper(a,b)
  if a == false then a = '' end
  if b == false then b = '' end
  return a < b
end 
table.sort(result_array, __redis__compare_helper)

        对于会产生随机结果但无法排序的命令(比如会产生一个元素),redis会在这类命令执行后将该脚本状态标记为lua_random_dirty,此后只允许调用只读命令,不允许修改数据库的值,否则返回错误:“Write commands not allowed after non deterministic commands.”。属于此类的redis命令有spop、srandmember、randomkey和time。

(6)原子性和执行时间

        redis的脚本执行是原子的,即脚本执行期间redis不会执行其它命令。所有的命令都必须等待脚本执行完成后才能执行(实际上就是脚本串行化了并行的redis命令)。为了防止某个脚本执行时间过长,导致redis无法提供服务(比如陷入死循环),redis提供了lua_time_limit参数限制脚本的最长运行时间,默认为5秒钟。当脚本运行时间超过这一限制后,redis将开始接受其它命令但不会执行(以确保脚本的原子性,因为此时脚本并没有被终止),而是会返回“BUSY”错误。现在我们打开两个redis-cli实例A和B来演示这一情况。首先在A中执行一个死循环脚本:

127.0.0.1:6379> eval "while true do end" 0

然后马上在B中执行一条命令:

127.0.0.1:6379> get foo

此时实例B中的命令并没有马上返回结果,因为redis已经被实例A发送的死循环脚本阻塞了,无法执行其它命令。等到5秒后实例B收到了“BUSY”错误:

(error) BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE.

此时redis虽然可以接受任何命令,但实际能执行的只有两个命令:script kill 和 shutdown nosave。在实例B中执行 script kill 命令可以终止当前脚本的运行:

127.0.0.1:6379> script kill
OK

此时脚本被终止并且实例A中会返回错误:

127.0.0.1:6379> eval "while true do end" 0
(error) ERR Error running script (call to f_694a5fe1ddb97a4c6a1bf299d9537c7d3d0f84e7): @user_script:1: Script killed by user with SCRIPT KILL... 
(33.60s)

        需要注意的是如果当前执行的脚本对redis的数据进行了修改(如调用set、lpush或del等命令),则 script kill 命令不会终止脚本的运行以防止只执行了一部分。因为如果脚本只执行了一部分就被终止,会违背脚本的原子性要求。比如在实例A中执行:

eval "redis.call('set', 'foo', 'bar') while true do end" 0

5秒钟后在实例B中尝试终止该脚本:

127.0.0.1:6379> script kill
(error) UNKILLABLE Sorry the script already executed write commands against the dataset. You can either wait the script termination or kill the server in a hard way using the SHUTDOWN NOSAVE command.

此时只能通过shutdown nosave命令强制终止redis。shutdown nosave与shutdown命令的区别在于前者不会进行持久化操作,这意味着所有发生在上一次快照后的数据库修改都会丢失。

        由于redis脚本非常高效,所以在大部分情况下都不用担心脚本的性能。但同时由于脚本的强大功能,很多原本在程序中执行的逻辑可以放到脚本中执行,这时就需要开发者根据具体应用权衡到底哪些任务适合交给脚本。通常来讲不应该在脚本中进行大量耗时的计算,因为毕竟redis是单进程单线程执行脚本,而程序能够多进程或多线程运行。
 

©️2020 CSDN 皮肤主题: 深蓝海洋 设计师: CSDN官方博客 返回首页
实付0元
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值