分布式锁的各种实现,一探究竟!

前言

全文字数 : 1W+

⏳ 阅览时长 : 15min

关键词 : 分布式锁、Redis、Etcd、ZooKeeper

今日咱们讲讲分布式锁,网上相关的内容有许多,可是比较涣散,刚好自己刚学习完总结下,分享给咱们,文章内容会比较多,咱们先从思维导图中了解要讲的内容。

分布式锁的各种实现,一探究竟!

什么是分布式锁

分布式锁是操控分布式体系之间同步拜访同享资源的一种办法,经过互斥来保持共同性。

了解分布式锁之前先了解下线程锁和进程锁:

线程锁:首要用来给办法、代码块加锁。当某个办法或代码运用锁,在同一时刻仅有一个线程履行该办法或该代码段。线程锁只在同一JVM中有作用,由于线程锁的完结在根本上是依靠线程之间同享内存完结的,比方Synchronized、Lock等

进程锁:操控同一操作体系中多个进程拜访某个同享资源,由于进程具有独立性,各个进程无法拜访其他进程的资源,因而无法经过synchronized等线程锁完结进程锁

比方Golang语言中的sync包就供给了根本的同步基元,如互斥锁

可是以上两种适合在单体架构运用,可是分布式体系中多个服务节点,多个进程涣散布置在不同节点机器中,此刻关于资源的竞争,上诉两种对节点本地资源的锁就无效了。

这个时分就需求分布式锁来对分布式体系多进程拜访资源进行操控,因而分布式锁是为了处理分布式互斥问题!

分布式锁的各种实现,一探究竟!

分布式锁的特性

互斥

互斥性很好了解,这也是最根本功用,便是在恣意时刻,只能有一个客户端才能获取锁,不能一起有两个客户端获取到锁。

防止死锁

为什么会呈现死锁,由于获取锁的客户端由于某些原因(如down机等)而未能开释锁,其它客户端再也无法获取到该锁,然后导致整个流程无法持续进行。

分布式锁的各种实现,一探究竟!

面临这种状况,当然有处理办法啦!

引进过期时刻:一般状况下咱们会设置一个 TTL(Time To Live,存活时刻) 来防止死锁,可是这并不能完全防止。

  1. 比方TTL为5秒,进程A取得锁
  2. 问题是5秒内进程A并未开释锁,被体系主动开释,进程B取得锁
  3. 刚好第6秒时进程A履行完,又会开释锁,也便是进程A开释了进程B的锁

只是加个过期时刻会设计到两个问题:锁过期和开释他人的锁问题

锁附加仅有性:针对开释他人锁这种问题,咱们能够给每个客户端进程设置【仅有ID】,这样咱们就能够在运用层就进行查看仅有ID。

主动续期:锁过期问题的呈现,是咱们对持有锁的时刻欠好进行预估,设置较短的话会有【提前过期】风险,可是过期时刻设置过长,或许锁长时刻得不到开释。

这种状况相同有处理办法,能够敞开一个看护进程(watch dog),检测失效时刻进行续租,比方Java技术栈能够用Redisson来处理。

可重入:

一个线程获取了锁,可是在履行时,又再次测验获取锁会产生什么状况?

是的,导致了重复获取锁,占用了锁资源,造成了死锁问题。

咱们了解下什么是【可重入】:指的是同一个线程在持有锁的状况下,能够屡次获取该锁而不会造成死锁,也便是一个线程能够在获取锁之后再次获取同一个锁,而不需求等候锁开释。

处理办法:比方完结Redis分布式锁的可重入,在完结时,需求凭借Redis的Lua脚本语言,并运用引证计数器技术,确保同一线程可重入锁的正确性。

容错

容错性是为了当部分节点(redis节点等)宕机时,客户端依然能够获取锁和开释锁,一般来说会有以下两种处理办法:

一种像etcd/zookeeper这种作为锁服务能够主动进行毛病切换,由于它本身便是个集群,另一种能够供给多个独立的锁服务,客户端向多个独立锁服务进行恳求,某个锁服务毛病时,也能够从其他服务获取到锁信息,可是这种缺陷很明显,客户端需求去恳求多个锁服务。

分类

本文会叙说四种关于分布式锁的完结,按完结办法来看,能够分为两种:自旋、watch监听

自旋办法

