分布式锁技术
什么是锁?
在介绍分布式锁之前,我们先来聊一聊锁:
和生活中的锁是一样的,都是为了锁住门或者其他东西而保护某些东西。
在程序中也是一样的,当出现多个线程对同一个资源进行操作的时候,如果没有进行合理的控制,那么就会出现问题,也就是我们常说的并发问题
举个小例子:
public static void main(String[] args) {
final int[] i = {0};
final ExecutorService pool = Executors.newCachedThreadPool();
for (int j = 0; j < 100; j++) {
pool.submit(new Runnable() {
public void run() {
i[0]++;
}
});
}
pool.shutdown();
pool.awaitTermination(1, TimeUnit.HOURS);
System.out.println(i[0]);
}
执行结果会有非100的情况,这就是多线程操作问题
在多线程中,由于不能确定每个线程的操作顺序,每个线程都在抢着执行所以最终执行顺序是随机的。
那么,解决这个问题的重点就是对这个执行方法进行加锁操作
锁特性
我们多聊一会,在程序中锁和锁是还是有一定的区别的,举个重口味的例子:
你突然肚子疼,厕所只有一个坑位。
你跑到厕所管理员跟前询问:“厕所里还有人么?”,这时厕所管理员会对你说:“现在还有人,你先在这里等着”
你刚排好队恰好有另一个人也过来了,看你在排队然后跟在了你的后面
明白了么,这就是公平锁,就是说加锁之前会先检查检查是否有排队的线程,优先执行排在前列的线程。
需要注意的一点是:公平锁并不能保证却对的公平。
然后我们继续:
你突然肚子疼,厕所只有一个坑位。
然后你站在厕所门口等着,这是过来一个人二话不说就要去上厕所。然后你和他进行友好决斗之后去了厕所
这就是非公平锁:线程加锁从不考虑排队问题,每个线程都在竞争锁资源,哪个线程能够抢上锁谁就能执行
你突然肚子疼,厕所只有一个坑位,恰好被别人给占了
于是你在厕所门口一会问一遍:“你好了没?”,一直问知道上厕所的人出来
这就是自旋锁:线程加不上锁就循环转圈等待,知道能加上锁为止
你突然肚子疼,厕所只有一个坑位,恰好过去你就能上
你女朋友正好过来也想上厕所,发现是你在里面就直接能进去
这就是可重入:标识是同一个线程加锁之后可以再次获取锁而不必等待,也不会出现死锁
这节画面感太强了 🤢
单机版锁
在Java中,为我们提供了两种加锁方式:
- synchronized
- Lock
两者的区别在于:
synchronized属于指令关键字,标注在方法上,也可以包裹方法块,而Lock属于接口,只适用于方法调用,使用起来比较灵活synchronized在退出的时候会自动释放锁,而Lock需要手动释放synchronized属于可重入,不可中断,非公平锁;而Lock属于可重入,允许不公平【可以设置公平】Lock基于CAS乐观锁,可以判断锁的状态,基于不同场景可以设置不同的锁以提高性能,而synchronized基于底层指令来实现锁,且不能判断锁的状态,在锁竞争非常激烈的时候性能会下降很多
案例这里就不给了,后续实际操作出案例
分布式锁?
你突然肚子疼,厕所只有一个坑位。
你去上厕所,发现厕所门口站了三堆人,都在等这一个坑位
这就属于分布式场景,那在哪里体现出锁呢?
厕所外面多了一个大门,等厕所里的人出来之后,打开大门,三堆人发现之后开始抢着进去,第一个进去的人就将大门锁住,然后去上厕所
那么,我们要考虑一个问题:已经存在分布式锁,那么是否需要加单机锁?
从理论上来讲应该是需要加上单机锁的。虽然是分布式场景,但是先从单机下将部分线程进行拦截,很大程度上能够降低开销。
既然是分布式场景下,那么必然是需要借助第三方组件,这样就涉及到了网络传输过程,高并发情况下必然会影响系统的整体性能和高可用性
实现方式
基于MySQL实现原理
MySQL加锁使用量很少,如果并发量不是很大的情况下想要实现分布式锁,而且还不想添加新的组件。可以考虑使用
我们知道MySQL自身是支持锁操作的,如果采用InnoDB引擎的话,支持表级锁和行级锁。
当然我们在这里使用行级锁中排他锁
将自动提交关闭,通过对行select for update增加排他锁,执行成功之后就算是加锁成功,而其他线程再没办法对同一条数据加锁,到此我们就认为得到排他锁的线程加锁成功
想要解锁的话,也很简单,只要commit()提交事务即可实现
基于排他锁的实现方式虽然简单,但是存在如下问题:
- 排他锁会占用连接,产生连接爆满的问题
- 存在并发问题【当然,也说过在并发量不大的情况下】
基于Redis实现原理
Redis实现分布式锁的原理也非常简单:
- Redis是KV形式存储,只要我们能将一个KEY存储在
Redis中,那么这个锁也就添加成功,再有其他线程过来我们只需要判断这个key是否存在就可以
# 锁是否存在
EXISTS lock:1
# 加锁
SET lock:1 1
- 其中一个线程加锁成功,在执行业务的过程中出现异常而不能导致锁释放,其他线程无法正常抢锁。基于这种现象我们可以通过设置过期时间来处理这个问题
SET lock:1 1 EX 60
- 虽然这样就可以实现,但是我们并不能保证
exists和set在执行的过程中是原子操作,我们将其合在一起
# NX表示只有KEY不存在的时候才会设置
# 如果返回0,说明lock存在,如果返回1,说明不存在
SET lock:1 1 NX EX 60
- 业务执行有长有短,如果执行时间超过了设置的有效期,那么锁自动释放其他线程抢锁就会造成问题。这里有引出了看门狗机制:线程获取到锁之后,另外开启一个线程循环判断过期时间,在设置的过期时间内业务没有执行完成那么就给当前KEY续期
expire lock:1 60
- 释放锁只需要将KEY删除就可以
# 释放锁
DELETE lock:1
# 为了能够更加严谨一些,最好是能够先判断一下,防止误释放 ,所以我们来通过lua脚本来操作
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
以上就是Redis实现分布式锁的原理,当然我们并不需要自己实现,已经有现成的技术框架我们可以直接使用
当然,为了加深印象,我们也会自己实现一次这个加锁过程
基于Zookeeper实现原理
Zookeeper作为分布式协调框架是非常NB的存在,在Zookeeper中已树形结构存储,每一层下不能存在重复节点,而节点可以分为:
- 持久节点,持久有序节点
- 临时节点,临时有序节点
有序就说明会根据创建顺序从小到大排序
临时节点属于当线程和Zookeeper的连接断开,当前节点就会自动删除,能够避免出现死锁现象。
这连看门狗都省了
基于Zookeeper的实现方式就可以是这样的:
- 创建
/lock节点,用来统一管理锁相关节点 - 客户端争先在
/lock节点下创建临时有序节点,Zookeeper根据其连接对节点进行排序 - 获取
/lock节点下的节点列表,根据Zookeeper返回的信息验证自己是否是当前列表中最小的节点,如果是证明自己现在可以得到锁;否则就通过Watch机制监听自己前一个节点,一旦监听到删除事件那么自己就说明自己得到锁 - 执行业务逻辑之后,删除节点或者直接断开连接,临时节点自动删除就会释放锁
说起来可能比较简单,但是做起来会相对有写难度。不过别急,会专门来实现一下这个代码!!!
基于Etcd实现原理
Etcd 是一个高度一致的分布式键值存储,主要用来做K8s的后端数据库,是基于GO语言开发的一款开源项目。
在容器化盛行的时代,诞生了两款开源项目:CoreOS和Etcd
我们主要是来分析一下Etcd如何实现分布式锁:
- 以
/lock为前缀创建全局唯一key,那么根据其Prefix机制,争抢客户端想要连接写操作,而实际上写入的key会进行排序:/lock/UUID1, /lock/UUID2。而在Etcd中是支持设置租约,当租约到期,key会自动失效 - 当一个客户端持有锁期间,其他客户端只能等待。而为了避免等待期间租约失效,客户端需创建一个定时任务作为
心跳进行续约。如果再此期间执行业务出现异常,key会因租约到期而被删除,从而进行锁释放防止死锁
看着很像Redis的锁实现方式
- 客户端执行PUT操作,将数据写入到Etcd中,根据其Revision机制,会给对应的客户端返回唯一的revision,客户端根据得到revision判断是否是当前
/lock列表中最小的,如果是则认为获得锁;否则就监听自己的前一个key,一旦监听到删除时间或者租约失效而删除的事件,那么自己就获得锁
ZK加锁的方式,但是ZK没有租约,而是临时节点
- 执行完对应的业务逻辑之后,将key删除就会自动释放锁
最后
这里我们主要是介绍一些原理性的东西,后面基于本节原理来做具体的程序实现,当然为了之后的通用性,会对其进行代码封装,保证拿来即用。
我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。