分布式锁其实就是,控制分布式系统不同进程共同访问共享资源的一种锁的实现。如果不同的系统或同一个系统的不同主机之间共享了某个临界资源,往往需要互斥来防止彼此干扰,以保证一致性。
业界流行的分布式锁实现,一般基于数据库,Redis和Zookeeper。
1. 数据库
1.1 基于悲观锁
- 在对任意记录进行修改前,先尝试为该记录加上排他锁(exclusive locking)。
- 如果加锁失败,说明该记录正在被修改,那么当前查询可能要等待或者抛出异常。 具体响应方式由开发者根据实际需要决定。
- 如果成功加锁,那么就可以对记录做修改,事务完成后就会解锁了。
- 其间如果有其他对该记录做修改或加排他锁的操作,都会等待我们解锁或直接抛出异常。
- select for update,拿到锁。
- 其他事务抢锁会阻塞。
- 拿到锁以后,通过 commit 释放锁。
1.2 基于乐观锁
使用版本号时,可以在数据初始化时指定一个版本号,每次对数据的更新操作都对版本号执行+1操作。并判断当前版本号是不是该数据的最新的版本号。
参考: https://zhuanlan.zhihu.com/p/524143305
2. redis
SETNX + expire 函数解决,2个函数没有达到原子性。
SET NX PX,一个函数搞定。但是过期了可以释放别人的锁。
通过UUID 判断,只能释放自己的锁,这个时候要通过 lua 脚本。
锁会有提前过期的风险。定时去检测这个锁的失效时间,如果锁快要过期了,就自动对锁进行续期。
参考:https://www.liuvv.com/p/e4e467c6.html
3. zookeeper
Zookeeper的节点Znode有四种类型,Zookeeper分布式锁实现应用了临时顺序节点。
- 持久节点:默认的节点类型。创建节点的客户端与zookeeper断开连接后,该节点依旧存在。
- 持久节点顺序节点:所谓顺序节点,就是在创建节点时,Zookeeper根据创建的时间顺序给该节点名称进行编号,持久节点顺序节点就是有顺序的持久节点。
- 临时节点:和持久节点相反,当创建节点的客户端与zookeeper断开连接后,临时节点会被删除。
- 临时顺序节点:有顺序的临时节点。
3.1 获取锁
当第一个客户端请求过来时,Zookeeper客户端会创建一个持久节点locks。如果它(Client1)想获得锁,需要在locks节点下创建一个顺序节点lock1
客户端Client1会查找locks下面的所有临时顺序子节点,判断自己的节点lock1是不是排序最小的那一个,如果是,则成功获得锁。
这时候如果又来一个客户端client2前来尝试获得锁,它会在locks下再创建一个临时节点lock2。
客户端client2一样也会查找locks下面的所有临时顺序子节点,判断自己的节点lock2是不是最小的,此时,发现lock1才是最小的,于是获取锁失败。获取锁失败,它是不会甘心的,client2向它排序靠前的节点lock1注册Watcher事件,用来监听lock1是否存在,也就是说client2抢锁失败进入等待状态。
此时,如果再来一个客户端Client3来尝试获取锁,它会在locks下再创建一个临时节点lock3。
同样的,client3一样也会查找locks下面的所有临时顺序子节点,判断自己的节点lock3是不是最小的,发现自己不是最小的,就获取锁失败。它也是不会甘心的,它会向在它前面的节点lock2注册Watcher事件,以监听lock2节点是否存在。
通过临时和序号的原理, 加上 watch 的原理
- 创建临时序号节点
- 获取最小的节点是否时读锁, 如果是读, 那么获得锁
- 如果不是, 阻塞等待, 每个节点只监听它的上一个节点, 获取通知
3.2 释放锁
Zookeeper的客户端业务完成或者发生故障,都会删除临时节点,释放锁。如果是任务完成,Client1会显式调用删除lock1的指令。
如果是客户端故障了,根据临时节点得特性,lock1是会自动删除的。
lock1节点被删除后,Client2可开心了,因为它一直监听着lock1。lock1节点删除,Client2立刻收到通知,也会查找locks下面的所有临时顺序子节点,发下lock2是最小,就获得锁。
4. etcd
4.1 实现分布式锁的基础
- Lease 机制
- 即租约机制(TTL,Time To Live),Etcd 可以为存储的 Key-Value 对设置租约,当租约到期,Key-Value 将失效删除;同时也支持续约,通过客户端可以在租约到期之前续约,以避免 Key-Value 对过期失效。
- Lease 机制可以保证分布式锁的安全性,为锁对应的 Key 配置租约,即使锁的持有者因故障而不能主动释放锁,锁也会因租约到期而自动释放。
- Revision 机制:
- 每个 Key 带有一个 Revision 号,每进行一次事务便加一,因此它是全局唯一的,如初始值为 0,进行一次
put(key, value)
,Key 的 Revision 变为 1,同样的操作,再进行一次,Revision 变为 2; - 换成 key1 进行 put(key1, value) 操作,Revision 将变为 3;这种机制有一个作用:通过 Revision 的大小就可以知道写操作的顺序。在实现分布式锁时,多个客户端同时抢锁,根据 Revision 号大小依次获得锁,可以避免 “羊群效应” (也称“惊群效应”),实现公平锁。
- 每个 Key 带有一个 Revision 号,每进行一次事务便加一,因此它是全局唯一的,如初始值为 0,进行一次
- Prefix 机制:
- 即前缀机制,也称目录机制,例如,一个名为
/mylock
的锁,两个争抢它的客户端进行写操作,实际写入的 Key 分别为:key1="/mylock/UUID1",key2="/mylock/UUID2"
,其中,UUID 表示全局唯一的 ID,确保两个 Key 的唯一性。 - 很显然,写操作都会成功,但返回的 Revision 不一样,那么,如何判断谁获得了锁呢?通过前缀“/mylock” 查询,返回包含两个 Key-Value 对的 Key-Value 列表,同时也包含它们的 Revision,通过 Revision 大小,客户端可以判断自己是否获得锁,如果抢锁失败,则等待锁释放(对应的 Key 被删除或者租约过期),然后再判断自己是否可以获得锁。
- 即前缀机制,也称目录机制,例如,一个名为
- Watch 机制:
- 即监听机制,Watch 机制支持监听某个固定的 Key,也支持监听一个范围(前缀机制),当被监听的 Key 或范围发生变化,客户端将收到通知;
- 在实现分布式锁时,如果抢锁失败,可通过 Prefix 机制返回的 Key-Value 列表获得 Revision 比自己小且相差最小的 Key(称为 Pre-Key),对 Pre-Key 进行监听,因为只有它释放锁,自己才能获得锁,如果监听到 Pre-Key 的 DELETE 事件,则说明 Pre-Key 已经释放,自己已经持有锁。
4.2 获取释放锁过程
下面描述了使用 Etcd 实现分布式锁的业务流程,假设对某个共享资源设置的锁名为:/lock/mylock
。客户端连接 Etcd,以 /lock/mylock
为前缀创建全局唯一的 Key。
设置租约
假设第一个客户端对应的 Key="/lock/mylock/UUID1"
,第二个为 Key="/lock/mylock/UUID2"
;客户端分别为自己的 Key 创建租约 Lease,租约的长度根据业务耗时确定,假设为 15s。
在一个客户端持有锁期间,其它客户端只能等待,为了避免等待期间租约失效,客户端需创建一个定时任务作为“心跳”进行续约。此外,如果持有锁期间客户端崩溃,心跳停止,Key 将因租约到期而被删除,从而锁释放,避免死锁。
判断获得锁
进行 Put 操作,假设两个客户端 Put 操作返回的 Revision 分别为1、2,客户端需记录 Revision 用以接下来判断自己是否获得锁。
客户端以前缀 /lock/mylock
读取 Key-Value 列表(Key-Value 中带有 Key 对应的 Revision),判断自己 Key 的 Revision 是否为当前列表中最小的,如果是则认为获得锁;
获得锁后,操作共享资源,执行业务代码。完成业务流程后,删除对应的 Key 释放锁。
监听
否则监听列表中前一个 Revision 比自己小的 Key 的删除事件,一旦监听到删除事件或者因租约失效而删除的事件,则自己获得锁。
4.3 golang实现
1 | package example |