万字详述分布式锁(Redis篇)

现在构思一下这样一个场景:我们有5000件库存,在高并发的场景下对其进行同时购买,如何解决超卖(也就是大家都买到了但是库存还有剩余)现象?

作为一个新手,我们可能会使用mysql存储5000这个数字,然后我们可以使用synchronized关键字锁住减库存service处的代码,这样在单机环境下可以顺利地解决高并发问题,但是有三种情况会导致锁失效:

  • 多例模式:Spring框架默认采用单例模式,也就是一整个系统中只有一个StockService,但是如果将StockService改成原型创建的话,则每个锁锁住的都是当前实例的对应代码部分,并不能阻止service实例之间的并发。

  • 事务:加上@Transactional之后超卖现象反而出现了,原因是如果单单在方法上加@Transactional是错误的,因为这样@Transactional的开启事务和提交事务是由AOP机制完成的,会先开启事务再抢锁,先释放锁再提交,那另一个线程可能在这个线程释放锁之后但提交之前获取到锁并读取数据,由于mysql的默认隔离级别为Repeatable Read,也就是可重复读,只能读到已提交事务的数据,这样的话,刚刚修改的库存就会被忽略,从而导致超卖。将MySQL隔离级别修改为Read Uncommitted可以解决这个问题。

  • 集群部署:这个和多例模式有异曲同工之妙,只是不再是对象层面上面的多例,而是服务器层面上的多例,多台服务器之间是不存在互斥约束的,故可并发修改库存,引起超卖问题。

我们再尝试用一个sql语句实现整个方法的业务逻辑,使用UPDATE db_stock SET count = count - 1 WHERE product_code = '1001' AND count >= 1; 这种方式实现锁就纯纯是依赖mysql语句执行产生的互斥性了:我们并没有对product_code和count建立索引,所以mysql会直接对聚簇索引进行全表扫描,并锁住整个表,让其他更新在这个事务未被提交之前(mysql默认autocommit为1,也就是不声明begin的话所有语句都视作立即提交的事务),其他语句都不能访问这个表,这样就做到了互斥。

但是我们这样做的弊端也很明显,就是这样做的复杂性和性能完全地交给了mysql,导致我们想提升性能或者增加业务时就必须仔细地研究mysql语句的执行情况,问题也就随之而来:例如刚才那个sql语句,我们直接使用会导致整张表被锁住,其他记录也不能同时更新了,这是不能够被允许的;再例如如果我们在业务上有更复杂的需求,如同一个商品有多条库存记录或需要记录库存变化前后的状态,这样一条SQL语句很可能很难写,写出来涉及联表查询的话还不如不写(性能很差)。

有一个可能的解决方案是我们的业务很简单,所以想用一行SQL,这种前提下,我们可以考虑给update的where涉及的字段配一个索引,这里我们来回顾一下mysql执行一条锁定读语句(update语句在找自己该更新哪些记录的时候相当于一条锁定读)时的步骤:

  • 首先快速地在B+树叶子节点中定位到该扫描区间的第一条记录,把该记录作为当前记录

  • 读取第一条记录(有二级读二级,无二级读聚簇)

  • Read Committed及以下加正经记录锁,不符合条件的索引先加锁后放锁(除了第一个超出边界的二级索引,在语句结束后也不会释放锁),Repeatable Read及以上加next-key锁且不释放锁

  • 判断是否符合索引下推的条件

  • 若为二级索引,则执行回表操作(也有可能是二级索引的更新),加正经记录锁,Read Committed及以下不符合的话会释放,反之不会

  • 判断边界条件

  • server层判断其余搜索条件是否成立

  • 获取当前记录所在单向链表的下一条记录,并将其作为新的当前记录

这样我们给where字段配了索引并使用索引进行查找时,在Repeatable Read的隔离级别下,就只会给索引条件成立的对应的聚簇索引加正经记录锁,不会牵扯到其他记录。

那我们放弃采用单句sql的前提下,也可以用mysql来实现悲观锁,也就是使用SELECT ... FOR UPDATE,这条语句会要求mysql给这些SELECT出来的语句加上X锁,那么在这个事务执行过程中,其他语句无法读取这几条记录,可以作为互斥锁使用。

