分布式锁解决方案md
在分布式系统中,分布式锁用来解决分布式系统中多线程、多进程在不同机器上共享资源访问的问题。本文简要介绍分布式锁的四种实现机制,包括数据库、Redis缓存、Zookeeper和Etcd,以加深了解。
1、分布式锁介绍
在单体应用中,通过锁机制实现多线程对共享资源的访问的,在分布式系统中,由于多线程、多进程是分布在不同的机器上,单机部署的并发锁控制机制已经不能满足分布式要求。分布式锁就是解决分布式系统中共享资源访问的问题,与单体应用不同的是,资源控制的最小粒度也从线程升级到了进程。
1.1 分布式锁的设计原则
为了满足分布式系统中资源的并发访问控制,分布式锁在设计上应满足以下原则:
- 在分布式系统环境下,一个方法在同一个时间只能被一个机器的同一个线程执行
- 高可用架构保证获取锁与释放锁过程中可靠性
- 获取锁与释放锁的高性能保证
- 中断响应,等待锁的线程,程序可以根据需要取消对锁的请求,如果一个线程正在等待锁,那么它依然可以收到一个通知,被告知无需等待,可以停止等待,可以处理死锁
1.2 分布式锁实现方法
常见的分布式锁实现方法有几种:基于数据库通过唯一索引实现、基于缓存Redis实现、基于一致性算法Zookeeper或Etcd实现
- 基于数据库实现 :主要是 利用数据库的唯一索引 来实现,同一时刻只能允许一个竞争者获取锁,加锁时候在数据库中插入一条锁记录,利用业务id进行防重。当第一个竞争者加锁成功后,后续竞争者再来加锁就会报唯一键值冲突
下面将分别介绍以上几种实现方法。
2、基于数据库实现
CREATE TABLE `distributed_lock` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `unique_method` varchar(255) NOT NULL COMMENT '锁定的方法名', `holder_id` varchar(255) NOT NULL COMMENT '锁持有者id', `create_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), UNIQUE KEY `unique_method_index` (`unique_method`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
其中id字段为自增id,unique_method字段就是防重的唯一方法名,也就是加锁的对象。在表中创建了唯一索引,保证unique_method的唯一性。
1)加锁即插入一条记录
insert into distributed_lock(unique_method, holder_id) values (‘unique_method’, ‘holder_id’);
如果当前sql执行成功代表加锁成功,如果抛出唯一索引异常(DuplicatedKeyException)则代表加锁失败,当前锁已经被其他竞争者获取。
2)解锁很简单,直接删除此条记录即可
delete from methodLock where unique_method=‘unique_method’ and holder_id=‘holder_id’;
3)数据库实现简单,操作简单,用操作数据库的方式即可实现锁,但是存在以下问题:
3、基于Redis实现
3.1 Redis基本实现
Redis加锁的基本实现:使用setnx、expire、delete以及LUA脚本实现
1) 使用setnx设置超时时间,锁定资源,客户端在此超时时间内完成对共享资源的访问
set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
- 第一个为key,我们使用key来当锁,因为key是唯一的
- 第二个为value,这里是锁竞争者的id,在解锁时需要判断当前解锁的竞争者id是否为锁持有者
- 第三个SET_IF_NOT_EXIST即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作
- 第四个SET_WITH_EXPIRE_TIME给这个key加一个过期时间的设置,具体时间由第五个参数决定
- 第五个参数为time,与第四个参数相呼应,代表key的过期时间
2) 使用随机字符串做value值,预防以下情况:客户端1获取锁成功,在某个操作阻塞很久后超时,自动释放锁,客户端2拿到此资源的锁,客户端1从阻塞中恢复过来,释放客户端2的锁,所以这个值需要是随机的
3) 释放锁的操作需要使用LUA脚本,包括get、判读和del,保证操作的原子性
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
- 判断当前解锁的竞争者id是否为锁的持有者,如果不是直接返回失败,如果是则进入第2步;
- 删除key,如果删除成功,返回解锁成功,否则解锁失败。
注意到这里解锁其实是分为2个步骤,涉及到解锁操作的一个原子性操作问题。这也是为什么解锁的时候用Lua脚本来实现,因为Lua脚本可以保证操作的原子性。
4) 节点宕机,在failover过程中丧失了锁的安全性。在主从架构下,客户端A从master获取到锁,在master将锁同步到slave之前主节点宕机了,slave节点升级为主节点,客户端B获取了客户端A相同的资源,但是已经A已经获取了另外一个锁,锁安全失效。
4、基于Zookeeper实现
4.1 Zookeeper的节点类型
Zookeeper节点可以看成是树形结构,每个目录都被定义为一个目录节点znode,znode一共有四种类型:
- 持久节点(PERSISTENT):默认的节点类型。创建节点的客户端与zookeeper断开连接后,该节点依旧存在 。
- 持久节点顺序节点(PERSISTENT_SEQUENTIAL):所谓顺序节点,就是在创建节点时,Zookeeper根据创建的时间顺序给该节点名称进行编号。
- 临时节点(EPHEMERAL) :和持久节点相反,当创建节点的客户端与zookeeper断开连接后,临时节点会被删除。
Zookeeper分布式锁是基于临时顺序节点实现的。
4.2 排他锁实现
排他锁实现机制是线程在zookeeper上创建临时有序节点,使用watch监控资源节点等待获得锁。具体流程如下:
- 线程x申请锁资源时候,先去判断是否有其它事务持有锁,如果有需等待锁释放
- 多个线程1、2、3并发请求在zk上创建一个名为 /lock 的节点,同时只能有一个线程创建成功,假设线程1创建成功,那么线程2、3在创建,只会提示该节点已经存在,这样模拟线程1加锁成功,让他执行业务
- 此时让线程2、3加锁失败,就监听/lock这个节点,模拟排队等待锁被释放
- 当线程1执行完业务逻辑后,删除/lock节点,模拟释放锁
- 当/lock被删除后,就会被线程2、3监听到,他们就可以重新尝试创建该节点
4.3 共享锁实现
排他锁有一个缺点就是,如果并发量大,那么同一时刻会有很多连接对同一节点进行监听,但检测到删除事件后,zk需要通知所有的连接,所有连接收到监听后,会同一时间在发生高并发竞争,给性能带来严重损耗。多数场景下考虑使用共享锁实现:
- 读锁:共享锁,如果前面没有写节点,则直接上锁;如果前面有写节点,则等待距离自己最近的写节点释放锁
- 写锁:如果前面没有节点,则直接上锁,如果前面有节点,则等待释放
-
通过Zookeeper上节点表示一个锁,类似于“/lockpath/[hostname]-请求类型-序号”的临时顺序节点
-
客户端通过调用create方法创建表示锁的临时顺序节点,如果是读请求为“/lockpath/[hostname]-R-序号”,写请求为“/lockpath/[hostname]-W-序号”
-
将临时节点加入到锁请求队列中
-
根据先进先出算法,锁队列中的第一个获取锁资源,判断读写请求分为以下步骤:
- 对于读请求,如果没有比自己序号更小的子节点或者比自己序号小的子节点都是读请求,那么表明已经成功获得共享锁,开始执行读取逻辑;如果有比自己序号小的子节点有写请求,则等待锁资源
-
对于写请求,如果不是序号最小的节点,则等待锁资源,否则获得锁开始处理业务
-
当事务处理完成或异常中断,锁资源释放以后会唤醒所有在队列中的线程,从第四步开始尝试重新申请锁资源
-
线程会注册Watcher监听lockpath子节点中前一个节点的状态,形成一个等待队列
4.4 实现分析
Zookeeper实现分布式锁有以下特征:
- 解决不可重入:客户端加锁时将主机和线程信息写入锁中,下一次再来加锁时直接和序列最小的节点对比,如果相同,则加锁成功,锁重入。
- 锁释放时机:由于创建的节点是顺序临时节点,当客户端获取锁成功之后session会话突然断开,ZK也会自动删除这个临时节点。
- 单点问题:ZK是集群部署的,主要一半以上的机器存活,就可以保证服务可用性。
5、基于Etcd实现
5.1 Etcd分布式锁实现机制
- 比如,一个名为/mylock的锁,两个客户端同时进行写操作,实际写入的值分别为:key1=”/mylock/uuid1”和key2=”/mylock/uuid2”,其中uuid表示全局唯一。很显然写操作都会成功,但是返回的revision值不一样,那么判断谁获得了锁呢?
- 通过前缀/mylock查询,返回包含两个key-value对应的列表,同时也包含他们的revision值,通过Revision大小,客户端可以判断自己是否获得了锁。如果锁资源争抢失败,则等待锁释放再判断是否可以获得锁
- 在实现分布式锁时,可以通过对Revision值比自己小且相差最小的key(称为pre-key)值进行监控,因为只有它释放锁,自己才能获得锁。如果监测到pre-key的DELETE事件,则说明pre-key已释放,自己将获得锁资源。
5.2 Etcd分布式锁实现流程
Etcd分布式锁实现流程如下所示,分为6个阶段:
1)准备阶段
客户端连接Etcd,以/lock/mylock为前缀创建全局唯一的key,假设第一个客户端对应的key为“/lock/mylock/UUIDA”,第二个客户端对应的key为“/lock/mylock/UUIDB”,第三个客户端对应的key为“/lock/mylock/UUIDC”。客户端分别为自己的key创建租约lease,租约的长度根据业务耗时确定。
2)创建定时任务作为租约的“心跳”
当客户端持有锁期间,其它客户端只能等待,为了避免等待期间租约失效,客户端需要创建一个定时任务作为心跳以保证租约的有效性。此外,如果持有锁期间客户端奔溃,心跳停止,key值也会因为租约到期而被删除,从而释放锁资源,避免死锁。
3) 客户端将自己全局唯一的key写入Etcd
客户端进行Put操作,将步骤1中创建的key值绑定租约写入Etcd,根据Etcd的revision机制,ETCD中会根据事务的操作顺序记录revision值。同时,客户端需要记录Etcd返回的revision值,用于接下来判断是否获得锁。在图中,Etcd中插入三条key-value记录,Revision分别为1/2/3,其中客户端A返回的Revision值为1。
4)客户端判断是否获得锁
5)执行业务
客户端在获得锁资源后,执行业务逻辑。
6)获得锁
完成业务流程后对应的key释放锁。
6、总结
以上介绍了分布式锁实现的几种机制,总结如下:
- 数据库:基于唯一索引创建锁资源表实现
- Redis:Key-value数据结构,基于set key命令实现key值的唯一性实现锁资源控制,使用Redlock算法的redission实现
- Zookeeper:文件结构,利用临时存储节点的唯一性特性,使用客户端Curator实现
- Etcd:分布式Key-value项目,基于Reversion的全局唯一性实现锁资源控制,使用Etcd的客户端进行API操作
总结上表,对比分布式锁四种实现机制的特点:
-
从理解的难易程度角度(从低到高):数据库 > 缓存 > Etcd>=Zookeeper
-
从实现的复杂性角度(从低到高):Zookeeper >=Etcd > 缓存 > 数据库
-
从性能角度(从高到低):缓存 > Etcd > Zookeeper > 数据库
-
从可靠性角度(从高到低):Zookeeper=Etcd > 缓存 > 数据库