技术预热

  1. 在上一篇文章中我们基于zk分别用临时节点不可重名的特性和临时顺序节点获取最小号的特性以此达到一个简单的分布式锁,今天将将我们的redis实现分布式锁。<br /> 我们知道在redis有一个setnx key value命令。意思是谁当且仅当key不存在的时候我们会创建一个key并且返回结果1,不等1则说明我们的key是存在于我们的redis中的,所以我们便会有这样的代码冲动!
if (jedis.setnx(realKey, value) == 1) {
   return true;
}
  很显然这样做并不是合理的,因为当你程序出现异常崩掉,在崩掉之前你没有将key值删除,那么其它服务是不是也就永远无法获取到锁了啊。当然这里就有同学会讲了,加一个过期时间,即使我程序崩掉了,也自然而然会删除掉对应的key,其它服务也就能正常拿到锁了。但是前提是你加锁和设置过期时间必须是在同一个原子操作里面否则也就不成立了。所以又会有 _set key value nx ex 20; 这个命令就相当于包含了_setnx和setex(过去时间)。

setnx 与 setex 缺陷

  但这样依然存在缺陷,假设我们有三个进程A B C,假设我们A进程抢到锁,但是由于执行时间(业务代码阻塞)大于过期时间自动删除了key,假设期间是所B抢到锁,B抢到锁后执行代码,此时A结束完阻塞了,A就会释放锁也就是del lock,那服务B还在执行的规程中,C发现A释放锁果断抢锁,那此时就存在B与C同时拿到了锁,这明显是不合理的。<br />首先上面的情况会有两个问题:
  1. 锁超时自动释放导致线程B也在同时执行同一个key的业务代码
  2. B获得锁,A执行完代码会删除了B的锁(判断下锁是不是自己的,可以通过存储的value值,否则不予释放)
  3. B释放锁(或者是A错把B的锁删了),导致C也能抢到锁

    所以我们的A服务醒来后不能随便删除锁,因为你删除的很有可能是别人的锁,所以必须加一个标识来表明这把锁属于谁,所以创删除锁的时候必须先判断下锁属不属于自己,属于自己才能删除,所以set key value,我们的value则可以充当我们的机器名(能唯一标识我们的服务)。到目前为止我们已经基本实现可分布式锁,但是由于其不具备可重入性所以并不是最佳实践。
    屏幕快照 2020-03-31 下午2.04.45.png

    业务执行时间超出锁的超时限制导致两个客户端同时持有锁的问题

    其次如果因为业务阻塞导致其它机器也能获取锁,导致无法按照我们串行的执行。常见的解决方案是调大锁的超时时间,之后若再出现超时带来的并发问题,人工介入修正数据。这也不是一个完美的方案,因为但业务逻辑执行时间是不可控的,所以还是可能出现超时,当前线程的逻辑没有执行完,其它线程乘虚而入。并且如果锁超时时间设置过长,当持有锁的客户端宕机,释放锁就得依靠redis的超时时间,这将导致业务在一个超时时间周期内不可用。
    基本上,如果在执行计算期间发现锁快要超时了,客户端可以给redis服务实例发送一个Lua脚本让redis服务端延长锁的时间,只要这个锁的key还存在而且值还等于客户端设置的那个值。客户端应当只有在失效时间内无法延长锁时再去重新获取锁(基本上这个和获取锁的算法是差不多的)。启动另外一个线程去检查的问题,这个key是否超时,在某个时间还没释放。当锁超时时间快到期且逻辑未执行完(看门狗),延长锁超时时间的伪代码: ```lua if redis.call(“get”,KEYS[1]) == ARGV[1] then

redis.call(“set”,KEYS[1],ex=3000)

else

//TODO 重新获取锁

<a name="Sn7mh"></a>
### redis的单点故障主从切换带来的两个客户端同时持有锁的问题
生产中redis一般是主从模式,主节点挂掉时,从节点会取而代之,客户端上却并没有明显感知。原先第一个客户端在主节点中申请成功了一把锁,但是这把锁还没有来得及同步到从节点,主节点突然挂掉了。然后从节点变成了主节点,这个新的节点内部没有这个锁,所以当另一个客户端过来请求加锁时,立即就批准了。这样就会导致系统中同样一把锁被两个客户端同时持有,不安全性由此产生。<br />不过这种不安全也仅仅是在主从发生 failover(故障转移) 的情况下才会产生,而且持续时间极短,业务系统多数情况下可以容忍。
<a name="K0G5j"></a>
## 利用hash结构实现可重入
      但是我们上面的流程并不支持可重入,因为我们的set key value nx ex 20 并没有判断是否是自己,它不区分过来的人,即使是自己再次执行也是返回1,重入得次数为多少。仅仅是判断这个key存不存在。要想实现可重入就必须传入多个参数,看过reentlock源码的同学肯定知道一般会有count值和线程引用的值入AtomicRefence<Thread> ,但是我们这里上面的逻辑仅仅是存了一个字符串而已(配上了过期时间),但是我们这里需要额外的一个属性,也就是重入次数count。<br />       所以要想存入多个值就可以用到hash结果:比如hset lock thread01 1,就设置了一个key为lock 的map为`{ thread01:1 }`,这样就满足了业务所需。所以我们需要在抢锁判断一下当前lock是否存在,不存在则设置当前锁的线程为thread01 重入次数为1,存在则判断当前线程是不是自己是的话则次数+1,否则返回false。<br />      同理释放锁也是如此,判断锁是否存在(可能过期直接释放了),不存在不用管表名已经释放了。存在呢则直接将当先线程次数-1,减完之后再判断重入次数是不是为0,是则删除hdel lock,不为0则count -1且重置过期时间。<br />![屏幕快照 2020-03-31 下午2.22.13.png](https://cdn.nlark.com/yuque/0/2020/png/771792/1585635750686-993df298-eaba-4a4d-a9d5-2ebc667ad4e5.png#crop=0&crop=0&crop=1&crop=1&height=969&id=mmnN9&name=%E5%B1%8F%E5%B9%95%E5%BF%AB%E7%85%A7%202020-03-31%20%E4%B8%8B%E5%8D%882.22.13.png&originHeight=969&originWidth=908&originalType=binary&ratio=1&rotation=0&showTitle=false&size=124857&status=done&style=none&title=&width=908)
<a name="q7lEZ"></a>
### 仍然存在并发问题
      在上述结构图中,hash依然存在并发问题,比如服务A、B分别进来,发现lock不存在,则他们会分别创建自己的lock,如果是hash 则同一个lock下存在2个entry,如 lock:{机器ip1=1,机器ip2=2}; 所以就会出现两个线程同时拿到锁的情况,所以就必须使用lua脚本。<br />备注:hget lock thread01     1
<a name="RReVa"></a>
## 为什么lua脚本结合redis命令可以实现原子性
Redis 提供了非常丰富的指令集,但是用户依然不满足,希望可以自定义扩充若干指令来完成一些特定领域的问题。Redis 为这样的用户场景提供了 lua 脚本支持,用户可以向服务器发送 lua 脚本来执行自定义动作,获取脚本的响应数据。Redis 服务器会单线程原子性执行 lua 脚本,保证 lua 脚本在处理的过程中不会被任意其它请求打断。<br />从 Redis 2.6.0 版本开始,通过内置的 Lua 解释器,可以使用EVAL 命令对 Lua 脚本进行求值。 Redis 使用单个 Lua 解释器去运行所有脚本,并且,Redis 也保证脚本会以原子性 (atomic) 的方式执行: 当某个脚本正在运行的时候,不会有其他脚本或 Redis 命令被执行。这和使用MULTI / EXEC 包围的事务很类似。在其他别的客户端看来,脚本的效果 (effffect) 要么是不可见的 (not visible),要么就是已完成的 (already completed)。<br />另一方面,这也意味着,执行一个运行缓慢的脚本并不是一个好主意。写一个跑得很快很顺溜的脚本并不难,因为脚本的运行开销 (overhead) 非常少,但是当你不得不使用一些跑得比较慢的脚本时,请小心,因为当这些蜗牛脚本在慢吞吞地运行的时候,其他客户端会因为服务器正忙而无法执行命令。
<a name="dqLm6"></a>
### Redis中如何执行lua脚本
具体语法不做过多介绍,可以参考我的附件。
```lua
● EVAL script numkeys key [key ...] arg [arg ...]
示例:redis> eval "return hello world" 0
hello world
● SCRIPT LOAD script
示例:redis> SCRIPT LOAD "return 'hello moto'"
0000000459
但是我们如果要执行大量的代码则使用如下命令:
● EVALSHA sha1 numkeys key [key ...] arg [arg ...]
evalsha 0000000459 0

