分布式锁的正确用法(第一节 ---基于单机的Redis)----Redisson

一般单机的情况我们用一个 Lock,synchronized锁锁住了就行了,为什么还要分布式锁呢,什么情况下会用分布式锁呢,带着问题,我们开始今天的精彩博弈---------------

什么时候用分布式锁?

诊所只有一个医生,很多患者前来就诊。

医生在同一时刻只能给一个患者提供就诊服务。

如果不是这样的话,就会出现医生在就诊肾亏的「肖菜鸡」准备开药时候患者切换成了脚臭的「谢霸哥」,这时候药就被谢霸哥取走了。

治肾亏的药被有脚臭的拿去了。

当并发去读写一个【共享资源】的时候,我们为了保证数据的正确,需要控制同一时刻只有一个线程访问。

分布式锁就是用来控制同一时刻,只有一个 JVM 进程中的一个线程可以访问被保护的资源。

分布式锁应该具备哪些特性?

1 互斥:在任何给定时刻,只有一个客户端可以持有锁;

2 无死锁:任何时刻都有可能获得锁,即使获取锁的客户端崩溃;

3 容错:只要大多数 Redis的节点都已经启动,客户端就可以获取和释放锁。

SETNX 命令是原子性的,那么为什么在分布式的时候不考虑它呢

带着场景去看看:
SETNX key value 命令是实现「互斥」特性。这个命令来自于SET if Not eXists的缩写,意思是:如果 key 不存在,则设置 value 给这个key,否则啥都不做。Redis 官方地址说的:

命令的返回值:

1:设置成功;
0:key 没有设置成功。

如下场景:

敲代码一天累了,想去放松按摩下肩颈。

168 号技师最抢手,大家喜欢点,所以并发量大,需要分布式锁控制。

同一时刻只允许一个「客户」预约 168 技师。

肖菜鸡申请 168 技师成功:

> SETNX lock:168 1
(integer) 1 # 获取 168 技师成功

谢霸哥后面到,申请失败:

> SETNX lock 2
(integer) 0 # 客户谢霸哥 2 获取失败

此刻,申请成功的客户就可以享受 168 技师的肩颈放松服务「共享资源」。

享受结束后,要及时释放锁,给后来者享受 168 技师的服务机会。

如何释放锁呢

> DEL lock:168
(integer) 1

在这里插入图片描述
肖菜鸡,事情可没这么简单。

这个方案存在一个存在造成锁无法释放的问题,造成该问题的场景如下:

客户端所在节点崩溃,无法正确释放锁;

业务逻辑异常,无法执行 DEL指令。

这样,这个锁就会一直占用,锁在我手里,我挂了,这样其他客户端再也拿不到这个锁了。

加超时释放锁----谁要这么写,就糟透了。

我可以在获取锁成功的时候设置一个「超时时间」比如设定按摩服务一次 60 分钟,那么在给这个 key 加锁的时候设置 60 分钟过期即可:

SETNX lock:168 1 // 获取锁
(integer) 1
EXPIRE lock:168 60 // 60s 自动删除
(integer) 1

这样,到点后锁自动释放,其他客户就可以继续享受 168 技师按摩服务了。
「加锁」、「设置超时」是两个命令,他们不是原子操作。
如果出现只执行了第一条,第二条没机会执行就会出现「超时时间」设置失败,依然出现锁无法释放。

setnx命令迭代升级

Redis 2.6.X 之后,官方拓展了 SET 命令的参数,满足了当 key 不存在则设置 value,同时设置超时时间的语义,并且满足原子性。

SET resource_name random_value NX PX 30000

NX:表示只有 resource_name 不存在的时候才能 SET 成功,从而保证只有一个客户端可以获得锁;
PX 30000:表示这个锁有一个 30 秒自动过期时间。

我们还要防止不能释放不是自己加的锁。我们可以在 value 上做文章。

把别人的锁拿去释放掉了

1 客户 1 获取锁成功并设置设置 30 秒超时;
2 客户 1 因为一些原因导致执行很慢(网络问题、发生 FullGC……),过了 30 秒依然没执行完,但是锁过期「自动释放了」;
3 客户 2 申请加锁成功;
4 客户 1 执行完成,执行 DEL 释放锁指令,这个时候就把客户 2 的锁给释放了。

--------》有个关键问题需要解决:自己的锁只能自己来释放。《------------

我要如何删除是自己加的锁呢?

我在加锁的时候设置一个「唯一标识」作为 value 代表加锁的客户端。
SET resource_name random_value NX PX 30000

