有趣有内涵的文章第一时间送达!
----- 第二天 -----
------------
分布式锁的实现有哪些?
1.Memcached 分布式锁
利用 Memcached 的 add 命令. 此命令是原子性操作, 只有在 key 不存在的情况下, 才能 add 成功, 也就意味着线程得到了锁.
2.Redis 分布式锁
和 Memcached 的方式类似, 利用 Redis 的 setnx 命令. 此命令同样是原子性操作, 只有在 key 不存在的情况下, 才能 set 成功.(setnx 命令并不完善, 后续会介绍替代方案)
3.Zookeeper 分布式锁
利用 Zookeeper 的顺序临时节点, 来实现分布式锁和等待队列. Zookeeper 设计的初衷, 就是为了实现分布式锁服务的.
4.Chubby
Google 公司实现的粗粒度分布式锁服务, 底层利用了 Paxos 一致性算法.
如何用 Redis 实现分布式锁?
Redis 分布式锁的基本流程并不难理解, 但要想写得尽善尽美, 也并不是那么容易. 在这里, 我们需要先了解分布式锁实现的三个核心要素:
1. 加锁
最简单的方法是使用 setnx 命令. key 是锁的唯一标识, 按业务来决定命名. 比如想要给一种商品的秒杀活动加锁, 可以给 key 命名为 "lock_sale_商品 ID" . 而 value 设置成什么呢? 我们可以姑且设置成 1. 加锁的伪代码如下:
setnx(key,1)
当一个线程执行 setnx 返回 1, 说明 key 原本不存在, 该线程成功得到了锁; 当一个线程执行 setnx 返回 0, 说明 key 已经存在, 该线程抢锁失败.
2. 解锁
有加锁就得有解锁. 当得到锁的线程执行完任务, 需要释放锁, 以便其他线程可以进入. 释放锁的最简单方式是执行 del 指令, 伪代码如下:
del(key)
释放锁之后, 其他线程就可以继续执行 setnx 命令来获得锁.
3. 锁超时
锁超时是什么意思呢? 如果一个得到锁的线程在执行任务的过程中挂掉, 来不及显式地释放锁, 这块资源将会永远被锁住, 别的线程再也别想进来.
所以, setnx 的 key 必须设置一个超时时间, 以保证即使没有被显式释放, 这把锁也要在一定时间后自动释放. setnx 不支持超时参数, 所以需要额外的指令, 伪代码如下:
expire(key, 30)
综合起来, 我们分布式锁实现的第一版伪代码如下:
- if(setnx(key,1) == 1){
- expire(key,30)
- try {
do something ......
- } finally {
- del(key)
- }
- }
好端端的代码, 怎么就回家等通知了呢?
因为上面的伪代码中, 存在着三个致命问题:
1. setnx 和 expire 的非原子性
设想一个极端场景, 当某线程执行 setnx, 成功得到了锁:
setnx 刚执行成功, 还未来得及执行 expire 指令, 节点 1 Duang 的一声挂掉了.
这样一来, 这把锁就没有设置过期时间, 变得 "长生不老", 别的线程再也无法获得锁了.
怎么解决呢? setnx 指令本身是不支持传入超时时间的, 幸好 Redis 2.6.12 以上版本为 set 指令增加了可选参数, 伪代码如下:
set(key,1,30,NX)
这样就可以取代 setnx 指令.
2. del 导致误删
又是一个极端场景, 假如某线程成功得到了锁, 并且设置的超时时间是 30 秒.
如果某些原因导致线程 B 执行的很慢很慢, 过了 30 秒都没执行完, 这时候锁过期自动释放, 线程 B 得到了锁.
随后, 线程 A 执行完了任务, 线程 A 接着执行 del 指令来释放锁. 但这时候线程 B 还没执行完, 线程 A 实际上删除的是线程 B 加的锁.
怎么避免这种情况呢? 可以在 del 释放锁之前做一个判断, 验证当前的锁是不是自己加的锁.
至于具体的实现, 可以在加锁的时候把当前的线程 ID 当做 value, 并在删除之前验证 key 对应的 value 是不是自己线程的 ID.
加锁:
- String threadId = Thread.currentThread().getId()
- set(key,
- threadId
- ,30,NX)
解锁:
- if(threadId .equals(redisClient.get(key))){
- del(key)
- }
但是, 这样做又隐含了一个新的问题, 判断和释放锁是两个独立操作, 不是原子性.
我们都是追求极致的程序员, 所以这一块要用 Lua 脚本来实现:
- String luaScript = 'if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end';
- redisClient.eval(luaScript , Collections.singletonList(key), Collections.singletonList(threadId));
这样一来, 验证和删除过程就是原子操作了.
3. 出现并发的可能性
还是刚才第二点所描述的场景, 虽然我们避免了线程 A 误删掉 key 的情况, 但是同一时间有 A,B 两个线程在访问代码块, 仍然是不完美的.
怎么办呢? 我们可以让获得锁的线程开启一个守护线程, 用来给快要过期的锁 "续航".
当过去了 29 秒, 线程 A 还没执行完, 这时候守护线程会执行 expire 指令, 为这把锁 "续命"20 秒. 守护线程从第 29 秒开始执行, 每 20 秒执行一次.
当线程 A 执行完任务, 会显式关掉守护线程.
另一种情况, 如果节点 1 忽然断电, 由于线程 A 和守护线程在同一个进程, 守护线程也会停下. 这把锁到了超时的时候, 没人给它续命, 也就自动释放了.
守护线程的代码并不难实现, 有了大体思路, 大家可以自己尝试实现一下.
几点补充:
本漫画纯属娱乐, 还请大家尽量珍惜当下的工作, 切勿模仿小灰的行为哦.
来源: http://www.92to.com/bangong/2018/05-28/33844773.html