但这种方法也有以下几个缺点:

  • 性能问题:和jvm本地锁性能差不多

  • 死锁问题:对多条数据加锁时,加锁顺序要一致

  • 库存操作要统一:不能某些sql用select for update,另外的sql用普通select,那就很混乱了

我们还可以使用mysql实现一个乐观锁来解决超卖问题,具体实现方法是使用时间戳、版本号或CAS机制。但其实乐观锁并不适合这种写操作特别频繁的场景,因为这样高强度的竞争会导致其性能极低,同时CAS又自身带有ABA问题。

总结一下:在单机环境中,我们要解决超卖问题的话,纯看性能:一个SQL>悲观锁>jvm锁>乐观锁

各种场景下的使用方法:

  • 如果追求极致性能、业务场景简单并且不需要记录数据前后变化的情况下:一个SQL

  • 如果写并发量较低(多读),争抢不是很激烈的情况下优先选择:乐观锁

  • 如果写并发量较高,一般经常冲突,选择乐观锁的话会导致业务代码不间断地重试:优先选择mysql悲观锁

  • 不推荐使用jvm本地锁

我们再尝试一下使用Redis而非mysql存储库存呢(单机状态)?

第一种方法仍然是不推荐的jvm本地锁机制,就不再多说;

第二种方法是使用redis构造乐观锁。我们可以使用watch命令,监控一个或多个key的值,如果在执行事务之前(也就是exec命令执行之前),key的值发生变化则取消事务执行,然后使用multi开启一个事务,执行减库存命令,最后执行exec命令执行事务。

当然了,这些解决方案在跨进程、跨服务、跨服务器的分布式场景下就会统统失效了,我们就要走进我们的主角:Redis实现的分布式锁:

分布式锁的主要实现借助于setnx命令。setnx命令的含义是key不存在就新增,存在就什么都不做,这样当多个setnx请求同时到来时,只有一个客户端可以成功,返回1(true),其他的客户端就返回0(false),这样我们就做到了独占排他使用。

加锁:

while(!this.redisTemplate.opsForValue().setIfAbsent("lock", "xxx")){
    try{
        Thread.sleep(100);
    }catch(InterruptedException e){
        e.printStackTrace();
    }
}

解锁:

this.redisTemplate.opsForValue().delete("lock");

这样实现有什么问题吗?

有,而且很大——我们设想,一个客户端抢到了锁,然后突然暴毙,这个锁就永远无法被其他客户端获取了,因为暴毙了的服务器无法再删除这个锁。

那我们给这个锁加一个过期时间?

加过期时间我们第一时间想到的无非就是expire命令,但是这样就和setnx是两条命令了,失去了原子性,同样容易出现在setnx和expire的间隙暴毙的可能性,无法从根本上解决问题。

所以我们采用新命令:set key value ex 3 nx

紧跟value的ex是指过期时间,最后的nx是指不存在才设置

加锁:

while(!this.redisTemplate.opsForValue().setIfAbsent("lock", "xxx",3,TimeUnit.SECONDS)){
    try{
        Thread.sleep(100);
    }catch(InterruptedException e){
        e.printStackTrace();
    }
}

解锁:

this.redisTemplate.opsForValue().delete("lock");

看似没问题了,我们再来思索一种场景:

  • 一个线程抢到了锁,开始执行一个极其漫长的业务(一个远大于过期时间的业务)

  • 时间到了,锁自动释放了,然后第二个线程拿到锁,开始执行业务

  • 这时第一个线程执行完了,强制delete删除锁,没错,它删掉了别人正在持有的锁

新的问题已经出现,怎么能够停止不前~

我们再来一版锁对线程有记忆的,就是在进入方法的时候生成一个随机uuid,成为锁的一部分,实现如下:

加锁:

String uuid = UUID.randomUUID().toString();
while(!this.redisTemplate.opsForValue().setIfAbsent("lock", uuid, 3,TimeUnit.SECONDS)){
    try{
        Thread.sleep(100);
    }catch(InterruptedException e){
        e.printStackTrace();
    }
}

解锁:

if(StringUtils.equals(uuid, this.redisTemplate.opsForValue().get("lock"))){
    this.redisTemplate.opsForValue().delete("lock");
}

