Redis从了解到掌握(五):Redis分布式锁实战

341 阅读7分钟

分布式锁使用场景

在学习Redis分布式锁之前,我们先这么来学习:首先思考这么一个场景,在一个促销活动中,如双十一,某些商品只有限定的数量库存,比如100,我们要做什么措施来满足需求呢?

新来的实习生同学,在看到这个需求后,很快就写出了代码:

@RequestMapping("/deduct_stock")
public String deductStock() throws InterruptedException {
	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("扣减失败,库存不足");
        }
}

思路清晰明了,首先从Redis缓存中拿现有的库存,然后如果还有库存就库存减一,然后回写缓存。

那么问题来了,在并发情况下,拿缓存,判断,减库存,再回写缓存,如果用户A和用户B当时拿的缓存数据都是100,在执行完逻辑之后,他们都把99写回缓存,但是库存却卖了两次,这就出现了并发问题。

实习生同学想了一下,加了个synchronized(this),但是生产环境实际上是部署了多个服务器,经过nginx转发,synchronized只对同一JVM环境内生效,在代码里粗暴地加一个同步是不足以解决问题的,实习生同学经过百度,找到了Redis分布式锁可以解决这个问题,然后他再稍微改了一下代码。

@RequestMapping("/deduct_stock")
public String deductStock() throws InterruptedException {
	String lockKey = "product_001";
    try {
            Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, userId); 
            stringRedisTemplate.expire(lockKey,30, TimeUnit.SECONDS);
            if(result){
                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 {
        	stringRedisTemplate.delete(lockKey);
        }
}

看到自己写的代码,实习生同学非常得意地用在前面系列文章刚学到的SETNX key value,把库存作为id,用户id作为value,也就是说在系统中,对于这个货物,只有拿到这个锁才能执行减库存的操作,然后怕出问题,还加了个30s过期,怕万一出了问题不释放锁。

刚把代码提交上去不久,很快又出了很多问题:

  1. redis存在不过期的锁,且没有被释放。
  2. 还是发生了莫名其妙的超卖。

这就差不多是白弄了分布式锁,实习生同学背了一个大锅,同时安排了高级的员工debug重现,发现是以下原因导致出的事故:

  1. 拿锁和加过期时间不是一步完成,在拿完锁后还没来得及加过期时间,服务器宕机,导致锁不释放。
  2. 过期时间是30秒,但是由于某些慢服务,30秒还没处理完,然后B用户又下单,下单程序完成前,A恰好处理完了把锁删了,然后又来一个用户C,刚好把锁拿了。

然后这个分布式锁等于白给了,有些问题好解决,首先把拿锁和过期时间放在一起设置,然后在删锁的时候,只有是自己的锁才删。

Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, userId, 30, TimeUnit.SECONDS);

if (userId.equals(stringRedisTemplate.opsForValue().get(lockKey))){
	stringRedisTemplate.delete(lockKey);

以上还是不能根本解决问题,实习生同学这时奋发图强学习Redisson的分布式实现原理,在项目中使用了Redisson才得以解决以上自己实现分布式锁但是解决不了的问题。

分布式锁Redisson

重新审视一下分布式锁,在以上场景下,我们需要用Redis来实现分布式锁,在分布式环境下放置库存超卖,实际上有很多需要重点解决的问题:

  1. 可重入避免死锁
  2. 锁在获取后要在finally中释放
  3. 锁续命(看门狗机制)
  4. 锁超时时防止删除别的线程获得的锁
  5. Redis命令的原子性

来看一下Redisson的实现原理:

Redisson实现原理图

Redisson的机制就是尝试获得锁,在业务处理完之前,时间到达过期时间的三分之一时就自动去续一次,在执行中使用了许多lua脚本来保证命令的原子性。

使用lua脚本的好处有:1、减少网络开销;2、原子操作;3、替代Redis的事务功能。

我们要使用Redisson的锁只需要一下几条简单的语句:

RLock redissonLock = redisson.getLock(lockKey);//拿锁
redissonLock.lock();//加锁
redissonLock.unlock();//释放锁

而其他诸如原子性、锁续命机制,Redisson都已经帮我们做好了,接下来看部分重要的源码实现,来学习其中的原理。

//RedissonLock.java
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
    internalLockLeaseTime = unit.toMillis(leaseTime);
    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));
    }

