分布式锁实现方案
01 背景
在单机系统中,多个线程同时访问某个共享资源时,可以采用线程间加锁保证数据一致性。但是,在分布式系统中,程序运行在多台机器上,各个节点之间无从知晓共享资源的锁定状态,即这种共享资源已经不是线程级别的,而是进程之间的。
此时,就需要引入分布式锁,以实现多个客户端互斥访问共享资源。
需要加锁的场景需要满足以下条件:
1、共享资源;
2、共享资源互斥;
3、多任务环境。
分布式锁的思路是:在系统中提供一个全局唯一的针对共享资源获取锁的组件,系统中需要访问共享资源时,都向该组件申请锁,待使用完毕后,释放锁。
02 特点
分布式锁一般要有以下特点:
排他性:任意时刻,只能有一个客户端能获取到锁。
容错性:分布式锁服务一般要满足AP(即可用性Availability、分区容错性Partition tolerance,这与分布式事务强调一致性有区别),也就是说,只要分布式锁服务集群节点大部分存活,客户端就可以进行加锁解锁操作。
避免死锁:分布式锁一定能得到释放,即使客户端在释放之前崩溃或者网络不可达。
03 方案
针对分布式锁的实现,目前比较常用的方案:
1、 基于数据库实现分布式锁
2、 基于缓存(redis)实现分布式锁
3、 基于Zookeeper实现分布式锁
04 DB锁
4.1 实现
1、唯一约束
2、基于数据库来做分布式锁的话,通常有两种做法:
基于数据库的乐观锁(lock in share mode)
基于数据库的悲观锁(select ... for update)
4.2 特点
优点:
实现简单
缺点:
1、可用性差(锁的可用性依赖于数据库,如果数据库故障,则系统不可用);
2、数据库性能存在瓶颈,不适合高并发场景;
3、锁的失效时间难以控制,删除锁失败容易导致死锁。即这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
说明:一般在分布式系统中使用这种机制实现分布式锁时,需要业务侧增加控制锁超时和重试的流程。
05 Redis分布式锁
5.1 实现
加锁和解锁的锁必须是同一个,常见的解决方案是给每个锁一个钥匙(唯一ID),加锁时生成,解锁时判断。
1、redis原子操作
基于Redis实现的锁机制,主要是依赖redis自身的原子操作。
加锁
setnx命令加锁,并设置锁的有效时间和持有人标识:
SET user_key user_value NX PX 100
参数:
NX:只在在键不存在时,才对键进行设置操作,SET key value NX 效果等同于 SETNX key value
PX millisecond:设置键的过期时间为millisecond毫秒,当超过这个时间后,设置的键会自动失效
解锁
检查是否持有锁,然后删除锁:
delete values命令删除锁
value具有唯一性,这是避免了一种情况:假设A获取了锁,过期时间200ms,此时250ms之后,锁已经自动释放了,A去释放锁,但是此时可能B获取了锁。A客户端就不能删除B的锁了。
2、Redlock
使用redis做分布式锁的缺点在于:如果采用单机部署模式,会存在单点问题,只要redis故障了。加锁就不行了。
基于以上的考虑,其实redis的作者也考虑到这个问题,他提出了一个RedLock的算法,这个算法的意思大概是这样的:
Redlock的实现如下:
1)获取当前时间。
2)依次获取N个节点的锁
3)判断是否获取锁成功。
如果client在上述步骤中获取到了(N/2 + 1)个节点锁,并且每个锁的过期时间都是大于0的,则获取锁成功,否则失败。失败时释放锁。
4)释放锁。
对所有节点发送释放锁的指令,之所以要对所有节点操作?因为分布式场景下从一个节点获取锁失败不代表在那个节点上加锁失败,可能实际上加锁已经成功了,但是返回时因为网络抖动超时了。
3、Redisson
Redisson是一个企业级的开源Redis Client,也提供了分布式锁的支持。
5.2 特点
优点:
性能好,实现起来较为方便。
缺点:
1、单点问题。这里的单点指的是单master,就算是个集群,如果加锁成功后,锁从master复制到slave的时候挂了,也是会出现同一资源被多个client加锁的。
2、执行时间超过了锁的过期时间。它获取锁的方式简单粗暴,获取不到锁直接不断尝试获取锁,比较消耗性能。
3、redis的设计定位决定了它的数据并不是强一致性的,在某些极端情况下,可能会出现问题,不够健壮。即便使用redlock算法来实现,在某些复杂场景下,也无法保证其实现100%没有问题。
06 zookeeper分布式锁
6.1 方案
当某客户端要进行逻辑的加锁时,就在zookeeper上的某个指定节点的目录(locker目录)下生成一个唯一的临时有序节点(locker/node_N),然后判断自己是否是这些有序节点中序号最小的一个,如果是,则算是获取了锁。如果不是,则说明没有获取到锁,那么就需要在序列中找到比自己小的那个节点,并对其调用exist()方法,对其注册事件监听,当监听到这个节点被删除了,那就再去判断一次自己当初创建的节点是否变成了序列中最小的。如果是,则获取锁,如果不是,则重复上述步骤。
当释放锁的时候,只需将这个临时节点删除即可。
6.2 特点
优点:
有效的解决单点问题,不可重入问题,非阻塞问题以及锁无法释放的问题。实现起来较为简单。
缺点:
性能上不如使用缓存实现分布式锁,如果有较多的客户端频繁的申请加锁、释放锁,对于zk集群的压力会比较大。
07 选择
具体选择哪种分布式锁实现方案,需要结合业务场景,结合对性能、可靠性、复杂性的要求,具体如下:
1、从理解的难易程度角度(从低到高)
数据库 > 缓存 > Zookeeper
2、从实现的复杂性角度(从低到高)
Zookeeper >= 缓存 > 数据库
3、从性能角度(从高到低)
缓存 > Zookeeper >= 数据库
4、从可靠性角度(从高到低)
Zookeeper > 缓存 > 数据库
综上所述:
如果系统不想引入过多网元,可以采用数据库锁实现,好处就是比较容易理解,但是这种方案业务层控制逻辑多且复杂,需要对业务侧足够了解,易于理解但是实现复杂度最高。
如果追求高性能,Redis是最佳选择,但是redis是有可能存在隐患的,可能会导致数据不对的情况,可靠性不如ZK。
如果系统已经存在ZK集群,优先选用ZK实现,实现最简单,且可以提供高可靠性,性能稍逊Redis缓存方案。