4. Redis高并发分布式锁实战---大厂生产级Redis高并发分布式锁实战

2023-11-09


本文是按照自己的理解进行笔记总结,如有不正确的地方,还望大佬多多指点纠正,勿喷。

课程内容:

1、高并发场景秒杀抢购超卖Bug实战重现

2、秒杀抢购场景下实战JVM级别锁与分布式锁

3、大厂分布式锁Redisson框架实战

4、Lua脚本语言快速入门与使用注意事项

5、Redisson分布式锁源码剖析

6、Redis主从架构锁失效问题解析

7、从CAP角度剖析Redis与Zookeeper分布式锁区别

8、Redlock分布式锁原理与存在的问题分析

9、大促场景如何将分布式锁性能提升100倍

参考博客:https://www.cnblogs.com/coderlei/p/16013956.html

1. 手写分布式锁

场景:秒杀减库存。减库存的接口

    @RequestMapping("/deduct_stock")
    public String deductStock() {
    	 int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
    	 if (stock > 0) {
                int realStock = stock - 1;
                stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
                System.out.println("扣减成功,剩余库存:" + realStock);
          } else {
                System.out.println("扣减失败,库存不足");
          }
          return "end";
    }

接口的含义是从redis拿到库存值,判断是否大于0,大于0 则减1 并更新redis存储的库存值,反之小于0,则打印扣减失败,库存不足。

首先不难看出接口是有并发问题的,如果同时多个线程执行减库存操作,查询出来的库存值都一致再存储到redis里边,那肯定就有问题了,假设同时过来三个线程查出来300库存,调用接口减库存,同时更新库存值299,这样的话就会造成超卖!

这样的话,可能大家第一想法就是有并发问题的这块代码加上锁,比如说synchronized,jvm的内置锁,进程级别的锁。代码如下:

    @RequestMapping("/deduct_stock")
    public String deductStock() {
    	 synchronizedthis{
             int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
             if (stock > 0) {
                    int realStock = stock - 1;
                    stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
                    System.out.println("扣减成功,剩余库存:" + realStock);
              } else {
                    System.out.println("扣减失败,库存不足");
              }
       		}
       		return "end";
    }

这样根据synchronized的特性,多个线程请求这个服务,线上环境部署一台应用,在单台服务器上是可以解决问题的。

synchronized是Java的一个关键字,用于实现线程安全,但是它的锁是基于Java虚拟机内存模型的,不同的Java虚拟机之间是独立的,无法实现分布式锁。因此,如果多个Tomcat之间需要共享锁,就需要使用分布式锁技术来实现。

但是在目前高可用集群环境多台服务器下还是会有问题的。

在这里插入图片描述

比如说上图,通过nginx负载均衡两个tomcat进程,分发请求到不同的tomcat里边去,发现还是会发生上边超卖的情况。

下边是我自己压测的过程,有兴趣的可以自己测试一下:

1.在本地启动两个tomcat,并将上边加了sync锁的代码打包放到tomcat里边运行。

2.配置nginx 权重1:1的负载上边两个tomcat的地址+端口,启动nginx

3.配置jmeter压测上边配置的nginx负载的tomcat,我这jmeter配置的200个线程数,Ramp-up :1s 循环次数4次,代表1s内执行200次请求,循环4次,启动jmeter。

4.通过两个tomcat打印的日志信息可以发现了有重复库存的出现,说明在同一时间请求,超卖问题已经出现,在集群环境下sync不能解决并发问题。

这其中还会发生一个问题,那就是有可能在同一台服务器上已经减过的库存再次出现,这是因为另一台服务器给set的值导致从redis取的时候取到已经减过的库存,导致的超卖问题,也是典型的分布式问题。

如果系统中用到redis的话,推荐用redis实现分布式锁解决这个问题,接下来一步步进行分析。

如果用redis实现分布式锁的话,我们一般用setnx这个命令来实现:

SETNX
格式:setnx key value 
将 key 的值设为 value ,当且仅当 key 不存在。
若给定的 key 已经存在,则 SETNX 不做任何动作。
SETNX 是『SET if Not eXists』(如果不存在,则 SET)的简写。
返回值: 
设置成功,返回 1 。
设置失败,返回 0

根据setnx的特性,多个进程过来请求的话,让他们同时去使用setnx命令去设置同一个值,如果设置成功,则说明抢到锁,可以进行执行逻辑代码,如果没设置成功的话,说明没抢到锁,没抢到的线程进行等待重试。

根据以上思路,快速实现一个简单的入门级别分布式锁,代码如下:

    @RequestMapping("/deduct_stock")
    public String deductStock() {
    	 //在实际应用过程中,肯定是给操作的对象上锁,比如说操作某件商品,就对应商品id进行上锁
    	 String lockKey = "lock:product_101";
    	 //上锁
    	 Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "xxx");
    	 if(!result) {
    	 	return "系统繁忙,请稍后再试";
    	 }
         int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
         if (stock > 0) {
             int realStock = stock - 1;
             stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
             System.out.println("扣减成功,剩余库存:" + realStock);
         } else {
         	System.out.println("扣减失败,库存不足");
         }
         //释放锁
         stringRedisTemplate.delete(lockKey);
         return "end";
    }

当然这个只是入门级别的分布式锁,肯定有很多问题会发生,在实际业务过程中,“上锁” 之后的业务代码肯定是会很多的,在操作过程中如果发生异常,就执行不到释放锁的代码,这样就会发生死锁的问题,上锁的“product_101”key就会一直在redis里边存在,其他的线程就不能再去对上锁的“product_101”进行操作了。

