分布式锁的实现(一)Redis篇

对比普通锁和分布式锁

  • 普通锁:主要针对同一台主机上的资源

分布式锁的实现(一)Redis篇_第1张图片

  • 分布式锁:主要针对不同主机之间获取资源

分布式锁的实现(一)Redis篇_第2张图片

分布式锁的实现方式主要有三种,分别通过rediszookeepermysql,本文是关于redis如何实现分布式锁。


redis实现分布式锁

1.redis中的两条基本命令

在聊redis的分布式锁之前,首先看看redis中的两条命令,这两条命令对redis分布式锁的实现有至关重要的作用。

setnx  key  value

setnx是SET if Not eXists(如果不存在,则 SET)的缩写,用法如下。

分布式锁的实现(一)Redis篇_第3张图片

当我们要设置的key不存在时,会返回1表示设置成功;如果设置的key已经存在,会返回0表示设置失败。

setex  key  seconds  value

ex表示生存时间,setex命令就是设置一个含有过期时间的键值对,如果key已经存在,那么value会覆盖旧值。

setex是一个原子性操作,即设置键值对过期时间是同时完成的。

分布式锁的实现(一)Redis篇_第4张图片


2.实际场景

以电商秒杀场景来说,为了减轻数据库的压力,我们往往会将库存存在redis中进行预减库存。尽管我们在update减少库存时,往往会在代码中判断库存量是否大于0,但是考虑这样一种场景:

假设现在redis中显示商品只有一件库存了,服务器A获取库存数量为1,经判断大于0,于是准备对其进行-1操作;这时服务器B获取库存数量,由于A还未修改,所以此时B获得的值也为1,如果不加限制,就会出现超卖的问题。

为了对共享数据进行控制,就要通过分布式锁


3.分布式锁的实现

(1)setnx(会死锁)

//使用,加锁实现减库存操作
Jedis jedis = getJedis();
jedis.setnx("lock","update");
/*
业务逻辑部分:
这里实现获取库存数量,并对其执行-1操作;
*/
jedis.del("lock")

如上所示,当服务器A获取到锁,也就是执行了setnx语句后,服务器B再想获取锁时就会被阻塞,只有当A对库存操作完并释放锁之后,服务器B才能对库存进行操作。

但是以上有一个很明显的问题就是,如果服务器A获取到锁之后宕机了怎么办?这时其他所有想要获取该数据的服务器会永远得不到该锁!这就造成了死锁。

为了解决这个问题,可以对set的值加上一个过期时间来进行改善,也就是通过setex命令

(2)setnx + setex(可行)

通过setex增加一个过期时间,这样就算服务器A宕机了,经过一段时间后锁也会自动释放,很好的避免了死锁问题。具体实现如下:

public class Test{
    private static final String LOCK_SUCCESS = "OK";
    private static final String SET_IF_NOT_EXIST = "NX";
    private static final String SET_WITH_EXPIRE_TIME = "PX";
    private static final Long RELEASE_SUCCESS = 1L;
    	
    	//加锁的方法
        public boolean tryGetDistributedLock(String lockKey, String requestId, int expireTime) {
        Jedis jedis = getJedis();
        //真正加锁是通过jedis的set方法,这里要用到"NX"和"PX"参数
        String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
        if (LOCK_SUCCESS.equals(result)) {
            //如果设置成功返回true
            return true;
        }
        return false;
    }
    	
    	//释放锁的方法
        public boolean releaseDistributedLock(String lockKey, String requestId) {
        Jedis jedis = getJedis();
        //通过lua脚本进行删除key
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
        if (RELEASE_SUCCESS.equals(result)) {
            return true;
        }
        return false;
    }
}


//使用,加锁实现减库存操作
Test test=new Test();
//尝试获取锁
boolean getRedisKey = test.tryGetDistributedLock("lock", "update", 5 * 60);
if(getRedisKey){
    /*
    获取库存并对其进行操作
    update...
    */
}
test.releaseDistributedLock("lock", "update");

获取锁

由于setnx+setex,也就是设置键值过期时间不是原子性的,为了保证原子性redis提供了"NX"和"PX"参数。

在redis中是这样实现的:

SET resource_key value NX PX 30000

而我们操作redis往往是通过java来实现的,在java中,我们通过jedis.set()方法,传入"NX"和"PX"参数即可。

private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_WITH_EXPIRE_TIME = "PX";
jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);

释放锁