但这样实现也存在问题:删除操作缺乏原子性,如果在通过if判断之后和删除lock之前,lock刚好过期,被释放并且被另一个线程获取,那删除的还是另一个线程的锁,还是无法从根本上解决误删问题。

那我们怎么才能让它成为一条语句呢:用lua脚本:

if redis.call('get', KEYS[1]) == ARGV[1]
then
    return redis.call('del', KEYS[1])
else 
    return 0
end

解锁:

String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
this.redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList("lock"), uuid);

有了lua脚本这个强大的工具,我们可以继续改善我们的分布式锁,让它具有可重入性

大体思路是,建立一个哈希表,名字叫lock,key是线程id,value是重入次数,没有这个哈希表或者哈希表中存在key为自己的线程id,那就能进,否则不行;释放锁同理,先给线程id对应的value减一,如果为0了就删掉哈希表:

加锁脚本:

if(redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1)
then
    redis.call('hincrby', KEYS[1], ARGV[1], 1);
    redis.call('expire', KEYS[1], ARGV[2]);
    return 1;
else
    return 0;
end

解锁脚本:

-- 判断hash set 可重入key的值是否等于0
-- 如果为 nil 代表自己的锁已不存在,在尝试解其他线程的锁:解锁失败
-- 如果为0代表,可重入次数被减1
-- 如果为1代表,可重入key解锁成功
if(redis.call('hexists', KEYS[1], ARGV[1]) == 0) then
    return nil;
elseif(redis.call('hincrby', KEYS[1], ARGV[1], -1) >0) then
    return 0;
else
    redis.call('del', KEYS[1]);
    return 1;
end;

代码实现:

由于这时代码量较大,我们单独整一个工具类:

public class RedisDistributedLock{
    private StringRedisTemplate redisTemplate;
    
    // 线程局部变量,可以在线程内共享参数
    private String lockname;
    private String uuid;
    private Integer expire = 30;
    private static final ThreadLocal<String> THREAD_LOCAL = new ThreadLocal<>();
    
    public RedisDistributedLock(StringRedisTemplate redisTemplate, String lockName) {
        this.redisTemplate = redisTemplate;
        this.lockName = lockName;
        this.uuid = THREAD_LOCAL.get();
        if(StringUtils.isBlank(uuid)){
            this.uuid = UUID.randomUUID().toString();
            THREAD_LOCAL.set(uuid);
        }
    }
    
    public void lock(){
        this.lock(expire);
    }
    