以上这一段就是lua脚本的使用,过程是首先判定是否存在,如果不存在就加锁,拿到这个锁,然后设置一个hash,key是锁的key,field是主线程id,value是1,接着设置过期时间,过期时间默认是30s,出处如下。

//Config.java
private long lockWatchdogTimeout = 30 * 1000;

而如果锁已经存在,就会判定是否是主线程拿的锁,如果是的话就重入,把前面hash结构set的值加1。

如果没拿到锁,说明被其他线程持有,那就返回锁的剩余时间。接下来往回看这段代码

public void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException {
    long threadId = Thread.currentThread().getId();
    Long ttl = tryAcquire(leaseTime, unit, threadId);
    // lock acquired
    if (ttl == null) {
        return;
    }
    RFuture<RedissonLockEntry> future = subscribe(threadId);
    commandExecutor.syncSubscription(future);
    try {
        while (true) {
            ttl = tryAcquire(leaseTime, unit, threadId);
            // lock acquired
            if (ttl == null) {
                break;
            }
            // waiting for message
            if (ttl >= 0) {
                getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
            } else {
                getEntry(threadId).getLatch().acquire();
            }
        }
    } finally {
        unsubscribe(future, threadId);
    }
//        get(lockAsync(leaseTime, unit));
}

可以看到,传入的是主线程id,按照上一段代码,如果拿到锁返回的是null,然后直接return了,如果上一步返回的不是null,说明返回了另外一个持有锁的剩余时间,然后用while死循环,用发布订阅消息,没过一段时间来尝试加锁。

拿到锁还会开启看门狗,锁续命机制,代码如下:

private void scheduleExpirationRenewal(final long threadId) {
    if (expirationRenewalMap.containsKey(getEntryName())) {
        return;
    }
    Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
        @Override
        public void run(Timeout timeout) throws Exception {
            RFuture<Boolean> future = commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
                    "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                        "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                        "return 1; " +
                    "end; " +
                    "return 0;",
                      Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
            future.addListener(new FutureListener<Boolean>() {
                @Override
                public void operationComplete(Future<Boolean> future) throws Exception {
                    expirationRenewalMap.remove(getEntryName());
                    if (!future.isSuccess()) {
                        log.error("Can't update lock " + getName() + " expiration", future.cause());
                        return;
                    }
                    if (future.getNow()) {
                        // reschedule itself
                        scheduleExpirationRenewal(threadId);
                    }
                }
            });
        }
    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
    if (expirationRenewalMap.putIfAbsent(getEntryName(), task) != null) {
        task.cancel();
    }
}

先通过key和value判定是否是当前线程获得的锁,如果是主线程获得的锁,就给锁续命再设置一次过期时间,等过了三分之一的时间internalLockLeaseTime/3再续一次,直到主线程把锁给释放了。

其他部分的代码就不贴出来了,感兴趣的可以自己去看。

总结

Redis在生产环境中的使用主要有两个方向,缓存和分布式锁,在前面系列的文章只学了原理但是没有学缓存的具体使用、会涉及到的坑(缓存和数据库不一致、bigkey、性能优化等等问题),在这篇文章学了Redis分布式锁,从使用场景,到使用方法,再到底层原理和源码展开来学,使用场景我们从一个需求来讲,不断优化,再引出Redisson学习优秀的实现,Redisson底层使用了lua脚本来保证命令的原子性,用看门狗机制来实现锁续命,然后还展示了部分源码来具体学习,至此我们学习到了Redis分布式锁的使用场景、基本使用、底层原理还有部分源码。