解决办法就是try{xxx} finally{} 不管抛异常还是怎么样都需要把这个锁给释放了:

    @RequestMapping("/deduct_stock")
    public String deductStock() {
    	 //在实际应用过程中,肯定是给操作的对象上锁,比如说操作某件商品,就对应商品id进行上锁
    	 String lockKey = "lock:product_101";
    	 //上锁
    	 Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "xxx");
    	 if(!result) {
    	 	return "系统繁忙,请稍后再试";
    	 }
    	 try {
             int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
             if (stock > 0) {
                 int realStock = stock - 1;
                 stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
                 System.out.println("扣减成功,剩余库存:" + realStock);
             } else {
                System.out.println("扣减失败,库存不足");
             }
         } finally {
             //释放锁
             stringRedisTemplate.delete(lockKey);
         }
         return "end";
    }

加try{}finally{}就一定解决问题了吗 也不一定, 如果服务器宕机,或者basis进行服务器重启的时候,一样执行不到finally的代码,还是会发生死锁的情况。

发生上边的情况我们可以进行设置超时时间,但是有个问题注意一下,比如说下边这段代码:

Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "xxx");
stringRedisTemplate.expire(lockKey, 10, TimeUnit.SECONDS);

不要把设置超时时间和设置key分开写,这样的话容易发生原子性问题,原子是什么,原子是最小的单位不可再分割。保证原子性的话,需要redis把它当成一条命令去执行,不能分开来执行。在开发过程中这点也要注意,如果像以上代码分开来的话比如说执行到设置key这段代码服务器发生问题宕机或者重启,还是会发生以上死锁问题,正确的代码如下:

    @RequestMapping("/deduct_stock")
    public String deductStock() {
    	 //在实际应用过程中,肯定是给操作的对象上锁,比如说操作某件商品,就对应商品id进行上锁
    	 String lockKey = "lock:product_101";
    	 //上锁 保证原子性
    	 Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "xxx", 10, TimeUnit.SECONDS);
    	 if(!result) {
    	 	return "系统繁忙,请稍后再试";
    	 }
    	 try {
             int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
             if (stock > 0) {
                 int realStock = stock - 1;
                 stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
                 System.out.println("扣减成功,剩余库存:" + realStock);
             } else {
                System.out.println("扣减失败,库存不足");
             }
         } finally {
             //释放锁
             stringRedisTemplate.delete(lockKey);
         }
         return "end";
    }

以上代码在并发不是特别高的情况下是不会有问题的,但是如果说在极端高并发的场景下并且执行的业务代码逻辑又特别长,第一个请求过来执行超过了10秒钟,锁就失效了,这样第二个请求就能获取锁去执行了,在执行过程中,第一个请求执行完了执行delete key去释放锁,这样,第三个请求就能进来了,然后第二个请求执行完,就会把第三个请求的锁释放掉,这样周而复始,还是会有问题,上边的超卖问题还是得不到解决,甚至 在极端高并发的情况下,造成大量的超卖。

首先分析这个问题出现的根本原因在哪里,不难看出当前线程抢占的锁被其他线程给删除掉了,这样肯定是不合理的,线程自己的锁肯定需要自己来删除,明白的这个点后,我们可以给锁加上一个用uuid生成的clientId放到value里边去,以此来判断是不是线程自己的锁,代码如下:

    @RequestMapping("/deduct_stock")
    public String deductStock() {
    	 //在实际应用过程中,肯定是给操作的对象上锁,比如说操作某件商品,就对应商品id进行上锁
    	 String lockKey = "lock:product_101";
    	 //上锁 保证原子性
    	 String clientId = UUID.randomUUID().toString();
    	 Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, clientId, 10, TimeUnit.SECONDS);
    	 if(!result) {
    	 	return "系统繁忙,请稍后再试";
    	 }
    	 try {
             int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
             if (stock > 0) {
                 int realStock = stock - 1;
                 stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
                 System.out.println("扣减成功,剩余库存:" + realStock);
             } else {
                System.out.println("扣减失败,库存不足");
             }
         } finally {
             //释放锁
             if	(clientId.equals(stringRedisTemplate.opsForValue().get(lockKey))) {
             	stringRedisTemplate.delete(lockKey);
             }
         }
         return "end";
    }

假设在这样的情况下,线程1在执行到判断是不是自己的锁的时候 也就是 if (clientId.equals(stringRedisTemplate.opsForValue().get(lockKey))) {}这段代码的时候刚好是9.9秒时间,在要删除锁的时候进行了卡顿,这个时候线程2进来了,线程2在执行逻辑的时候,线程1的卡顿结束,又把线程2的锁给删除了,这样说白了判断clientId和删除key这两个代码还是有原子性问题的,但是就没有线程的api可以调用了。

我们发现上边有很多问题都是因为时间超时导致redis存储的锁失效,然后其他线程并发来执行,当然我们也可以将超时时间设置的长一些,设置为30s,当然有业务逻辑超过30s的很少,但是还是存在的,比如说再跑定时任务的时候用到了分布式锁,超过了30秒钟就一样还是会出现锁超时的问题,所以这样单纯的延长时间还是治标不治本,所以想要完美的解决这个问题就要引入一个完美的解决方案叫做锁续命(watchDog)。

锁续命(watchDog):假设主线程抢到锁开始执行业务逻辑,开启一个分线程,在分线程里边做一个定时任务,比如说设置的锁超时时间是30s,那么我们的定时任务时间就设置为10s,定时任务设置的时间一定要比锁超时时间小,每10s定时任务先去判断主线程有没有结束,没有结束的话说明主线程就还在,还在进行业务逻辑操作,这个时候我们执行一条expire命令,将主线程锁的超时时间重新设置为30s,这样的话只要主线程还没结束,主线程就会被分线程定时任务去做续命逻辑,维持在30s,判断主线程结束,就不再执行续命逻辑。

2. Redis Lua

Redis Lua脚本

Redis在2.6推出了脚本功能,允许开发者使用Lua语言编写脚本传到Redis中执行。使用脚本的好处如下:

