分布式锁设计与实现

分布式锁的实现由多种方式,但是不管怎样,分布式锁一般要有以下特点:

排他性:任意时刻,只能有一个client能获取到锁

容错性:分布式锁服务一般要满足AP,也就是说,只要分布式锁服务集群节点大部分存活,client就可以进行加锁解锁操作

避免死锁:分布式锁一定能得到释放,即使client在释放之前崩溃或者网络不可达

除了以上特点之外,分布式锁最好也能满足可重入、高性能、阻塞锁特性(AQS这种,能够及时从阻塞状态唤醒)等

分布式锁方案对比

 rediszookeekeretcd
一致性算法paxos/ZABraft
CAPAPCPCP/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

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值