Redis中文API文档.pdf

基于redission开源框架实现

RedLock算法的核心原理:

使用N个完全独立、没有主从关系的Redis master节点以保证他们大多数情况下都不会同时宕机,N一般为奇数。一个客户端需要做如下操作来获取锁:

  1. 获取当前时间(单位是毫秒)。

  2. 轮流用相同的key和随机值在N个节点上请求锁,在这一步里,客户端在每个master上请求锁时,会有一个和总的锁释放时间相比小的多的超时时间。比如如果锁自动释放时间是10秒钟,那每个节点锁请求的超时时间可能是5-50毫秒的范围,这个可以防止一个客户端在某个宕掉的master节点上阻塞过长时间,如果一个master节点不可用了,我们应该尽快尝试下一个master节点。

  3. 客户端计算第二步中获取锁所花的时间,只有当客户端在大多数master节点上成功获取了锁((N/2) +1),而且总共消耗的时间不超过锁释放时间,这个锁就认为是获取成功了。

  4. 如果锁获取成功了,那现在锁自动释放时间就是最初的锁释放时间减去之前获取锁所消耗的时间。

  5. 如果锁获取失败了,不管是因为获取成功的锁不超过一半(N/2+1)还是因为总消耗时间超过了锁释放时间,客户端都会到每个master节点上释放锁,即便是那些他认为没有获取成功的锁。

参考原文链接:https://blog.csdn.net/weixin_39846186/article/details/111700993