Redis分布式锁实战

1,044 阅读9分钟

1:项目背景是用户下一个订单,然后扣减库存

@PostMapping("/order")
public String order() {
    //模拟数据库获取库存剩余商品数量
    Integer count  = (Integer)redisTemplatel.opsForValue().get(PRODUCT_KEY);
    if(count > 0) {
        count = count - 1;
        redisTemplatel.opsForValue().set(PRODUCT_KEY,count);
        return "success";
    }else {
        return "库存不足";
    }

}

1.1:代码看起来的确是没什么问题,正常从数据库中查看缓存,如果该商品有那么就减1,否则就直接返回库存不足的信息给客户端

2.2:但是如果在高并发场景下,这个代码是有问题的

  • 1:获取库存:我们首先要从数据库中获取剩余的库存数量
  • 2:更新库存:修改数据库中剩余的库存

2.3:上面二个操作并不是原子操作,那么就有可能发生下面这种情况

- 1: Thread-1先查询库存,获取库存数量为100个
- 2: Thread-1还没有更新库存,此时Thread-2就来查询库存了,此时Thread-2查询到的库存还是100个
- 3: Thread-1更新库存为99个
- 4: Thread-2减去一个库存,更新库存还是99个,那么就有问题了,卖出去了2个商品,但是库存只减了一个,那么就产生**超卖**的现象了

2:加锁保证不超卖

  • synchronized:如果项目是单机部署,那么可以使用synchronized关键字来保证,但是一般软件公司都会是集群部署,而synchronized是基于JVM来实现的锁,所以在分布式环境下也不能保证超卖现象
  • redis分布式锁:redis提供了一些命令,可以让我们实现分布式锁

3:添加redis分布式锁

@PostMapping("/order")
public String order() {
    try{
        Object result = redisTemplate.opsForValue().get(LOCK_KEY);
        if(result != null) {
            //说明这个key是存在的,那么就不能继续执行下去
            return "请稍后再试";
        }
        redisTemplate.opsForValue().set(LOCK_KEY,LOCK_VALUE);
        //模拟数据库获取库存剩余商品数量
        Integer count  = (Integer)redisTemplate.opsForValue().get(PRODUCT_KEY);
        if(count > 0) {
            count = count - 1;
            redisTemplate.opsForValue().set(PRODUCT_KEY,count);
            return "success";
        }else {
            return "库存不足";
        }
    }finally {
        redisTemplate.delete(LOCK_KEY);
    }
}
  • 1: 一个线程进来首先判断这个redis中有没有这个key,如果有,那么就不让继续执行,如果没有,那么就获取锁,执行减库存的操作
  • 2: 逻辑好像没什么问题,但是我们从redis中查询这个key,然后设置key并不是原子操作,那么就无法保证线程安全

4: redis加锁保证原子性

  • redis给我们提供了setnx这个命令就可以保证 查询key 设置key的原子性
@PostMapping("/order")
public String order() {
    try{
        Boolean result = redisTemplate.opsForValue().setIfPresent(LOCK_KEY, LOCK_VALUE);
        if(!result) {
            return "服务繁忙,请稍后再试";
        }
        //模拟数据库获取库存剩余商品数量
        Integer count  = (Integer)redisTemplate.opsForValue().get(PRODUCT_KEY);
        if(count > 0) {
            count = count - 1;
            redisTemplate.opsForValue().set(PRODUCT_KEY,count);
            return "success";
        }else {
            return "库存不足";
        }
    }finally {
        redisTemplate.delete(LOCK_KEY);
    }
}
  • 现在解决原子性的问题,但是这个代码依然还有问题
  • 1:如果这个线程执行到一半,系统挂了,那么这个key就会一直存在,只要这个key一直存在,那么后面的线程就永远拿不到锁,也就没办法执行后续的操作

5:给KEY添加失效时间

@PostMapping("/order")
public String order() {
    try{
        Boolean result = redisTemplate.opsForValue().setIfPresent(LOCK_KEY, LOCK_VALUE,10, TimeUnit.SECONDS);
        if(!result) {
            return "服务繁忙,请稍后再试";
        }
        //模拟数据库获取库存剩余商品数量
        Integer count  = (Integer)redisTemplate.opsForValue().get(PRODUCT_KEY);
        if(count > 0) {
            count = count - 1;
            redisTemplate.opsForValue().set(PRODUCT_KEY,count);
            return "success";
        }else {
            return "库存不足";
        }
    }finally {
        redisTemplate.delete(LOCK_KEY);
    }
}
  • 现在我们增加了实效时间,那么即使这个线程执行到一半挂了,没有释放这把锁,那么到了10s之后,这把锁也会自动释放,其它的线程依然能够拿到这把锁,那就没问题了吗??
  • 1: 现在的超时时间是10s,如果我们减库存的业务逻辑很复杂,这个线程业务处理需要15s,那么问题就出现了
