四、redis事务_真正实现分布式Lua_分布式锁

1、redis中的事务
1.1 在redis中事务相关的5个命令:
    watch [key1] [key2]:监视一个或多个key,在事务开始之前如果被监视的key有改动,则事务被打断
    multi:标记一个事务的开始
    exec:执行事务
    discard:取消事务的执行
    unwatch:取消监视的key
    注意:
        a.执行取消事务命令(discard)后,再进行事务的执行(exec),那么事务是不会执行的;
        b.事务中的命令出现命令性错误(例如:命令语法错误),执行事务时,所有的命令都不会被执行;
        c.事务中出现执行时错误(类似Java的运行时异常),执行事务时,部分命令会被执行成功,也即是不保证原子性;
        进一步说明:假设该事务有三步修改操作,就算在步骤1出现了执行时错误,步骤2、3依然会继续执行的;
        d.使用watch监视key在事务之前被改动,所有命令正常执行;
        e.使用watch监视key,此时在事务执行前key被改动,事务将取消不会执行所有命令;
1.2 redis中如何保证事务的原子性:redis中自带的事务命令,最致命就是不保证原子性,所以在使用redis的事务时,一定要谨慎。redis中可以通过Lua实现真正的事务操作。
    从 Redis 2.6.0 版本开始,通过内置的 Lua 解释器,可以使用 EVAL 命令对 Lua 脚本进行求值。Lua 嵌入 Redis 优势:
    1)减少网络开销: 不使用 Lua 的代码需要向 Redis 发送多次请求, 而脚本只需一次即可, 减少网络传输;
    2)原子操作: Redis 将整个脚本作为一个原子执行, 无需担心并发, 也就无需事务;
    3)复用: 脚本会永久保存 Redis 中, 其他客户端可继续使用.

   1.2.1 脚本语法:EVAL script numkeys key [key ...] arg [arg ...]
   1.2.1.1 参数说明:
        1) script  参数是一段 Lua 5.1 脚本程序,它会被运行在 Redis 服务器上下文中,这段脚本不必(也不应该)定义为一个 Lua 函数。
        2) numkeys 参数用于指定键名参数的个数。
        3) 键名参数 key [key ...] 从 EVAL 的第三个参数开始算起,表示在脚本中所用到的那些 Redis 键(key),这些键名参数可以在 Lua 中通过全局变量 KEYS 数组,用为基址的形式访问( KEYS[1] , KEYS[2] ,以此类推)。
        4) 附加参数 arg [arg ...] ,可以在 Lua 中通过全局变量 ARGV 数组访问,访问的形式和 KEYS 变量类似( ARGV[1] 、 ARGV[2] ,诸如此类)。
   1.2.1.2 使用示例1-eval命令:
        > eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1ParamVal key2ParamVal first second
        1) "key1"
        2) "key2"
        3) "first"
        4) "second"

        脚本代码说明:
        a. "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}"      是被求值的 Lua 脚本;
        b. 数字 2 指定了键名参数的数量;
        c. key1ParamVal 和 key2ParamVal 是键名参数值,分别使用 KEYS[1] 和 KEYS[2] 访问;
        d. first 和 second 则是附加参数,可以通过 ARGV[1] 和 ARGV[2] 访问;

   1.2.1.3 使用示例2-使用Lua脚本中函数,执行Redis命令:redis.call()、redis.pcall()
        > eval "return redis.call('set',KEYS[1],'bar')" 1 foo
        > eval "return redis.call('get','foo')" 0

        redis.call()与redis.pcall()的区别:
            redis.call() 在执行命令的过程中发生错误时,脚本会停止执行,并返回一个脚本错误,错误的输出信息会说明错误造成的原因。
            redis.pcall() 出错时并不引发(raise)错误,而是返回一个带 err 域的 Lua 表(table),用于表示错误:

   1.2.1.4 evalsha命令:根据给定的 sha1 校验码,对缓存在服务器中的脚本进行求值。
        EVALSHA sha1 numkeys key [key ...] arg [arg ...]
        --根据给定的 sha1 校验码,对缓存在服务器中的脚本进行求值
        LocalHost_Redis:0> evalsha "232fd51614574cf0867b83d384a5e898cfd24e5a" 0
        "hello moto"

        --载入一个脚本
        LocalHost_Redis:0> script load "return 'hello moto'"
        "232fd51614574cf0867b83d384a5e898cfd24e5a"

        --判断脚本是否被载入
        script exists 232fd51614574cf0867b83d384a5e898cfd24e5a

        --清空缓存
        script flush

        --杀死当前正在运行的 Lua 脚本,当且仅当这个脚本没有执行过任何写操作时,script kill 这个命令才生效。执行之后,当前正在运行的脚本会被杀死,执行这个脚本的客户端会从 EVAL 命令的阻塞当中退出,并收到一个错误作为返回值。假如当前正在运行的脚本已经执行过写操作,那么即使执行 SCRIPT KILL ,也无法将它杀死,因为这是违反 Lua 脚本的原子性执行原则的。在这种情况下,唯一可行的办法是使用 SHUTDOWN NOSAVE 命令,通过停止整个 Redis 进程来停止脚本的运行,并防止不完整(half-written)的信息被写入数据库中。
        script kill


        备注:
            1)EVAL 命令要求你在每次执行脚本的时候都发送一次脚本主体(script body)。Redis 有一个内部的缓存机制,因此它不会每次都重新编译脚本,不过在很多场合,付出无谓的带宽来传送脚本主体并不是最佳选择。为了减少带宽的消耗, Redis 实现了 EVALSHA 命令,它的作用和 EVAL 一样,都用于对脚本求值,但它接受的第一个参数不是脚本,而是脚本的 SHA1 校验和(sum)。
            2)EVALSHA 命令的表现如下:
            如果服务器还记得给定的 SHA1 校验和所指定的脚本,那么执行这个脚本
            如果服务器不记得给定的 SHA1 校验和所指定的脚本,那么它返回一个特殊的错误,提醒用户使用 EVAL 代替 EVALSHA

            3)客户端库的底层实现可以一直乐观地使用 EVALSHA 来代替 EVAL ,并期望着要使用的脚本已经保存在服务器上了,只有当 NOSCRIPT 错误发生时,才使用 EVAL 命令重新发送脚本,这样就可以最大限度地节省带宽。这也说明了执行 EVAL 命令时,使用正确的格式来传递键名参数和附加参数的重要性:因为如果将参数硬写在脚本中,那么每次当参数改变的时候,都要重新发送脚本,即使脚本的主体并没有改变,相反,通过使用正确的格式来传递键名参数和附加参数,就可以在脚本主体不变的情况下,直接使用 EVALSHA 命令对脚本进行复用,免去了无谓的带宽消耗。
    
    
    1.2.1.5 相关说明
    1)脚本的原子性:Redis 使用单个 Lua 解释器去运行所有脚本,并且, Redis 也保证脚本会以原子性(atomic)的方式执行:当某个脚本正在运行的时候,不会有其他脚本或 Redis 命令被执行。这和使用 MULTI / EXEC 包围的事务很类似。在其他别的客户端看来,脚本的效果(effect)要么是不可见的(not visible),要么就是已完成的(already completed)。另一方面,这也意味着,执行一个运行缓慢的脚本并不是一个好主意。写一个跑得很快很顺溜的脚本并不难,因为脚本的运行开销(overhead)非常少,但是当你不得不使用一些跑得比较慢的脚本时,请小心,因为当这些蜗牛脚本在慢吞吞地运行的时候,其他客户端会因为服务器正忙而无法执行命令。
    2)脚本缓存:Redis 保证所有被运行过的脚本都会被永久保存在脚本缓存当中,这意味着,当 EVAL 命令在一个 Redis 实例上成功执行某个脚本之后,随后针对这个脚本的所有 EVALSHA命令都会成功执行。刷新脚本缓存的唯一办法是显式地调用 SCRIPT FLUSH 命令,这个命令会清空运行过的所有脚本的缓存。通常只有在云计算环境中,Redis 实例被改作其他客户或者别的应用程序的实例时,才会执行这个命令。
    缓存可以长时间储存而不产生内存问题的原因是,它们的体积非常小,而且数量也非常少,即使脚本在概念上类似于实现一个新命令,即使在一个大规模的程序里有成百上千的脚本,即使这些脚本会经常修改,即便如此,储存这些脚本的内存仍然是微不足道的。事实上,用户会发现 Redis 不移除缓存中的脚本实际上是一个好主意。比如说,对于一个和 Redis 保持持久化链接(persistent connection)的程序来说,它可以确信,执行过一次的脚本会一直保留在内存当中,因此它可以在流水线中使用 EVALSHA 命令而不必担心因为找不到所需的脚本而产生错误(稍候我们会看到在流水线中执行脚本的相关问题)。
    
    3)全局变量保护:为了防止不必要的数据泄漏进 Lua 环境, Redis 脚本不允许创建全局变量。如果一个脚本需要在多次执行之间维持某种状态,它应该使用 Redis key 来进行状态保存。企图在脚本中访问一个全局变量(不论这个变量是否存在)将引起脚本停止, EVAL 命令会返回一个错误:
    LocalHost_Redis:0>eval 'a=10' 0
    "ERR Error running script (call to f_933044db579a2f8fd45d8065f04a8d0249383e57): @enable_strict_lua:8: user_script:1: Script attempted to create global variable 'a' " LocalHost_Redis:0>
    Lua 的 debug 工具,或者其他设施,比如打印(alter)用于实现全局保护的 meta table ,都可以用于实现全局变量保护。实现全局变量保护并不难,不过有时候还是会不小心而为之。一旦用户在脚本中混入了 Lua 全局状态,那么 AOF 持久化和复制(replication)都会无法保证,所以,请不要使用全局变量。避免引入全局变量的一个诀窍是:将脚本中用到的所有变量都使用 local关键字定义为局部变量。
    
    4)沙箱(sandbox)和最大执行时间:脚本应该仅仅用于传递参数和对 Redis数据进行处理,它不应该尝试去访问外部系统(比如文件系统),或者执行任何系统调用。除此之外,脚本还有一个最大执行时间限制,它的默认值是5 秒钟,一般正常运作的脚本通常可以在几分之几毫秒之内完成,花不了那么多时间,这个限制主要是为了防止因编程错误而造成的无限循环而设置的。最大执行时间的长短由lua-time-limit选项来控制(以毫秒为单位),可以通过编辑 redis.conf 文件或者使用 CONFIG GET 和 CONFIG SET 命令来修改它。当一个脚本达到最大执行时间的时候,它并不会自动被 Redis 结束,因为Redis必须保证脚本执行的原子性,而中途停止脚本的运行意味着可能会留下未处理完的数据在数据集(data set)里面。
    因此,当脚本运行的时间超过最大执行时间后,以下动作会被执行:
      a.Redis 记录一个脚本正在超时运行
      b.Redis 开始重新接受其他客户端的命令请求,但是只有 SCRIPT KILL 和 SHUTDOWN NOSAVE 两个命令会被处理,对于其他命令请求, Redis 服务器只是简单地返回 BUSY 错误。
      c.可以使用 SCRIPT KILL 命令将一个仅执行只读命令的脚本杀死,因为只读命令并不修改数据,因此杀死这个脚本并不破坏数据的完整性
      d.如果脚本已经执行过写命令,那么唯一允许执行的操作就是 SHUTDOWN NOSAVE ,它通过停止服务器来阻止当前数据集写入磁盘
    
    5)流水线(pipeline)上下文(context)中的 EVALSHA:在流水线请求的上下文中使用 EVALSHA 命令时,要特别小心,因为在流水线中,必须保证命令的执行顺序。一旦在流水线中因为 EVALSHA 命令而发生 NOSCRIPT 错误,那么这个流水线就再也没有办法重新执行了,否则的话,命令的执行顺序就会被打乱。为了防止出现以上所说的问题,客户端库实现应该实施以下的其中一项措施:
      a.总是在流水线中使用 EVAL 命令
      b.检查流水线中要用到的所有命令,找到其中的 EVAL 命令,并使用 SCRIPT EXISTS 命令检查要用到的脚本是不是全都已经保存在缓存里面了。如果所需的全部脚本都可以在缓存里找到,那么就可以放心地将所有 EVAL 命令改成 EVALSHA 命令,否则的话,就要在流水线的顶端(top)将缺少的脚本用 SCRIPT LOAD 命令加上去。
      可用版本:>= 2.6.0
      时间复杂度: EVAL 和 EVALSHA 可以在 O(1) 复杂度内找到要被执行的脚本,其余的复杂度取决于执行的脚本本身。        

