浅谈Redis分布式锁(Redisson)

浅谈Redis分布式锁(Redisson)

每天多学一点点~
话不多说,这就开始吧…

1.前言

分布式锁一般用Zookeeper(强一致性),但是Redis也可以,并且已经有比较成熟的Redisson框架。今天就来学习学习Redis锁的问题。

Redis系列文章

2.Redis单机锁

先看一下下面这段代码

    /**
     * 为了防止超卖,可以加锁,单机jvm没什么问题,但是分布式下(比如用nginx分发的话) 还是有问题
     */
    @RequestMapping("/testlock")
    public String deductStock() throws InterruptedException {
        synchronized (this) {
            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); //获取库存
            if (stock > 0) {
                int realStock = stock - 1;  //库存减1
                stringRedisTemplate.opsForValue().set("stock", realStock + "");//设置最新库存
                System.out.println("扣减成功,剩余库存:" + realStock + "");
            } else {
                System.out.println("扣减失败,库存不足");
            }
        }
        return "结束";
    }

上述代码,在单机高并发情况下,并没有什么问题,性能也还可以(jkd1.8已经对synchronized做了不少优化~),但是一般大型电商,不可能是部署一个实例,这样我们启动两个端口,8080,8010,用nginx负载模拟一下,会出现什么问题

worker_processes  1;
events {
    worker_connections  1024;
}
http {
    include       mime.types;
    default_type  application/octet-stream;
    upstream mytest{
	server 127.0.0.1:8080 weight=1;  
	server 127.0.0.1:8010 weight=1;
    }
    sendfile        on;
    server {
        listen       80;
        server_name  localhost;
        location / {
            proxy_pass   http://mytest;
        }
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }
    }
}

Nginx配置
设置Redis中库存为50,启动Nginx和jemeter,模拟500个用户,访问nginx进行分发
jmeter配置8080实例
8010实例
可以看到,两个实例都出现了相同减库存操作(比如48,47),这样就会出现超卖的现象,显然,synchronized在分布式环境下不起作用~

3.Redis分布式锁(基于setnx命令)

那么记下来我们将代码修改一下
springboot2.1.2版本,对应redis也是2.1.2,升级了setIfAbsent方法,加上了失效时间~

    @RequestMapping("/testlock")
    public String deductStock() throws InterruptedException {
        String lockKey = "product_001";
        String clientId = UUID.randomUUID().toString();     //使用uuid当作value,为finally释放锁做准备
        try {
            //使用setnx命令(若key不存在,则新增;存在,不做操作),并设置失效时间(因为单机测试,所以设置短一点)
            Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, clientId,10, TimeUnit.SECONDS);
            if (!result) {      //其他线程让其直接返回,保证只有一个线程 走下去
                return "errorCode:当前商品正在抢购,请稍后再试";
            }
            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); //获取库存
            if (stock > 0) {
                int realStock = stock - 1;
                stringRedisTemplate.opsForValue().set("stock", realStock + "");         //设置最新库存
                System.out.println("扣减成功,剩余库存:" + realStock + "");
            } else {
                System.out.println("扣减失败,库存不足");
            }
        } finally {
            //  谁加的锁,谁释放(判断当前线程生成的锁是否是自己生成的)
            if (clientId.equals(stringRedisTemplate.opsForValue().get(lockKey))) {
                stringRedisTemplate.delete(lockKey);
            }
        }
        return "抢购成功";
    }

上述代码,在大多数情况下(一般传统的软件公司,比如博主现在的公司)已经可以了~**不足的地方有二

  1. 万一在代码执行到finally的时候挂了,锁没释放,因为加了失效时间,所以会锁几秒钟,并且失效时间也需要根据业务好好斟酌斟酌!
  2. 用jmeter测试,如果并发量特别大,将jmeter的Ramp-up设置为1s(决定多长时间启动所有线程),线程数500,也就是并发500,库存500,那么没有拿到锁的线程直接return了,并不会减少库存。将Ramp-up调大一点,比如5s,并发小,这样剩余库存才会变小

用jmeter测试一下,库存500,Ramp-up设置成1s,线程数500,并发500,库存会怎样
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

用jmeter测试一下,库存500,Ramp-up设置成5s,线程数500,并发100,库存会怎样

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
综上,上述代码还不是很完美。Redisson框架就很好的解决了这个问题(redisson的lock方法当拿不到锁的时候会一致while循环等待获取锁,后面会详细讲),在介绍Redisson之前,博主先介绍一下Lua脚本~