根据数据库和根据Etcd的完结便是需求在客户端未取得锁时,进入一个循环,不断的测验恳求是否能取得锁,直到成功或许超时过期停止。

监听办法

这种办法只需求客户端Watch监听某个key就能够了,锁可用的时分会通知客户端,客户端不需求反复恳求,根据zooKeeper和根据Etcd完结分布式锁便是用这种办法。

完结办法

分布式锁的完结办法有数据库、根据Redis缓存、ZooKeeper、Etcd等,文章首要从这几种完结办法并结合问题的办法打开叙说!

根据MySQL

运用数据库表来完结完结分布式锁,是不是感觉有点疑问,是的,我再写之前搜集资料的时分也有点疑问,尽管这种办法咱们并不推崇,可是咱们也能够作为一个计划来进行了解,咱们看看究竟怎样做的:

比方在数据库中创立一个表,表中包含办法名等字段,并在办法名name字段上创立仅有索引,想要履行某个办法,就运用这个办法名向表中刺进一条记载,成功刺进则获取锁,删去对应的行便是锁开释。

//锁记载表
CREATE TABLE `lock_info` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `name` varchar(64) NOT NULL COMMENT '办法名',
  PRIMARY KEY (`id`),
  UNIQUE KEY `idx_name` (`method_name`) 
) ENGINE=InnoD

这儿首要是用name字段作为仅有索引来完结,仅有索引确保了该记载的仅有性,锁开释就直接删掉该条记载就行了。

缺陷也许多:

  1. 数据库是单点,十分依靠数据库的可用性
  2. 需求额外自己维护TTL
  3. 在高并发常见下数据库读写是十分缓慢

这儿咱们就不必过多的文字了,实践中咱们更多的是用根据内存存储来完结分布式锁。

根据Redis

面试官问:你了解分布式锁吗?想必绝大部分面试者都会说关于Redis完结分布式锁的办法,OK,进入正题【根据Redis分布式锁】

Redis 的分布式锁, setnx 指令并设置过期时刻就行吗?

setnx lkey lvalue expire lockKey 30

正常状况下是能够的,可是这儿有个问题,便是setnx并不是原子性的,也便是说setnx和expire是分两步履行的,【加锁和超时】两个操作是分隔的,假如expire履行失利了,那么锁相同得不到开释。

关于为什么要加锁和超时时刻的设定在文章开头【防止死锁】有提到,不明白的能够多看看。

Redis正确的加锁指令是什么?

//确保原子性履行指令
SET lKey randId NX PX 30000

randId是由客户端生成的一个随机字符串,该客户端加锁时具有仅有性,首要是为了防止开释他人的锁。

咱们来看这么相同流程,如下图:

分布式锁的各种实现,一探究竟!

  1. Client1 获取锁成功。
  2. 由于Client1 事务处理时刻过长, 锁过期时刻到了,锁主动开释了
  3. Client2 获取到了对应同一个资源的锁。
  4. Client1 事务处理完结,开释锁,可是开释掉了Client2 持有的锁。
  5. 而Client3此刻还能取得锁,相同Client2此刻持有锁,都乱套了。

而这个randId就能够在开释锁的时分防止了开释他人的锁,由于在开释锁的时分,Client需求先获取到该锁的值(randId),判别是否相同后才能删去。

if (redis.get(lKey).equals(randId)) {
    redis.del(lockKey);
}

加锁的时分需求原子性,开释锁的时分该怎样做到原子性?

这个问题很好,咱们在加锁的时分经过原子性指令防止了潜在的设置过期时刻失利问题,开释锁相同是Get + Del两条指令,这儿相同存在开释他人锁的问题。

脑瓜嗡嗡的,咋那么多需求考虑的问题呀,看累了歇息会,咋们持续往下看!

这儿问题的本源在于:锁的判别在客户端,开释在服务端,如下图:

分布式锁的各种实现,一探究竟!

所以 应该将锁的判别和删去都在redis服务端进行,能够凭借lua脚本确保原子性,开释锁的中心逻辑【GET、判别、DEL】,写成 Lua 脚,让Redis履行,这样完结能确保这三步的原子性。

// 判别锁是自己的,才开释
if redis.call("GET",KEYS[1]) == ARGV[1]
then
    return redis.call("DEL",KEYS[1])
else
    return 0
end

