分布式锁使用场景
在学习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过期,怕万一出了问题不释放锁。
刚把代码提交上去不久,很快又出了很多问题:
- redis存在不过期的锁,且没有被释放。
- 还是发生了莫名其妙的超卖。
这就差不多是白弄了分布式锁,实习生同学背了一个大锅,同时安排了高级的员工debug重现,发现是以下原因导致出的事故:
- 拿锁和加过期时间不是一步完成,在拿完锁后还没来得及加过期时间,服务器宕机,导致锁不释放。
- 过期时间是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来实现分布式锁,在分布式环境下放置库存超卖,实际上有很多需要重点解决的问题:
- 可重入避免死锁
- 锁在获取后要在finally中释放
- 锁续命(看门狗机制)
- 锁超时时防止删除别的线程获得的锁
- Redis命令的原子性
来看一下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分布式锁的使用场景、基本使用、底层原理还有部分源码。