4.Lua脚本

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

  1. 减少网络开销:本来5次网络请求的操作,可以用一个请求完成,原先5次请求的逻辑放在redis服务器上完成。使用脚本,减少了网络往返时延。这点跟管道类似。
  2. 原子操作:Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。管道不是原子的
  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] ,诸如此类)。
举个例子:

eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second

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

下面我们用jedis和redisTemplate两个方式测试下lua脚本

		    /**
    		 * 用 jedis方式操作lua脚本
    		 */
            jedis.set("qiuqiu_stock_10086", "20");  
            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], count-b) " +
                            //模拟语法报错回滚操作"   bb == 0 " +
                            "   return 1 " +
                            " end " +
                            " return 0 ";
            Object obj = jedis.eval(script, Arrays.asList("qiuqiu_stock_10086"), Arrays.asList("10"));
            System.out.println(obj);

		   /**
		     * 用 redisTemplate 测试lua 脚本
		     */
		redisTemplate.opsForValue().set("qiuqiu_stock_10086", 20);
        RedisScript script = RedisScript.of(
                " 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], count-b) " +
                //模拟语法报错回滚操作"   bb == 0 " +
                " return 1 " +
                " end " +
                " return 0 ",Long.class);   //注意是Long类型,而不是Integer
        List<String> list = new ArrayList<>();
        list.add("qiuqiu_stock_10086");
        Object obj = redisTemplate.execute(script,new StringRedisSerializer(),new StringRedisSerializer(),list,"10");
        System.out.println(obj);

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

Lua脚本到这里介绍完毕,各位只要知道其是原子操作的就行,具体需要用到的时候再去网上搜一下脚本怎么写。那么为何要介绍Lua脚本呢,就是为了介绍接下来的Redisson框架!

5.Redisson框架

Redisson官网
Redisson框架Git中文文档

我们把上面依然会有问题的redis分布式锁的代码用redisson框架修改一下


		/**
	     *   pom依赖
	     */
	     <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson</artifactId>
            <version>3.6.5</version>
        </dependency>
        
		/**
	     *   在springboot启动类注入 redisson
	     */
    @Bean
    public Redisson redisson() {
        // 此为单机模式
       Config config = new Config();
       config.useSingleServer().setAddress("redis://127.0.0.1:6379").setDatabase(0).setPassword("123456");
        /*config.useClusterServers()
                .addNodeAddress("redis://192.168.73.130:8001")
                .addNodeAddress("redis://192.168.73.131:8002")
                .addNodeAddress("redis://192.168.73.132:8003")
                .addNodeAddress("redis://192.168.73.130:8004")
                .addNodeAddress("redis://192.168.73.131:8005")
                .addNodeAddress("redis://192.168.73.132:8006");*/
        return (Redisson) Redisson.create(config);
    }


    /**
     *   redisson方式 加锁
     */
    @RequestMapping("/testlock")
    public String deductStock() throws InterruptedException {
        String lockKey = "product_001";
        RLock redissonLock = redisson.getLock(lockKey);  //获取锁
        try {
            // 加锁,实现锁续命功能
            redissonLock.lock();		//加锁
            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 {
            redissonLock.unlock();	//释放锁
        }
        return "end";
    }

同样的功能,用Redisson,只需要三步,获取锁,加锁,释放锁。

用jmeter测试一下,库存500,Ramp-up设置成1s,线程数500,并发500,库存会怎样

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
可以看到,用了redisson框架后,每秒并发500,依然没有剩余库存,因为redisson的lock方法当拿不到锁的时候会一致while循环等待获取锁。redisson底层都是用lua脚本实现的。

6.浅看Redisson源码

从redissonLock.lock();方法入手

加锁
>>>java.util.concurrent.locks.Lock#lock
   >>>org.redisson.RedissonLock#lock()
      >>>org.redisson.RedissonLock#lockInterruptibly()
	     >>>org.redisson.RedissonLock#lockInterruptibly(long, java.util.concurrent.TimeUnit) //加锁
		    >>>org.redisson.RedissonLock#tryAcquire    //获取锁
			   >>>org.redisson.RedissonLock#tryAcquireAsync   //异步获取锁
			      >>>org.redisson.RedissonLock#scheduleExpirationRenewal  //,通过lua脚本设置锁续命,并递归调用

释放锁
>>>org.redisson.RedissonRedLock#unlock
   >>>org.redisson.RedissonMultiLock#unlockInner
      >>>org.redisson.api.RLockAsync#unlockAsync()
	     >>>org.redisson.RedissonLock#unlockAsync()
		    >>>org.redisson.RedissonLock#unlockAsync(long)
			   >>>org.redisson.RedissonLock#unlockInnerAsync //调用异步解锁

主线程执行,分线程续命,默认超时时间30s
lockInterruptibly 加锁

@Override
public void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException {
    // 1.尝试获取锁
    Long ttl = tryAcquire(leaseTime, unit);
    // 2.获得锁成功
    if (ttl == null) {
        return;
    }
    // 3.等待锁释放,并订阅锁
    long threadId = Thread.currentThread().getId();
    Future<RedissonLockEntry> future = subscribe(threadId);
    get(future);
	
    try {
    	// 支持重入锁 通过while循环
        while (true) {
            // 4.重试获取锁
            ttl = tryAcquire(leaseTime, unit);
            // 5.成功获得锁
            if (ttl == null) {
                break;
            }
            // 6.等待锁释放
            if (ttl >= 0) {
                getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
            } else {
                getEntry(threadId).getLatch().acquire();
            }
        }
    } finally {
        // 7.取消订阅
        unsubscribe(future, threadId);
    }
}

tryAcquire() 获取锁

private Long tryAcquire(long leaseTime, TimeUnit unit) {
    // 1.将异步执行的结果以同步的形式返回
    return get(tryAcquireAsync(leaseTime, unit, Thread.currentThread().getId()));
}

private <T> Future<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, long threadId) {
    if (leaseTime != -1) {
        return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
    }
    // 2.用默认的锁超时时间去获取锁
    Future<Long> ttlRemainingFuture = tryLockInnerAsync(LOCK_EXPIRATION_INTERVAL_SECONDS,
                TimeUnit.SECONDS, threadId, RedisCommands.EVAL_LONG);
    ttlRemainingFuture.addListener(new FutureListener<Long>() {
        @Override
        public void operationComplete(Future<Long> future) throws Exception {
            if (!future.isSuccess()) {
                return;
            }
            Long ttlRemaining = future.getNow();
            // 成功获得锁
            if (ttlRemaining == null) {
                // 3.锁过期时间刷新任务调度
                scheduleExpirationRenewal();
            }
        }
    });
    return ttlRemainingFuture;
}