    public void lock(Integer expire) {
        this.expire = expire;
        String script = "if (redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1) " +
            "then" +
                " redis.call('hincrby', KEYS[1], ARGV[1], 1);" +
                " redis.call('expire', KEYS[1], ARGV[2]);" +
                " return 1;" +
            "else" +
                " return 0;" +
            "end";
        if (!this.redisTemplate.execute(new DefaultRedisScript<>(script,Boolean.class),Arrays.asList(lockName), uuid, expire.toString())){
            try {
                // 没有获取到锁,重试
                Thread.sleep(60);
                lock(expire);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    
    public void unlock(){
        String script = "if(redis.call('hexists', KEYS[1], ARGV[1]) == 0) then " +
                " return nil; " +
            "elseif(redis.call('hincrby', KEYS[1], ARGV[1], -1) > 0) then " +
                " return 0; " +
            "else " +
                " redis.call('del', KEYS[1]); " +
                " return 1; " +
            "end;";
        // 如果返回值没有使用Boolean,Spring-data-redis 进行类型转换时将会把 null转为 false
        // 这就会影响我们逻辑判断
        // 所以返回类型只好使用 Long:null-解锁失败;0-重入次数减1;1-解锁成功。
        Long result = this.redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList(lockName), uuid);
        // 如果未返回值,代表尝试解其他线程的锁
        if (result == null) {
            throw new IllegalMonitorStateException("attempt to unlock lock,not locked by lockName: "+ lockName + " with request: " + uuid);
        } else if (result == 1) {
            THREAD_LOCAL.remove();
        }
    }
    
}
​

最后一步,之前出现过业务执行时间比锁过期时间长得多的现象,我们就忽略了而已。现在我们要解决这个问题:让锁能够自动续期:

if(redis.call('hexists', KEYS[1], ARGV[1]) == 1) then
    redis.call('expire', KEYS[1], ARGV[2]);
    return 1;
else
    return 0;
end

那很显然我们需要让这个lua脚本定时周期执行,加锁的时候开始第一次。那我们不妨来总结一下,定时任务可以通过哪几种方式实现:

  • 使用线程创建定时任务:纯new个线程来忙等

  • 使用TimerTask创建定时任务:Timer+TimerTask

  • 使用线程池创建定时任务:使用ScheduledExecutorService

  • 使用Quartz框架实现定时任务

  • 使用@Schedule:Spring框架中的工具

  • 使用xxl-job实现分布式定时任务

(详情见:实现定时任务的 6 种方式,一网打尽! - 沙滩de流沙 - 博客园

在本次代码编写中,我们决定使用Timer定时器的方法来实现:

在RedisDistributedLock添加renewExpire方法:

private static final Timer TIMER = new Timer();
​
private void renewExpire(){
    String script = "if(redis.call('hexists', KEYS[1], ARGV[1]) == 1) then redis.call('expire', KEYS[1], ARGV[2]); return 1; else return 0; end";
    TIMER.schedule(new TimerTask() {
        @Override
        public void run() {
            // 如果uuid为空,则终止定时任务
            if (StringUtils.isNotBlank(uuid)) {
                redisTemplate.execute(new DefaultRedisScript<>(script,Boolean.class), Arrays.asList(lockName), RedisDistributeLock.this.uuid,expire.toString());
                renewExpire();
            }
        }
    }, expire * 1000 / 3);
}

然后我们修改lock():获取锁之后,renew一下。unlock不用动

看起来我们的分布式锁在分布式状态下已经完美了,,,吗?

其实不,在集群状态下还是有问题的。让我们设想以下场景:

  • 客户端A从master获取到锁

  • 在master将锁同步到slave之前,master宕机了

  • slave节点晋级为master节点

  • 客户端B取得了同一个资源被客户端A已经获取到的另外一个锁

==》锁失效了!!

可以看到,这种锁失效的现象本质上来源于我们原来只需要在一个个体上确定拥有锁的状态,在启用集群之后,我们需要在集群内部同步这种锁的状态,这种同步通信使得其出现了出错的机会。

对此,Redis官方也提出了一种新的应对方案,名字叫红锁。当使用红锁时,客户端为了获取将会执行以下操作:

  • 客户端获取当前的时间戳(以毫秒为单位),并作为初始时间

  • 客户端尝试在N个实例中顺序使用相同的键名、相同的随机值来获取锁定。每个实例尝试获取锁都需要时间,客户端应该设置一个远小于总锁定时间的尝试超时时间。例如,如果自动释放时间为10秒,则尝试获取锁的超时时间可能在5到10毫秒之间。这样可以防止客户端长时间与处于故障状态的Redis节点进行通信;如果某个实例不可用,尽快尝试与下一个实例进行通信。

  • 遍历完成之后,客户端再次获取当前时间,减去步骤1中的起始时间,就是获取锁的总花费时间。当且仅当客户端能获取超过一半的实例的锁,并且获取锁所花费的时间小于锁有效时间,则认为已经获取锁。

  • 如果已经获取锁,则将有效时间减去获取锁花费的时间。

  • 如果没能获取锁,将再次遍历尝试解锁所有实例。

当客户端没能获取锁时,应该在延迟任意一段时间后重试,避免同时获取同一资源的多个客户端之间不同步,也就是分脑问题。并且,客户端在大多数Redis实例中尝试获取锁的速度越快,出现脑裂(以及需要重试)的窗口就越小,因此理想情况下,客户端尝试将SET命令发送到N个实例中同时使用多路复用。

还有一点可以预见到的是:客户端在遍历获取锁发现自己失败了之后遍历释放锁也很重要,因为这样就不需要等待锁到期了才释放,节省时间,除非客户端和Redis实例发生了分区错误。

自己写分布式锁对像我一样的程序员来说,难度还是太高了。为此,专门有个组件,提供给不会手写分布式锁的菜鸟,让我们也能享受到使用Redis高性能分布式锁的遍历:它就是Redisson

接下来的内容,我们将从源码层面详细分析Redisson的几个组件。但我们先了解一下Redisson的使用方法:

1.引入依赖

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

2.添加配置

@Configuration
public class RedissonConfig {
    @Bean
    public RedissonClient redissonClient(){
        Config config = new Config();
        // 可以用"rediss://"来启用SSL连接
        config.useSingleServer().setAddress("redis://172.16.116.100:6379");
        return Redisson.create(config);
    }
}

3.代码中使用

@Autowired
private RedissonClient redissonClient;
​
public void checkAndLock() {
    // 加锁,获取锁失败重试
    RLock lock = this.redissonClient.getLock("lock");
    lock.lock();
    // 先查询库存是否充足
    Stock stock = this.stockMapper.selectById(1L);
    // 再减库存
    if (stock != null && stock.getCount() > 0){
        stock.setCount(stock.getCount() - 1);
        this.stockMapper.updateById(stock);
    }
    // 释放锁
    lock.unlock();
}

接下来我们依次分析一下,人家的各种分布式锁是怎么实现的:

1.可重入锁

​
   private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
       //获取线程ID
       long threadId = Thread.currentThread().getId();
       //在这里尝试加锁,获取锁的有效时间
       Long ttl = this.tryAcquire(-1L, leaseTime, unit, threadId);
       //ttl是当前锁的有效时间
       if (ttl != null) {
           CompletableFuture<RedissonLockEntry> future = this.subscribe(threadId);
            this.pubSub.timeout(future);
            RedissonLockEntry entry;
            if (interruptibly) {
                entry = (RedissonLockEntry)this.commandExecutor.getInterrupted(future);
            } else {
                entry = (RedissonLockEntry)this.commandExecutor.get(future);
            }
​
            try {
                while(true) {
                    ttl = this.tryAcquire(-1L, leaseTime, unit, threadId);
                    if (ttl == null) {
                        return;
                    }
​
                    if (ttl >= 0L) {
                        try {
                            entry.getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
                        } catch (InterruptedException var14) {
                            if (interruptibly) {
                                throw var14;
                            }
​
                            entry.getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
                        }
                    } else if (interruptibly) {
                        entry.getLatch().acquire();
                    } else {
                        entry.getLatch().acquireUninterruptibly();
                    }
                }
            } finally {
                this.unsubscribe(entry, threadId);
            }
        }
    }
​

如果前后代码逻辑看不懂也没关系,我们进入前面这个tryAcquire方法看看,毕竟它是真的在加锁:

private Long tryAcquire(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
    return (Long)this.get(this.tryAcquireAsync(waitTime, leaseTime, unit, threadId));
}

又调用了tryAcquireAsync,我们继续追踪:

private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
    RFuture ttlRemainingFuture;
    if (leaseTime > 0L) {
        //在这里获取锁,返回当前锁剩余有效时间,设置了leaseTime就传入leaseTime
        ttlRemainingFuture = this.tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
    } else {
        //否则传入默认internalLockLeaseTime
        ttlRemainingFuture = this.tryLockInnerAsync(waitTime, this.internalLockLeaseTime, TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
    }
​
    CompletionStage<Long> f = ttlRemainingFuture.thenApply((ttlRemaining) -> {
        if (ttlRemaining == null) {
            if (leaseTime > 0L) {
                this.internalLockLeaseTime = unit.toMillis(leaseTime);
            } else {
                this.scheduleExpirationRenewal(threadId);
            }
        }
​
        return ttlRemaining;
    });
    return new CompletableFutureWrapper(f);
}

还在套娃,调用了tryLockInnerAsync,那我们继续往里:

    <T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
        return this.evalWriteAsync(this.getRawName(), 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(this.getRawName()), new Object[]{unit.toMillis(leaseTime), this.getLockName(threadId)});
    }

我以为啥呢,原来和我们自己写一样,也是使用的lua脚本,而且写的和我们差不多。

Redisson还提供了几种其它的锁供我们使用:

  • Fair Lock:排队等待,前面的宕机了要每个等五秒

  • MultiLock:同时加锁,所有锁都锁上了才算成功

  • RedLock:上文讲到的那个红锁

  • ReadWriteLock:和java读写锁类似

  • Semaphore:就是我们用到的信号量,限制拿到者的人数

  • PermitExpirableSemaphore:可以过期的信号量

  • CountDownLatch:就是倒数器,倒数结束则放行

具体查看:8. 分布式锁和同步器 · redisson/redisson Wiki · GitHub

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值