释放锁的时候,我们并没有直接进行del lock ,而是通过lua脚本进行判断后再删除key,为什么呢?看看下面这种场景:

分布式锁的实现(一)Redis篇_第5张图片

从上图可以很清晰的看到,线程1释放了线程2的锁!这不就乱了套了。因为我们在加锁的时候,key和value是一样的,也就意味着钥匙是一样的。因此,我们需要安全的释放锁——“不是我的锁,我不能瞎释放”。

故我们引入了lua脚本对参数进行判断,只有参数匹配时才释放对应的锁。在java中,通过往jedis.eval()方法中传入luascript(表示要执行的语句) key value三个值来执行lua脚本。

Tips:redis使用lua为什么能保证原子性?

1.Redis 使用相同的 Lua 解释器来运行所有命令。

2.Redis 还保证以原子方式执行脚本: 在执行脚本时不会执行其他脚本或 Redis 命令,类似于事务。从所有其他客户端的角度来看,脚本的效果要么仍然不可见,要么已经完成。

但是存在的另一个问题是,它在执行的过程中如果一个命令报错不会回滚已执行的命令,所以要保证lua脚本的正确性。


(3)redisson

这里先提一下redisTemplatejedislettuceredisson的关系

1.Jedis是Redis官方推荐的面向Java的操作Redis的老牌客户端,效率高

2.RedisTemplate是SpringDataRedis中对JedisApi的高度封装,基于SpringBooot自动配置原理

3.在SpringBoot2.x时,lettuce替代了jedis,属于新式redis客户端

4.redission是redis的分布式客户端,提供了分布式操作和一些高级功能

总结:单机低并发选择jedis,分布式高并发选择redission

以上我们通过setnx + setex的方式,并没有实现可重入锁,以及,我们设置了一个过期时间,如果已经超过了过期时间,但是任务还没有执行完成怎么办?这些在redisson中都有实现,可以供我们进行参考。

redisson已经封装好了,我们直接使用即可,下面简单看几段代码:

获取锁

如果一个线程持有锁且任务还没执行完时,会通过一个定时任务不断刷新锁的过期时间。

private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, final long threadId) {

    //如果带有过期时间,则按照普通方式获取锁
    if (leaseTime != -1) {
        return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
    }
    
    //先按照30秒的过期时间来执行获取锁的方法
    RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(
        commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(),
        TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
        
    //如果还持有这个锁,则开启定时任务不断刷新该锁的过期时间
    ttlRemainingFuture.addListener(new FutureListener<Long>() {
        @Override
        public void operationComplete(Future<Long> future) throws Exception {
            if (!future.isSuccess()) {
                return;
            }

            Long ttlRemaining = future.getNow();
            // lock acquired
            if (ttlRemaining == null) {
                scheduleExpirationRenewal(threadId);
            }
        }
    });
    return ttlRemainingFuture;
}

释放锁

释放锁时会有一个值对锁进行记录,类似一个计数器。计数器的值大于0,表示可重入,每释放一次,就刷新过期时间。和AQS是十分类似的。

这里就不贴代码了,感兴趣的同学可以自己去看一看。


redis分布式锁存在的问题

问题

以上我们所讨论的方案,都是单点redis的情况。如果我们有多台redis构成一个集群,那么肯定会有一些问题。

在集群中,往往主库负责写操作,然后同步到从库。从库负责读操作,以减轻主库压力。

当我们申请一个锁的时候,对应就是一条命令 setnx mykey myvalue ,在redis sentinel集群中,这条命令先是落到了主库。假设这时主库down了,而这条数据还没来得及同步到从库,sentinel将从库中的一台选举为主库了。这时,我们的新主库中并没有mykey这条数据,若此时另外一个client执行 setnx mykey hisvalue , 也会成功,即也能得到锁。这就意味着,此时有两个client获得了锁。这不是我们希望看到的。


解决方案

既然有问题就会有解决的办法。

为了解决故障转移情况下的缺陷,Antirez 发明了 Redlock 算法。加锁的时候,它会想多半节点发送 setex mykey myvalue 命令,只要过半节点成功了,那么就算加锁成功了。释放锁的时候需要想所有节点发送del命令。这是一种基于大多数都同意的一种机制。在java中,可以使用redisson的redlock。

使用redlock解决问题的同时,也会带来一些性能上的亏损,毕竟没有十全十美的解决方案。

你可能感兴趣的:(redis,面试题总结,redis,分布式,数据库,分布式锁,锁)