1:首先Thread-1这把锁的超时时间是10s,但是业务处理需要15s,那么就意味着这个线程还没处理完业务这把锁就已经释放了
2:此时如果在第11s的时候这时候又有第二个线程Thread-2进来,由于Thread-1的锁在第10s就已经释放了,
   所以Thread-2是肯定能拿到这把锁的,Thread-2拿到这把锁之后就继续执行减库存的操作
3:到第15s的时候,Thread-1的减库存的业务处理完之后,就会执行释放锁的操作,但是此时Thread-1释放的锁已经不
   再是自己获取的那把锁了,而是Thread-2的锁了
4:这个时候就会有锁失效的问题了,如果现在有上万个线程进来,那么就会有问题

截屏2021-07-19 下午5.09.19.png

6:给线程添加唯一值

@PostMapping("/order")
public String order() {
    String uuid = UUID.randomUUID().toString();
    try{
        Boolean result = redisTemplate.opsForValue().setIfPresent(LOCK_KEY, uuid,10, TimeUnit.SECONDS);
        if(!result) {
            return "服务繁忙,请稍后再试";
        }
        //模拟数据库获取库存剩余商品数量
        Integer count  = (Integer)redisTemplate.opsForValue().get(PRODUCT_KEY);
        if(count > 0) {
            count = count - 1;
            redisTemplate.opsForValue().set(PRODUCT_KEY,count);
            return "success";
        }else {
            return "库存不足";
        }
    }finally {
        if(uuid.equals(redisTemplate.opsForValue().get(LOCK_KEY))) {
            redisTemplate.delete(LOCK_KEY);
        }
    }
}
  • 1:这时候即使锁被Thread-2拿到了,Thread-1执行释放锁的时候会判断一下这个唯一值,发现值不一致之后也不会删除别人的锁
  • 2:但是现在还有一个问题,就是这个超时时间的限制,如果时间太短,那么就会在业务处理结束之前就已经释放锁了,这样会有什么问题呢??
  • 1:假设我们现在库存只有一个商品,此时Thread-1在处理业务,但是还没有更新库存,此时因为锁的超时时间已经到了,那么Thread-2就会获得锁,Thread-2去查询库存的时候发现还有一个商品,那么Thread-2也就执行减库存的操作,这时候超卖现象就发生了
  • 2:如果我们的超时时间设置的过长,如果这时候Thread-1挂了,那么就要等超时时间到了才能自动释放锁,空闲的这段时间其它线程也是没办法获取到锁的,也就造成了资源的浪费,那么怎么解决这个问题呢??

7:给锁续约时间

  • 1:当Thread-1获取到锁之后,就给Thread-1设置一个定时器,这个定时器的作用就是给Thread-1的超时时间续约的
  • 2:假设我们给Thread-1的锁的超时时间设置为30s,那么这个定时器会从超时时间的1/3,也就是10s开始执行,处理的逻辑也很简单,如果这个线程正在运行,还没有释放锁,那么就将锁的超时时间重新设置为30s,如果这个线程异常结束了,那么就不续约时间,等超时时间一到就自动释放锁,如果续约的时候发现这把锁已经释放了,也就是Thread-1自己处理完业务,自己主动释放了锁,那么也就不会继续为该锁续约时间

8:Redisson实现原理

    RLock redissonLock = redisson.getLock(LOCK_KEY);
    //获得分布式锁
    redissonLock.lock();
    //释放分布式锁
    redissonLock.unlock();
  • 1:使用Redisson来实现分布式锁非常简单,就是简单的二行代码,现在来分析一下它的底层实现原理
  • 2:进入到lock()方法,在RedissonLock类中
@Override
public void lock() {
    try {
        lock(-1, null, false);
    } catch (InterruptedException e) {
        throw new IllegalStateException();
    }
}

-3:继续进入lock()方法

