今天我们讲讲分布式锁网上相关的内容有很多但是比较分散我自己重新学习总结共 4 种实现方式分享给大家。文章内容比较多预计阅读 22 分钟建议大家先收藏。不 BB上文章目录01 什么是分布式锁分布式锁是控制分布式系统之间同步访问共享资源的一种方式通过互斥来保持一致性。了解分布式锁之前先了解下线程锁和进程锁线程锁主要用来给方法、代码块加锁。当某个方法或代码使用锁在同一时刻仅有一个线程执行该方法或该代码段。线程锁只在同一JVM中有效果因为线程锁的实现在根本上是依靠线程之间共享内存实现的比如Synchronized、Lock等。进程锁控制同一操作系统中多个进程访问某个共享资源因为进程具有独立性各个进程无法访问其他进程的资源因此无法通过synchronized等线程锁实现进程锁。比如Golang语言中的sync包就提供了基本的同步基元如互斥锁。但是以上两种适合在单体架构应用但是分布式系统中多个服务节点多个进程分散部署在不同节点机器中此时对于资源的竞争上诉两种对节点本地资源的锁就无效了。这个时候就需要分布式锁来对分布式系统多进程访问资源进行控制因此分布式锁是为了解决分布式互斥问题02 分布式锁的特性互斥互斥性很好理解这也是最基本功能就是在任意时刻只能有一个客户端才能获取锁不能同时有两个客户端获取到锁。避免死锁为什么会出现死锁因为获取锁的客户端因为某些原因(如down机等)而未能释放锁其它客户端再也无法获取到该锁从而导致整个流程无法继续进行。面对这种情况当然有解决办法啦引入过期时间通常情况下我们会设置一个 TTL(Time To Live存活时间) 来避免死锁但是这并不能完全避免。比如TTL为5秒进程A获得锁问题是5秒内进程A并未释放锁过期被自动释放进程B获得锁刚好第6秒时进程A执行完又会释放锁也就是进程A释放了进程B的锁仅仅加个过期时间会设计到两个问题锁过期和释放别人的锁问题。锁附加唯一性针对释放别人锁这种问题我们可以给每个客户端进程设置【唯一ID】这样我们就可以在应用层就进行检查唯一ID。自动续期锁过期问题的出现是我们对持有锁的时间不好进行预估设置较短的话会有【提前过期】风险但是过期时间设置过长可能锁长时间得不到释放。这种情况同样有处理方式可以开启一个守护进程watch dog,检测失效时间进行续租比如Java技术栈可以用Redisson来处理。可重入一个线程获取了锁但是在执行时又再次尝试获取锁会发生什么情况是的导致了重复获取锁占用了锁资源造成了死锁问题。我们了解下什么是【可重入】指的是同一个线程在持有锁的情况下可以多次获取该锁而不会造成死锁也就是一个线程可以在获取锁之后再次获取同一个锁而不需要等待锁释放。解决方式比如实现Redis分布式锁的可重入在实现时需要借助Redis的Lua脚本语言并使用引用计数器技术保证同一线程可重入锁的正确性。容错容错性是为了当部分节点(redis节点等)宕机时客户端仍然能够获取锁和释放锁一般来说会有以下两种处理方式一种像etcd/zookeeper这种作为锁服务能够自动进行故障切换因为它本身就是个集群另一种可以提供多个独立的锁服务客户端向多个独立锁服务进行请求某个锁服务故障时也可以从其他服务获取到锁信息但是这种缺点很明显客户端需要去请求多个锁服务。03 分布式锁分类本文会讲述四种关于分布式锁的实现按实现方式来看可以分为两种自旋、watch监听自旋方式基于数据库和基于Redis的实现就是需要在客户端未获得锁时进入一个循环不断的尝试请求是否能获得锁直到成功或者超时过期为止。监听方式这种方式只需要客户端Watch监听某个key就可以了锁可用的时候会通知客户端客户端不需要反复请求基于zooKeeper和基于Etcd实现分布式锁就是用这种方式。04 实现方式分布式锁的实现方式有数据库、基于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) ) ENGINEInnoD这里主要是用name字段作为唯一索引来实现唯一索引保证了该记录的唯一性锁释放就直接删掉该条记录就行了。缺点也很多数据库是单点非常依赖数据库的可用性需要额外自己维护TTL在高并发常见下数据库读写是非常缓慢这里我们就不用过多的文字了现实中我们更多的是用基于内存存储来实现分布式锁。基于Redis面试官问你了解分布式锁吗想必绝大部分面试者都会说关于Redis实现分布式锁的方式OK进入正题【基于Redis分布式锁】Redis 的分布式锁 setnx 命令并设置过期时间就行吗setnx lkey lvalue expire lockKey 30正常情况下是可以的但是这里有个问题虽然setnx是原子性的但是setnx expire就不是了也就是说setnx和expire是分两步执行的【加锁和超时】两个操作是分开的如果expire执行失败了那么锁同样得不到释放。关于为什么要加锁和超时时间的设定在文章开头【避免死锁】有提到不明白的可以多看看。Redis正确的加锁命令是什么//保证原子性执行命令 SET lKey randId NX PX 30000randId是由客户端生成的一个随机字符串该客户端加锁时具有唯一性主要是为了避免释放别人的锁。我们来看这么一样流程如下图Client1 获取锁成功。由于Client1 业务处理时间过长 锁过期时间到了锁自动释放了Client2 获取到了对应同一个资源的锁。Client1 业务处理完成释放锁但是释放掉了Client2 持有的锁。而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 的高可用这就有主从同步问题发生。 试想这样的场景Client1请求Master加锁成功然而Master异常宕机加锁信息还未同步到从库上主从复制是异步的此时从库Slave1被哨兵提升为新主库锁信息不在新的主库上未同步到Slave1面对这种问题Redis 的作者提出一种解决方 Redlock 是基于多个 Redis 节点都是 Master的一种实现该方案基于 2 个前提不再需要部署从库和哨兵实例只部署主库但主库要部署多个官方推荐至少 5 个实例Redlock加锁流程Client先获取「当前时间戳T1」Client依次向这 5 个 Redis 实例发起加锁请求用前面讲到的 SET 命令且每个请求会设置超时时间毫秒级要远小于锁的有效时间如果某一个实例加锁失败包括网络超时、锁被其它人持有等各种异常情况就立即向下一个 Redis 实例申请加锁如果Client从 3 个大多数以上 Redis 实例加锁成功则再次获取「当前时间戳T2」如果 T2 - T1 锁的过期时间此时认为客户端加锁成功否则认为加锁失败加锁成功去操作共享资源例如修改 MySQL 某一行或发起一个 API 请求加锁失败Client向「全部节点」发起释放锁请求前面讲到的 Lua 脚本释放锁Redlock释放锁: 客户端向所有 Redis 节点发起释放锁的操作问题 1为什么要在多个实例上加锁本质上为了容错我们看图中的多个Master示例节点实际够构成了一个分布式系统分布式系统中总会有异常节点多个实例加锁的话即使部分实例异常宕机剩余的实例加锁成功整个锁服务依旧可用问题 2为什么步骤 3 加锁成功后还要计算加锁的累计耗时加锁操作的针对的是分布式中的多个节点所以耗时肯定是比单个实例耗时更还要考虑网络延迟、丢包、超时等情况发生网络请求次数越多异常的概率越大。所以即使 N/21 个节点加锁成功但如果加锁的累计耗时已经超过了锁的过期时间那么此时的锁已经没有意义了问题 3为什么释放锁要操作所有节点主要是为了保证清除节点异常情况导致残留的锁比如在某一个 Redis 节点加锁时可能因为「网络原因」导致加锁失败。或者客户端在一个 Redis 实例上加锁成功但在读取响应结果时网络问题导致读取失败那这把锁其实已经在 Redis 上加锁成功了。所以说释放锁的时候不管以前有没有加锁成功都要释放所有节点的锁。这里有一个关于Redlock安全性的争论这里就一笔带过吧大家有兴趣可以去看看RedLock红锁安全性争论上基于EtcdEtcd是一个Go语言实现的非常可靠的kv存储系统常在分布式系统中存储着关键的数据通常应用在配置中心、服务发现与注册、分布式锁等场景。本文主要从分布式锁的角度来看Etcd是如何实现分布式锁的Lets Go !Etcd特性介绍Lease机制即租约机制(TTL,Time To Live)etcd可以为存储的kv对设置租约当租约到期kv将失效删除同时也支持续约keepaliveRevision机制每个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包也实现了分布式锁我们可以更便捷的实现分布式锁不过内部实现逻辑差不多首先concurrency.NewSession方法创建Session对象然后Session对象通过concurrency.NewMutex 创建了一个Mutex对象加锁和释放锁分别调用Lock和UnLock基于ZooKeeperZooKeeper 的数据存储结构就像一棵树这棵树由节点组成这种节点叫做 Znode 加锁/释放锁的过程是这样的Client尝试创建一个 znode 节点比如/lock比如Client1先到达就创建成功了相当于拿到了锁其它的客户端会创建失败znode 已存在获取锁失败。Client2可以进入一种等待状态等待当/lock 节点被删除的时候ZooKeeper 通过 watch 机制通知它持有锁的Client1访问共享资源完成后将 znode 删掉锁释放掉了Client2继续完成获取锁操作直到获取到锁为止ZooKeeper不需要考虑过期时间而是用【临时节点】Client拿到锁之后只要连接不断就会一直持有锁。即使Client崩溃相应临时节点Znode也会自动删除保证了锁释放。Zookeeper 是怎么检测这个客户端是否崩溃的呢每个客户端都与 ZooKeeper 维护着一个 Session这个 Session 依赖定期的心跳(heartbeat)来维持。如果 Zookeeper 长时间收不到客户端的心跳就认为这个 Session 过期了也会把这个临时节点删除。当然这也并不是完美的解决方案以下场景中Client1和Client2在窗口时间内可能同时获得锁Client 1 创建了 znode 节点/lock获得了锁。Client 1 进入了长时间的 GC pause。或者网络出现问题、或者 zk 服务检测心跳线程出现问题等等Client 1 连接到 ZooKeeper 的 Session 过期了。znode 节点/lock 被自动删除。Client 2 创建了 znode 节点/lock从而获得了锁。Client 1 从 GC pause 中恢复过来它仍然认为自己持有锁。好现在我们来总结一下 Zookeeper 在使用分布式锁时优劣Zookeeper 的优点不需要考虑锁的过期时间使用起来比较方便watch 机制加锁失败可以 watch 等待锁释放实现乐观锁缺点性能不如 Redis部署和运维成本高客户端与 Zookeeper 的长时间失联锁被释放问题05 总结文章内容比较多涉及到的知识点也很多如果看一遍没理解那么建议你收藏一下多读几遍构建好对于分布式锁你的情景结构。总结一下吧本文主要总结了分布式锁和使用方式实现分布式锁可以有多种方式。数据库通过创建一条唯一记录来表示一个锁唯一记录添加成功锁就创建成功释放锁的话需要删除记录但是很容易出现性能瓶颈因此基本上不会使用数据库作为分布式锁。RedisRedis提供了高效的获取锁和释放锁的操作而且结合Lua脚本Redission等有比较好的异常情况处理方式因为是基于内存的读写效率也是非常高。Etcd利用租约(Lease)WatchRevision机制提供了一种简单实现的分布式锁方式集群模式让Etcd能处理大量读写性能出色但是配置复杂一致性问题也存在。Zookeeper利用ZooKeeper提供的节点同步功能来实现分布式锁而且不用设置过期时间可以自动的处理异常情况下的锁释放。如果你的业务数据非常敏感在使用分布式锁时一定要注意这个问题不能假设分布式锁 100% 安全。当然也需要结合自己的业务可能大多数情况下我们还是使用Redis作为分布式锁一个是我们比较熟悉然后性能和处理异常情况也有较多方式我觉得满足大多数业务场景就可以了。