分布式锁解决集群下发生的并发问题

目录

1.为什么要用分布式锁

1.1发生情景

1.2发生原因

1.3解决方式

2.分布式锁的功能原理

2.1分布式锁的要求

2.2分布式锁的实现

2.3基于Redis实现分布式锁

2.4Redis代码实现

3.分布式锁的实现

4.分布式锁的误删问题

4.1为什么会发生误删问题

4.2误删的解决方法

4.3对于分布式锁的改造

4.3.1获取锁

4.3.2释放锁

5.分布式锁的原子性问题

5.1解决方法

6.利用Redission优化分布式锁

6.1为什么要优化

6.2Redisson


1.为什么要用分布式锁

1.1发生情景

我们在多线程执行一个需要实现一个人对应一个业务的场景下,例如经典的一人一单的情况下,可能在多线程情况下我们需要用锁来保证线程安全,但是我们如果有多个集群部署服务的话,可能会导致即便每个线程加了锁仍然发生线程安全问题,导致数据错误。

1.2发生原因

加了锁但是仍然发生线程安全问题的原因是,我们每个服务器如tomcat,都会由单独的一个JVM来运行,每个JVM的锁监视器都不同,从而导致不同服务器上的线程对锁的判定也不同,这样也会产生线程安全问题:两个不同监视器的锁无法锁住另外一个监视器的线程,于是还是发生了并行运行.

1.3解决方式

此时我们就要让多个JVM来使用同一把锁来解决,因为保证线程安全的本质是让线程交替执行,尽量避免并行执行,所以我们就可以通过让多个服务器的jvm的锁互通,来保证每次多个服务器中的线程都是交替进行,而不是并行。


2.分布式锁的功能原理

分布式锁的最基本实现原理是在多个服务集群中用一个锁来管理所有的线程

2.1分布式锁的要求

2.2分布式锁的实现

我们可以通过mysql,redis,zookeeper来实现分布式锁

Mysql:

由于mysql本身就由互斥锁机制,所以可以直接利用mysql来实现互斥,而且mysql本身有事务回滚机制,所以断连时就会自动释放锁,来保证安全

Redis:

我们可以利用setnx:只允许值为空的时候存入,我们就可以利用这个机制来实现互斥性,当需要锁时存入值,释放锁时删除值,以成功为依据来判断可以获取锁。而且redis我们可以设置一个超时时间,来实现即使断联也可以释放锁来保证安全

Zookeeper:原理和Redis,Mysql类似

2.3基于Redis实现分布式锁

其中获取锁中的threadl是事务,保证添加锁和设置超时时间必须同步,防止在进行设置时间时服务宕机。同时我们用非阻塞的方式来保证执行效率

执行逻辑如下:

2.4Redis代码实现

首先定义一个接口,接口内容就是获取锁与释放锁

public interface ILock {
    /**
     * 尝试获取锁
     * @param timeoutSec 锁持有的超时时间,过期后自动释放,保证安全
     * @return true表示获取成功
     */ 
    boolean tryLock(long timeoutSec);

    /**
     * 释放锁
     */
    void unlock();
}

实现类:

其中业务名和stringRedisTemplate我们创建对象时传入,获取锁方法也只需要根据业务不同在调用方法时传入

public class SimpleRedisLock implements ILock{
    private String name;
    private static final String KEY_PREFIX="lock:";

    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    private StringRedisTemplate stringRedisTemplate;
    @Override

    //获取锁
    public boolean tryLock(long timeoutSec) {
        //获取线程标识,来表示锁的不同
        long id = Thread.currentThread().getId();
        //存入的值:线程名,值,锁的持有时间,时间单位,其中只需要传入时间即可,name和stringRedisTemplate由创建对象时传入
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX+name,id+"",timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(flag);//因为是包装类,所以可能返回null导致空指针异常
        //当为ture时返回ture,为false时返回false,null时返回false,
    }

    @Override
    //释放锁
    public void unlock() {
        stringRedisTemplate.delete(KEY_PREFIX+name);

    }

3.分布式锁的实现

有了实现类后,就可以根据我们的业务来进行改造了

如一个业务方法原本加了锁,但是无法满足我们的分布式系统中的安全性,我们要对其进行改造

原方法:

 synchronized (userId.toString()){
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        }

改造后的方法:

    //创建锁对象
        SimpleRedisLock simpleRedisLock = new SimpleRedisLock("oreder:"+userId,stringRedisTemplate);
            //获取锁
        boolean isLock= simpleRedisLock.tryLock(1200);

            //判断是否获取锁成功
        if(!isLock){
            //获取失败
            return Result.fail("不允许重复下单");
        }
            try{
                IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
                return proxy.createVoucherOrder(voucherId);
            }finally {
                //释放锁,无论如何都释放
                simpleRedisLock.unlock();
            }
    }

其中获取锁失败了有两种处理,1.直接返回失败信息 2.递归重复执行,根据业务来选择,例如是我们的业务是抢优惠券,一人一单,此时我们就可以用第一种来保证业务安全,即不多重复进行抢的操作

一般获取锁失败的可能是一个人重复执行了这一操作,所以我们就用第一种

改造完成后,就可以发现,我们多个服务都会使用同一把redis锁(都用的同一台redis服务器),因此完成了同步分布式锁


4.分布式锁的误删问题

4.1为什么会发生误删问题

当我们有两个线程同时进行时,其中一个线程如线程1先获取了锁,由于我们的锁有一个时间限制,业务执行时间过长,导致业务还没进行完就释放锁,此时锁被另外一个线程2拿走,等线程1完成业务后,会执行释放锁的操作(前面我们设置的),但是锁此时在线程2上,这时线程2就会直接释放锁,线程2也同时执行业务,再来一个线程3,由于锁被释放了,所以线程3能够正常后去锁和执行业务,此时发生了线程2和线程3并行执行的情况,此时可能会发生线程安全问题,我们的锁意义就没有了

别看发生的可能性小,但是在高并发的情况下,出现的次数就不容忽视

4.2误删的解决方法

刚才错误发生的核心在于一个线程想要释放锁时发现锁被其他线程拿走了,释放的是别的线程的锁

此时我们就需要在释放锁时判断这个锁是不是自己的,要在获取锁时加上一个标识

4.3对于分布式锁的改造

根据上述思想,我们改造思路如下,添加一个标识和判断标识即可,标识我们可以用uuid来表示

因为如果用ThreadLocal里的线程id,由于线程id是根据创建的线程递增的,是有规律的,所以在分布式系统或集群模式下不同的JVM可能产生相同id的线程,造成线程冲突

我们原先也存入了一个标识,但是这个标识仅仅是ThreadLocal里的id,所以我们进行改造

4.3.1获取锁

原方法:

//获取锁
    public boolean tryLock(long timeoutSec) {
        //获取线程标识,来表示锁的不同
        long id = Thread.currentThread().getId();
        //存入的值:线程名,值,锁的持有时间,时间单位,其中只需要传入时间即可,name和stringRedisTemplate由创建对象时传入
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX+name,id+"",timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(flag);//因为是包装类,所以可能返回null导致空指针异常
        //当为ture时返回ture,为false时返回false,null时返回false,
    }

改造后

private static final String ID_PREFIX = UUID.randomUUID().toString(true) +"-";
//获取锁
    public boolean tryLock(long timeoutSec) {
        //获取线程标识,来表示锁的不同
        String id =ID_PREFIX + Thread.currentThread().getId();
        //存入的值:线程名,值,锁的持有时间,时间单位,其中只需要传入时间即可,name和stringRedisTemplate由创建对象时传入
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX+name,id+"",timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(flag);//因为是包装类,所以可能返回null导致空指针异常
        //当为ture时返回ture,为false时返回false,null时返回false,
    }

4.3.2释放锁

原方法:

//释放锁
    public void unlock() {
        stringRedisTemplate.delete(KEY_PREFIX+name);

    }

改造后:

//释放锁
    public void unlock() {
        String threadId  =ID_PREFIX + Thread.currentThread().getId();
        //获取线程标识
        String id =stringRedisTemplate.opsForValue().get(KEY_PREFIX+name);
        //判断线程标识是否一致

        if (threadId.equals(id)) {
            //释放锁
            stringRedisTemplate.delete(KEY_PREFIX+name);
        }

    }

5.分布式锁的原子性问题

