分布式前修课:分布式锁技术

143 阅读9分钟

分布式锁技术

什么是锁?

在介绍分布式锁之前,我们先来聊一聊

和生活中的锁是一样的,都是为了锁住门或者其他东西而保护某些东西。

在程序中也是一样的,当出现多个线程对同一个资源进行操作的时候,如果没有进行合理的控制,那么就会出现问题,也就是我们常说的并发问题

举个小例子:

image-20211113103630349.png

 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基于底层指令来实现锁,且不能判断锁的状态,在锁竞争非常激烈的时候性能会下降很多

案例这里就不给了,后续实际操作出案例

分布式锁?

你突然肚子疼,厕所只有一个坑位。

你去上厕所,发现厕所门口站了三堆人,都在等这一个坑位

这就属于分布式场景,那在哪里体现出呢?

厕所外面多了一个大门,等厕所里的人出来之后,打开大门,三堆人发现之后开始抢着进去,第一个进去的人就将大门锁住,然后去上厕所

image-20211113200318825.png

那么,我们要考虑一个问题:已经存在分布式锁,那么是否需要加单机锁?

从理论上来讲应该是需要加上单机锁的。虽然是分布式场景,但是先从单机下将部分线程进行拦截,很大程度上能够降低开销。

既然是分布式场景下,那么必然是需要借助第三方组件,这样就涉及到了网络传输过程,高并发情况下必然会影响系统的整体性能和高可用性

image-20211113201042390.png

实现方式

基于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
  • 虽然这样就可以实现,但是我们并不能保证existsset在执行的过程中是原子操作,我们将其合在一起
 # 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语言开发的一款开源项目。

在容器化盛行的时代,诞生了两款开源项目:CoreOSEtcd

我们主要是来分析一下Etcd如何实现分布式锁:

  • /lock为前缀创建全局唯一key,那么根据其Prefix机制,争抢客户端想要连接写操作,而实际上写入的key会进行排序:/lock/UUID1, /lock/UUID2。而在Etcd中是支持设置租约,当租约到期,key会自动失效
  • 当一个客户端持有锁期间,其他客户端只能等待。而为了避免等待期间租约失效,客户端需创建一个定时任务作为心跳进行续约。如果再此期间执行业务出现异常,key会因租约到期而被删除,从而进行锁释放防止死锁

看着很像Redis的锁实现方式

  • 客户端执行PUT操作,将数据写入到Etcd中,根据其Revision机制,会给对应的客户端返回唯一的revision,客户端根据得到revision判断是否是当前/lock列表中最小的,如果是则认为获得锁;否则就监听自己的前一个key,一旦监听到删除时间或者租约失效而删除的事件,那么自己就获得锁

ZK加锁的方式,但是ZK没有租约,而是临时节点

  • 执行完对应的业务逻辑之后,将key删除就会自动释放锁

最后

这里我们主要是介绍一些原理性的东西,后面基于本节原理来做具体的程序实现,当然为了之后的通用性,会对其进行代码封装,保证拿来即用。

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