分布式锁的实现方式与对比
from: https://www.pomelolee.com/1787.html
前言
随着互联网的发展,各种高并发、海量处理的场景越来越多。为了实现高可用、可扩展的系统,常常使用分布式,这样避免了单点故障和普通计算机cpu、内存等瓶颈。
但是分布式系统也带来了数据一致性的问题,比如用户抢购秒杀商品多台机器共同执行出现超卖等。有些同学容易将分布式锁与线程安全混淆,线程安全是指的线程间的协同。如果是多个进程间的协同需要用到分布式锁,本文总结了几种常见的分布式锁。
基于数据库
悲观锁—事务
比如用户抢购秒杀商品的场景,多台机器都接收到了抢购的请求,可以将获取库存、判断有货、用户付款、扣减库存等多个数据库操作放到一个事务,这样当一台机器与数据库建立链接请求了抢购商品这个事务,另外的机器只能等这个机器将请求完成才能操作数据库。在实际应用场景中,常常库存与交易是两个独立的系统,这时的事务是一个分布式事务,需要用到两段式、三段式提交。
优点:是比较安全的一种实现方法。
缺点:在高并发的场景下开销是不能容忍的。容易出现数据库死锁等情况。
乐观锁—基于版本号
乐观锁常常用于分布式系统对数据库某张特定表执行update操作。考虑线上选座的场景,用户A和B同时选择了某场次电影的一个座位,都去将座位的状态设置为已售。
设想这样的执行序列:
1、用户A判断该座位为未售状态;
2、用户B判断该座位为未售状态;
3、用户A执行update座位为已售;
4、用户B执行update座位为已售。
这样会出现同一个座位售出两次的情况,解决方案是在这张数据库表中增加一个版本号的字段。执行操作前读取当前数据库表中的版本号,在执行update语句时将版本号放在where语句中,如果更新了记录则说明成功,如果没有更新记录,则说明此次update失败。
加了乐观锁的执行序列:
1、用户A查询该座位,得到该座位是未售状态,版本号是5;
2、用户B查询该座位,得到该座位是未售状态,版本号是5;
3、用户A执行update语句将座位状态更新为已售,版本号更新为6;
4、用户B执行update语句时此时这个座位的记录版本号为6,没有版本号为5的这个座位的记录,执行失败。
优点:乐观锁的性能高于悲观锁,并不容易出现死锁。
缺点:乐观锁只能对一张表的数据进行加锁,如果是需要对多张表的数据操作加分布式锁,基于版本号的乐观锁是办不到的。
基于memcached
memcached可以基于add命令加锁。memcached的add指令是指如果有这个key,add命令则失败,如果没有这个key,则add命令成功。并且memcached支持设置过期时间的add原子操作。并发add同一个key也只有一个会成功。
基于memcached的add指令加分布式锁的思路为:定义一个key为分布式锁的key,如果add一个带过期时间的key成功则执行相应的业务操作,执行完判断锁是否过期,如果锁过期则不删除锁,如果锁没过期则删除锁。带过期时间是防止出现机器宕机,一直不能释放锁。
很多人基于memcached实现的分布式锁没有判断锁是否过期,执行完相应的业务操作直接删除锁会出现以下问题。
设想这样的执行序列:
1、机器A成功add一个带过期时间的key;
2、机器A在执行业务操作时出现较长时间的停顿,比如出现了较长时间的GC pause;
3、机器A还未在较长的停顿中恢复出来,锁已经过期,机器B成功add一个带过期时间的锁;
4、此时机器A从较长的停顿中恢复出来,执行完相应业务操作,删除了机器Badd的锁;
5、此时机器B的业务操作是在没有锁保护的情况下执行的。
但是memcached并没有提供一个判断key是否存在的操作,需要依赖于加锁的时候的时钟与执行完业务操作的时钟相减获得执行时间,将执行时间与锁的过期时间进行对比。或者将锁key对应的value设置为当前时间加上过期时间的时钟,执行完相应的业务操作获取锁key的值与当前时钟进行对比。
注:过期时间一定要长于业务操作的执行时间。
优点:性能高于基于数据库的实现方式。
基于redis
redis提供了setNx原子操作。基于redis的分布式锁也是基于这个操作实现的,setNx是指如果有这个key就set失败,如果没有这个key则set成功,但是setNx不能设置超时时间。
基于redis组成的分布式锁解决方案为:
1、setNx一个锁key,相应的value为当前时间加上过期时间的时钟;
2、如果setNx成功,或者当前时钟大于此时key对应的时钟则加锁成功,否则加锁失败退出;
3、加锁成功执行相应的业务操作(处理共享数据源);
4、释放锁时判断当前时钟是否小于锁key的value,如果当前时钟小于锁key对应的value则执行删除锁key的操作。
注:这对于单点的redis能很好地实现分布式锁,如果redis集群,会出现master宕机的情况。如果master宕机,此时锁key还没有同步到slave节点上,会出现机器B从新的master上获取到了一个重复的锁。
设想以下执行序列:
1、机器AsetNx了一个锁key,value为当前时间加上过期时间,master更新了锁key的值;
2、此时master宕机,选举出新的master,新的master正同步数据;
3、新的master不含锁key,机器BsetNx了一个锁key,value为当前时间加上过期时间;
这样机器A和机器B都获得了一个相同的锁;解决这个问题的办法可以在第3步进行优化,内存中存储了锁key的value,在执行访问共享数据源前再判断内存存储的锁key的value与此时redis中锁key的value是否相等如果相等则说明获得了锁,如果不相等则说明在之前有其他的机器修改了锁key,加锁失败。同时在第4步不仅仅判断当前时钟是否小于锁key的value,也可以进一步判断存储的value值与此时的value值是否相等,如果相等再进行删除。
此时的执行序列:
1、机器AsetNx了一个锁key,value为当前时间加上过期时间,master更新了锁key的值;
2、此时,master宕机,选举出新的master,新的master正同步数据;
3、机器BsetNx了一个锁key,value为此时的时间加上过期时间;
4、当机器A再次判断内存存储的锁与此时的锁key的值不一样时,机器A加锁失败;
5、当机器B再次判断内存存储的锁与此时的锁key的值一样,机器B加锁成功。
注:如果是为了效率而使用分布式锁,例如:部署多台定时作业的机器,在同一时间只希望一台机器执行一个定时作业,在这种场景下是允许偶尔的失败的,可以使用单点的redis分布式锁;如果是为了正确性而使用分布式锁,最好使用再次检查的redis分布式锁,再次检查的redis分布式锁虽然性能下降了,但是正确率更高。
基于zookeeper
基于zookeeper的分布式锁大致思路为:
1、客户端尝试创建ephemeral类型的znode节点/lock;
2、如果客户端创建成功则加锁成功,可以执行访问共享数据源的操作,如果客户端创建失败,则证明有别的客户端加锁成功,此次加锁失败;
3、如果加锁成功当客户端执行完访问共享数据源的操作,则删除znode节点/lock。
基于zookeeper实现分布式锁不需要设置过期时间,因为ephemeral类型的节点,当客户端与zookeeper创建的session在一定时间(session的过期时间内)没有收到心跳,则认为session过期,会删除客户端创建的所有ephemeral节点。
但是这样会出现两个机器共同持有锁的情况。设想以下执行序列。
1、机器A创建了znode节点/lock;
2、机器A执行相应操作,进入了较长时间的GC pause;
3、机器A与zookeeper的session过期,相应的/lock节点被删除;
4、机器B创建了znode节点/lock;
5、机器A从较长的停顿中恢复;
6、此时机器A与机器B都认为自己获得了锁。
与基于redis的分布式锁,基于zookeeper的锁可以增加watch机制,当机器创建节点/lock失败的时候可以进入等待,当/lock节点被删除的时候zookeeper利用watch机制通知机器。但是这种增加watch机制的方式只能针对较小客户端集群,如果较多客户端集群都在等待/lock节点被删除,当/lock节点被删除时,zookeeper要通知较多机器,对zookeeper造成较大的性能影响。这就是所谓的羊群效应。
优化的大致思路为:
1、客户端调用创建名为“lock/numberlock”类型为EPHEMERAL_SEQUENTIAL的节点;
2、客户端获取lock节点下所有的子节点;
3、判断自己是否是序号最小的节点的,如果是最小的节点则加锁成功,如果不是序号最小的节点,则在比自己小的并且最接近的节点注册监听;
4、当被关注的节点删除后,再次获取lock节点下的所有子节点,判断是否是最小序号,如果是最小序号则加锁成功;
优化后的思路,虽然能一定程度避免羊群效应,但是也不能避免两个机器共同持有锁的情况。