分布式锁解决服务集群中的悲观锁失效问题

本文探讨了在分布式环境中使用悲观锁的问题,转向基于Redis的分布式锁解决方案,涉及setnx、expire、lua脚本以及Redisson库。重点讲解了自定义Redis锁的实现、原子性问题、Redisson的可重用性和重试机制,以及如何通过lua脚本保证操作的原子性。
摘要由CSDN通过智能技术生成

前面我们已经在单体服务的环境下使用悲观锁解决了“一人一单”问题,但是在分布式的集群环境下,悲观锁就不行了,如下图所示,悲观锁只在当前的JVM中有效,集群环境有相当多的JVM,每个JVM又监视不同的锁,这样悲观锁就没办法针对用户了。

分布式锁:

满足分布式系统或集群模式下多进程可见并且互斥的锁。

 基于Redis的分布式锁

Redis如何保证互斥:set nx

如何防止死锁:expire 设置超时时间

如何保证获取锁和设置超时时间的原子性(要么都成功,要么都失败):

set lock value ex [time] nx

思考一

自定义实现Redis分布式锁的获取锁和释放锁方法。 

@Data
@AllArgsConstructor
public class SimpleRedisLock implements ILock{

    /**
     * 业务名称
     */
    private String name;

    /**
     * 锁的前缀
     */
    private static final String KEY_PREFIX = "lock:";

    private StringRedisTemplate stringRedisTemplate;

    @Override
    public boolean tryLock(long timeoutSec) {
        // 获取线程标示
        long threadId = Thread.currentThread().getId();
        // 获取锁
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unlock() {
        stringRedisTemplate.delete(KEY_PREFIX + name);
    }
}

原本基于JVM锁的实现

        //单体服务的悲观锁实现
/*        synchronized (userId.toString().intern()) { // <--- !!!在这里上锁!!!
            //获取和事务有关的代理对象
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
       }*/

 分布式锁实现

        Long userId = UserHolder.getUser().getId();

        /* 分布式锁 */
        // 创建锁对象
        SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
        // 获取锁
        boolean isLock = lock.tryLock(10);
        // 判断是否获取锁成功
        if (!isLock) {
            // 获取锁失败,直接返回错误信息
            return Result.fail("不允许重复下单!");
        }
        try {
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        } finally {
            // 释放锁
            lock.unlock();
        }

 观察下图思考一种情况:如果业务执行的时间较长,甚至比锁设置的过期时间还长,这会导致业务还未完成就释放锁。

试想:线程A获取锁并执行业务,A业务还未完成A锁就释放了,紧接着线程B获取锁并执行业务,而A业务完成了,在上述代码中,A此时直接释放了不是自己的锁,继而导致线程C获取锁并执行业务...,如此下去,分布式锁要求的互斥就在此时失效了,线程安全的问题又发生了。

解决方案:

在释放锁之前,判断锁是不是自己的,是的话才释放,不是就不管了。这样才可以避免可能的线程安全问题。

 思考二

优化后:

import cn.hutool.core.lang.UUID;
import lombok.AllArgsConstructor;
import lombok.Data;
import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.concurrent.TimeUnit;

@Data
@AllArgsConstructor
public class SimpleRedisLock implements ILock{

    /**
     * 业务名称
     */
    private String name;

    /**
     * 锁的前缀
     */
    private static final String KEY_PREFIX = "lock:";

    private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";

    private StringRedisTemplate stringRedisTemplate;

    @Override
    public boolean tryLock(long timeoutSec) {
        // 获取线程标示
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        // 获取锁
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success);
    }

    @Override
    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);
        }
        // 标识不一致就不管
    }
}

在上面代码中,为每个线程都设置了专属的线程标识,并将线程标识存入Redis中,这样在释放锁的时候再次获取线程标识,并和Redis中存着的标识进行判断,如果一致才释放锁,不一致就不管。

但是这样就好了吗?观察下面的时序图,由于“判断锁”和“释放锁”是两个动作,无法保证原子性,就有可能出现:判断锁是自己的锁之后,JVM由于垃圾回收等机制导致线程内部发生阻塞,有可能一直阻塞到锁已经超时释放了才继续执行业务,而这样就又发生了释放了其它线程锁的情况,又出现了线程不安全的问题。

所以,必须保证判断锁和释放锁这两个动作的原子性。

思考三 Redis使用Lua代码保证原子性

 如何保证?

  • Redis提供的事务

可以保证操作的原子性,但不能保证操作的一致性,而且Redis的事务中的多个操作实际上是批处理的。

  • Redis的Lua脚本

Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。Lua是一种编程语言,基本语法参考

 所以现在关键就是将“判断锁”和“释放锁”用Lua脚本表示出来并执行,就可以保证操作的原子性

用Lua语言表示:

-- 锁的key
local key = "lock:order:5"
-- 当前线程的标识
local threadId = ".........."

-- 获取锁中的线程标识 get key
local id = redis.call('get', key)
-- 比较线程标识与锁中的标识是否一致
if (id == threadId) then
    -- 释放锁 del key
    return redis.call('del', key)