假如Client1获取到锁后,由于事务问题需求较长的处理时刻,超过了锁过期时刻,该怎样办?

已然事务履行时刻超过了锁过期时刻,那么咱们能够给锁续期呀,比方敞开一个看护进程,定时监测锁的失效时刻,在快要过期的时分,对锁进行主动续期,从头设置过期时刻。

Redisson框架中就完结了这个,就要WatchDog(看门狗):加锁时没有指定加锁时刻时会启用 watchdog 机制,默认加锁 30 秒,每 10 秒钟查看一次,假如存在就从头设置 过期时刻为 30 秒(即 30 秒之后它就不再续期了)

分布式锁的各种实现,一探究竟!

嗯嗯,这应该就比较稳健了吧!

嘿嘿,以上这些都是锁在「单个」Redis 实例中或许产生的问题,的确单节点分布式锁能处理大部分人的需求。可是一般都是用【Redis Cluster】或许【岗兵模式】这两种办法完结 Redis 的高可用,这就有主从同步问题产生。

试想这样的场景:

  1. Client1恳求Master加锁成功
  2. 可是Master反常宕机,加锁信息还未同步到从库上(主从复制是异步的)
  3. 此刻从库Slave1被岗兵提升为新主库,锁信息不在新的主库上(未同步到Slave1)

分布式锁的各种实现,一探究竟!

面临这种问题,Redis 的作者提出一种处理方 Redlock, 是根据多个 Redis 节点(都是 Master)的一种完结,该计划根据 2 个条件:

  1. 不再需求布置从库和岗兵实例,只布置主库
  2. 但主库要布置多个,官方推荐至少 5 个实例

Redlock加锁流程:

  1. Client先获取「当前时刻戳T1」
  2. Client顺次向这 5 个 Redis 实例建议加锁恳求(用前面讲到的 SET 指令),且每个恳求会设置超时时刻(毫秒级,要远小于锁的有效时刻),假如某一个实例加锁失利(包括网络超时、锁被其它人持有等各种反常状况),就立即向下一个 Redis 实例请求加锁
  3. 假如Client从 >=3 个(大多数)以上 Redis 实例加锁成功,则再次获取「当前时刻戳T2」,假如 T2 – T1 < 锁的过期时刻,此刻,认为客户端加锁成功,否则认为加锁失利
  4. 加锁成功,去操作同享资源(例如修正 MySQL 某一行,或建议一个 API 恳求)
  5. 加锁失利,Client向「悉数节点」建议开释锁恳求(前面讲到的 Lua 脚本开释锁)

Redlock开释锁:

客户端向一切 Redis 节点建议开释锁的操作

分布式锁的各种实现,一探究竟!

问题 1:为什么要在多个实例上加锁?

本质上为了容错,咱们看图中的多个Master示例节点,实践够构成了一个分布式体系,分布式体系中总会有反常节点,多个实例加锁的话,即便部分实例反常宕机,剩余的实例加锁成功,整个锁服务依旧可用!

问题 2:为什么步骤 3 加锁成功后,还要核算加锁的累计耗时?

加锁操作的针对的是分布式中的多个节点,所以耗时肯定是比单个实例耗时更,还要考虑网络延迟、丢包、超时等状况产生,网络恳求次数越多,反常的概率越大。

所以即便 N/2+1 个节点加锁成功,但假如加锁的累计耗时现已超过了锁的过期时刻,那么此刻的锁现已没有意义了

问题 3:为什么开释锁,要操作一切节点?

首要是为了确保铲除节点反常状况导致残留的锁!

比方:在某一个 Redis 节点加锁时,或许由于「网络原因」导致加锁失利。

或许客户端在一个 Redis 实例上加锁成功,但在读取响应结果时,网络问题导致读取失利,那这把锁其完结已在 Redis 上加锁成功了。

所以说开释锁的时分,不管以前有没有加锁成功,都要开释一切节点的锁。

这儿有一个关于Redlock安全性的争辩,这儿就一笔带过吧,咱们有爱好能够去看看:

Java面试365:RedLock红锁安全性争辩(上)4 附和 0 谈论文章

分布式锁的各种实现,一探究竟!

根据Etcd

Etcd是一个Go语言完结的十分可靠的kv存储体系,常在分布式体系中存储着关键的数据,一般运用在装备中心、服务发现与注册、分布式锁等场景。

