8-分布式锁之Redis6+Lua脚本实现原生分布式锁

603 阅读9分钟

这是我参与11月更文挑战的第9天,活动详情查看:2021最后一次更文挑战

本章是对分布式锁介绍,以及使用Redis实现分布式锁会遇到哪些坑,最后面将会手把手教你掌握分布式锁lua脚本与Redis的原生代码编写

什么是分布式锁

分布式锁可以理解为:控制分布式的系统有序性的对共享资源进行操作,通过互斥来保证数据的一致性。举个例子:假如系统共享的资源就是一间房子,房子里面有各式各样的书,分布式系统就是要进去看书的人。这时,分布式锁就是用来保证这间房子一次只能进去一个人,并且只有一扇门与一把钥匙。然后许多的人要去看书,看书可以但是要排队。这时,第一个人拿着钥匙把门打开后进屋看书并把门锁上,然后第二个人因为没有钥匙需要等待第一个人出来给钥匙才能进去,以此类推。

使用分布式锁的目的

分布式锁实现的背景就是保证同一时间只有一个客户端可以对共享资源进行操作。比如:送完即止的优惠券,12306车票购买。

实现的核心就是为了防止分布式系统中的多个线程之间相互干扰,我们需要用一种分布式协调技术来对进程进行一些调度。还有就是利用互斥的机制来控制对共享资源的访问,这就是分布式锁要解决的问题。

本地锁与分布式锁介绍

本地锁:在JavaSE中,我们可以使用volatile、synchronize、lock加锁的机制给资源加上锁,这种只适合单机情况下使用,因为是锁当前进程的。

分布式锁:分布式锁我们可以使用Redis、zookeeper等实现,虽然也是锁,但是可以对多个进程之间共用的锁来进行标记。

在没有使用分布式锁时用户会对资源发送请求,当用户发送请求到节点一时发现该节点有锁,所以用户并没有拿到资源。紧接着用户从节点二发送请求,发现节点二并没有锁,用户就可以获取到资源了,这是不行的。所以使用了分布式锁来进行管理,当节点需要请求资源时,会通过一个中央管理申请锁,只有申请锁成功的节点才能对数据资源进行操作。

设计分布式锁应该考虑的东西

(1)排他性:在分布式应用的集群中,同一个方法在同一时间内只能被一台机器上的一个线程执行,通俗来讲就是我在使用时其他都不能使用,有我没他。

(2)容错性:分布式锁需要得到释放,就好比如某一个节点申请到锁后,就要去对数据进行操作。当还没对数据进行操作时,这时系统发生了崩溃该节点的锁并没有得到释放,那下一个节点岂不是在处于一直等待什么也干不了(该现象简称:死锁),所有这个锁一定要得到释放。

(3)满足可重入、高性能、高可用

(4)注意分布式锁的开销,锁粒度

忘了就给我从头看!

大家在开发项目使用Redis实现分布式锁,会遇到哪些坑呢?怎么避免这些坑呢?下面,我就给你们来解答一下!

使用Redis实现分布式锁会遇到哪些坑

实现分布式锁的方式有很多种,可以用Redis、Zookeeper、MySql数据库这几种,性能最好的是Redis了,也是最容易理解的。

分布式锁离不开Key-Value的设置,Key是锁的唯一标识,一般按业务来决定这个Key叫什么名字。比如:优惠券活动加锁,key命名为“coupon:id” value就可以使用固定的值,比如设置为1。

基于Redis实现分布式锁,官方文档地址:www.redis.cn/commands.ht…

使用setnx key value来实现分布式锁

在第四章我们已经讲过String的一些常用命令,Setnx命令就可以实现分布式锁

setnx 的含义就是 SET if Not Exists ,他有两个参数(key,value),该方法是原子性操作

如果key不存在,等同于为key执行了set操作,操作成功返回1

如果key存在,那么就等同于不操作,key也没发生改变,操作后返回0

使用setnx命令,同时在Redis上创建一个相同的key,因为redis的key是不允许重复的,只要谁
能够创建key成功,谁就能够获取到锁,没有创建key成功则进入等待。

释放锁del(key)

得到锁的线程执行完任务后,需要释放该锁,让其他的线程可以进入对该资源进行访问,
调用 del (key)
在执行完操作的时候,删除对应的key,每个key都有对应的有效期

配置锁超时expire(key,30s)

如果客户端崩溃或者网络中断导致的资源永远被锁住,即死锁。因此需要给key配置过期的时间
以保证即使没有被限时释放锁,但也要把锁自动释放,把资源让给后面的使用。

结合伪代码来讲解,methodA是一个资源,然后执行setnx命令判断返回结果,如果返回结果是1,那么我们就给这个key配置过期时间,下面在进行一系列的业务逻辑方法。当业务逻辑执行完后,再用del命令把该key进行删除。这时如果别的线程进来,发现setnx方法执行的返回结果是0,那么该线程首先会进入一个等待时间,再去调用methodA方法申请锁。

methodA(){

 String key = "coupon_66"
 
 if(setnx(key,1) == 1){
 
 expire(key,30,TimeUnit.MILLISECONDS)
 
 try {
 //做对应的业务逻辑
 //查询⽤户是否已经领券
 //如果没有则扣减库存
 //新增领劵记录
 } finally {
 
 del(key)
 }
 }else{
 
 //睡眠100毫秒,然后⾃旋调⽤本⽅法
 methodA()
 }
}

提问:使用这种方式实现分布式锁,会存在什么问题呢?

这种方式多个命令之间不是原子性操作。如果setnx和expire之间,如果setnx成功,但是expire失败了,且这时候出现宕机了,那么这个资源就变成了死锁。我们可以这样解决

