Redis的基本事务机制
Redis 事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
Redis 事务的主要作用就是串联多个命令防止别的命令插队。
Multi、Exec、Discard
Multi:该命令其实是一个组队的过程,在输入Multi命令后,输入的命令会依次进入到命令队列,此时并不执行队列中任何一条语句的操作。
Exec:输入Exex命令后,Redis会亮命令队列中的命令依次执行,队列是先进先出。
Discard:在组队过程中放弃组队,清空命令队列。
两种常见错误:
1、组队错误:如果组队语句发生错误,在执行EXEC命令后会出现错误报告,整个命令队列的操作都会被取消。
2、执行错误:组队的语句并没有错误,但是存在逻辑的问题(比如:incr 字符串),在执行过程中会出现错误报告,但是队列中其他语句会正常执行,只会跳过错误的语句。
乐观锁
乐观锁(Optimistic Lock),每次取数据都乐观地认为其他线程不会修改数据,所以不会上锁。但是在更新的时候会判断一下在此期间有没有其他线程去更新这个数据,可以使用版本号等机制,一般用于多读,提高吞吐量。
Redis 利用 check-and-set 乐观锁机制实现事务。
Watch、Unwatch
在执行 multi 之前,先执行 watch key,可以对其进行监视。如果在事务执行中 key 被改变,那么当前事务将终端。
而 Unwatch 取消对所有 key 的监视。如果在执行 WATCH 命令之后,EXEC 命令或 DISCARD 命令先被执行,就不需要再执行 UNWATCH 命令 。
秒杀系统的问题
超卖
这是一个线程之间的并发问题:最简单的就是通过乐观锁解决。直接利用 Redis 中的 Multi 和 exec 命令进行解决。
通常乐观锁的判断条件是锁是否被使用,因此乐观锁解决过程中会出现一个新问题,售出成功率低十分低,比如还有库存量为100,此时20位用户共同抢同一件商品只有一位用户可以抢购成功,这明显不符合逻辑。
这就是 少卖,乐观锁造成库存遗留的问题。
解决方法:这里的锁**(其实所谓的锁就是一个条件,判断线程是否能进入业务执行操作的条件)**通过自己实现,在库存拿货前一刻,查询库存剩余量是否大于零,若大于零则可以购买。(该方法是针对库存问题的解决)
拓展:有些情况下,数据是否变化 可能是判断是否能执行下一步的唯一条件,那么应该怎么解决呢?
方法一:分批加锁,实现分段锁(每次锁定的资源减少)。
比如:现在库存数量为100,分成十份把数据存入十张表中,每张表管理10个库存量。当用户进行抢购时,从不同的表(也就是不同入口),分别进行抢购,售卖的成功率就能大大提高。
方法二:加入悲观锁,这个方法比较容易理解,就是把业务逻辑设置成串行执行的模式,但是在秒杀系统中是不可取的,因为这个方法效率十分低,在实际中并不能满足开发需求,但是在某些特定情况,如下面的一人一单问题可能会用到。
连接超时
连接超时问题:一般可以通过连接池(需要用 synchronized 关键词进行锁住池)进行解决。
一人一单
这个问题最简单的思想就是在执行下单操作前,查询该用户是否已经购买过该商品(数据库中用户id是唯一的,并且在购买记录中商品号和用户id会一起存入订单中),如果发现已经有订单,则不允许用户下单。
但是实际情况并非如此,现在很多平台都可以多方登录,最基础的就是PC端和移动端同时登录账号进行抢购。用户此前确实尚未购买商品,但是由于多线程并发抢购,会导致出现一人多单的问题。
解决方法:通过加入悲观锁, 也就是在代码块前加入 synchronized 关键字。这把锁的判断条件可以设置为用户的id,也就是每个用户只允许通过一个线程抢购商品,由于 synchronized 的条件需要是类或者对象,因此把用户id通过toString()和intern方法执行操作作为锁对象。
注意:toString()底层原理是通过new关键字关键不同的字符串实例,而intern()方法是从常量池进行查找,并非new出新的字符串对象,因此确保了是同一个用户在不同环境中使用的是同一把锁。
集群模式的并发问题
集群模式会将服务部署到不同的服务器中,各个服务器使用的JVM不同,synchronized锁本质上是基于JVM的锁监视器实现的,因此在不同的JVM中,其实使用了不同的锁,因此要解决这个并发问题,需要实现跨JVM锁或者说是跨进程锁。
分布式锁
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
基本属性:互斥、高可用、高性能(高并发)、安全性(死锁问题)。
其他特性:可重用性、公平性等。
分布式锁的实现方法
MySQL数据库:利用mysql本身的互斥锁机制。
Redis数据库:利用setnx的互斥命令。
Zookeeper:利用节点的唯一性和有序性实现互斥。
基于Redis实现分布式锁
获取锁
SETNX 和 EXPIRE
新问题: 加锁之后就马上出现了故障(宕机),无法设置过期时间?
采用下面的命令,实现原子性操作。
SET key (key其实就是一把锁) value EX time NX
释放锁
手动释放:DEL
超时释放(业务超时或者宕机)
问题一:误删锁(极端情况)
场景:某个线程(线程1)获取到锁,但是执行过程中进入了业务阻塞,并且阻塞时间过长,然后Redis将会主动执行超时释放锁,此时另一个线程(线程2)迫不及待获取到了锁并开始执行业务,这时后线程1从阻塞状态出来,完成业务并且释放了锁(线程1本身的锁已经被Redis释放,此时它释放的是线程2的锁),如果这时有线程3抢占到了这把锁,问题就会不断出现,业务逻辑将十分混乱。
解决:每个线程在释放锁的时候,判断这把锁是不是自己的锁,因此就要给锁加入标识。
标识的设置:在集群模式下,不同的JVM之间产生的线程id可能会又冲突,因此不能用线程id作为标识。因此可以借助UUID获取唯一标识,再拼接当前的线程id,完成标识的设置。
步骤:
1、通过UUID拼接线程id;
UUID.randomUUID().toString() + Thread.currentThread().getId();
2、在获取到锁的时候,把上面的值存入value中。
3、在释放锁的时候,查询当前锁里面存放的标识,与自己锁的唯一标识比较,相同则释放锁。
问题二:误删锁(更极端情况)
场景:基于问题一解决后,某个线程(线程1)获取到锁,在执行完业务准备释放锁,并且已经判断完锁的标识与自己的相符合,此时又进入了阻塞,而阻塞的时间过长,Redis又主动执行超时释放锁,此时另一个线程(线程2)迫不及待获取到了锁并开始执行业务,这时后线程1从阻塞状态出来,完成业务并且释放了锁(由于之前已经判断了标识,因此很自信的释放了锁),但是很明显,它的锁已经早就被释放了。
阻塞说明: 虽然线程1完成了业务,没有业务阻塞,但是JVM可能就在这时候执行full gc,同样会对线程造成阻塞。
根本原因: 判断锁标识和释放锁是两个先后的动作,没有保证原子性。
解决方法:
1、乐观锁
通过锁把两个动作加入到一个事务中,实现原子性。但是效率非常低,因此一般不推荐。
2、Lua脚本
将复杂的或者多步的redis操作,写成一个脚本,然后提交给redis执行,该脚本不会被其他命令插队,可以完成—些redis事务性的操作。