1、减少网络开销∶ 本来5次网络请求的操作,可以用一个请求完成,原先5次请求的逻辑放在redis服务器上完成。使用脚本,减少了网络往返时延。这点跟管道类似。

2、原子操作: Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。管道不是原子的,不过redis的批量操作命令(类似mset)是原子的。

3、替代redis的事务功能: redis自带的事务功能很鸡肋,而redis的lua脚本几乎实现了常规的事务功能,官方推荐如果要使用redis的事务功能可以用redis lua替代。

从Redis2.6.0版本开始,通过内置的Lua解释器,可以使用EVAL命令对Lua脚本进行求值。EVAL命令的格式如下:

EVAL script numkeys key [key ...] arg [arg ...]

script参数是一段Lua脚本程序,它会被运行在Redis服务器上下文中,这段脚本不必(也不应该)定义为一个Lua函数。numkeys参数用于指定键名参数的个数。键名参数key [key …]从EVAL的第三个参数开始算起,表示在脚本中所用到的那些Redis键(key),这些键名参数可以在Lua中通过全局变量KEYS数组,用1为基址的形式访问( KEYS[1], KEYS[2],以此类推)。

在命令的最后,那些不是键名参数的附加参数arg [arg …],可以在Lua中通过全局变量ARGV数组访问,访问的形式和KEYS变量类似(ARGV[1]、ARGV[2],诸如此类)。例如

127.0.0.1:6379> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]]" 2 key1 key2 first second
1) "key1"
2) "key2"
3) "first"
4) "second"

在这里插入图片描述

其中"return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]"是被求值的Lua脚本,数字2指定了键名参数的数量, key1和key2是键名参数,分别使用KEYS[1]和KEYS[2]访问,而最后的 first和second 则是附加参数,可以通过ARGV[1]和ARGV[2]访问它们。

在Lua脚本中,可以使用redis.call()函数来执行Redis命令

Jedis调用示例详见上面jedis连接示例:

jedis.set( "product_stock_10016", "15");//初始化商品10016的库存
string script = " local count = redis.call( 'get ', KEYS[1])+
    "local a = tonumber(count) " +
    " local b = tonumber(ARGV[1])" +" 
    if a >= b then" +
    " redis.call( 'set' , KEYS[1],a-b)" +
    "return 1 "+
    "end " +
    " return 0 ";
Object obj = jedis.eval(script,Arrays.asList("product_stock_10016"),Arrays.asList("10"));
System.out. println(obj);

注意,不要在Lua脚本中出现死循环和耗时的运算,否则redis会阻塞,将不接受其他的命令,所以使用时要注意不能出现死循环、耗时的运算。redis是单进程、单线程执行脚本。管道不会阻塞redis。

3. Redisson

依据上边的场景加问题,市面上有很多优秀的分布式锁框架,其中一个Redisson的实现中,就有锁续命的实现,使用方法也很简单。

引入redisson的jar包

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.16.8</version>
</dependency>

引入之后,进行redisson客户端的配置,注入到spring容器。

@Bean
 public Redisson redisson() {
     // 此为单机模式
     Config config = new Config();
     config.useSingleServer().setAddress("redis://localhost:6379").setDatabase(0);
     return (Redisson) Redisson.create(config);
 }

然后我们再实现分布式锁就很简单了,代码如下:

    @Autowired
    private Redisson redisson;
    
    @RequestMapping("/deduct_stock")
    public String deductStock() {
    	 //在实际应用过程中,肯定是给操作的对象上锁,比如说操作某件商品,就对应商品id进行上锁
    	 String lockKey = "lock:product_101";
    	 //上锁 保证原子性
    	/* String clientId = UUID.randomUUID().toString();
    	 Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, clientId, 10, TimeUnit.SECONDS);
    	 if(!result) {
    	 	return "系统繁忙,请稍后再试";
    	 }*/
    	//获取锁对象
    	 RLock redissonLock = redisson.getLock(lockKey);
        //加分布式锁
         redissonLock.lock();  //  .setIfAbsent(lockKey, clientId, 30, TimeUnit.SECONDS);
    	 try {
             int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
             if (stock > 0) {
                 int realStock = stock - 1;
                 stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
                 System.out.println("扣减成功,剩余库存:" + realStock);
             } else {
                System.out.println("扣减失败,库存不足");
             }
         } finally {
             //释放锁
             /*if	(clientId.equals(stringRedisTemplate.opsForValue().get(lockKey))) {
             	stringRedisTemplate.delete(lockKey);
             }*/
            //解锁
            redissonLock.unlock();
         }
         return "end";
    }

再redisson加分布所锁的过程中,也就是 redissonLock.lock(),lock()方法是做了很多操作的。

在这里插入图片描述
redisson的加锁核心流程:

如上图所示:假设现在有两个线程同时调用lock()方法给同一个key加锁,原理也就是跟执行setnx命令差不多,只能有一个线程能加锁成功,如果线程1执行成功,那么相应的线程2就执行不成功,线程2就会自旋并间歇性的尝试去加锁,检测锁是否还存在,如果不存在会去尝试加锁,在的话就会继续等待(不会一直while尝试加锁,会有阻塞的逻辑)。线程1加锁成功会另外开启一个后台线程每隔一段时间检查是否还持有锁,如果持有则延长加锁的时间。

有了以上的逻辑,我们继续根据源码推敲。

加锁成功:

在RedissonLock类的lock()方法中发现会先获取线程的id,然后执行tryAcquire()方法,并且传递一个值为-1l的leaseTime参数

trytryAcquire()紧接着调用tryAcquireAsync()方法并且根据leaseTime参数值判断是否-1走相应的逻辑:

在这里插入图片描述

我们点进去else继续看发现会根据传递的参数执行一段lua脚本:

在这里插入图片描述