本文首要从分布式锁的视点来看Etcd是怎样完结分布式锁的,Let’s Go !

Etcd特性介绍:

  • Lease机制:即租约机制(TTL,Time To Live),etcd能够为存储的kv对设置租约,当租约到期,kv将失效删去;一起也支撑续约,keepalive
  • Revision机制:每个key带有一个Revision特点值,etcd每进行一次事务对应的大局Revision值都会+1,因而每个key对应的Revision特点值都是大局仅有的。经过比较Revision的巨细就能够知道进行写操作的顺序
  • 在完结分布式锁时,多个程序一起抢锁,根据Revision值巨细顺次取得锁,防止“惊群效应”,完结公正锁
  • Prefix机制:也称为目录机制,能够根据前缀取得该目录下一切的key及其对应的特点值
  • Watch机制:watch支撑watch某个固定的key或许一个前缀目录,当watch的key产生变化,客户端将收到通知

为什么这些特性就能够让Etcd完结分布式锁呢?由于Etcd这些特功能够满意完结分布式锁的以下要求:

  • 租约机制(Lease):用于支撑反常状况下的锁主动开释才能
  • 前缀和 Revision 机制:用于支撑公正获取锁和排队等候的才能
  • 监听机制(Watch):用于支撑抢锁才能
  • 集群模式:用于支撑锁服务的高可用

有了这些知识理论咱们一起看看Etcd是怎样完结分布式锁的,由于我自己也是Golang开发,这儿咱们也放一些代码。

先看流程,再结合代码注释!

分布式锁的各种实现,一探究竟!

func main() {
    config := clientv3.Config{
        Endpoints:   []string{"xxx.xxx.xxx.xxx:2379"},
        DialTimeout: 5 * time.Second,
    }
    // 获取客户端衔接
    client, err := clientv3.New(config)
    if err != nil {
        fmt.Println(err)
        return
    }
    // 1. 上锁(创立租约,主动续租,拿着租约去抢占一个key )
    // 用于请求租约
    lease := clientv3.NewLease(client)
    // 请求一个10s的租约
    leaseGrantResp, err := lease.Grant(context.TODO(), 10) //10s
    if err != nil {
        fmt.Println(err)
        return
    }
    // 拿到租约的id
    leaseID := leaseGrantResp.ID
    // 预备一个用于撤销续租的context
    ctx, cancelFunc := context.WithCancel(context.TODO())
    // 确保函数退出后,主动续租会停止
    defer cancelFunc()
        // 确保函数退出后,租约会失效
    defer lease.Revoke(context.TODO(), leaseID)
    // 主动续租
    keepRespChan, err := lease.KeepAlive(ctx, leaseID)
    if err != nil {
        fmt.Println(err)
        return
    }
    // 处理续租应对的协程
    go func() {
        select {
        case keepResp := <-keepRespChan:
            if keepRespChan == nil {
                fmt.Println("lease has expired")
                goto END
            } else {
                // 每秒会续租一次
                fmt.Println("收到主动续租应对", keepResp.ID)
            }
        }
    END:
    }()
    // if key 不存在,then设置它,else抢锁失利
    kv := clientv3.NewKV(client)
    // 创立事务
    txn := kv.Txn(context.TODO())
    // 假如key不存在
    txn.If(clientv3.Compare(clientv3.CreateRevision("/cron/lock/job7"), "=", 0)).
        Then(clientv3.OpPut("/cron/jobs/job7", "", clientv3.WithLease(leaseID))).
        Else(clientv3.OpGet("/cron/jobs/job7")) //假如key存在
    // 提交事务
    txnResp, err := txn.Commit()
    if err != nil {
        fmt.Println(err)
        return
    }
    // 判别是否抢到了锁
    if !txnResp.Succeeded {
        fmt.Println("锁被占用了:", string(txnResp.Responses[0].GetResponseRange().Kvs[0].Value))
        return
    }
    // 2. 处理事务(锁内,很安全)
    fmt.Println("处理使命")
    time.Sleep(5 * time.Second)
    // 3. 开释锁(撤销主动续租,开释租约)
    // defer会撤销续租,开释锁
}

