分布式锁
why:
在单体应用程序中,我们想要保证一个变量的可见性及原子性,我们可以用volatile(对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性)、synchronized、乐观锁、悲观锁等等来控制。并发情况下使用上面说的机制来保证变量的可见性及原子性就不可行了(如下图),从而产生了很多分布式机制(如分布式事务、分布式锁等),主要的作用是“保证数据的一致性”。
如上图,假设变量a是剩余库存,值为1,这时候三个用户进来下单,正好三个请求被分到了三个不同的服务节点上面,三个节点 检查剩余库存,发现还有1个,然后都去进行扣减,这样就导致库存为负数,有两个用户没有货发,就是俗称的超卖。在这种场景中,我们就需要一种方法解决这个问题,这就是分布式锁要解决的问题。
where:
不同的实现:
本地锁可以通过语言本身支持,要实现分布式锁,就必须依赖中间件,数据库、redis、zookeeper等,主要有以下几种实现方式:
1)Memcached:利用 Memcached 的 add 命令。此命令是原子性操作,只有在 key 不存在的情况下,才能 add 成功,也就意味着线程得到了锁。
2)Redis:和 Memcached 的方式类似,利用 Redis 的 setnx 命令(现在是set EX NX)。此命令同样是原子性操作,只有在 key 不存在的情况下,才能 set 成功。
3)Zookeeper:利用 Zookeeper 的顺序临时节点,来实现分布式锁和等待队列。Zookeeper 设计的初衷,就是为了实现分布式锁服务的。
4)Chubby:Google 公司实现的粗粒度分布式锁服务,底层利用了 Paxos 一致性算法。
redis的demo:
分布式锁的目标:
实现:
a、锁的可重入性(递归调用不应该被阻塞、避免死锁);
b、锁的超时(避免死锁、死循环等意外情况);
c、锁的阻塞(保证原子性等);
d、锁的特性支持(阻塞锁、可重入锁、公平锁、联锁、信号量、读写锁)
how:
基于数据库:
a、直接创建一张锁表,当我们要锁住某个方法或资源时,我们就在该表中增加一条记录,想要释放锁的时候就删除这条记录。给某字段添加唯一性约束,如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。
b、使用的是MySql的InnoDB引擎时,在查询语句后面增加for update
,数据库会在查询过程中(须通过唯一索引查询)给数据库表增加排他锁,我们可以认为获得排它锁的线程即可获得分布式锁,通过 connection.commit() 操作来释放锁。
优点:直接借助数据库,容易理解
缺点:数据库单点、无失效时间、不阻塞、不可重入、无法保证一定使用行锁(部分情况下MySQL自动使用表锁而不是行锁)、排他锁长时间不提交导致占用数据库连接等问题。
redis方案:
a、redis 的 setnx()、expire() 方法做分布式锁:setnx 的含义就是 SET if Not Exists
,其主要有两个参数 setnx(key, value)
。该方法是原子的,如果 key 不存在,则设置当前 key 成功,返回 1;如果当前 key 已经存在,则设置当前 key 失败,返回 0;
b、 Redlock 做分布式锁:Redlock 是 Redis 的作者 antirez 给出的集群模式的 Redis 分布式锁,它基于 N 个完全独立的 Redis 节点(通常情况下 N 可以设置成 5);
c、redisson 做分布式锁:redisson 是 redis 官方的分布式锁组件;
优点:性能好;
缺点:超时时间来控制锁的失效时间并不是十分的靠谱。
具体详解:
Setnx命令,并非单指Redis的setnx key value这条命令。一般代指Redis中对set命令加上nx参数进行使用,set这个命令,目前已经支持这么多参数可选:
SET key value [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL]。
原理(如下图):主要依托了它的key不存在才能set成功的特性,进程A拿到锁,在没有删除锁的Key时,进程B自然获取锁就失败了。(注意:加超时时间,时怕进程A不讲道理啊,锁没等释放呢,万一崩了,直接原地把锁带走了,导致系统中谁也拿不到锁。)
加超时时间,也会有问题:如果进程A操作锁内资源,超过设置的超时时间,那么就会导致其他进程拿到锁,等进程A回来了,回手就是把其他进程的锁删了,如图:
解决:超时后,错删别人锁的问题。
在用Setnx的时候,key虽然是主要作用,但是value也不能闲着,可以设置一个唯一的客户端ID,或者用UUID这种随机数。当解锁的时候,先获取value判断是否是当前进程加的锁,再去删除。if(uuid.equals(redisTool.get( 'Test')) redisTool.del( 'Test');
问题:get和del并非原子操作,还是有进程安全问题。
解决:可以使用Lua脚本,通过Redis的eval/evalsha命令来运行。(原因:再多lua脚本,执行也是一个命令(eval/evalsha)去执行的,一条命令没执行完,其他客户端是看不到的)。但是直接使用稍微复杂。可以直接使用Redisson(原因是:源码中加锁/释放锁操作都是用Lua脚本完成的,封装的非常完善,开箱即用)
RedLock(有Redisson为何还有这个RedLock):
RedLock的中文是直译过来的,就叫红锁。红锁并非是一个工具,而是Redis官方提出的一种分布式锁的算法。Redisson中,就实现了redLock版本的锁。对红锁的理解:多数成功。RedLock作者指出,需要多个实例,但是都是独自部署的,没有主从关系,之所以要用独立的,是避免了redis异步复制造成的锁丢失,比如:主节点没来的及把刚刚set进来这条数据给从节点,就挂了。
基于zookeeper:
基本思想:每个客户端对某个方法加锁时,在 Zookeeper 上与该方法对应的指定节点的目录下,生成一个唯一的临时有序节点。判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。当释放锁的时候,只需将这个临时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。
优点:有效的解决单点问题,不可重入问题,非阻塞问题以及锁无法释放的问题
缺点:效率没有“缓存实现的分布式锁”高
排他锁:
排他锁,又称写锁或独占锁。保证当前有且仅有一个事务获得锁,并且锁释放之后,所有正在等待获取锁的事务都能够被通知到。
Zookeeper 的强一致性特性,能够很好地保证在分布式高并发情况下节点的创建一定能够保证全局唯一性,即Zookeeper将会保证客户端无法重复创建一个已经存在的数据节点。可以利用Zookeeper这个特性,实现排他锁。
定义锁:通过Zookeeper上的数据节点来表示一个锁
获取锁:客户端通过调用 create
方法创建表示锁的临时节点,可以认为创建成功的客户端获得了锁,同时可以让没有获得锁的节点在该节点上注册Watcher监听,以便实时监听到lock节点的变更情况。
释放锁:客户端发生宕机、异常、正常结束;
共享锁:
共享锁,又称读锁。如果事务T1对数据对象O1加上了共享锁,那么当前事务只能对O1进行读取操作,其他事务也只能对这个数据对象加共享锁,直到该数据对象上的所有共享锁都被释放。
共享锁与排他锁的区别在于,加了排他锁之后,数据对象只对当前事务可见,而加了共享锁之后,数据对象对所有事务都可见。
定义锁:通过Zookeeper上的数据节点来表示一个锁,是一个类似于 /lockpath/[hostname]-请求类型-序号
的临时顺序节点;
获取锁:户端通过调用 create
方法创建表示锁的临时顺序节点,如果是读请求,则创建 /lockpath/[hostname]-R-序号
节点,如果是写请求则创建 /lockpath/[hostname]-W-序号
节点
获取锁的逻辑:
1)创建完节点后,获取 /lockpath 节点下的所有子节点,并对该节点注册子节点变更的Watcher监听
2)确定自己的节点序号在所有子节点中的顺序
3.1)对于读请求:1. 如果没有比自己序号更小的子节点,或者比自己序号小的子节点都是读请求,那么表明自己已经成功获取到了共享锁,同时开始执行读取逻辑 2. 如果有比自己序号小的子节点有写请求,那么等待3.2
3.2)对于写请求,如果自己不是序号最小的节点,那么等待
4)接收到Watcher通知后,重复步骤1
流程:
共享锁有羊群效应:
在实现共享锁的 “判断读写顺序”逻辑,第1个步骤是:创建完节点后,获取 /lockpath
节点下的所有子节点,并对该节点注册子节点变更的Watcher监听。任何一次客户端移除共享锁之后,Zookeeper将会发送子节点变更的Watcher通知给所有机器,系统中将有大量的 “Watcher通知” 和 “子节点列表获取” 这个操作重复执行。
这些重复操作很多都是 “无用的”,实际上:每个锁竞争者只需要关注序号比自己小的那个节点是否存在即可。