前言

关键词 : 分布式锁、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 就不是了,也就是说 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 安全性的争论,这里就一笔带过吧,大家有兴趣可以去看看:

基于 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 作为分布式锁,一个是我们比较熟悉,然后性能和处理异常情况也有较多方式,我觉得满足大多数业务场景就可以了。

谢谢你读到最后,希望本文对你有帮助~
欢迎点赞、收藏、关注三连支持一下~
我会继续加油的~

参考

https://mp.weixin.qq.com/s/Fkga3KaU0fBv5zXM-b8JhA
https://zhuanlan.zhihu.com/p/378797329
https://www.cnblogs.com/aganippe/p/16011508.html