在解决了刚才的误删的问题后,我们的分布式锁还是有一定的缺陷

如,在线程1完成业务后判断锁是自己的,在释放锁的时候,遇到了阻塞,如果超过了锁持有的时间,就会超时释放锁,此时线程2就可以得到锁,在执行业务的时候,线程1阻塞完成,释放了锁

但是由于线程1是释放锁的时候阻塞的,已经判断过了,如图

String id =stringRedisTemplate.opsForValue().get(KEY_PREFIX+name);
        //判断线程标识是否一致

        if (threadId.equals(id)) {
            //释放锁
            stringRedisTemplate.delete(KEY_PREFIX+name);
        }

就会直接将线程2的锁给删除,此时线程3就可以获得锁,执行业务,这样线程2和线程3又发生了并行执行,从而导致线程安全问题

5.1解决方法

根据上述我们可以得知,我们必须要将判断锁和释放锁这两个操作保证原子性,这两个操作必须同时发生同时失败 

我们可以通过Redis提供的Lua脚本功能来实现,在一个脚本钟编写多条Redis命令,确保多条命令执行时的原子性

我们可以利用Redis来调用脚本,

# 执行redis命令
redis.call('命令名','key','其他参数')

我们执行set name jack 脚本就是这样 

#执行set name jack
redis.call('set','name','jack')

例如,我们要执行set name Rose ,再执行get name 则脚本如下:

#先执行set name jack
redis.call('set','name','jack')
#再执行get name
local name = redis.call('get','name')
#返回
return name

注意:Lua语言的数组是从下标1开始的

 

我们根据业务写好脚本后:

-- 锁的key
local key = KEYS[1]

-- 当前线程标识
local threadId = ARGV[1]

-- 获取锁中的线程标识 get key
redis.casll('get',KEYS[1])
-- 比较线程标识于锁的标识是否一致
if(id == ARGV[1]) then
	-- 释放锁 del key
 	return redis.call('del',KEYS[1])
end
return 0

在我们的分布式锁的类下利用静态代码块来初始化调用脚本的类

然后修改释放锁的操作即可

对比原代码

//释放锁
    public void unlock() {
        String threadId  =ID_PREFIX + Thread.currentThread().getId();
        //获取线程标识
        String id =stringRedisTemplate.opsForValue().get(KEY_PREFIX+name);
        //判断线程标识是否一致

        if (threadId.equals(id)) {
            //释放锁
            stringRedisTemplate.delete(KEY_PREFIX+name);
        }

    }

我们可以发现少了判断部分,这是因为判断部分都在脚本里写好了

这样就可以满足原子性了

6.利用Redission优化分布式锁

6.1为什么要优化

我们原先的分布式锁存在什么问题

1.不可重入:

即同一个线程无法多次获取同一把锁,如果一个线程又两个方法:A,B.

当执行A时,需要获取锁,调用B的时候要获取锁的时候就会无法获取,一直等待A释放,A又在等B调用完成才能执行完,又在等B,从而发生死锁.

2.不可重试:

获取锁只尝试一次就返回false,没有重试机制

3.超时释放:

锁超时释放虽然可以避免死锁,但是如果业务执行耗时较长,也会导致锁释放,存在安全隐患

4.主从一致性

如果Redis提供了主从集群,主从同步存在延迟,当主宕机时,如果从并同步主中的锁数据,则会出现锁实现 

6.2Redisson

我们可以利用Redisson来实现我们需要的各种锁

6.3使用Redisson实现分布式锁

根据上述类似

我们创建一个配置类:

@Configuration
public class RedissonConfigs {
    @Bean
    public RedissonClient redissonClient(){
        //创建
        Config config = new Config();
        config.useSingleServer().setAddress("redis://192.168.150.104:6379").setPassword("123321");
        return Redisson.create(config);
    }
}

配置完成以后就可以在需要创建锁的地方使用了

            //创建锁对象
//        SimpleRedisLock simpleRedisLock = new SimpleRedisLock("order:"+userId,stringRedisTemplate);
        RLock lock = redissonClient.getLock("lock:order:"+userId);
            //获取锁
        boolean isLock= lock.tryLock();//无参就是失败不等待,直接释放

以上就是关于redis分布式锁的一些问题和相关解决了

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值