lua脚本具有减少网络开销、原子操作、替代redis的事物等优势,这里这段lua脚本大概的意思是:判断传入的getLock()方法里边的name存不存在,如果返回0,表示不存在则存入一个hash结构,key为传入的name(也就是上边代码的lockKey),value是根据threadId生成的一个唯一的名称(相当于上边手写分布式锁的clientId)并且给这个key设置对应参数1(unit.toMillis(leaseTime))里边的超时时间也就是30s,后边的hincrby加上增量1是为了可重入锁设置,可重入锁是在加锁的代码块里执行逻辑,可能在加锁的代码块之外的代码还有可能发生并发问题就再尝试加一层锁,当然是同一个线程,是以线程为单位,当一个线程获取对象锁之后,这个线程可以再次获取本对象上的锁,而其他的线程是不可以的。成功后返回null,否则判断hash结构(hexists)加上传入的参数,key为锁名称,value为生成的客户端唯一值(this.getLockName(threadId)),如果存在则设置这个结构hincrby为哈希表key中field键的值加上增量1并且设置超时时间为传入参数的时间leaseTime(30s)(可重复锁)。

这里解释一下为什么说传入的leaseTime是30s,上边截图里解释了调用tryAcquireAsync()方法的else代码块,这里传入的时间是 this.internalLockLeaseTime,根据代码跟踪发现this.internalLockLeaseTime = commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(),继续跟踪发现this.lockWatchdogTimeout = 30000L,如果不做任何设置,就是30s,也可以通过设置redisson里边的config参数,设置其他的超时时间(不建议)。

继续分析tryAcquireAsync()方法,执行完lua脚本加锁或者重置锁的超时时间之后,会调用一个回调方法,这个回调方法会调用一个重置到期时间(锁续命)的方法:

在这里插入图片描述

重置到期方法会调用更新过期时间的方法renewExpiration(),该方法主要实现了延时任务,也就是this.internalLockLeaseTime / 3L, TimeUnit.MILLISECONDS按照不设置超时时间的情况下,默认是30s除去3,就是每10s会执行一次去重置该锁的过期时间。

定时刷新任务续命。

在这里插入图片描述
如果续命成功返回1,一定会走下面这个if里面的逻辑,这个逻辑就是回调,再等待10s

在这里插入图片描述

上面的Lua脚本大概意思就是根据锁名称和根据线程id生成的唯一标识判断该锁是否还存在,如果存在则重新设置超时时间默认为30s并且返回true,然后继续调用回调方法,回调方法判断如果执行成功的话,继续每隔10s调用renewExpiration(),也就是延时任务自己去续命。

加锁不成功:

我们重新回到加锁的tryAcquireAsync方法,继续分析tryLockInnerAsync方法执行的lua脚本,在如果线程不存在就直接加锁,和锁重复的判断逻辑之后,还有一段脚本return redis.call(‘pttl’, KEYS[1]),意思是返回该锁剩余的过期时间,假如加锁成功之后过期时间是30s,过了5s,剩余时间就是25s。

在这里插入图片描述

重新回到最开始的lock方法,在尝试加锁不成功之后返回超时时间,如果超时时间不为null(lua脚本执行的时候如果加锁成功会返回nil 也就是null),会执行while(true)循环进行等待加锁。

在这里插入图片描述

就会进入到这个while(true)里面,进入这个里面就会再尝试加一次锁,意思就是把这个ttl值又做了一次刷新。

如果超时时间大于0,就又开始进入阻塞状态,阻塞的时间就是ttl的值的时间。

在这里插入图片描述

其中this.commandExecutor.getNow(future)).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS); latch()方法会返回一个Semaphore(信号量),然后调用tryAcquire方法进行等待(许可)上边尝试加锁返回的锁的剩余的超时时间(返回的ttl),等待的过程中不会占用cpu,就算1000个线程在等待也会让出cpu空间,不会耗费性能,等待结束后继续调用tryAcquire()方法尝试加锁,也就是上图所示的间歇性尝试加锁(自旋)。

但是这样还有有一个问题,那就是如果在获取到返回的超时时间之后,假如返回的超时时间是20s,在这20s内锁被释放掉了,难道该线程还需要一直等待20s吗,答案肯定是不需要,有一个地方进行阻塞等待,就肯定需要去唤醒在阻塞的线程继续一起抢锁,这样才能完成的形成一个闭环。

