参考:
图灵课堂
https://github.com/redisson/redisson/wiki/%E7%9B%AE%E5%BD%95
https://blog.csdn.net/asd051377305/article/details/108384490
分布式锁的引入
当在单机单线程情况下,是不用考虑任何并发问题的,一切都是那么的美好,那么的顺其自然。
在单机多线程情况下,就要考虑并发的问题,但是不慌,因为有JUC包提供的功能十分齐全的锁机制,可以给我们的代码保驾护航,免了我们的后顾之忧。
但是随着业务量的增长,分布式越来越多的出现在我们的项目代码中,再使用原始的JUC锁,就有些力不从心了,要进行技术选择的升级。
如果还是使用单机的JUC锁,那么就是在自己的节点上加锁,但是真正要操作的数据是多个节点机器并存的,那么就一定会出现重复操作的现象,这种问题是必须要避免的。例如秒杀情况下的超卖问题,此时请求流量突增,如果没有进行相关锁处理,一定会出现超卖问题,这个是一定要规避的。
1. 初始阶段,使用redis实现分布式锁,setnx key value;这样的命令是如果没有就设置值,如果有就不进行操作;返回值是integer类型;1:成功;0:失败;最后执行完因为代码后删除这个key就可以了;
2. 但是上面的还是有问题,如果刚加完锁,节点宕机,重启之后,去redis恢复数据,这个锁还是在的,那么别的就无法加锁了,造成了死锁问题;此时改进就是加一个超时时间,如果是分开操作,就是非原子操作,就可能还存在无法释放锁的问题,所以要在同一条命令中执行加锁和加超时时间。
在Redis中,SETNX(SET if Not Exists)命令本身并不直接支持设置超时时间(过期时间)。SETNX命令仅用于在键不存在时设置其值,如果键已存在,则不做任何操作。 要设置键的超时时间(过期时间),你需要使用EXPIRE命令或SET命令的扩展选项。但请注意,SETNX和EXPIRE是两个独立的命令,你需要分别执行它们。 然而,如果你想要在一个原子操作中实现SETNX并设置超时时间,你可以使用Redis的SET命令的扩展选项,如EX(以秒为单位设置键的过期时间)和NX(仅当键不存在时设置键的值)。以下是一个示例:
SET mykey myvalue EX 10 NX
这个命令会在mykey不存在时设置其值为myvalue,并设置其过期时间为10秒。如果mykey已经存在,则命令不会执行任何操作。
上面的这个操作是原子性的,可以保证加锁的同时加上锁超时时间。
3. 但是这样的就好了吗?不是,因为锁加上去了,但是有可能会出现别的线程来了释放锁,锁也没有带有标识,就可以会出现线程1加锁,线程2来了解锁,这样相当于没有加锁。
4. 那么就可以针对这样的情况进行优化,加上一个标识,如何在锁释放之前先判断一下是不是我这个线程加的锁,如果是那么就释放,如果不是就不释放。但是这里还是要注意一点,释放锁是不是原子性的,如果是分开写了两行,那么就不是原子性了,这个要注意。这块可以使用lua脚本来实现。
5. 还有一个情况就是,如果我突然之间执行时间增长,但是锁过期时间不变,那么到期就主动释放锁了,此时还是类似没有加锁,那么 还是有并发问题。
上面就是分布式锁的一些场景,需要多考虑。
分布式锁需满足四个条件
首先,为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:
- 互斥性。在任意时刻,只有一个客户端能持有锁。
- 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
- 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了,即不能误解锁。
- 具有容错性。只要大多数Redis节点正常运行,客户端就能够获取和释放锁。
锁,归根到底还是要加锁,解锁,只是要保证原子性。redisson就很好的保证了加锁解锁的原子性,是通过lua脚本来实现的,并且也使用了发布订阅功能来唤醒等待的线程。
Redisson分布式原理
redisson锁默认是非公平锁。
首先是要保证加锁解锁都是原子性的;然后还有一个就是加锁解锁都是同一个线程;然后就是锁不能在没有执行完业务代码之前就失效,要能够进行锁续命;然后别的没有获取到锁的线程要尝试获取锁,这个过程不能太频繁,要注意性能;并且当持有锁的线程释放锁之后要能令这些等待的线程能够获取到锁。
加锁和解锁是要一一对应的,否则会出现死锁的情况。
// 1.构造redisson实现分布式锁必要的Config Config config = new Config(); config.useSingleServer().setAddress("IP地址加端口号").setPassword("密码").setDatabase(0); // 2.构造RedissonClient RedissonClient redissonClient = Redisson.create(config); // 3.获取锁对象实例(无法保证是按线程的顺序获取到) RLock rLock = redissonClient.getLock(lockKey); try { /** * 4.尝试获取锁 * waitTimeout 尝试获取锁的最大等待时间,超过这个值,则认为获取锁失败 * leaseTime 锁的持有时间,超过这个时间锁会自动失效(值应设置为大于业务处理的时间,确保在锁有效期内业务能处理完) */ boolean res = rLock.tryLock((long)waitTimeout, (long)leaseTime, TimeUnit.SECONDS); if (res) { //成功获得锁,在这里处理业务 } } catch (Exception e) { throw new RuntimeException("aquire lock fail"); }finally{ //无论如何, 最后都要解锁 rLock.unlock(); }
注意:加锁和解锁都用到了lua脚本,lua可以保证原子性。
加锁
多个线程并发请求,调用getLock方法,某个线程获取到锁之后,别的线程就无法再获取锁,就进入一个等待队列。
获取到锁的线程:会执行业务逻辑。其中会启动一个异步后台线程去定时监控当前线程是否执行完毕,如果没有执行完毕,就进行过期时间的续期,默认是30S,是可以修改这个默认值的。这个是形象的看门口机制,是通过异步线程,并且是循环调用来实现的,组件中这样的设置很常见。
加锁的方法,先判断是否加锁了,如果没有锁,就加锁,设置过期时间,并且设置一个重入次数加1;返回null。
如果有锁了,并且是当前线程的,那么证明是锁重入场景,锁重入次数加1,重新设置过期时间;返回过期时间ttl。
如果有锁了,并且不是当前线程,就返回锁过期时间ttl。
返回null证明是加锁成功了,返回过期时间是让进入队列的竞争线程过去这么久之后进行尝试获取锁,以为如果一直频繁的自旋去尝试获取锁,对CPU来说是压力很大的,这个要多注意。
解锁:
如果锁不存在,那么就证明获取锁的线程已经执行完毕,所以释放过锁了;可能是以为宕机等原因没有来得发布消息告诉别的线程去抢锁,所以要发布一个消息;发布这个消息是为了令阻塞等待的线程去争抢锁。这里还用到了redis的消息的发布订阅功能。返回1.
如果锁存在,但是锁标识不一致,证明不是当前占用锁的线程,无法释放锁,无法执行后续逻辑。不允许释放非当前线程加的锁。返回null。
如果锁存在,并且锁标识一致,那么就将重入次数减一。
如果减一之后重入次数不为0,证明还有别的锁未释放,不能释放锁,重新设置过期时间,返回0;
如果减一之后重入次数未0,证明可以释放锁了,此时可以直接del掉锁,然后发布一个消息,通知等待的线程去争抢锁。返回1.
主从架构锁失效问题
因为为了保证高可用,我们一般都是要真的集群加从节点,用来保证数据的完整性。如果在master节点加锁成功,还没有来得及同步到从节点就宕机了,这个锁就失效了,还可能有锁并发问题。
CAP机制解析zk和redis分布式锁的区别
zk是CP架构的,就是保证了分区容错性和一致性,保证一致性的技术手段是半数机制原理,就是数据要存储入半数以上的节点才会认为是存储成功,否则就会认为存储失败,回滚插入操作。可以看成是同步的,必须要半数以上的写入成功才会认为是成功。
redis的分布式锁是保证AP,就是分区容错性和高可用性,高可用性是主节点接收到请求之后就返回写入成功,可以认为是一个异步的,后面异步进行从节点复制,但是可能会丢失部分数据,但是保证了高可用性。
高可用和一致性是天然相悖的,这个要看业务场景如何去均衡了。