在释放锁的时候,客户端将自己的「唯一标识」与锁上的「标识」比较是否相等,匹配上则删除,否则没有权利释放锁。

伪代码如下:

// 比对 value 与 唯一标识
if (redis.get("lock:168").equals(random_value)){
   redis.del("lock:168"); //比对成功则删除
 }

有没有想过,这是 GET + DEL 指令组合而成的,这里又会涉及到原子性问题
我们可以通过 Lua 脚本来实现,这样判断和删除的过程就是原子操作了。

// 获取锁的 value 与 ARGV[1] 是否匹配,匹配则执行 del
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

秒杀时候,下单扣减缓存也是这样操作的

 StringBuilder lua = new StringBuilder();
  lua.append("if (redis.call('exists', KEYS[1]) == 1) then");
  lua.append("    local stock = tonumber(redis.call('get', KEYS[1]));");
  lua.append("    if (stock == -1) then");
  lua.append("        return 1;");
  lua.append("    end;");
  lua.append("    if (stock > 0) then");
  lua.append("        redis.call('incrby', KEYS[1], -1);");
  lua.append("        return stock;");
  lua.append("    end;");
  lua.append("    return 0;");
  lua.append("end;");
  lua.append("return -1;");

这样通过唯一值设置成 value 标识加锁的客户端很重要,仅使用 DEL 是不安全的,因为一个客户端可能会删除另一个客户端的锁。

使用上面的脚本,每个锁都用一个随机字符串“签名”,只有当删除锁的客户端的“签名”与锁的 value 匹配的时候,才会删除它。

超时时间设置多长合适呢

这个时间不能瞎写,一般要根据在测试环境多次测试,然后压测多轮之后,比如计算出平均执行时间 200 ms。

那么锁的超时时间就放大为平均执行时间的 3~5 倍。
为啥要放放大呢?
因为如果锁的操作逻辑中有网络 IO 操作、JVM FullGC 等,线上的网络不会总一帆风顺,我们要给网络抖动留有缓冲时间。

加锁的时候设置一个过期时间,同时客户端开启一个「守护线程」,定时去检测这个锁的失效时间。

如果快要过期,但是业务逻辑还没执行完成,自动对这个锁进行续期,重新设置过期时间。
怎么实现上面这个业务呢,那就是我们今天的主角—Redisson
在使用分布式锁时,它就采用了「自动续期」的方案来避免锁过期,这个守护线程我们一般也把它叫做「看门狗」线程。

一路优化下来,方案似乎比较「严谨」了,抽象出对应的模型如下。

1 通过 SET lock_resource_name random_value NX PX expire_time,同时 启动守护线程为快要过期但还没执行完的客户端的锁续命;
2 客户端执行业务逻辑操作共享资源;
3 通过 Lua 脚本释放锁,先 get 判断锁是否是自己加的,再执行 DEL

加锁,锁的位置放哪里比较合适

根据前面的分析,我们已经有了一个「相对严谨」的分布式锁了。

于是「谢霸哥」就写了如下代码将分布式锁运用到项目中,以下是伪代码逻辑:


public void doSomething() {
  redisLock.lock(); // 上锁
    try {
        // 处理业务
        .....
        redisLock.unlock(); // 释放锁
    } catch (Exception e) {
        e.printStackTrace();
    }
}

有没有想过:一旦执行业务逻辑过程中抛出异常,程序就无法执行释放锁的流程。
所以释放锁的代码一定要放在 finally{} 块中。

加锁的位置也有问题,放在 try 外面的话,如果执行 redisLock.lock() 加锁异常,但是实际指令已经发送到服务端并执行,只是客户端读取响应超时,就会导致没有机会执行解锁的代码。
所以 redisLock.lock() 应该写在 try 代码块,这样保证一定会执行解锁逻辑。

public void doSomething() {
    try {
        // 上锁
        redisLock.lock();
        // 处理业务
        ...
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
      // 释放锁
      redisLock.unlock();
    }
}

可重入锁

Redisson 类库就是通过 Redis Hash 来实现可重入锁
当线程拥有锁之后,往后再遇到加锁方法,直接将加锁次数加 1,然后再执行方法逻辑。
退出加锁方法之后,加锁次数再减 1,当加锁次数为 0 时,锁才被真正的释放。
可以看到可重入锁最大特性就是计数,计算加锁的次数。
所以当可重入锁需要在分布式环境实现时,我们也就需要统计加锁次数。