在没有抢到锁的线程会利用redis的订阅功能,订阅一个名叫“redisson_lock__channel”+ 锁的名称的channel频道,等待频道发布消息进行唤醒。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
那么什么时候会给频道 发布消息呢,肯定是在unlock方法,释放锁的时候会去在频道发布消息,告诉在等待的线程可以进行抢锁了。进入unlock方法之后发现调用了unlockAsync方法,该方法又调用了unlockInnerAsync方法,unlockInnerAsync方法打开之后发现依旧是一段lua脚本:

    protected RFuture<Boolean> unlockInnerAsync(long threadId) {
        return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
                "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
                        "return nil;" +
                        "end; " +
                        "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
                        "if (counter > 0) then " +
                        "redis.call('pexpire', KEYS[1], ARGV[2]); " +
                        "return 0; " +
                        "else " +
                        "redis.call('del', KEYS[1]); " +
                        "redis.call('publish', KEYS[2], ARGV[1]); " +
                        "return 1; " +
                        "end; " +
                        "return nil;",
                Arrays.asList(getRawName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
    }

改lua脚本大概意思是:首先根据this.getRawName()也就是锁名称和this.getLockName(threadId)是以上生成的代表持有锁的唯一标识(客户端标识)判断是否存在,如果不存在代表没有自己的锁,肯定是不能删除的,然后继续判断可重入锁,进行减1,如果大于0则重置超时时间,否则根据锁名称进行删除锁并发布订阅,内容为频道名称和解锁内容(0)并返回1。

在这里插入图片描述

发布成功之后,回到刚刚加锁的代码块中回看订阅频道的类:

protected CompletableFuture<RedissonLockEntry> subscribe(long threadId) {
    return this.pubSub.subscribe(this.getEntryName(), this.getChannelName());
}

this.pubSub这个类中去发布的订阅,这个类是LockPubSub,在这个类中有消费onMessage方法,也就是说发布成功之后,当队列有消息监听改队列的线程会调用onMessage方法去消费:

在这里插入图片描述

先判断lua脚本里返回的消息是不是0,调用poll方法删除检索并删除队列的元素,如果不为null回调run方法。然后调用getLatch方法获取当初阻塞等待的信号量,调用release()方法唤醒线程去抢锁。

为了满足各种需求redisson还提供了其他丰富的api,其中trylock()也是一种尝试加锁的api,但是不会自旋阻塞去一直尝试加锁,没有看门狗(getLockWatchdogTimeout())的逻辑。它会根据设置的时间去尝试加锁,加锁成功返回true,失败返回false,加锁成功后设置的leaseTime就是锁的最长超时时间,三个参数如下:

boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException;

waitTime是指在这段时间内尝试加锁会去等锁,比如说设置10s,在10s内加锁成功返回true,失败返回false,超过等待时间,加锁失败后不会发生自旋去一直尝试加锁。

leaseTime是指超时时间,加锁成功后,leaseTime就是锁设置的超时时间。

unit是指时间的单位SECONDS,MINUTES,HOURS…

4. redis分布式锁在集群中存在的问题

假设线程1在主节点加锁成功,主节点在同步数据到从节点的过程中宕机,重新选举从节点为主节点,这个时候新的主节点是不存在线程1的锁的,这个时候线程2过来加锁成功执行逻辑完成,再来一个线程过来加锁成功,而线程1并发问题还没执行完成,这样的话就又会出现“超卖”的问题,这样的问题我们称为redis主从架构锁失效问题

关于zk(zookeeper) : zk也支持集群架构,zk也可以实现分布式锁。假如现在有一个zk集群,主节点是leader,有两个从节点follower1,follower2。从cap(CAP 理论是针对分布式数据库而言的,它是指在一个分布式系统中,一致性(Consistency, C)、可用性(Availability, A)、分区容错性(Partition Tolerance, P)三者不可兼得。)的理论来说redis集群着重于满足ap(如果要可用性高并允许分区,则需放弃一致性。一旦分区发生,节点之间可能会失去联系,为了实现高可用,每个节点只能用本地数据提供服务,而这样会导致全局数据的不一致性。),对可用性满足的多一点,基于zk的集群架构对cp(如果不要求可用性,相当于每个请求都需要在各服务器之间强一致,而分区容错性会导致同步时间无限延长,如此 CP 也是可以保证的。很多传统的数据库分布式事务都属于这种模式。)对数据一致性的要求更高一点,比如说要基于zk集群实现分布式锁,也是去设置key,value和redis类似在主节点进行写数据,但是zk主节点写入数据后并不会直接向客户端返回结果,而是先向从节点follower节点同步数据,从节点同步完成数据后向主节点发送成功消息,主节点收到消息后计算集群内有半数以上节点同步完成数据才会认为数据真的写成功,这个时候才会向客户端反馈成功消息继续执行逻辑,以此来保证数据一致性。这个就是zk的zab协议中的广播——主从同步(主从同步数据比较简单, 当有写操作时,如果是从机接收,会转到主机。做一次转发,保证写都是在主机上进行。主先提议事务,收到过半回复后,再发提交。 主收到写操作时,先本地生成事务为事务生成zxid,然后发给所有follower节点。 当follower收到事务时,先把提议事务的日志写到本地磁盘,成功后返回给leader。 leader收到过半反馈后对事务提交。再通知所有的follower提交事务,follower收到后也提交事务,提交后就可以对客户端进行分发了。),当zk集群的主节点宕机后,也会发生选举,但是zk底层的zab协议中的选举机制决定了会选举出同步数据更多的节点为主节点(后续会解释),所以如果用zk来实现分布式锁的话,上述锁阐述的redis主从架构锁失效问题也就会得到解决,但是相应的因为底层实现的原因性能就会比redis差很多。

选redis还是zk实现分布式锁:首先zk的性能肯定不如redis,但是从分布式锁的角度语义上来说,zk可能更适合一些,所以如果对性能要求比较高的话就选redis,对数据的强一致性有特别严格要求的话就选zk,现在的主流的分布式锁方案还是redis,也有一些办法去减少redis主从架构锁失效问题。

在这里插入图片描述

redis的红锁(建议不要使用):

首先需要实现红锁需要有多个redis节点,这个节点最好是奇数节点会对资源的利用率更高。假如说现在有3个redis节点,他们之间的关系是相等的,没有主从、集群关系,都是对等的单节点,过来存储锁也是一样的类似setnx(如果存在key不做操作)的机制,但是需要在所有节点去执行存储的动作,有半数以上的节点返回存储成功客户端才会认为加锁成功,才能走加锁的逻辑。这样做的好处就是因为半数以上成功才算成功的机制就算其中一个节点宕机也不会产生锁失效的问题。但是这样就失去了使用redis 的意义,对性能上也会产生影响。因为集群架构是会立马返回结果,但是这种红锁的机制也是去牺牲了一些可用性去同步多个节点后才会返回结果。对数据一致性会保证的更好一点。

redlock在redisson里边的实现,使用demo如下:

在这里插入图片描述
在这里插入图片描述

对于redisson中的redlock这样做还是会有问题的,单实例肯定不是很可靠吧?加锁成功之后,结果 Redis 服务宕机了,这不就凉凉~这时候会提出来将 Redis 主从部署。即使是主从,也是存在巧合的!比如说现在为了高可用给每个redis节点加上一个从节点,主从结构中存在明显的竞态:

  1. 客户端 A 从 master 获取到锁
  2. 在 master 将锁同步到 slave 之前,master 宕掉了。
  3. slave 节点被晋级为 master 节点
  4. 客户端 B 取得了同一个资源被客户端 A 已经获取到的另外一个锁。安全失效

有时候程序就是这么巧,比如说正好一个节点挂掉的时候,多个客户端同时取到了锁。如果你可以接受这种小概率错误,那用这个基于复制的方案就完全没有问题。

当然也可以直接简单粗暴多加一些单节点,但是根据以上的半数以上机制来看节点加的越多,需要存值的节点也就越多,消耗的性能就越多,这样就违背了用redis 的初衷。

那我使用集群呢?

如果还记得前面的内容,应该是知道对集群进行加锁的时候,其实是通过 CRC16 的 hash 函数来对 key 进行取模,将结果路由到预先分配过 slot 的相应节点上。

发现其实还是发到单个节点上的!

还有一个问题就是redis宕机后恢复数据的问题,就算不用主从结构,单机节点追求redis高性能的情况下一般设置持久化策略是不会设置立即持久化的,比如aof大多数情况下会设置1s后持久化这样子。假设现在其中一个节点宕机又立即重启的情况下,redis恢复数据如果aof持久化配置的策略不是每一条都存储的情况下,还是有可能丢失数据从而发生以上锁失效问题。

redis作者也对redlock有一定的争议:

https://www.cnblogs.com/liuzhihang/p/15003362.html

结论

Redisson RedLock 是基于联锁 MultiLock 实现的,但是使用过程中需要自己判断 key 落在哪个节点上,对使用者不是很友好。

Redisson RedLock 已经被弃用,直接使用普通的加锁即可,会基于 wait 机制将锁同步到从节点,但是也并不能保证一致性。仅仅是最大限度的保证一致性。

**分布式锁的优化:**分布式锁从底层来讲就是把并行执行的请求给串行化了,因为redis是单线程的肯定就不会有并发问题了。

分布式锁一旦加了之后,对同一个商品的下单请求,会导致所有客户端都必须对同一个商品的库存锁key进行加锁。

比如,对iphone这个商品的下单,都必对“iphone_stock”这个锁key来加锁。这样会导致对同一个商品的下单请求,就必须串行化,一个接一个的处理。假设加锁之后,释放锁之前,查库存 -> 创建订单 -> 扣减库存,这个过程性能很高吧,算他全过程20毫秒,这应该不错了。那么1秒是1000毫秒,只能容纳50个对这个商品的请求依次串行完成处理。

缺陷: 同一个商品多用户同时下单的时候,会基于分布式锁串行化处理,导致没法同时处理同一个商品的大量下单的请求。

这种方案,要是应对那种低并发、无秒杀场景的普通小电商系统,可能还可以接受。

解决方案:

  1. 从粒度着手,锁的粒度范围越小越好,加锁的代码越少性能就越高,因为加锁的代码会串行执行,没有必要加锁的代码肯定是让他们并行执行这样效率更高。

  2. 分段锁。其实说出来也很简单,看过java里的ConcurrentHashMap的源码和底层原理,应该知道里面的 核心思路:分段加锁!

把数据分成很多个段,每个段是一个单独的锁,所以多个线程过来并发修改数据的时候,可以并发的修改不同段的数据。不至于说,同一时间只能有一个线程独占修改ConcurrentHashMap中的数据。

另外,Java 8中新增了一个LongAdder类,也是针对Java 7以前的AtomicLong进行的优化,解决的是CAS类操作在高并发场景下,使用乐观锁思路,会导致大量线程长时间重复循环。

LongAdder中也是采用了类似的分段CAS操作,失败则自动迁移到下一个分段进行CAS的思路。

其实分布式锁的优化思路也是类似的,之前我们是在另外一个业务场景下落地了这个方案到生产中,不是在库存超卖问题里用的。

但是库存超卖这个业务场景不错,很容易理解,所以我们就用这个场景来说一下。

在这里插入图片描述

分段加锁思想。假如你现在iphone有1000个库存,那么你完全可以给拆成20个库存段,要是你愿意,可以在数据库的表里建20个库存字段,每个库存段是50件库存,比如stock_01对应50件库存,stock_02对应50件库存。类似这样的,也可以在redis之类的地方放20个库存key。

接着,1000个/s 请求,用一个简单的随机算法,每个请求都是随机在20个分段库存里,选择一个进行加锁。

每个下单请求锁了一个库存分段,然后在业务逻辑里面,就对数据库或者是Redis中的那个分段库存进行操作即可,包括查库存 -> 判断库存是否充足 -> 扣减库存。

相当于一个20毫秒,可以并发处理掉20个下单请求,那么1秒,也就可以依次处理掉20 * 50 = 1000个对iphone的下单请求了。

一旦对某个数据做了分段处理之后,有一个坑一定要注意:就是如果某个下单请求,咔嚓加锁,然后发现这个分段库存里的库存不足了,此时咋办?
这时你得自动释放锁,然后立马换下一个分段库存,再次尝试加锁后尝试处理。 这个过程一定要实现。

分布式锁并发优化方案的不足:

最大的不足,很不方便,实现太复杂。

  • 首先,你得对一个数据分段存储,一个库存字段本来好好的,现在要分为20个分段库存字段;
  • 其次,你在每次处理库存的时候,还得自己写随机算法,随机挑选一个分段来处理;
  • 最后,如果某个分段中的数据不足了,你还得自动切换到下一个分段数据去处理。
    这个过程都是要手动写代码实现的,还是有点工作量,挺麻烦的。

不过我们确实在一些业务场景里,因为用到了分布式锁,然后又必须要进行锁并发的优化,又进一步用到了分段加锁的技术方案,效果当然是很好的了,一下子并发性能可以增长几十倍。

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

4. Redis高并发分布式锁实战---大厂生产级Redis高并发分布式锁实战 的相关文章

  • socket.io 广播功能 & Redis pub/sub 架构

    如果有人能帮助我解决一个小疑问 我将不胜感激 使用socket io广播功能和在Redis上使用pub sub设计架构有什么区别 例如 在另一个示例中 node js 服务器正在侦听 socket io 针对 键 模型 todo 和值 数据
  • Redis Docker compose无法处理RDB格式版本10

    我无法在 docker compose 文件中启动 redis 容器 我知道docker compose文件没问题 因为我的同事可以成功启动项目 我读到有一个删除 dump rdb 文件的解决方案 但我找不到它 我使用Windows机器 任
  • Redis发布/订阅:查看当前订阅了哪些频道

    我目前有兴趣查看我拥有的 Redis 发布 订阅应用程序中订阅了哪些频道 当客户端连接到我们的服务器时 我们将它们注册到如下所示的通道 user user id 这样做的原因是我希望能够看到谁 在线 目前 我在不知道客户端是否在线的情况下盲
  • 2 个具有共享 Redis 依赖的 Helm Chart

    目前 我有 2 个 Helm Charts Chart A 和 Chart B Chart A 和 Chart B 对 Redis 实例具有相同的依赖关系 如Chart yaml file dependencies name redis v
  • 如何在 emacs lua-mode 中配置缩进?

    完整的 emacs 新手在这里 我在 Ubuntu 上使用 emacs 23 1 1emacs 入门套件 https github com technomancy emacs starter kit 我主要在 lua 模式下工作 安装了pa
  • 为什么Redis中没有有序的hashmap?

    Redis 数据类型 http redis io topics data types包括排序集 http redis io topics data types intro sorted sets以及其他用于键值存储的必要数据结构 但我想知道
  • 在Luasocket中,在什么条件下,即使在select告诉它可以安全读取之后,accept调用也可以阻塞?

    卢阿索基特select http w3 impa br diego software luasocket socket html select函数应该告诉何时可以在不阻塞的情况下读取套接字 它显然也可以用来告诉服务器套接字何时准备好接受新连
  • 如何终止Lua脚本?

    如何终止 Lua 脚本 现在我在 exit 方面遇到问题 我不知道为什么 这更像是一个 Minecraft ComputerCraft 问题 因为它使用了包含的 API 这是我的代码 while true do if turtle dete
  • 使用redis进行树形数据结构

    我需要为基于树的键值开发一个缓存系统 与Windows注册表编辑器非常相似 其中缓存键是字符串 表示树中到值的路径 可以是原始类型 int string bool double 等 或子树本身 例如 key root x y z w val
  • gsub 的转义字符串

    我读了一个文件 local logfile io open log txt r data logfile read a print data output n w r 1 2 n t x re S 是的 日志文件看起来很糟糕 因为它充满了各
  • Laravel 异常队列最大尝试次数超出

    我创建了一个应用程序来向多个用户发送电子邮件 但在处理大量收件人时遇到问题 该错误出现在failed jobs table Illuminate Queue MaxAttemptsExceededException App Jobs ESe
  • Redis 队列工作程序在 utcparse 中崩溃

    我正在尝试按照以下教程获得基本的 rq 工作 https blog miguelgrinberg com post the flask mega tutorial part xxii background jobs https blog m
  • 如何配置Lettuce Redis集群异步连接池

    我正在配置我的生菜重新分配池 当我按照官方文档配置时 连接池无法正常初始化 无法获取连接 官方文档指出 RedisClusterClient clusterClient RedisClusterClient create RedisURI
  • 如何在 Lua - Lightroom 插件中使用 HMAC

    首先我要提的是我对 Lua 真的很陌生 如果你认为我的问题太愚蠢 请耐心等待 这是我的要求 我需要使用 HMAC sha256 进行 Lightroom 插件开发 因为我使用它是为了安全 我试图使用这个但没有运气https code goo
  • 超出 Redis 连接/缓冲区大小限制

    在对我们的应用程序服务器进行压力测试时 我们从 Redis 中得到以下异常 ServiceStack Redis RedisException 无法连接到 redis host 6379 处的 redis 实例 gt System Net
  • ServiceStack.Redis:无法连接:sPort:

    我经常得到 ServiceStack Redis 无法连接 sPort 0 或 ServiceStack Redis 无法连接 sPort 50071 或其他端口号 当我们的网站比较繁忙时 就会出现这种情况 Redis 本身看起来很好 CP
  • 使用环境变量在 redis.conf 中设置动态路径

    我有一个环境变量MY HOME其中有一个目录的路径 home abc 现在 我有一个redis conf文件 我需要像这样设置这个路径 redis conf pidfile MY HOME local var pids redis pid
  • 将文件传递给活动作业/后台作业

    我通过标准文件输入接收请求参数中的文件 def create file params file upload Upload create file file filename img png end 但是 对于大型上传 我想在后台作业中执行
  • 在Windows上使用gcc 5.3.0编译Lua 5.2.4模块

    我需要用 gcc 5 3 0 编译 Lua 5 2 4 模块 在 Windows 上 在此之前 我按照以下步骤构建 Lua 5 2 4 gcc c DLUA BUILD AS DLL c ren lua o lua obj ren luac
  • Redis 在键过期时更新排序集

    我有一个 Redis 服务器 其中包含一组键值对和一个排序集 提供这些键值对的键的索引 键值对可以进入 已完成 状态 此时需要在 1 小时后删除它们 这可以通过在键上设置到期时间来简单地实现 但从排序集中清除它们似乎更成问题 我可以有一个过

随机推荐

  • Redis使用总结(二、缓存和数据库双写一致性问题)

    首先 缓存由于其高并发和高性能的特性 已经在项目中被广泛使用 在读取缓存方面 大家没啥疑问 都是按照下图的流程来进行业务操作 但是在更新缓存方面 对于更新完数据库 是更新缓存呢 还是删除缓存 又或者是先删除缓存 再更新数据库 其实大家存在很
  • Flutter 升级2.5之后报错?

    Q Flutter执行命令升级新版本后 用flutter doctor命令检查时存在如下问题 按照提示键入命令后 再次出现报错 A 当我们升级SDK后 执行flutter doctor 这里是提示我们需要安装Android开发的命令行工具
  • iPhone/iPad用iTunes“同步”不等于“备份”

    一个很 基础 却很 重要 很多人 搞不清楚 解释又很花时间的问题 就是 iPhone 跟电脑 iTunes 同步 和 备份 有什么不同 首先 Sync 翻译成中文 同步 本来就是一个定义 认知有点模糊的中文动词 尤其对电脑不是很熟悉的朋友
  • Java 构造函数的详解

    我们人出生的时候 有些人一出生之后再起名字的 但是有些人一旦出生就已经起好名字的 那么我们在java里面怎么在对象一旦创建就赋值呢 1 构造方法的作用 构造方法作用 对对象进行初始化 如图 2 构造函数与普通函数的区别 1 一般函数是用于定
  • CTF工具压缩包爆破神器Fcrackzip详细用法

    Fcrackzip简介 Fcrackzip是一款专门破解zip类型压缩文件密码的工具 工具小巧方便 破解速度快 能使用字典和指定字符集破解 适用于linux mac osx 系统 Fcrackzip下载 Windows下载 下载链接 htt
  • 「爬虫教程」吐血整理,最详细的爬虫入门教程

    初识爬虫 学习爬虫之前 我们首先得了解什么是爬虫 来自于百度百科的解释 网络爬虫 又称为网页蜘蛛 网络机器人 在FOAF社区中间 更经常的称为网页追逐者 是一种按照一定的规则 自动地抓取万维网信息的程序或者脚本 通俗来讲 假如你需要互联网上
  • Spring中Bean的实例化详细流程

    还是举个例子 我有一个朋友小汪他远赴南方某城市打工 然后安定下来后他的朋友很想来家里玩 但是呢我这个朋友家里搞的很乱 所以他不好意思请朋友来家里玩 这时我的另一个朋友说那请一个保姆把家里好好整理一下就可以了 然后给他介绍了一个保姆大S PS
  • C语言 信号处理机制

    C语言中信号标示一种时间 它可能异步地发生 也就是并不与城市执行过程中的任何事件保持同步 如果程序中未设置该信号的处理函数 则选择缺省方式 大部分为终止程序运行 信号头文件
  • 面向对象的编程思想和Python的类,访问和属性,继承

    面向对象的编程思想和Python的类 类的方法和属性 实例方法 这一文从面相对象的角度 介绍类的定义 类的属性和自定义方法 本文将从访问限制 属性 继承 方法重写这几个方面继续介绍面向对象的编程思想和Python类的继承 一 访问权限 Py
  • XML建模

    文章目录 思路 思路 把配置文件读到内存里并解析出来 gt 建立xml模型 有几个节点就创建几个模型 把他们的关系放到模型里 gt 对模型进行完善 gt 把解析出来的数据放到模型里 XML建模的具体文件 内附注释
  • Linux unit 测试工具,单元测试工具 CUnit 简介

    1 CUnit简介 1 1 CUnit简要描述 CUnit是一个编写 管理及运行c语言单元测试的系统 它使用一个简单的框架来构建测试结构 并为普通数据结构的测试提供丰富的断言 此外 CUnit为测试的运行和结果查看提供了许多不同的接口 包括
  • centos7 keepalived 离线安装

    两台服务器 master 10 214 130 100 slave 10 214 130 101 vip keepalived虚拟ip 10 214 130 102 1 下载 登陆官网 http www keepalived org dow
  • IDEA Maven 依赖分析插件Maven Helper

    IDEA 安装Maven Helper插件 1 打开setting 找到Plugins选项 安装Maven Helper 插件 如果有就跳过这一步 检索 Maven Helper 安装成功后 重新启动IDEA编辑器 2 使用Maven He
  • java 将pdf转word

    可以使用 Apache POI 库来实现将 PDF 转换为 Word 文档的功能 首先 需要将 Apache POI 库的依赖添加到项目中
  • 推荐几个很实用的编程网站

    目录 一 W3School 二 LeetCode 三 PythonTip 四 Codewars 五 Code Monkey 本文精选了有关代码 编程 Java Python SQL Git 和Ruby on Rails学习的网站 这些网站为
  • mac扩展屏,HIDPI

    2k 4k 均可开启 HIDPI
  • java.lang.RuntimeException: Canvas: trying to draw too large(277114284bytes) bitmap.

    java lang RuntimeException Canvas trying to draw too large 277114284bytes bitmap 今天运行一个小项目报错 E AndroidRuntime FATAL EXCE
  • 【PCL】得到一个点云中的最高值、最低值

    有一个点云 想得到它x y z三个轴上的最大值和最小值 可以用pcl getMinMax3D函数 在这儿 函数参数 1 点云 2 放最小值的容器 3 放最大值的容器 容器类型是点云中点的类型 正好有三个值 代码 Created by eth
  • 分布式事务-LCN

    2PC两阶段提交协议 分布式事务通常采用2PC协议 全称Two Phase Commitment Protocol 该协议主要为了解决在分布式数据库场景下 所有节点间数据一致性的问题 分布式事务通过2PC协议将提交分成两个阶段 阶段一为准备
  • 4. Redis高并发分布式锁实战---大厂生产级Redis高并发分布式锁实战

    分布式缓存技术Redis 1 手写分布式锁 2 Redis Lua 3 Redisson 4 redis分布式锁在集群中存在的问题 本文是按照自己的理解进行笔记总结 如有不正确的地方 还望大佬多多指点纠正 勿喷 课程内容 1 高并发场景秒杀