暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

今天来叨唠一下“Redis事务”

6曦轩 2021-06-21
303

前言

关于 Redis 的“起承转合”,我前面已经用五个篇章的长度作了一个 Redis 基础篇——“起”篇的详细阐述,相信大家无论之前有没有接触过 Redis,都能从中学到不少东西。基础篇的内容顾名思义,只是个基础,主要说了 Redis 的发展以及 Redis 的基本数据类型,内容跟平时使用关联会比较大,难度不算大,希望大家能好好消化。这里送上基础篇的飞机票: 

【起】Redis 概述篇——带你走过 Redis 的前世今生[1]

【起】Redis 基础篇——基本数据结构之String,Hash[2]

【起】Redis 基础篇——基本数据结构之 List,Set[3]

【起】Redis 基础篇——基本数据结构之 ZSet,Bitmap…[4]

【起】Redis 基础篇——基本数据结构之总结篇[5]

在“承”篇中,我会围绕 Redis 的原理来阐述,讲一些相对比较高级的特性,比如本篇章要讲到的 pub/sub(发布/订阅)模式,持久化机制,高性能特性,事务,内存回收机制等等,在接下来的篇章中,我会为大家穿针引线,把每个篇章的内容都串起来,这里就先不占用大家的前言篇章。

那话归正题,我们今天来看一下关于 Redis 的发布/订阅模式。

正文

Redis 事务

先看官网 

https://redis.io/topics/transactions/ 

http://redisdoc.com/topic/transaction.html

为什么要用事务

我们知道 Redis 的单个命令是原子性的(比如 get set mget mset
),如果涉及到多个命令的时候,需要把多个命令作为一个不可分割的处理序列,就需要用到事务。

例如我们之前说的用 setnx
实现分布式锁,我们先 set
,然后设置对 key 设置 expire,防止 del 发生异常的时候锁不会被释放,业务处理完了以后再 del,这三个动作我们就希望它们作为一组命令执行。

Redis 的事务有两个特点:

1.按进入队列的顺序执行;

    2.不会受到其他客户端的请求的影响;


Redis 的事务涉及到四个命令:multi(开启事务),exec(执行事务),discard(取消事务),watch(监视)

事务的用法

案例场景:tom 和 mic 各有 1000 元,tom 需要向 mic 转账 100 元。