end
return 0

采用动态传参后:

RedisScript接口由DefaultRedisScript实现 

 

 lua脚本文件:

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

业务代码:

    /**
     * 业务名称
     */
    private String name;

    /**
     * 锁的前缀
     */
    private static final String KEY_PREFIX = "lock:";

    private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";

    private StringRedisTemplate stringRedisTemplate;    

    /**
     * 声明RedisScript,DefaultRedisScript是其实现类
     */
    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;

    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>(); // 实例化
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua")); // 读取脚本文件
        UNLOCK_SCRIPT.setResultType(Long.class); // 指定返回值的类型
    }  


    /**
     * lua脚本,execute执行lua脚本,将判断和更新强行作为原子操作
     */
    @Override
    public void unlock() {
        // 调用lua脚本
        stringRedisTemplate.execute( // 标识一致才更改
                UNLOCK_SCRIPT, // 参数一指定RedisScript
                Collections.singletonList(KEY_PREFIX + name), // 参数二指定Key的List集合,这里使用工具类得到List集合
                ID_PREFIX + Thread.currentThread().getId() // 参数三是可变参数,指定Value
        );
        //
    }

基于Redis的分布式锁的问题

  • 不可重用性

同一个线程无法多次获取同一把锁。例如:方法A调用方法B,线程在方法A中获取锁,然后执行业务后调用方法B,而方法B中又要获取同一把锁,如果锁时不可重用的,方法B就只能等待锁的释放,而锁又无法释放,因为方法A又在等待调用方法B,所以就出现了死锁。

  • 不可重试

获取锁只尝试一次就返回false,没有重试机制。部分业务要求锁是阻塞的、可重试的。

  • 超时释放

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

  • 主从一致性

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

Redisson解决问题

Redisson入门

1、引入依赖

<!--        Redisson-->
        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson</artifactId>
            <version>3.13.6</version>
        </dependency>

2、配置Redisson客户端

/**
 * 配置Redisson客户端
 */
@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient() {
        // 配置类
        Config config = new Config();
        // 添加redis地址,这里添加了单点的地址,也可以使用config.useClusterServers()添加集群地址
        config.useSingleServer().setAddress("redis://192.168.58.138:6379").setPassword("6y7u8i9o@cn");
        // 创建客户端
        return Redisson.create(config);
    }
}

3、使用Redission的分布式锁

Redisson可重用性原理

在Lock锁中,他是借助于底层的一个voaltile的一个state变量来记录重入的状态的,比如当前没有人持有这把锁,那么state=0,假如有人持有这把锁,那么state=1,如果持有这把锁的人再次持有这把锁,那么state就会+1 ,如果是对于synchronized而言,他在c语言代码中会有一个count,原理和state类似,也是重入一次就加一,释放一次就-1 ,直到减少成0 时,表示当前这把锁没有被人持有

在redission中,我们的也支持支持可重入锁

 上面的流程用java实现已经不能保证操作的原子性了,只能用lua脚本来实现。

lua脚本实现:

local key = KEYS[1]; -- 锁的标识
local threadId = ARGV[1] -- 线程唯一标识
local releaseTime = ARGV[2]; -- 锁的自动释放时间
-- 判断是否存在
if (redis.call('exists', key) == 0) then 
    -- 不存在,获取锁
    redis.call('hset', key, threadId, '1');
    -- 设置有效期
    redis.call('pexpire', key, releaseTime);
    return 1;
end;
-- 锁已经存在,判断threadId是否是自己
if (redis.call('hexists', key, threadId) == 1) then
    -- 不存在,获取锁,重入次数+1
    redis.call('hincrby', key, threadId, '1');
    -- 设置有效期
    redis.call('pexpire', key, releaseTime);
    return 1;
end;

return 0; -- 到这,说明获取的不是自己的锁,获取锁失败

追溯源码:

RedissonLock.java中,针对tryLock和unLock都实现有方法,底层都是lua脚本。

Redisson的锁重试和“看门狗”机制

RLock lock = redissonClient.getLock(KEY)

boolean success = lock.tryLock(WaitTime, LeaseTime, TimeUnit)

这里的参数一waitTime就表示尝试获取锁时的等待时间,如果不填的话就默认一但获取失败就离开不等待。

我们回头看tryLock的Lua脚本,两个if都是判断是否获取锁成功的,并且获取锁成功都返回nil,而最后获取锁不成功就返回锁的剩余有效期。

 看调用tryLockInnerAsync的方法。

如果 tryLockInnerAsync返回的ttl(锁的剩余时间)为null,就表示锁是空着的,返回true。

 继续看tryLock的方法,如果ttl不为null,则表示锁获取失败,则尝试等待并再次获取锁。

 这里的subsribe表示订阅锁。

而在RedissonLock的unLock方法的Lua脚本中,这里的

redis.call('publish', KEYS[2], ARGV[1])

就是在发布通知,表明锁已经释放 

 锁重试的流程图:

 锁释放流程图:

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值