加锁逻辑
我们可以使用 Redis hash 结构实现,key 表示被锁的共享资源, hash 结构的 fieldKey 的 value 则保存加锁的次数。
在这里插入图片描述
通过 Lua 脚本实现原子性,假设 KEYS1 = 「lock」, ARGV「1000,uuid」:

---- 1 代表 true
---- 0 代表 false
if (redis.call('exists', KEYS[1]) == 0) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return 1;
end ;
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return 1;
end ;
return 0;

加锁代码首先使用 Redis exists 命令判断当前 lock 这个锁是否存在。

如果锁不存在的话,直接使用 hincrby创建一个键为 lock hash 表,并且为 Hash 表中键为 uuid 初始化为 0,然后再次加 1,最后再设置过期时间。

如果当前锁存在,则使用 hexists判断当前 lock 对应的 hash 表中是否存在 uuid 这个键,如果存在,再次使用 hincrby 加 1,最后再次设置过期时间。

最后如果上述两个逻辑都不符合,直接返回。
解锁逻辑

-- 判断 hash set 可重入 key 的值是否等于 0
-- 如果为 0 代表 该可重入 key 不存在
if (redis.call('hexists', KEYS[1], ARGV[1]) == 0) then
    return nil;
end ;
-- 计算当前可重入次数
local counter = redis.call('hincrby', KEYS[1], ARGV[1], -1);
-- 小于等于 0 代表可以解锁
if (counter > 0) then
    return 0;
else
    redis.call('del', KEYS[1]);
    return 1;
end ;
return nil;

首先使用 hexists 判断 Redis Hash 表是否存给定的域。

如果 lock 对应 Hash 表不存在,或者 Hash 表不存在 uuid 这个 key,直接返回 nil。

若存在的情况下,代表当前锁被其持有,首先使用 hincrby使可重入次数减 1 ,然后判断计算之后可重入次数,若小于等于 0,则使用 del 删除这把锁。

解锁代码执行方式与加锁类似,只不过解锁的执行结果返回类型使用 Long。这里之所以没有跟加锁一样使用 Boolean ,这是因为解锁 lua 脚本中,三个返回值含义如下:

1 代表解锁成功,锁被释放
0 代表可重入次数被减 1
null 代表其他线程尝试解锁,解锁失败.

这一篇就到这里把,太长了阅读性不是很好,下一篇重点介绍Redisson

本文参考:
https://mp.weixin.qq.com/s?__biz=Mzg2MzU3Mjc3Ng==&mid=2247485064&idx=1&sn=06a9b0313707436f222a559a7e30d54e&chksm=ce77c0cff90049d975b93b6a37b9dc4bca9165872073b0ec273d1a8b02cfd4d06bc786c52c21&mpshare=1&scene=23&srcid=0820Pwg3JazA6bp0667cVWMB&sharer_sharetime=1661006754336&sharer_shareid=5e1f720976235b1e81fd0d6731dbec3a#rd

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Python基于Scrapy-Redis分布式爬虫设计毕业源码(毕设项目).zip 该项目代码主要针对计算机、自动化等相关专业的学生从业者下载使用,项目代码都经过严格调试,确保可以运行!放心下载使用。 也可作为期末课程设计、课程大作业、毕业设计等。具有较高的学习借鉴价值!也可直接当做个人项目使用。 ## 开发环境:Python + Scrapy框架 + redis数据库 ## 程序开发工具: PyCharm 程序采用 python 开发的 Scrapy 框架来开发,使用 Xpath 技术对下载的网页进行提取解析,运用 Redis 数据库做分布式, 设计并实现了针对当当图书网的分布式爬虫程序,scrapy-redis是一个基于redis的scrapy组件,通过它可以快速实现简单分布式爬虫程序,该组件本质上提供了三大功能: scheduler - 调度器 dupefilter - URL去重规则(被调度器使用) pipeline - 数据持久化 Scrapy是一个比较好用的Python爬虫框架,你只需要编写几个组件就可以实现网页数据的爬取。但是当我们要爬取的页面非常多的时候,单个主机的处理能力就不能满足我们的需求了(无论是处理速度还是网络请求的并发数),这时候分布式爬虫的优势就显现出来。 而Scrapy-Redis则是一个基于Redis的Scrapy分布式组件。它利用Redis对用于爬取的请求(Requests)进行存储和调度(Schedule),并对爬取产生的项目(items)存储以供后续处理使用。scrapy-redi重写了scrapy一些比较关键的代码,将scrapy变成一个可以在多个主机上同时运行的分布式爬虫。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值