一、分布式锁简介
1.1为什么要分布式锁
在单机时代,虽然不需要分布式锁,但也面临过类似的问题,只不过在单机的情况下,如果有多个线程要同时访问某个共享资源的时候,我们可以采用线程间加锁的机制,即当某个线程获取到这个资源后,就立即对这个资源进行加锁,当使用完资源之后,再解锁,其它线程就可以接着使用了。例如,在JAVA中,甚至专门提供了一些处理锁机制的一些API(synchronize/Lock等)。
但是到了分布式系统的时代,这种线程之间的锁机制,就没作用了,系统可能会有多份并且部署在不同的机器上,这些资源已经不是在线程之间共享了,而是属于进程之间共享的资源。
因此,为了解决这个问题,我们就必须引入「分布式锁」。
1.2分布式锁是什么
分布式锁,是指在分布式的部署环境下,通过锁机制来让多客户端互斥的对共享资源进行访问。
1.3 分布式锁要满足哪些要求呢?
(1)排他性:在同一时间只会有一个客户端能获取到锁,其它客户端无法同时获取
(2)避免死锁:这把锁在一段有限的时间之后,一定会被释放(正常释放或异常释放)
(3)高可用:获取或释放锁的机制必须高可用且性能佳
1.4分布式锁的条件
1、在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行;
2、高可用的获取锁与释放锁;
3、高性能的获取锁与释放锁;
4、具备可重入特性;
5、具备锁失效机制,防止死锁;
6、具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。
二、分布式锁的实现方式有哪些?
目前主流的有三种,从实现的复杂度上来看,从上往下难度依次增加:
(1)基于数据库实现
(2)基于Redis实现
(3)基于ZooKeeper实现
(4)etcd实现分布式锁
2.1 基于乐观锁数据库实现
乐观锁机制其实就是在数据库表中引入一个版本号(version)字段来实现的。当我们要从数据库中读取数据的时候,同时把这个version字段也读出来,如果要对读出来的数据进行更新后写回数据库,则需要将version加1,同时将新的数据与新的version更新到数据表中,且必须在更新的时候同时检查目前数据库里version值是不是之前的那个version,如果是,则正常更新。如果不是,则更新失败,说明在这个过程中有其它的进程去更新过数据了。
如图,假设同一个账户,用户A和用户B都要去进行取款操作,账户的原始余额是2000,用户A要去取1500,用户B要去取1000,如果没有锁机制的话,在并发的情况下,可能会出现余额同时被扣1500和1000,导致最终余额的不正确甚至是负数。但如果这里用到乐观锁机制,当两个用户去数据库中读取余额的时候,除了读取到2000余额以外,还读取了当前的版本号version=1,等用户A或用户B去修改数据库余额的时候,无论谁先操作,都会将版本号加1,即version=2,那么另外一个用户去更新的时候就发现版本号不对,已经变成2了,不是当初读出来时候的1,那么本次更新失败,就得重新去读取最新的数据库余额。
通过上面这个例子可以看出来,使用「乐观锁」机制,必须得满足:(1)锁服务要有递增的版本号version(2)每次更新数据的时候都必须先判断版本号对不对,然后再写入新的版本号
2.2 基于乐观锁数据库实现
悲观锁也叫作排它锁,在Mysql中是基于 for update 来实现加锁的,例如:
//锁定的方法-伪代码public boolean lock(){ connection.setAutoCommit(false) for(){ result = select * from user where id = 100 for update; if(result){ //结果不为空, //则说明获取到了锁 return true; } //没有获取到锁,继续获取 sleep(1000); } return false;}//释放锁-伪代码connection.commit();
上面的示例中,user表中,id是主键,通过 for update 操作,数据库在查询的时候就会给这条记录加上排它锁。(需要注意的是,在InnoDB中只有字段加了索引的,才会是行级锁,否者是表级锁,所以这个id字段要加索引)
当这条记录加上排它锁之后,其它线程是无法操作这条记录的。
那么,这样的话,我们就可以认为获得了排它锁的这个线程是拥有了分布式锁,然后就可以执行我们想要做的业务逻辑,当逻辑完成之后,再调用上述释放锁的语句即可。
2.3基于Redis的实现
基于Redis实现的锁机制,主要是依赖redis自身的原子操作,例如:
SET user_key user_value NX PX 100
redis从2.6.12版本开始,SET命令才支持这些参数:NX:只在在键不存在时,才对键进行设置操作,SET key value NX 效果等同于 SETNX key value PX millisecond:设置键的过期时间为millisecond毫秒,当超过这个时间后,设置的键会自动失效
上述代码示例是指,当redis中不存在user_key这个键的时候,才会去设置一个user_key键,并且给这个键的值设置为 user_value,且这个键的存活时间为100ms
为什么这个命令可以帮我们实现锁机制呢?因为这个命令是只有在某个key不存在的时候,才会执行成功。那么当多个进程同时并发的去设置同一个key的时候,就永远只会有一个进程成功。当某个进程设置成功之后,就可以去执行业务逻辑了,等业务逻辑执行完毕之后,再去进行解锁。
解锁很简单,只需要删除这个key就可以了,不过删除之前需要判断,这个key对应的value是当初自己设置的那个。
另外,针对redis集群模式的分布式锁,可以采用redis的Redlock机制。
2.4基于ZooKeeper实现
zookeeper锁相关基础知识:
(1)zk一般由多个节点构成(单数),采用zab一致性协议。因此可以将zk看成一个单点结构,对其修改数据其内部自动将所有节点数据进行修改而后才提供查询服务。
(2)zk的数据以目录树的形式,每个目录称为 znode, znode中可存储数据(一般不超过1M),还可以在其中增加子节点。
(3)子节点有三种类型。序列化节点,每在该节点下增加一个节点自动给该节点的名称上自增。临时节点,一旦创建这个znode的客户端与服务器失去联系,这个 znode 也将自动删除。最后就是普通节点。
(4)Watch机制,client可以监控每个节点的变化,当产生变化会给client产生一个事件。
zk基本锁
原理:利用临时节点与watch机制。每个锁占用一个普通节点/lock,当需要获取锁时在/lock下创建一个临时节点,创建成功则表示获取锁成功,失败则watch/lock节点,有删除操作后再去争锁。临时节点好处在于当进程挂掉后能自动上锁的节点自动删除即取消锁。
缺点:所有取锁失败的进程都监听父节点,很容易发生羊群效应,即当释放锁后所有等待进程一起来创建节点,并发量很大。
zk锁优化
原理:上锁改为创建临时有序节点,每个上锁的节点均能创建节点成功,知识其序号不同。只有序号最小的可以拥有锁,当需要不是最小的则watch序号排在前面的一个节点(公平锁)。
zk步骤:
1.在/lock节点下创建一个有序临时节点(EPHEMERAL_SEQUENTIAL)。
2.判断创建的节点序号是否最小,如果是最小则获取锁成功。不是则取锁失败,然后watch序号比本身小的前一个节点。
3.当取锁失败,设置watch后则等待watch事件到来后,再次判断是否序号最小。
4.取锁成功则执行代码,最后删除本身节点,释放了锁。
zk优点:
具备高可用、可重入、阻塞锁特性,可解决失效死锁问题。
zk缺点:
因为需要频繁的创建和删除节点,性能上不如Redis方式。
2.5基于etcd实现分布式锁
etcd是与zookeeper类似的高可用强一致性的服务发现仓库,使用key-value的存储方式。相对于zookeeper具有以下优点:
(1)简单:使用Golang编写,部署更简单;使用HTTP 作为接口使用简单;使用Raft算法(2)保证强一致性,便于理解。
(3)数据持久化:默认数据一更新就进行持久化。
(4)安全:支持SSL客户端安全认证。
三、分布式锁总结
3.1 分布式锁存在的问题
(1)均可能存在多进程拥有锁的情况。redis锁主要是expire时间与代码执行时间的问题,zk锁的问题在于zk是通过心跳监控进程存活状态,如果进程进行GC pause或者因为网络原因导致很长时间没与zk联系,则将导致zk认为进程已挂,而后锁自动释放,而此时进程并未挂任然在执行。
(2)Redlock锁的时间问题。由于redis的expire的实现是通过pexpireat,如果某个节点发生时钟跳跃,则该节点可能过早释放锁导致一系列问题。
3.2 解决方案
(1)获取锁时提供一个fencing token(两种说法,一种说需要有序,一种说随机值就可以,我觉得随机值就可以),在进程获取锁后对数据进行操作时,数据所在的资源服务器需要去锁中查看当前token,如果token对的才执行,不对则放弃执行。
(2)我觉得对于放弃执行的应该在我们的代码块中增加类似事物的rollback的操作。因此如果资源服务器拒绝了我们的操作则表明此时起码已经存在了另外一个进程拥有锁了,为了保证数据安全性不能继续执行,因此需要回滚到执行代码块之前而继续去竞争锁。
(3)至于Redis锁的时间问题,Antirez说在运维层面是可以控制时钟跳跃的区间的,只要能控制跳跃区间与expire的比例就没问题,详细可看《基于Redis的分布式锁真的安全吗?》
3.3 总结
(1)大多数时候采用zk锁就好了,没必要再考虑安全性的问题。其实也可以通过zk锁+幂等校验来达到双层保障。
(2)fencing机制需要对数据服务进行修改适配,个人觉得没这个必要吧。。。
有疑问加站长微信联系(非本文作者)