分布式锁的实现由多种方式,但是不管怎样,分布式锁一般要有以下特点:
•排他性:任意时刻,只能有一个client能获取到锁
•容错性:分布式锁服务一般要满足AP,也就是说,只要分布式锁服务集群节点大部分存活,client就可以进行加锁解锁操作
•避免死锁:分布式锁一定能得到释放,即使client在释放之前崩溃或者网络不可达
除了以上特点之外,分布式锁最好也能满足可重入、高性能、阻塞锁特性(AQS这种,能够及时从阻塞状态唤醒)等
分布式锁方案对比
redis | zookeeker | etcd | |
---|---|---|---|
一致性算法 | 无 | paxos/ZAB | raft |
CAP | AP | CP | CP/AP |
高可用 | 主从 | N+1可用(奇数个) | N+1可用 |
接口类型 | 客户端 | 客户端 | http/grpc |
实现 | set命令 | 临时节点 | restful API |
关于分布式一致性协议,参考我之前的整理:几种常见的分布式一致性协议介绍
- redis无法保证数据一致性
- zk的性能比较差,扩展能力,社区活跃度低于etcd(ZK比较成熟)
- 可以选择基于etcd
DB锁
在数据库新建一张表用于控制并发控制,表结构可以如下所示:
CREATE TABLE `lock_table` (
`id` int(11) unsigned NOT NULL COMMENT '主键',
`key_id` bigint(20) NOT NULL COMMENT '分布式key',
`memo` varchar(43) NOT NULL DEFAULT '' COMMENT '可记录操作内容',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`,`key_id`),
UNIQUE KEY `key_id` (`key_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
key_id作为分布式key用来并发控制,memo可用来记录一些操作内容(比如memo可用来支持重入特性,标记下当前加锁的client和加锁次数)。将key_id设置为唯一索引,保证了针对同一个key_id只有一个加锁(数据插入)能成功。此时lock和unlock伪代码如下:
def lock :
exec sql: insert into lock_table(key_id, memo, update_time) values (key_id, memo, NOW())
if result == true :
return true
else :
return false
def unlock :
exec sql: delete from lock_table where key_id = 'key_id' and memo = 'memo'
注意,伪代码中的lock操作是非阻塞锁,也就是tryLock,如果想实现阻塞(或者阻塞超时)加锁,只修反复执行lock伪代码直到加锁成功为止即可。基于DB的分布式锁其实有一个问题,那就是如果加锁成功后,client端宕机或者由于网络原因导致没有解锁,那么其他client就无法对该key_id进行加锁并且无法释放了。为了能够让锁失效,需要在应用层加上定时任务,去删除过期还未解锁的记录,比如删除2分钟前未解锁的伪代码如下:
def clear_timeout_lock :
exec sql : delete from lock_table where update_time < ADDTIME(NOW(),'-00:02:00')
因为单实例DB的TPS一般为几百,所以基于DB的分布式性能上限一般也是1k以下,一般在并发量不大的场景下该分布式锁是满足需求的,不会出现性能问题。不过DB作为分布式锁服务需要考虑单点问题,对于分布式系统来说是不允许出现单点的,一般通过数据库的同步复制,以及使用vip切换Master就能解决这个问题。
以上DB分布式锁是通过insert来实现的,如果加锁的数据已经在数据库中存在,那么用select xxx where key_id = xxx for udpate
方式来做也是可以的。
Redis锁
Redis锁是通过以下命令对资源进行加锁:
set key_id key_value NX PX expireTime
其中,set nx命令只会在key不存在时给key进行赋值,px用来设置key过期时间,key_value一般是随机值,用来保证释放锁的安全性(释放时会判断是否是之前设置过的随机值,只有是才释放锁)。由于资源设置了过期时间,一定时间后锁会自动释放。
set nx
保证并发加锁时只有一个client能设置成功(Redis内部是单线程,并且数据存在内存中,也就是说redis内部执行命令是不会有多线程同步问题的),此时的lock/unlock伪代码如下:
def lock:
if (redis.call('set', KEYS[1], ARGV[1], 'ex', ARGV[2], 'nx')) then
return true
end
return false
def unlock:
if (redis.call('get', KEYS[1]) == ARGV[1]) then
redis.call('del', KEYS[1])
return true
end
return false
分布式锁服务中的一个问题
如果一个获取到锁的client因为某种原因导致没能及时释放锁,并且redis因为超时释放了锁,另外一个client获取到了锁,此时情况如下图所示:
那么如何解决这个问题呢,一种方案是引入锁续约机制,也就是获取锁之后,释放锁之前,会定时进行锁续约,比如以锁超时时间的1/3为间隔周期进行锁续约。
关于开源的redis的分布式锁实现有很多,比较出名的有redisson[1]、百度的dlock[2],关于分布式锁,笔者也写了一个简易版的分布式锁redis-lock,主要是增加了锁续约和可同时针对多个key加锁的机制。
对于高可用性,一般可以通过集群或者master-slave来解决,redis锁优势是性能出色,劣势就是由于数据在内存中,一旦缓存服务宕机,锁数据就丢失了。像redis自带复制功能,可以对数据可靠性有一定的保证,但是由于复制也是异步完成的,因此依然可能出现master节点写入锁数据而未同步到slave节点的时候宕机,锁数据丢失问题。
ETCD 分布式锁的基本原理
分布式环境下,多台机器上多个进程对同一个共享资源(数据、文件等)进行操作,如果不做互斥,就有可能出现“余额扣成负数”,或者“商品超卖”的情况。为了解决这个问题,需要分布式锁服务。
首先,来看一下分布式锁应该具备哪些条件:
- 互斥性:在任意时刻,对于同一个锁,只有一个客户端能持有,从而保证只有一个客户端能够操作同一个共享资源;
- 安全性:即不会形成死锁,当一个客户端在持有锁的期间崩溃而没有主动解锁的情况下,其持有的锁也能够被正确释放,并保证后续其它客户端能加锁;
- 可用性:当提供锁服务的节点发生宕机等不可恢复性故障时,“热备” 节点能够接替故障的节点继续提供服务,并保证自身持有的数据与故障节点一致。
- 对称性:对于任意一个锁,其加锁和解锁必须是同一个客户端,即,客户端 A 不能把客户端 B 加的锁给解了。
Etcd 实现分布式锁的基础
Etcd 的高可用性、强一致性不必多说,前面章节中已经阐明,本节主要介绍 Etcd 支持的以下机制:Watch 机制、Lease 机制、Revision 机制和 Prefix 机制,正是这些机制赋予了 Etcd 实现分布式锁的能力。
- Lease机制:即租约机制(TTL,Time To Live),Etcd 可以为存储的 key-value 对设置租约,当租约到期,key-value 将失效删除;同时也支持续约,通过客户端可以在租约到期之前续约,以避免 key-value 对过期失效。Lease 机制可以保证分布式锁的安全性,为锁对应的 key 配置租约,即使锁的持有者因故障而不能主动释放锁,锁也会因租约到期而自动释放。
- Revision机制:每个 key 带有一个 Revision 号,每进行一次事务加一,因此它是全局唯一的,如初始值为 0,进行一次
put(key, value)
,key 的 Revision 变为 1;同样的操作,再进行一次,Revision 变为 2;换成 key1 进行put(key1, value)
操作,Revision 将变为 3。这种机制有一个作用:通过 Revision 的大小就可以知道进行写操作的顺序。在实现分布式锁时,多个客户端同时抢锁,根据 Revision 号大小依次获得锁,可以避免 “羊群效应” (也称 “惊群效应”),实现公平锁。 - Prefix机制:即前缀机制,也称目录机制。例如,一个名为
/mylock
的锁,两个争抢它的客户端进行写操作,实际写入的 key 分别为:key1="/mylock/UUID1"
,key2="/mylock/UUID2"
,其中,UUID 表示全局唯一的 ID,确保两个 key 的唯一性。很显然,写操作都会成功,但返回的 Revision 不一样,那么,如何判断谁获得了锁呢?通过前缀/mylock
查询,返回包含两个 key-value 对的的 KeyValue 列表,同时也包含它们的 Revision,通过 Revision 大小,客户端可以判断自己是否获得锁,如果抢锁失败,则等待锁释放(对应的 key 被删除或者租约过期),然后再判断自己是否可以获得锁; - Watch机制:即监听机制,Watch 机制支持 Watch 某个固定的 key,也支持 Watch 一个范围(前缀机制),当被 Watch 的 key 或范围发生变化,客户端将收到通知;在实现分布式锁时,如果抢锁失败,可通过 Prefix 机制返回的 KeyValue 列表获得 Revision 比自己小且相差最小的 key(称为 pre-key),对 pre-key 进行监听,因为只有它释放锁,自己才能获得锁,如果 Watch 到 pre-key 的 DELETE 事件,则说明 pre-key 已经释放,自己已经持有锁。
etcd实现分布式锁流程
下面描述使用 Etcd 实现分布式锁的业务流程,假设对某个共享资源设置的锁名为:/lock/mylock
步骤 1: 准备
客户端连接 Etcd,以 /lock/mylock
为前缀创建全局唯一的 key,假设第一个客户端对应的 key="/lock/mylock/UUID1"
,第二个为 key="/lock/mylock/UUID2"
;客户端分别为自己的 key 创建租约 - Lease,租约的长度根据业务耗时确定,假设为 15s;
步骤 2: 创建定时任务作为租约的“心跳”
当一个客户端持有锁期间,其它客户端只能等待,为了避免等待期间租约失效,客户端需创建一个定时任务作为“心跳”进行续约。此外,如果持有锁期间客户端崩溃,心跳停止,key 将因租约到期而被删除,从而锁释放,避免死锁。
步骤 3: 客户端将自己全局唯一的 key 写入 Etcd
进行 put 操作,将步骤 1 中创建的 key 绑定租约写入 Etcd,根据 Etcd 的 Revision 机制,假设两个客户端 put 操作返回的 Revision 分别为 1、2,客户端需记录 Revision 用以接下来判断自己是否获得锁。
步骤 4: 客户端判断是否获得锁
客户端以前缀 /lock/mylock
读取 keyValue 列表(keyValue 中带有 key 对应的 Revision),判断自己 key 的 Revision 是否为当前列表中最小的,如果是则认为获得锁;否则监听列表中前一个 Revision 比自己小的 key 的删除事件,一旦监听到删除事件或者因租约失效而删除的事件,则自己获得锁。
步骤 5: 执行业务
获得锁后,操作共享资源,执行业务代码。
步骤 6: 释放锁
完成业务流程后,删除对应的key释放锁。
参考:http://www.xuyasong.com/?p=1789
参考:https://www.jianshu.com/p/8a4dc6d900cf