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事务中的命令如果有某条执行失败,不会影响其他命令的执行,不会回滚。