Redis中的事务操作与线上秒杀案例

300 阅读6分钟

Redis中的事务是一个单独的操作,事务可以串联多个命令,将这些命令序列化依次执行,保证在执行的过程中不会被其他外来的命令插队。

Muitl,Exec,discard

在Mutil到Exec命令之间的命令都会被添加到命令队列中,在执行Exec命令时,会提交命令队列,依次执行队列中的命令,执行期间,队列中的命令不会被其他外来的命令插队,在组队的过程中可以通过discard命令来放弃组队操作。在组队期间如果遇到语法错误的命令,则整个命令队列将会被抛弃,如果在执行过程中某一个或者多个命令出错,则只是出错的命令执行失败,命令队列中的其他命令不会收到影响。

事务冲突问题

Redis的事务虽然能够保证多个命令依次执行,不会被其他命令插队,但是多个事务之间操作同一个共享数据时依旧会出现问题。

线上秒杀案例:

假设当前要做一个线上秒杀的活动,还剩最后一件商品时有多个用户同一时间捕捉到了最后一个库存,判断库存大于0后,都成功购买了此件商品,实际上只剩最后一件商品,却卖出了多件,虽然我们将削减库存的相关操作放到一个事务中,这些操作依次执行,多个事务之间也是依次执行,但由于事务中只能包含redis命令,无法存在对库存数量的判断逻辑,还是会导致超卖问题的发生。

悲观锁和乐观锁

悲观锁(Pessimistic Lock)

悲观锁顾名思义就是很悲观,每当去拿数据的时候都会认为别人可能会修改,所以在拿到数据时会先给数据上锁,直到对数据相关操作完成后才会释放锁,别人才能对数据进行操作。传统的关系型数据库就用到了很多的悲观锁,如行锁,表锁等,都是在做操作之前先上锁。

乐观锁(Optimistic Lock)

顾名思义就是很乐观,每次去拿数据时都会觉得别人不会对数据进行修改,当更新数据时,会先看在此期间有没有人对此数据进行过修改,使用版本号等机制。乐观锁适用于多读的应用类型,可以提高吞吐量。

watch操作解决秒杀超卖问题

利用上面的乐观锁思想我们可以解决炒卖问题,在执行mutil之前可以先对我们后续要操作的数据进行watch操作来监视这个数据,当执行exec时如果发现监视的数据已经被其他事务改变,我们就会放弃此次执行。而这也可以解决上面所说的超卖问题,只要多个用户捕捉到库存为一时,对库存数据执行watch操作,当其中一个用户成功购买,对库存数据进行改变后,其他用户事务执行时发现改数据已被改变,就会放弃执行,从而避免了超卖问题。但是上面的操作虽然解决了超卖问题,却出现了新的问题,假设在秒杀开启的一瞬间有一百个并发请求同时进来,watch到库存数据后,去执行事务,但是此时某一位用户先执行完事务秒杀成功,改变库存后,剩下的其他用户在执行事务时发现数据已经改变,就会放弃事务执行,从而导致其他所有的用户都秒杀失败,在这种情况下会导致有多个库存参与秒杀,经过一轮秒杀后还有库存残留的问题。

lua脚本解决库存残留问题

lua是一个脚本语言,没有提供强大的库,不适合作为独立的程序开发语言来使用,但是lua脚本可以轻松被C/C++调用,也可以调用C/C++的函数,所以是作为一款嵌入式脚本语言来使用。

lua脚本在Redis中的优势

lua可以将复杂或者多步Redis操作写成一个脚本,一次性提交给Redis执行,减少反复连接Redis的次数,提升性能。

lua脚本类似Redis中的事务,有一定的原子性,不会被其他命令插队,同时相较于Redis事务,lua脚本支持逻辑判断,而不是只能有redis命令,这也是可以解决库存残留问题的关键,之所以会出现超卖问题或者库存残留问题,无非是因为Redis中的事务只支持Redis命令,不支持更多的逻辑判断,所以无法在库存为零时进行判断停止秒杀,要么超卖要么库存遗留,而lua脚本不仅可以支持Redis命令,同时可以在其中穿插逻辑判断,在库存为零时,返回特定的值不继续扣减库存,有点类似Java中的同步代码块。使用lua脚本也可以同时解决超卖问题,所以我们这里使用lua脚本对执行Redis命令以及执行相关的逻辑判断时,不需要加事务,也不需要watch监视库存。

下面是一段lua脚本,我们需要传入秒杀成功集合key和商品库存key,当用户已秒杀返回2,库存小于等于0时返回0,秒杀成功扣减库存,同时将用户添加到已秒杀用户的集合中,返回1.

String secKillScript = "local userid=KEYS[1];\r\n" +
        "local prodid=KEYS[2];\r\n"+
        "local qtkey='sk:'..prodid..":kc";\r\n"+
        "local usersKey='sk:'..prodid..":user"\r\n"+
        "local userExists=redis.call("sismember",usersKey,userid);\r\n"+   
        "if tonumber(userExists) == 1 then \r\n"+         //用户已秒杀过
        "return 2;\r\n"+                                  //return 2
        "end\r\n"+                                     
        "local num= redis.call("get",qtkey);\r\n"+        //获取库存数据
        "if tonumber(num)<=0 then\r\n"+                   //库存小于等于0
        "return 0;\r\n"+                                  //返回1
        "else \r\n"+
        "redis.call("decr",qtkey);\r\n"+                  //库存大于0
        "redis.call("sadd",usersKey,userid);\r\n"+        //扣减库存,将用户添加到成功集合
        "end\r\n"+
        "return 1";                                       //返回1

执行lua脚本后,只需要对返回值做判断就能判断是否秒杀成功。

事务的三个特性

单独的隔离操作:

事务中的所有命令都会被序列化,依次执行,不会被外来命令插队。

没有隔离级别的概念:

与关系型数据库的事务相比,Redis的事务,直到命令队列被提交前不会对数据进行任何的实际操作。

不保证原子性

和关系型数据库不同的是,Redis事务中的命令如果有某条执行失败,不会影响其他命令的执行,不会回滚。