<T> Future<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId,
                RedisStrictCommand<T> command) {
    internalLockLeaseTime = unit.toMillis(leaseTime);
    // 3.使用 EVAL 命令执行 Lua 脚本获取锁
    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
              "if (redis.call('exists', KEYS[1]) == 0) then " +
                  "redis.call('hset', KEYS[1], ARGV[2], 1); " +
                  "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                  "return nil; " +
              "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 nil; " +
              "end; " +
              "return redis.call('pttl', KEYS[1]);",
                Collections.<Object>singletonList(getName()), internalLockLeaseTime,
                        getLockName(threadId));
}

unlockInnerAsync 释放锁

    protected RFuture<Boolean> unlockInnerAsync(long threadId) {
        // 1.通过 EVAL 和 Lua 脚本执行 Redis 命令释放锁
        return this.commandExecutor.evalWriteAsync(this.getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN, 
                "if (redis.call('exists', KEYS[1]) == 0) then " +
                        "redis.call('publish', KEYS[2], ARGV[1]);" +
                        " return 1; " +
                        "end;" +
                        "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(this.getName(), this.getChannelName()), new Object[]{LockPubSub.unlockMessage, this.internalLockLeaseTime, this.getLockName(threadId)});
    }

大体博主画个图
在这里插入图片描述
以上是博主自己看的源码,不一定准确,还是推荐各位小伙伴们去官网学习~

Redis集群,当master挂了从节点重新选举时产生新的master,如果加的是同一把锁,会导致其他线程在洗呢master加锁成功。这样就需要用zookeeper(强一致性)加锁。但是一般这些问题偶尔发生,都是可以容忍的~如果非要强一致性,100%不出问题,那么博主这里推荐另一个Redis锁框架----Redlock框架(目前市面上的实现还是有bug的,据博主所致用的还比较少,底层实现原理和zk差不多,有兴趣的可以去看看)

7.redisson亮点总结

  1. 看门狗,解决了业务执行时间超过了锁的超时时间,进行续约,(但如果在续约前发生了FULLGC,也无法解决);
  2. 解决获取锁失败后循环重试(有sleep),占用CPU资源,影响性能问题,通过pubsub解决(pubsub没有持久化功能,redis5的stream可解决);

8.结语

世上无难事,只怕有心人,每天积累一点点,fighting!!!

  • 3
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 6
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值