1.3 redis 分布式锁
    redis在我们日常开发中,除了用来做缓存提高应用程序的性能,降低数据库压力之外。可能用途最广泛地当属用redis来做分布式锁了。
    在单机中,我们要解决并发时线程安全的问题会使用JDK的synchronized或者Lock类,或者直接使用线程安全的类,例如JUC(java.util.concurrent并发包)。而在大型的应用程序中,单机部署显然不能满足我们的需求,这个时候要在分布式集群环境中对互斥资源进行控制访问,就需要使用到分布式锁。

    1.3.1 通过redis实现分布式锁(3种方法):
        方法1:redis中主要通过setnx命令实现,全称是“SET if Not eXists”,意为如果不存在则写入。如果不存在key则返回1,已经存在了这个key,则会返回0。释放锁时直接调用del命令删除即可。
        示列:
            LocalHost_Redis:0>setnx redis_lock lock1
            "1"
            LocalHost_Redis:0>setnx redis_lock lock2
            "0"
            注意,使用setnx有一定的风险,我们知道加锁就有存在“死锁”的可能性,而打破死锁的方法之一就是主动释放资源(设置锁过期时间),然而setnx并没有提供过期时间的设置,redis提供了另外一个命令——expire来设置key值得过期时间,所以改造上面的例子为以下所示:
            设置redis_lock锁的过期为5秒
            LocalHost_Redis:0>expire redis_lock 5
            "1"

        方法2:通过组合setnx和expire命令,能达到我们想要的结果。但是请注意,它仍然存在一个问题,那就是这两个命令并不是原子性的,如果在执行expire redis_lock 5时,redis服务恰好宕机,此时这个key将会一直存在。好在redis为我们提供了set命令的分布式用法并且可以设置为过期时间,关键是原子性的。官方的命令参数为set key value [expiration EX seconds|PX milliseconds] [NX|XX]。 [expiration EX seconds|PX milliseconds]参数EX表示过期时间单位为“秒”,PX表示过期时间单位为“毫秒”。[NX|XX]参数NX表示“SET if Not eXists”不存在则写入,XX表示“SET if eXists”存在则写入,分布式锁的场景中使用“NX”参数。

        示列:设置一个key值名为“lock”的锁,5秒后自动删除
        set redis_lock lock1 ex 5 nx

posted @ 2020-12-09 17:10  爱笑的berg  阅读(584)  评论(0编辑  收藏  举报