不过clientv3供给的concurrency包也完结了分布式锁,咱们能够更快捷的完结分布式锁,不过内部完结逻辑差不多:

  1. 首先concurrency.NewSession办法创立Session目标
  2. 然后Session目标经过concurrency.NewMutex 创立了一个Mutex目标
  3. 加锁和开释锁分别调用Lock和UnLock

根据ZooKeeper

ZooKeeper 的数据存储结构就像一棵树,这棵树由节点组成,这种节点叫做 Znode

加锁/开释锁的进程是这样的

分布式锁的各种实现,一探究竟!

  1. Client测验创立一个 znode 节点,比方/lock,比方Client1先到达就创立成功了,相当于拿到了锁
  2. 其它的客户端会创立失利(znode 已存在),获取锁失利。
  3. Client2能够进入一种等候状态,等候当/lock 节点被删去的时分,ZooKeeper 经过 watch 机制通知它
  4. 持有锁的Client1拜访同享资源完结后,将 znode 删掉,锁开释掉了
  5. Client2持续完结获取锁操作,直到获取到锁停止

ZooKeeper不需求考虑过期时刻,而是用【暂时节点】,Client拿到锁之后,只需衔接不断,就会一直持有锁。即便Client溃散,相应暂时节点Znode也会主动删去,确保了锁开释。

Zookeeper 是怎样检测这个客户端是否溃散的呢?

每个客户端都与 ZooKeeper 维护着一个 Session,这个 Session 依靠定期的心跳(heartbeat)来维持。

假如 Zookeeper 长时刻收不到客户端的心跳,就认为这个 Session 过期了,也会把这个暂时节点删去。

当然这也并不是完美的处理计划

以下场景中Client1和Client2在窗口时刻内或许一起取得锁:

  1. Client 1 创立了 znode 节点/lock,取得了锁。
  2. Client 1 进入了长时刻的 GC pause。(或许网络呈现问题、或许 zk 服务检测心跳线程呈现问题等等)
  3. Client 1 衔接到 ZooKeeper 的 Session 过期了。znode 节点/lock 被主动删去。
  4. Client 2 创立了 znode 节点/lock,然后取得了锁。
  5. Client 1 从 GC pause 中恢复过来,它依然认为自己持有锁。

好,现在咱们来总结一下 Zookeeper 在运用分布式锁时优劣:

Zookeeper 的长处:

  1. 不需求考虑锁的过期时刻,运用起来比较便利
  2. watch 机制,加锁失利,能够 watch 等候锁开释,完结乐观锁

缺陷:

  1. 功能不如 Redis
  2. 布置和运维成本高
  3. 客户端与 Zookeeper 的长时刻失联,锁被开释问题

总结

文章内容比较多,涉及到的知识点也许多,假如看一遍没了解,那么建议你收藏一下多读几遍,构建好关于分布式锁你的情景结构。

总结一下吧,本文首要总结了分布式锁和运用办法,完结分布式锁能够有多种办法。

数据库:经过创立一条仅有记载来表示一个锁,仅有记载添加成功,锁就创立成功,开释锁的话需求删去记载,可是很简略呈现功能瓶颈,因而根本上不会运用数据库作为分布式锁。

Redis:Redis供给了高效的获取锁和开释锁的操作,并且结合Lua脚本,Redission等,有比较好的反常状况处理办法,由于是根据内存的,读写功率也是十分高。

Etcd:运用租约(Lease),Watch,Revision机制,供给了一种简略完结的分布式锁办法,集群模式让Etcd能处理很多读写,功能出色,可是装备杂乱,共同性问题也存在。

Zookeeper:运用ZooKeeper供给的节点同步功用来完结分布式锁,并且不必设置过期时刻,能够主动的处理反常状况下的锁开释。

假如你的事务数据十分敏感,在运用分布式锁时,一定要注意这个问题,不能假定分布式锁 100% 安全。

当然也需求结合自己的事务,或许大多数状况下咱们仍是运用Redis作为分布式锁,一个是咱们比较了解,然后功能和处理反常状况也有较多办法,我觉得满意大多数事务场景就能够了。

谢谢你读到最终,期望本文对你有帮助~

欢迎点赞 、收藏 、重视 三连支撑一下~

我会持续加油的~

参阅:

mp.weixin.qq.com/s/Fkga3KaU0…

zhuanlan.zhihu.com/p/378797329

www.cnblogs.com/aganippe/p/