从悲观锁、乐观锁到分布式锁
前言
我们在设计商品秒杀模块时为了防止“库存”超卖的情况,我们常常会使用一个锁的机制,解决多线程下数据一致性问题,但是在分布式集群下单节点的锁往往是无法满足业务的需求,本篇博客从悲观锁、乐观锁开始,逐步介绍分布式数据一致性的利器——分布式锁。
正文
悲观锁与乐观锁
悲观锁与乐观锁的差异:
- 悲观锁:一段执行逻辑加上悲观锁,不同线程同时执行时,只能有一个线程执行,其他的线程在入口处等待,直到锁被释放
sychronized提供的是悲观锁 - 乐观锁:一段执行逻辑加上乐观锁,不同线程同时执行时,可以同时进入执行,在最后更新数据的时候要检查这些数据是否被其他线程修改了(版本和执行初是否相同),没有修改则进行更新,否则放弃本次操作.
常见实现悲观锁与乐观锁的方案:
Java
- 悲观锁:
synchronized或ReentrantLock - 乐观锁:
java.util.concurrent.atomic原子变量
Mysql
- 悲观锁:
for update - 乐观锁:时间戳或者
version版本号
Redis
- 悲观锁:无
- 乐观锁:使用
watch监控对象变化实现乐观锁
Zookeeper
- 悲观锁:无
- 乐观锁:使用
version版本号实现乐观锁
分布式锁
有的时候,我们需要保证一个方法在同 一时间内只能被同一个线程执行。在单机模式下,可以通过sychronized、锁等方式来实现。
分布式锁的三个动作:
- 加锁
- 解锁
- 锁过期
实现分布式锁的解决方案
数据库锁方案
- 通过一个一张表的一条记录,来判断资源的占用情况
- 使用基于数据库的排它锁 (即
select * from tb_User for update) - 使用乐观锁的方式,即
CAS操作(或version字段)
Redis方案
Redis分布式锁的三种行为:
- 加锁:使用
setnx来抢夺锁,将锁的标识符设置为1,表示锁已被占用。 - 解锁:使用
setnx来释放锁,将锁的标识符设置为0,表示锁已被释放。 - 锁过期:用
expire给锁加一个过期时间防止锁忘记了释放,expire时间过期将返回0。
setnx和expire都是原子操作,实际应用中使用lua脚本来确保操作的原子性。
Redis某些极端情况下会导致数据不一致的情况:
情况一:master宕机
redis cluster集群环境下,假如现在A客户端想要加锁,它会根据路由规则选择一台master节点写入key mylock,在加锁成功后,master节点会把key异步复制给对应的slave节点。- 如果此时
redis master节点宕机,为保证集群可用性,会进行主备切换,slave变为了redis master。B客户端在新的master节点上加锁成功,而A客户端也以为自己还是成功加了锁的。 - 此时就会导致同一时间内多个客户端对一个分布式锁完成了加锁,导致各种脏数据的产生。
情况二:锁过期了,业务还没做完
某种特殊的场景导致业务处理比较慢,但是锁过期了,数据被其他线程修改,导致脏数据的产生。
情况三:长时间获取不到锁,导致事务超时
事务超时用于控制事务执行的超时,执行时间是事务内所有代码执行总和,单位为秒。默认值为-1表示永不超时,如果部分业务设置了超时时间,此时如果长时间获取不到锁,就会导致事务超时自动回滚。
情况四:B的锁被A给释放了
模拟场景:
- A、B两个线程来尝试给
key myLock加锁,A线程先拿到锁(假如锁3秒后过期),B线程就在等待尝试获取锁。 - 那如果此时业务逻辑比较耗时,执行时间已经超过
redis锁过期时间,这时A线程的锁自动释放(删除key),B线程检测到myLock这个key不存在,执行
SETNX命令也拿到了锁。 - 但是,此时A线程执行完业务逻辑之后,还是会去释放锁(删除
key),这就导致B线程的锁被A线程给释放了。
解决方案:每个线程加锁时要带上自己独有的value值来标识,只释放指定value的key,否则就会出现释放锁混乱的场景。
Redisson方案
Redisson是一个企业级的开源Redis Client,也提供了分布式锁的支持:
Redisson所有指令都通过lua脚本执行,而lua脚本支持原子性执行;- 假设
Redisson设置一个key的默认过期时间为30s,Redisson中的“看门狗”机制,它会在你获取锁之后,每隔10秒帮你把key的超时时间设为30s这样的话,就算一直持有锁也不会出现key过期了,其他线程获取到锁的问题了; Redisson的“看门狗”逻辑保证了没有死锁发生:如果机器宕机了,看门狗也就没了。此时就不会延长key的过期时间,到了30s之后就会自动过期了,其他线程可以获取到锁;
RedLock方案
RedLock是多节点redis实现的分布式锁算法,它可以解决redis哨兵模式下异步的主从复制下master宕机时数据不一致导致锁的互斥失效。
RedLock的算法的特点:
- 同步:
RedLock由于每个redis的时间流速相差不多,所以RedLock可以认为是同步算法; - 互斥:任何时刻只能有一个
client获取锁; - 释放死锁:即使锁定资源的服务崩溃或者分区,仍然能释放锁;
- 容错性:只要多数
redis节点(一半以上)在使用,client就可以获取和释放锁;
假设有5个完全独立的redis主服务器:
- 获取当前时间戳;
client尝试按照顺序使用相同的key,value获取所有redis服务的锁,在获取锁的过程中的获取时间比锁过期时间短很多,这是为了不要过长时间等待已经关闭的redis服务。并且试着获取下一个redis实例。比如:TTL为5s,设置获取锁最多用1s,所以如果一秒内无法获取锁,就放弃获取这个锁,从而尝试获取下个锁;client通过获取所有能获取的锁后的时间减去第一步的时间,这个时间差要小于TTL时间并且至少有3个redis实例成功获取锁,才算真正的获取锁成功;- 如果成功获取锁,则锁的真正有效时间是
TTL减去第三步的时间差 的时间;比如:TTL是5s,获取所有锁用了2s,再减去时钟漂移(但实际上可以忽略),则真正锁有效时间为3s; - 如果客户端由于某些原因获取锁失败,便会开始解锁所有
redis实例;因为可能已经获取了小于3个锁,必须释放,否则影响其他client获取锁;
TTL: redis key 的过期时间或有效生存时间
时钟漂移:指两个电脑间时间流速基本相同的情况下,两个电脑(或两个进程间)时间的差值;如果电脑距离过远会造成时钟漂移值 过大
Zookeeper方案
Zookeeper分布式锁的特点:
- 基于
zookeeper临时有序节点可以实现的分布式锁。大致思想即为:每个客户端对某个方法加锁时,在zookeeper上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点。 - 判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。 当释放锁的时候,只需将这个瞬时节点删除即可。
Curator是一个zookeeper的开源客户端,也提供了分布式锁的实现;
使用zookeeper实现分布式锁的算法流程,大致如下:
- 如果锁空间的根节点不存在,首先创建
Znode根节点。这里假设为“/test/lock”。这个根节点,代表了一把分布式锁。 - 客户端如果需要占用锁,则在“
/test/lock”下创建临时的且有序的子节点。 - 客户端如果需要占用锁,还需要判断,判断自己创建的子节点是否为当前子节点列表中序号最小的子节点。如果是则认为获得锁,否则监听前一个
Znode子节点变更消息,获得子节点变更通知后重复此步骤直至获得锁; - 获取锁后,开始处理业务流程。完成业务流程后,删除对应的子节点,完成释放锁的工作。以便后面的节点获得分布式锁。
Redis分布式锁和Zookeeper分布式锁的区别
redis分布式锁,其实需要自己不断去尝试获取锁,比较消耗性能;zookeeper分布式锁,获取不到锁,注册个监听器即可,不需要不断主动尝试获取锁,性能开销较小;redis获取锁的那个客户端bug了或者挂了,那么只能等待超时时间之后才能释放锁;而zk的话,因为创建的是临时znode,只要客户端挂了,znode就没了,此时就自动释放锁;redis选择虽然使用了Raft算法但是它本身采用的是异步复制,所以它的数据并不是强一致性,某些极端情况下会导致数据不一致的情况,但是可以采用Redisson方案、RedLock方案减少这些极端情况带来的影响;zookeeper使用了基于Paxos算法的zab协议保证了数据的强一致性;