使用原子命令:设置和配置过期时间 setnx / setex 
比如:set key 1 ex 30 nx (ex:设置键的过期时间为second秒,nx:只在键不存在时才能进行设置操作)

那在Java代码里实现方式如下:
redisTemplate.opsForValue().setIfAbsent("seckill_1","success",30,TimeUnit.MILLISECONDS)

这时还会有一种问题,业务超时问题。当设置key的过期时间为30s。假如线程A业务执行的时间超过了30s,这时线程B就会得到了锁。这个时候线程A的业务执行完后需要删除锁,线程B的业务还没执行完,结果就是线程A删除了线程B加的锁。这个问题该如何解决呢?

我们可以在del释放锁之前做一个判断,验证当前的锁是不是自己加的锁,那value应该是存当前线程的标识或者uuid,用来当做记号。如果在删除锁时可以判断是该标识吗,如果是才进行删除锁。

String key = "coupon_66"

String value = Thread.currentThread().getId()

if(setnx(key,value) == 1){

 expire(key,30,TimeUnit.MILLISECONDS)
 
 try {
 
 //做对应的业务逻辑
 
 } finally {
 
 //删除锁,判断是否是当前线程加的
 
 if(get(key).equals(value)){
 //还存在时间间隔
 
 del(key)
 }
 }
}else{
 
 //睡眠100毫秒,然后⾃旋调⽤本⽅法
}

在这里我们还需要进一步细化线程误删的思想:当线程A获取到正常值时,返回代码中判断期间锁过期了,线程B刚好重新设置了新值,线程A那边有判断value是自己的标识,然后调用del方法,结果就是删除了新设置的线程B的值。核心还是判断和删除命令,不是原子性操作导致。

Lua原生代码编写实现分布式锁

首先我们先认识一下什么是Lua,Lua是一种轻量小巧的脚本语言,其设计目的是为了嵌入应用程序,从而为程序提供灵活的扩展和定制功能。

Lua应用场景:游戏开发、独立应用脚本、Web应用脚本、扩展和数据库插件、安全系统

前面说了Redis做分布式锁存在的问题

核心就是保证多个指令的原子性,加锁使用setnx或者setex 可以保证原子性,那解锁使用判断和删除原子性

怎么保证多个命令的原子性,采用lua脚本+Redis,由于【判断和删除】是用lua脚本执行,所以要么全成功,要么全失败。

lua脚本代码

//获取lock的值和传递的值⼀样,调⽤删除操作返回1,否则返回0

String script = "if redis.call('get',KEYS[1])== ARGV[1] then return
redis.call('del',KEYS[1]) else return 0 end";

//Arrays.asList(lockKey)是key列表,uuid是参数

Integer result = redisTemplate.execute(new
DefaultRedisScript<>(script, Integer.class),
Arrays.asList(lockKey), uuid);

完整代码

/**
* 原⽣分布式锁 开始
* 1、原⼦加锁 设置过期时间,防⽌宕机死锁
* 2、原⼦解锁:需要判断是不是⾃⼰的锁
*/
@RestController
@RequestMapping("/api/v1/coupon")

public class CouponController {
 @Autowired
 private StringRedisTemplate redisTemplate;
 
 @GetMapping("add")
 public JsonData
saveCoupon(@RequestParam(value ="coupon_id",required = true) int couponId){
 //防⽌其他线程误删
 String uuid =UUID.randomUUID().toString();
 String lockKey ="lock:coupon:"+couponId;
 lock(couponId,uuid,lockKey);
 return JsonData.buildSuccess();
 }
 private void lock(int couponId,String
uuid,String lockKey){
 //lua脚本
 String script = "if redis.call('get',KEYS[1]) == ARGV[1] then
return redis.call('del',KEYS[1]) else return 0 end";
 Boolean nativeLock = redisTemplate.opsForValue().setIfAbsent(lockKey,uuid,Duration.ofSeconds(30));
 System.out.println(uuid+"加锁状态:"+nativeLock);
 if(nativeLock){
 //加锁成功
 try{
 //TODO 做相关业务逻辑
 TimeUnit.SECONDS.sleep(10L);
 } catch (InterruptedException e)
{
 } finally {
 //解锁
 Long result =
redisTemplate.execute( new DefaultRedisScript<>(script,Long.class)
,Arrays.asList(lockKey),uuid);
 System.out.println("解锁状态:"+result);
 }
 }else {
 //⾃旋操作
 try {
 System.out.println("加锁失败,
睡眠5秒 进⾏⾃旋");
 
TimeUnit.MILLISECONDS.sleep(5000);
 } catch (InterruptedException e)
{ }
 //睡眠⼀会再尝试获取锁
 lock(couponId,uuid,lockKey);
 }
 }
}

根据上面的代码可以明白了分布式锁的原理,但如何实现锁的自动续期或者避免业务执行时间过长,锁过期了呢?

使用原生方式的话,一般把锁的过期时间设置久一点,比如10分钟之类的。

再有就是原生代码+Redis实现分布式锁使用,这个比较复杂,且有些锁的续期问题更难处理。

官方推荐方式:redis.io/topics/dist…

本章小结:

本章我们了解了分布式锁与本地锁,并对使用分布式锁会需要哪些问题。

分布式锁解决的方案:

加锁+配置过期时间:保证原子性操作。

解锁:防止误删除、也要保证原子性操作

最后面还使用了lua脚本实现分布式锁的原子性,本章内容需要小伙伴们好好掌握一下分布式锁的核心概念与处理方法。