private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {

        //获取当前线程的id
        long threadId = Thread.currentThread().getId();
        //核心方法,这个方法就是获取锁的,如果返回的ttl != null 说明没有获取锁,返回的是已经获得锁的线程的剩余时间
        Long ttl = tryAcquire(-1, leaseTime, unit, threadId);
        // lock acquired
        if (ttl == null) {
            return;
        }

        RFuture<RedissonLockEntry> future = subscribe(threadId);
        if (interruptibly) {
            commandExecutor.syncSubscriptionInterrupted(future);
        } else {
            commandExecutor.syncSubscription(future);
        }

        try {
            while (true) {
                //这里尝试继续获取一下锁
                ttl = tryAcquire(-1, leaseTime, unit, threadId);
                // lock acquired
                if (ttl == null) {
                    break;
                }

                // waiting for message
                if (ttl >= 0) {
                    try {
                        // while并不是一直不停的循环执行的,它会获取到已经获得锁的线程的剩余时间,然后在这些时间之后再执行尝试获取锁
                        future.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
                    } catch (InterruptedException e) {
                        if (interruptibly) {
                            throw e;
                        }
                        future.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
                    }
                } else {
                    if (interruptibly) {
                        future.getNow().getLatch().acquire();
                    } else {
                        future.getNow().getLatch().acquireUninterruptibly();
                    }
                }
            }
        } finally {
            unsubscribe(future, threadId);
        }
//        get(lockAsync(leaseTime, unit));
    }
  • 4:进入 Long ttl = tryAcquire(-1, leaseTime, unit, threadId);方法
private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
    //不会进入到这个if里面
    if (leaseTime != -1) {
        return tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
    }
    //这里就是尝试获取锁
    RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,
                                                            TimeUnit.MILLISECONDS, 
                                                            threadId, RedisCommands.EVAL_LONG);
    //获取到锁之后,这里就会执行一个回调函数,这个函数的作用就是为了给锁的实效时间进行续命的                                                   
    ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
        if (e != null) {
            return;
        }

        // lock acquired
        if (ttlRemaining == null) {
            scheduleExpirationRenewal(threadId);
        }
    });
    return ttlRemainingFuture;
}
  • 5:首先看下tryLockInnerAsync方法
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
    internalLockLeaseTime = unit.toMillis(leaseTime);

    return evalWriteAsync(getName(), LongCodec.INSTANCE, command,
            "if (redis.call('exists', KEYS[1]) == 0) then " +
                    "redis.call('hincrby', 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.singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}
  • 6:Redisson底层使用了大量的lua脚本,我们大致看一下这个lua脚本是什么意思
    • 6.1: redis.call('exists', KEYS[1]) == 0) 首先判断这个KEY是否存在,也就是我们在使用分布式锁的那个KEY值,redisson.getLock(KEY);

    • 6.2:如果 == 0 ,那么就代表没有,这时候就可以获取锁了,这个KEY的VALUE值就是当前线程ID

    • 6.3:继续给该锁设置超时时间

    • 6.4:如果这个KEY已经存在了,那么就会判断下这个KEY的值是不是相同的,也就是判断一下当前尝试获取锁的线程与正在使用锁的线程是不是同一个线程,Redisson分布式锁是支持可重入的

    • 6.5:如果都不满足,那么就返回该锁的有效时间

  • 7:Redisson给锁续命 进入scheduleExpirationRenewal(threadId);方法
private void renewExpiration() {
    ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
    if (ee == null) {
        return;
    }
    
    Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
        @Override
        public void run(Timeout timeout) throws Exception {
            ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
            if (ent == null) {
                return;
            }
            Long threadId = ent.getFirstThreadId();
            if (threadId == null) {
                return;
            }
            
            RFuture<Boolean> future = renewExpirationAsync(threadId);
            future.onComplete((res, e) -> {
                if (e != null) {
                    log.error("Can't update lock " + getName() + " expiration", e);
                    EXPIRATION_RENEWAL_MAP.remove(getEntryName());
                    return;
                }
                
                if (res) {
                    // reschedule itself
                    renewExpiration();
                }
            });
        }
    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
    
    ee.setTimeout(task);
}
  • 7.1:使用了 TimerTask 来实现,它会取锁失效时间的1/3的时间来作为延迟执行时间,比如这把锁的有效时间是30s,那么就会在获取锁之后的第10s开始执行,如果10s的时候这个key还在,并且这个线程正在执行,那么就重新给该线程的有效时间设置为30s,然后在10s后又执行该方法,也就是下面这个方法

     就是不断的调用方法本身,来达到类似定时任务的效果
     if (res) { 
         // reschedule 
         itself renewExpiration(); 
     }
    

8:如果线程没有获取到锁呢?

  • 没有获取到锁的线程会拿到已经获取到锁的有效时间,它不会不停的尝试获取锁,而是会在锁有效时间之后再尝试,比如说:Thread-2去尝试获取锁,但是Thread-1已经拿到锁了,此时Thread-2并不会不停的去尝试获取锁,Thread-2会拿到Thread-1锁的有效时间,比如Thread-1锁还有15s后过期,这时候Thread-2就会等15s后再尝试去获取锁