【第三章 小功能大用处】3.4、事务与Lua

in with 0 comment

事务与Lua

为了保证多余命令组合的原子性,Redis提供了简单的事务功能以及集成Lua脚本来解 决这个问题。本节首先简单介绍Redis中事务的使用方法以及它的局限性,之后重点介 绍Lua语言的基本使用方法,以及如何将Redis和Lua进行集成,最后给出Redis管理 Lua脚本的相关命令。

  1. 事务

    熟悉关系型数据库的读者应该对事务比较了解,简单的说,事务表示一组动作, 要么全部执行,要么全部不执行。例如在社交网站上用户A关注了用户B,那么需 要在用户A的关注表中加入用户B,并且在用户B的粉丝表中添加用户A,这两个行 为要么全部执行,要么全部不执行,否则会出现数据不一致的情况。

    Redis提供了简单的事务功能,将一组需要一起执行的命令放到multi和exec两个 命令之间。multi命令代表事务开始,exec命令代表事务结束,它们之间的明年 了是原子顺序执行的,例如下面操作实现了上述用户关注问题。

    127.0.0.1:6379> multi
    OK
    127.0.0.1:6379> sadd user:a:follow user:b
    QUEUEd
    127.0.0.1:6379> sadd user:bfans user:a
    QUEUEd
    

    可以看到sadd命令此时的返回结果是QUEUED,代表命令并没有真正执行,而是暂 时保存在Redis中。如果此时另一个客户端执行sismember user:a:follow user:b返回结果应该为0。

    127.0.0.1:6379> sismember user:a:follow user:b
    (integer) 0
    

    只有当exec执行后,用户A关注用户B的行为才算完成,如下所示返回的两个结果 对应sadd命令。

    127.0.0.1:6379> exec
    1) (integer) 1
    2) (integer) 1
    127.0.0.1:6379> sismember user:a:follow user:b
    (integer) 1
    

    如果要停止事务的执行,可以使用discard命令代替exec命令即可。

    127.0.0.1:6379> discard
    OK
    127.0.0.1:6379> sismember user:a:follow user:b
    (integer) 0
    

    如果事务中的命令出现错误,Redis的处理机制也不尽相同。

    • 命令错误

      例如下面操作错将set命令写成了sett,属于语法错误,会造成整个事务无 法执行,key和counter的值未发生变化:

      127.0.0.1:6379> mget key counter
      1) "hello"
      2) "100"
      127.0.0.1:6379> multi
      OK
      127.0.0.1:6379> sett key world
      (error) ERR unknown command 'sett'
      127.0.0.1:6379> incr counter
      QUEUED
      127.0.0.1:6379> exec
      (error) EXECABORT Transaction discarded because of previous errors.
      127.0.0.1:6379> mget key counter
      1) "hello"
      2) "100"
      
    • 运行时错误

      例如用户B在添加粉丝列表时,误把sadd命令写成了zadd命令,这种就是运 行时命令,因为语法是正确的:

      127.0.0.1:6379> multi
      OK
      127.0.0.1:6379> sadd user:a:follow user:b
      QUEUED
      127.0.0.1:6379> zadd user:b:fans user:a
      QUEUED
      127.0.0.1:6379> exec
      1) (integer) 1
      2) (error) WRONGTYPE Operation against a key holding the wrong kind of value.
      127.0.0.1:6379> sismember user:a:follow user:b
      (integer) 1
      

      可以看到Redis并不支持回滚功能,sadd user:a:follow user:b命令已经 执行成功,开发人员需要自己修复这类问题。

      有些应用场景需要在事务之前,确保事务中的key没有被其他客户端修改 过,才执行事务,否则不执行(类似乐观锁)。Redis提供了watch命令来解 决这类问题,下表展示了两个客户端执行命令的时序。

      时间点客户端-1客户端-2
      T1set key "java"
      T2watch key
      T3multi
      T4append key python
      T5append key jedis
      T6exec
      T7get key

      可以看到“客户端-1”在执行multi之前执行了“watch”命令,“客户端-2”在 “客户端-1”执行exec之前修改了key值,造成事务没有执行(exec结果为 null),整个代码如下所示:

      #T1:客户端1
      127.0.0.1:6379> set key "java"
      OK
      #T2:客户端1
      127.0.0.1:6379> watch key
      OK
      #T3:客户端1
      127.0.0.1:6379> multi
      OK
      #T4:客户端2
      127.0.0.1:6379> append key python
      (integer) 11
      #T5:客户端1
      127.0.0.1:6379> append key jedis
      QUEUED
      #T6:客户端1
      127.0.0.1:6379> exec
      (nil)
      #T7:客户端1
      127.0.0.1:6379> get key
      "javapython"
      

      Redis提供了简单的事务,之所以说它简单,主要是因为它不支持事务中的 回滚特性,同时无法实现命令之间的逻辑关系计算,当然也体现了Redis的 “keep it simple”的特性。

  2. Lua用法简述

    Lua语言是1993年由巴西一个大学研究小组发明,其设计目标是作为嵌入式程序 移植到其他应用程序,它是由C语言实现的,虽然简单小巧但是功能强大,所以许 多应用都选用它作为脚本语言,尤其是在游戏领域,例如大名鼎鼎的暴雪公司将 Lua语言引入到“魔兽世界”这款游戏中,Rovio公司将Lua语言作为“愤怒的小鸟” 这款火爆游戏的关卡升级引擎,Web服务器Nginx将Lua语言作为扩展,增强自身 功能。Redis将Lua作为脚本语言可以帮助开发者定制自己的Redis命令,在这之 前,必须修改源码。以下是对Lua语言的使用做一个基本的介绍。

    • 数据类型及其逻辑处理

      Lua语言提供了如下几种数据类型:booleans(布尔)、numbers(数值) 、strings(字符串)、tables(表格),和许多高级语言相比,相对简 单。下面将结合例子对Lua的基本数据类型和逻辑处理进行说明。

      (1) 字符串

      下面定义一个字符串类型的数据:

      local strings val = "world"
      

      其中,local代表val是一个局部变量,如果没有local代表室全局变量。 print函数可以打印出变量的值,例如下面代码将打印world,其中“--”是 Lua语言的注释。

      -- 结果是“world”
      print(hello)
      

      (2) 数组

      在Lua中,如果要使用类似数组的功能,可以用tabels类型,下面代码使用 定义了一个tables类型的变量myArray,但和大多数编程语言不同的是, Lua的数据下标从1开始计算:

      local tables myArray = {"redis", "jedis", true, 88.0}
      --true
      print(myArray[3])
      

      如果想遍历这个数组,可以使用for和while,这些关键字和许多编程语言是 一致的。

      • for

        下面代码会计算1到100的和,关键字for以end作为结束符:

        local int sum = 0
        for i = 1, 100
        do
            sum = sum + i
        end
        --输出结果为5050
        print(sum)
        

        要遍历myArray,首先需要知道tables的长度,只需要在变量前加一个 #号即可:

        for i = 1, #myArrary
        do
            print(myArray[i])
        end
        

        除此之外,Lua还提供了内置函数ipairs,使用for index, value ipairs(tables)可以遍历出所有的索引下标和值:

        for index, value in ipairs(myArray)
        do
            print(index)
            print(index)
        end
        
      • while

        下面代码同样会计算1到100的和,只不过使用的是while循环,while 循环同样以end作为结束符。

        local int sum = 0
        local int i = 0
        while i <= 100
        do
            sum = sum + i
            i = i + 1
        end
        --输出结果为5050
        print(sum)
        
      • if else

        要确定数组中是否包含了jedis,有则打印true,注意if以end结尾, if后紧跟then:

        local tables myArray = {"redis", "jedis", true, 88.0}
        for i = 1, #myArray
        do
            if myArray[i] == "jedis"
            then
                print("true")
                break
            else
                --do nothing
            end
        end
        

      (3) 哈希

      如果要使用类似哈希的功能,同样可以使用tables类型,例如下面代码定义 了一个tables,每个元素包含了key和value,其中 strings1 .. strings2是将两个字符串进行连接:

      local tabels user_1 = {age = 28, name = "tome"}
      --user_1 age is 28
      print("user_1 age is ".. user_1["age"])
      

      如果要遍历user_1,可以使用Lua的内置函数pairs:

      for key,value in paris(user_1)
      do print(key .. value)
      end
      
    • 函数定义

      在Lua中,函数以funciton开头,以end结尾,funcName是函数名,中间部 分是函数体:

      function funcName()
          ...
      end
      

      contact函数将两个字符串拼接:

      function contact(str1, str2)
          return str1 .. str2
      end
      --"hello world"
      print(contact("hello","world"))
      
  3. Redis与Lua

    • 在Redis中使用Lua

      在Redis中执行Lua脚本有两种方法:eval和evalsha。

      (1)eval

      eval 脚本内容 key格式 key列表 参数列表

      下面例子使用了key列表和参数列表来为Lua脚本提供更多的灵活性:

      127.0.0.1:6379> eval 'return "hello " .. KEYS[1] .. ARGV[1]' 1 redis world
      "hello redisworld"
      

      此时KEYS[1]="redis",ARGV[1]="world",所以最终返回结果是"hello redisworld".

      如果Lua脚本较长,还可以使用redis-cli-eval直接执行文件。

      eval命令和--eval参数本质是一样的,客户端如果想执行Lua脚本,首先在 客户端编写好Lua脚本代码,然后把脚本作为字符串发送给服务端,服务端 会将执行结果返回给客户端,整个过程如下图:

      (2) evalsha

      除了使用eval,Redis还提供了evalsha命令来执行Lua脚本。如下图所示, 首先要将Lua脚本加载到Redis服务端,得到改脚本的SHA1校验和,evalsha 命令使用SHA1作为参数可以直接执行对应Lua脚本,避免每次发送Lua脚本的 开销。这样客户端就不需要每次执行脚本内容,而脚本也会常驻在服务端, 脚本功能得到了复用。

      加载脚本: script load命令可以将脚本内容加载到Redis内存中, 例如下面将lua_get.lua加载到Redis中,得到SH1为: "741dc2440db1fea7c0a0bde841fa68eefaf149c"

      # redis-cli script load "$(cat lua_get.lua)"
      "741dc2440db1fea7c0a0bde841fa68eefaf149c"
      

      执行脚本: evalsha的使用方法如下,参数使用SHA1值,执行逻辑和 eval一致。

      evalsha 脚本 SHA1 值 key 个数 key 列表 参数列表

      所以只需要执行如下操作,就可以调用lua_get.lua脚本:

      127.0.0.1:6379> evalsha 741dc2440db1fea7c0a0bde841fa68eefaf149c 1 redis world
      "hello redisworld"
      
    • Lua的Redis API

      Lua可以使用redis.call函数实现对Redis的访问,例如下面代码是Lua使 用redis.call调用了Redis的set和get操作:

      redis.call("set", "hello", "world")
      redis.call("get", "hello")
      

      放在Redis的执行效果如下:

      127.0.0.1:6379> eval 'return redis.call("get", KEYS[1])' 1 hello
      "world"
      

      除此之外Lua还可以使用redis.pcall函数实现对Redis的调用, redis.call和redis.pcall的不同在于,如果redis.call执行失败,那么 脚本执行结束会直接返回错误,而redis.pcall会忽略错误继续执行脚本, 所以在实际开发中要根据具体的应用场景进行函数的选择。

      开发提示:Lua可以使用redis.log函数将Lua脚本的日志输出到Redis的日 志文件中,但是一定要控制日志级别。

  4. 案例

    Lua脚本功能为Redis开发运维人员带来如下三个好处:

    • Lua脚本在Redis中是原子执行的,执行过程中间不会插入其他命令。
    • Lua脚本可以帮助开发和运维人员创造出自己定制的命令,并可以将这些命令 常驻在Redis内存中,实现复用的效果。
    • Lua脚本可以将多条命令一次性打包,有效地减少网络开销。

    下面以一个例子说明Lua脚本的使用,当前列表记录着热门用户的id,假设这个 列表有5个元素,如下所示:

    127.0.0.1:6379> lrange hot:user:list 0 -1
    1) "user:1:ratio"
    2) "user:8:ratio"
    3) "user:3:ratio"
    4) "user:99:ratio"
    5) "user:72:ratio"
    

    user:{id}:ratio代表用户的热度,它本身又是一个字符串类型的键:

    127.0.0.1:6379> mget user:1:ratio user:8:ratio user:3:ratio user:99:ratio user:72:ratio
    1) "986"
    2) "762"
    3) "556"
    4) "400"
    5) "101"
    

    现要求将列表中所有的键对应热度做加1操作,并且保证是原子执行,此功能可以 利用Lua脚本来实现。

    1)将列表中所有元素取出,赋值给mylist:

    local mylist = redis.call("lrange", KEYS[1], 0, -1)
    

    2)定义局部变量count=0,这个count就是最后incr的总次数:

    local count = 0
    

    3)遍历mylist中所有元素,每次做完count自增,最后返回count:

    for index.key in pairs(mylist)
    do
        redis.call("incr",key)
        count = count + 1
    end
    return count
    

    将上述脚本写入lrange_and_mincr.lua文件中,并执行如下操作,返回结果为5。

    redis-cli  --eval lrange_and_mincr.lua hot:user:list
    (integer) 5
    

    执行后所有用户的热地自增1:

    127.0.0.1:6379> mget user:1:ratio user:8:ratio user:3:ratio user:99:ratio user:72:ratio
    1) "987"
    2) "763"
    3) "557"
    4) "401"
    5) "102"