tom 的账户余额减少 100 元,mic 的账户余额增加 100 元。

    127.0.0.1:6379> set tom 1000
    OK
    127.0.0.1:6379> set mic 1000
    OK
    127.0.0.1:6379> multi
    OK
    127.0.0.1:6379> decrby tom 100
    QUEUED
    127.0.0.1:6379> incrby mic 100
    QUEUED
    127.0.0.1:6379> exec
    1) (integer) 900
    2) (integer) 1100
    127.0.0.1:6379> get tom
    "900"
    127.0.0.1:6379> get mic
    "1100"

    通过 multi 的命令开启事务。事务不能嵌套,多个 multi 命令效果一样。

    multi 执行后,客户端可以继续向服务器发送任意多条命令, 这些命令不会立即被执行, 而是被放到一个队列中, 当 exec 命令被调用时, 所有队列中的命令才会被执行。

    通过 exec 的命令执行事务。如果没有执行 exec,所有的命令都不会被执行。如果中途不想执行事务了,怎么办?

    可以调用 discard 可以清空事务队列,放弃执行。

      multi
      set k1 1
      set k2 2
      set k3 3
      discard

      watch 命令

      在 Redis 中还提供了一个 watch 命令。

      它可以为 Redis 事务提供 CAS 乐观锁行为( Check and Set Compare and Swap),也就是多个线程更新变量的时候,会跟原值做比较,只有它没有被其他线程修改的情况下,才更新成新的值。

      我们可以用 watch 监视一个或者多个 key,如果开启事务之后,至少有一个被监视 key 键在 exec 执行之前被修改了, 那么整个事务都会被取消(key 提前过期除外)。可以用 unwatch 取消。

      client 1client 2
      127.0.0.1:6379> set balance 1000
      OK
      127.0.0.1:6379> watch balance
      OK
      127.0.0.1:6379> multi
      OK
      127.0.0.1:6379> incrby balance 100
      QUEUED
      -
      -127.0.0.1:6379> decrby balance 100
      (integer) 900
      127.0.0.1:6379> exec 
      (nil)
      127.0.0.1:6379> get balance 
      "900"
      -

      事务可能遇到的问题

      我们把事务执行遇到的问题分成两种,一种是在执行 exec 之前发生错误,一种是在执行 exec 之后发生错误。

      在执行 exec 之前发生错误

      比如:入队的命令存在语法错误,包括参数数量,参数名等等(编译器错误)。

        127.0.0.1:6379> multi
        OK
        127.0.0.1:6379> set ck 666
        QUEUED
        127.0.0.1:6379> hset ck 2673
        (error) ERR wrong number of arguments for 'hset' command
        127.0.0.1:6379> exec
        (error) EXECABORT Transaction discarded because of previous errors.

        在这种情况下事务会被拒绝执行,也就是队列中所有的命令都不会得到执行。

        在执行 exec 之后发生错误

        比如,类型错误,比如对 String 使用了 Hash 的命令,这是一种运行时错误。

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

          最后我们发现 set k1 1
          的命令是成功的,也就是在这种发生了运行时异常的情况下,只有错误的命令没有被执行,但是其他命令没有受到影响。

          这个显然不符合我们对原子性的定义,也就是我们没办法用 Redis 的这种事务机制来实现原子性,保证数据的一致。

          思考:

          为什么在一个事务中存在错误,Redis 不回滚?

          这个问题我们必须从 Redis 的设计开始说起,用过关系型数据库的朋友们应该会感到奇怪,为啥 Redis 不支持事务回滚?我们先看看,Redis 有没有必要支持回滚。我们知道,Redis 的命令执行失败只有在语法错误的情况下(在将这个命令放入事务队列期间,Redis能够发现此类问题),这个问题一般不会发生在生产环境中。而且事务回滚并不能解决任何程序错误。例如,如果某个查询会将一个键的值递增2,而不是1,或者递增错误的键,那么事务回滚机制是没有办法解决这些程序问题的。另外就是,Redis已经在系统内部进行功能简化,这样可以确保更快的运行速度,因为Redis不需要事务回滚的能力。

          Lua 脚本

          Lua/ˈluə/是一种轻量级脚本语言,它是用 C 语言编写的,跟数据的存储过程有点类似。


          使用 Lua 脚本来执行 Redis 命令的好处:

          1.一次发送多个命令,减少网络开销。

          2.Redis 会将整个脚本作为一个整体执行,不会被其他请求打断,保持原子性。3.对于复杂的组合命令,我们可以放在文件中,可以实现程序之间的命令集复用。


          在 Redis 中调用 Lua 脚本

          使用 eval ɪ'væl/ 方法,语法格式:

            redis> eval lua-script key-num [key1 key2 key3 ....] [value1 value2 value3 ....]

            eval 代表执行 Lua 语言的命令。

            lua-script 代表 Lua 语言脚本内容。key-num 表示参数中有多少个 key,需要注意的是 Redis 中 key 是从 1 开始的,如果没有 key 的参数,那么写 0。[key1 key2 key3…]是 key 作为参数传递给 Lua 语言,也可以不填,但是需要和 key-num 的个数对应起来。[value1 value2 value3 ….]这些参数传递给 Lua 语言,它们是可填可不填的。


            示例,返回一个字符串,0 个参数:

              redis> eval "return 'Hello World'" 0

              在 Lua 脚本中调用 Redis 命令

              使用 redis.call(command, key [param1, param2…])进行操作。语法格式:

                redis> eval "redis.call('set',KEYS[1],ARGV[1])" 1 lua-key lua-value

                command 是命令,包括 set、get、del 等。

                key 是被操作的键。param1,param2…代表给 key 的参数。


                注意跟 Java 不一样,定义只有形参,调用只有实参。

                Lua 是在调用时用 key 表示形参,argv 表示参数值(实参)。

                设置键值对

                在Redis 中调用 Lua 脚本执行 Redis 命令

                  redis> eval "return redis.call('set',KEYS[1],ARGV[1])" 1 ck 2673 
                  redis> get ck

                  以上命令等价于 set ck 2673。

                  在 redis-cli 中直接写 Lua 脚本不够方便,也不能实现编辑和复用,通常我们会把脚本放在文件里面,然后执行这个文件。

                  在 Redis 中调用 Lua 脚本文件中的命令,操作 Redis

                  创建 Lua 脚本文件:

                    cd usr/local/soft/redis5.0.5/src
                    vim ck.lua

                    Lua 脚本内容,先设置,再取值:

                      redis.call('set','ck','lua666')
                      return redis.call('get','ck')

                      在 Redis 客户端中调用 Lua 脚本

                        cd usr/local/soft/redis5.0.5/src
                        redis-cli --eval ck.lua 0

                        得到返回值:

                          [root@localhost src]# redis-cli --eval ck.lua 0
                          "lua666"

                          案例:对 IP 进行限流

                          需求:在 X 秒内只能访问 Y 次。

                          设计思路:用 key 记录 IP,用 value 记录访问次数。

                          拿到 IP 以后,对 IP+1。如果是第一次访问,对 key 设置过期时间(参数 1)。否则判断次数,超过限定的次数(参数 2),返回 0。如果没有超过次数则返回 1。超过时间, key 过期之后,可以再次访问。

                          KEY[1]是 IP, ARGV[1]是过期时间 X,ARGV[2]是限制访问的次数 Y。

                          --ip_limit.lua

                          --IP 限流,对某个 IP 频率进行限制 ,6 秒钟访问 10 次

                            local num=redis.call('incr',KEYS[1])
                            if tonumber(num)==1
                            then redis.call('expire',KEYS[1],ARGV[1])
                            return 1
                            elseif tonumber(num)>tonumber(ARGV[2])
                            then return 0
                            else
                            return 1
                            end

                            6 秒钟内限制访问 10 次,调用测试(连续调用 10 次):

                              ./redis-cli --eval    "ip_limit.lua" app:ip:limit:192.168.8.111 , 6 10

                              app:ip:limit:192.168.8.111 是 key 值 ,后面是参数值,中间要加上一个空格 和一个逗号,再加上一个空格。

                              即:./redis-cli –eval [lua 脚本] [key…]空格,空格[args…]

                              多个参数之间用一个 空格 分割 。

                              代码:LuaTest.java

                              缓存 Lua 脚本

                              为什么要缓存

                              在脚本比较长的情况下,如果每次调用脚本都需要把整个脚本传给 Redis 服务端,会产生比较大的网络开销。为了解决这个问题,Redis 提供了 EVALSHA 命令,允许开发者通过脚本内容的 SHA1 摘要来执行脚本。

                              如何缓存

                              Redis 在执行 script load 命令时会计算脚本的 SHA1 摘要并记录在脚本缓存中,执行 EVALSHA 命令时 Redis 会根据提供的摘要从脚本缓存中查找对应的脚本内容,如果找到了则执行脚本,否则会返回错误:"NOSCRIPT No matching script. Please use EVAL
                              ." ```



                              127.0.0.1:6379> script load "return 'Hello World'" "470877a599ac74fbfda41caa908de682c5fc7d4b" 127.0.0.1:6379> evalsha "470877a599ac74fbfda41caa908de682c5fc7d4b" 0 "Hello World"

                                自乘案例


                                Redis 有 incrby 这样的自增命令,但是没有自乘,比如乘以 3,乘以 5。


                                我们可以写一个自乘的运算,让它乘以后面的参数:

                                local curVal = redis.call("get", KEYS[1]) if curVal == false then curVal = 0 else curVal = tonumber(curVal) end curVal = curVal * tonumber(ARGV[1]) redis.call("set", KEYS[1], curVal) return curVal



                                  把这个脚本变成单行,语句之间使用分号隔开


                                  ```lua
                                  local curVal = redis.call("get", KEYS[1]); if curVal == false then curVal = 0 else curVal = tonumber(curVal) end; curVal=curVal *tonumber(ARGV[1]); redis.call("set", KEYS[1], curVal); return curVal

                                  script load '命令'

                                    127.0.0.1:6379> script load 'local curVal = redis.call("get", KEYS[1]); if curVal == false then curVal = 0 else curVal = tonumber(curVal) end; curVal = curVal * tonumber(ARGV[1]); redis.call("set", KEYS[1], curVal); return curVal'
                                    "be4f93d8a5379e5e5b768a74e77c8a4eb0434441"

                                    调用:

                                      127.0.0.1:6379> set num 2
                                      OK
                                      127.0.0.1:6379> evalsha be4f93d8a5379e5e5b768a74e77c8a4eb0434441 1 num 6
                                      (integer) 12

                                      脚本超时

                                      Redis 的指令执行本身是单线程的,这个线程还要执行客户端的 Lua 脚本,如果 Lua 脚本执行超时或者陷入了死循环,是不是没有办法为客户端提供服务了呢?

                                        eval 'while(true) do end' 0

                                        为了防止某个脚本执行时间过长导致 Redis 无法提供服务, Redis 提供了 lua-time-limit 参数限制脚本的最长运行时间,默认为 5 秒钟。

                                          lua-time-limit 5000(redis.conf 配置文件中)

                                          当脚本运行时间超过这一限制后,Redis 将开始接受其他命令但不会执行(以确保脚本的原子性,因为此时脚本并没有被终止),而是会返回“BUSY”错误。

                                          Redis 提供了一个 script kill 的命令来中止脚本的执行。新开一个客户端:

                                            script kill

                                            如果当前执行的 Lua 脚本对 Redis 的数据进行了修改(SET、DEL 等),那么通过 script kill 命令是不能终止脚本运行的。

                                              127.0.0.1:6379> eval "redis.call('set','ck','666') while true do end" 0

                                              因为要保证脚本运行的原子性,如果脚本执行了一部分终止,那就违背了脚本原子性的要求。最终要保证脚本要么都执行,要么都不执行。

                                                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 的区别在于 shutdown nosave 不会进行持久化操作,意味着发生在上一次快照后的数据库修改都会丢失。

                                                总结:如果我们有一些特殊的需求,可以用 Lua 来实现,但是要注意那些耗时的操作。

                                                By the way

                                                有问题?可以给我留言或私聊 有收获?那就顺手点个赞呗~

                                                当然,也可以到我的公众号下「6曦轩」,

                                                回复“学习”,即可领取一份 【Java工程师进阶架构师的视频教程】~

                                                回复“面试”,可以获得: 【本人呕心沥血整理的 Java 面试题】

                                                回复“MySQL脑图”,可以获得 【MySQL 知识点梳理高清脑图】

                                                还有阿里云【腾讯云】的购买优惠噢~具体请联系我

                                                曦轩我是科班出身的程序员,php,Android以及硬件方面都做过,不过最后还是选择专注于做 Java,所以有啥问题可以到公众号提问讨论(技术情感倾诉都可以哈哈哈),看到的话会尽快回复,希望可以跟大家共同学习进步,关于服务端架构,Java 核心知识解析,职业生涯,面试总结等文章会不定期坚持推送输出,欢迎大家关注~

                                                References

                                                [1]
                                                 【起】Redis 概述篇——带你走过 Redis 的前世今生: https://blog.csdn.net/weixin_42669785/article/details/106687639
                                                [2]
                                                 【起】Redis 基础篇——基本数据结构之String,Hash: https://blog.csdn.net/weixin_42669785/article/details/106937843
                                                [3]
                                                 【起】Redis 基础篇——基本数据结构之 List,Set: https://blog.csdn.net/weixin_42669785/article/details/107410238
                                                [4]
                                                 【起】Redis 基础篇——基本数据结构之 ZSet,Bitmap…: https://blog.csdn.net/weixin_42669785/article/details/107410265
                                                [5]
                                                 【起】Redis 基础篇——基本数据结构之总结篇: https://blog.csdn.net/weixin_42669785/article/details/107530103


                                                文章转载自6曦轩,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

                                                评论