分布式锁与实现(一):基于Redis实现

分布式锁与实现(一):基于Redis实现

现在很多大型网站都是分布式部署,分布式场景中的数据一致性问题一直是一个比较重要的话题。分布式的CPA理论告诉我们,任何一个分布式系统都无法同时满足:一致性(consistency)、可用性(Availability)和分区容错性(partition tolerance),最多只能同时满足两项。所以,很多系统在设计之初就要对这三者做出取舍。
在现在的绝大多数场景中,都是牺牲一致性来换取系统的高可用性,系统往往只需要保证“最终一致性”。

实现可靠性

  1. 互斥性:在任意时刻,只有一个客户端能持有锁;
  2. 不会发生死锁:即使有一个客户端在持有锁的期间奔溃而没有主动释放锁,也能保证后续其他客户端能加锁;
  3. 具有容错性:只要大部分Redis节点正常运行,客户端就可以加锁和解锁;
  4. 解铃还须系铃人:加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给释放了。

选用Redis实现分布式锁的原因

1,Redis有很高的性能;
2,Redis命令对此支持较好,实现起来比较方便。

使用命令介绍

1,SETNX(set if not exists)

SETNX key val

当且仅当key不存在时,set一个key为val的字符串,返回1;若key存在,则什么都不做,返回0.

2,expire

expire key timeout

为key设置一个超时时间,单位为second,超过这个时间,锁会自动释放,避免死锁。

3,delete

delete key

删除key

实现

使用jedis来连接Redis

实现思想

  • 获取锁的时候,使用setnx加锁,并使用expire命令为锁添加一个超时间时间,超过该时间则自动释放锁。锁的value值为一个随机生成的UUID,通过此在释放锁的时候进行判断。
  • 获取锁的时候还设置一个获取的超时时间,若超过这个时间则放弃获取锁。
  • 释放锁的时候,通过UUID判断是不是该锁,若是该锁,则执行delete进行锁的释放。

使用Jedis实现

	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;
  1. 尝试获取分布式锁
/**
     * 尝试获取分布式锁
     * @param jedis Redis客户端
     * @param lockKey 锁
     * @param requestId 请求标识
     * @param expireTime 超期时间
     * @return 是否获取成功
     */
    public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
        String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
        if (LOCK_SUCCESS.equals(result)) {
            return true;
        }
        return false;
    }
  • 第一个参数为key:使用key来当锁,因为锁是唯一的;
  • 第二个为value:通过给value赋值为requestId,我们就可以知道这把锁是那个客户加的,在解锁的时候就可以有依据,requestId可以使用UUID.randomUUID().toString()方法生成;
  • 第三个为nxxx:这个参数填写的是NX,意思是SET IF NOT EXIST,即当key不存在的时候,我们进行set操作;若key已经存在,则不作任何操作;
  • 第四个为expx:这个是PX,意思是我们要给这个key设置一个过期的设置,具体时间由第五个参数决定。
  • 第五个为time,与第四个参数相呼应,代表key的过期时间。

注意:这里使用set将设置锁和超时时间合并在一起,是因为如果分开操作,那么当客户端获取锁之后,设置超时时间之前,客户端发生了故障,崩溃了,那么这个锁永远都不会释放。
例如:

	if (conn.setnx(lockKey) == 1) {
		/**程序突然奔溃,则后续这个锁永远不会得到释放
		*/
		conn.expire(lockKey, lockExpire);
		return true;
	}
  1. 释放分布式锁
/**
     * 释放分布式锁
     * @param jedis Redis客户端
     * @param lockKey 锁
     * @param requestId 请求标识
     * @return 是否释放成功
     */
    public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
        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;
    }

释放锁时需要考虑几点:
如果锁已经不属于当前客户端的时候会释放他人加的锁,比如:客户A加锁,一段时间之后客户A解锁,但是在执行解锁命令之前,锁已经过期了,此时客户B尝试加锁成功,然后客户端A再执行删除锁操作,则会将客户B的锁给删除了。

  这里使用了Lua表达式保证原子性:首先获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁。
eval()方法是将Lua代码交给Redis服务端执行。

注意

  这种方案也不是完美的方案,它只是相对安全一点,因为如果真的超时了,当前线程的逻辑没有执行完,其他线程也会乘虚而入。
  所以Redis分布式锁不要用于较长时间的任务,如果真的偶尔出现了问题,造成的数据小错乱可能需要人工介入解决。

修改库存实例

key值的超时时间,也叫作“锁有效时间”。这个是锁的自动释放时间,也是一个客户端在其他客户端可能抢占锁之前可以执行任务的时间,这个时间从获取锁的时间点开始计算。
在一个非分布式的、单点的、保证永不宕机的环境下,这种方式没有任何问题。

public void main(){
	if(RedisUtil.tryGetDistributedLock()){
		//编写sql语句
		//修改库存
		RedisUtil.releaseDistributedLock();  //释放锁
	}
}

使用Redis构建分布式锁–多台服务器

用Redis来实现分布式锁最简单的方式就是在实例里创建一个键值,创建出来的键值一般都是有一个超时时间的,所以每个锁最终都会释放。而当一个客户端想要释放锁时,它只需要删除这个键值即可。
这种方法看似有效,但是存在一个问题:如果我们的系统架构里存在一个单点故障,如果Redis的master宕机了怎么办呢?那加一个slave节点可以吗?这种方案是不可行的,因为这种方案无法保证互斥性,因为Redis的复制是异步的,举例来说:

  1. 客户端A在master拿到了锁;
  2. master节点在把A创建的key写入slave之前,宕机了;
  3. slave编程master节点;
  4. B也得到了与A相同的锁,因为原来的slave里还没有A持有锁的信息。

Redlock算法(实现真正的分布式)

我们假设我们有5个Redis mater节点,这些节点都是完全独立的(分布在不同的机器或者虚拟机上),我们不用任何复制或者其他隐含的分布式协调算法。一个客户端需要做如下操作来获取锁:

  1. 获取当前时间,单位是毫秒;
  2. 轮流用相同的key和随机值在N个节点上请求锁。在这一步里,客户端在每个master上请求锁时,会有一个和总的锁的释放时间相比小的多的超时时间,即每个请求锁都有一个超时时间(在这个时间段里不能获取锁就返回)。比如:如果锁自动释放时间是10s,那每个节点锁请求的超时时间可能是5-50ms,这个可以防止一个客户端在某个宕掉的master节点上阻塞过程时间(当节点宕掉之后,超过设置的这个超时时间,请求可以返回,不需要一直处于阻塞等待锁状态)。如果一个master节点不可用了,我们应该尽快尝试下一个master节点。
  3. 获取当前时间,计算第二步中获取锁所花的时间,只有当客户端在大多数master节点上成功获取了锁,而且获取所有锁总共消耗的时间不超过锁的有效时间,这个锁就认为是获取成功了
  4. 如果锁获取成功了,那现在锁自动释放时间就是最初的锁释放时间—之前获取锁所消耗的时间。
  5. 如果获取锁失败了,不管是因为获取成功的锁不超过一半,还是因为总消耗时间超过了锁释放时间,客户端都会到每个master节点上释放锁,即使是那些他认为没有获取成功的锁(因为可能存在返回给客户端的响应包丢失的情况)。

失败的重试

当一个客户端获取失败时,这个客户端应该在一个随机延时后进行重试,采用随机延时是为了避免不同客户端同时重试,导致谁都无法获取锁的情况。强调:客户端如果没有在大多数节点获取到锁,一定要尽快在获取锁的节点上释放锁,这样就没必要等到key超时后才主动释放锁,然后才能重新获取锁。

释放锁

只需要在所有节点都释放锁就行,不管之前有没有在该节点成功获取锁。

性能和fsync

在使用Redis作为锁时,用户不止要求延时低、同时要求高吞吐量。为了达到这个要求,一定会使用多路传输来和N个服务器进行通信,以降低延时。

故障恢复

对于普通的故障恢复,可以考虑信息持久化问题。如果我们启用AOF持久化功能,情况会好很多。
  举例来说,我们可以发送SHUTDOWN命令来升级一个Redis服务器然后重启它,因为Redis超时时效是语义层面实现的,所以在服务器关掉期间超时时间还是算在内的,我们所有的要求还是满足了的。
  然后这个是基于我们做的是一次正常的shutdown,但是如果是断电这种意外停机呢?
  - 如下面的情况:一共有5个Redis节点:A、B、C、D、E

  1. 客户端1成功锁住了A、B、C,获取锁成功;
  2. 节点C崩溃重启了,但是客户端在C上加的锁没有持久化下来,丢失了;
  3. 节点C重启后,客户端2锁住了C、D、E,获取锁成功。

这样,客户端1和客户端2都锁住了C、D、E,获取锁成功。

为了解决这样的问题,又提出了“延时重启”的概念:
  一个节点崩溃后,先不立即重启它,而是等待一段时间再重启,这段时间应该大于锁的有效时间。这样的话,节点在重启钱所参与的锁都会过期,在重启后就不会对现有的锁在成影响。
  如果Redis是默认地配置成每秒在磁盘上执行一次fsync同步文件到磁盘操作,那就可能在一次重启后我们锁的key就丢失了。理论上如果我们想要在所有服务重启的情况下都确保锁的安全性,我们需要在持久化设置里设置成永远执行fsync操作,但是这个反过来又会造成性能远不如其他同级别的传统用来实现分布式锁的系统。
   然而问题其实并不像我们第一眼看起来那么糟糕,基本上只要一个服务节点在宕机重启后不去参与现在所有仍在使用的锁,这样正在使用的锁集合在这个服务节点重启时,算法的安全性就可以维持,因为这样就可以保证正在使用的锁都被所有没重启的节点持有。 为了满足这个条件,我们只要让一个宕机重启后的实例,至少在我们使用的最大TTL(锁有效时间)时间内处于不可用状态,超过这个时间之后,所有在这期间活跃的锁都会自动释放掉。 使用延时重启的策略基本上可以在不适用任何Redis持久化特性情况下保证安全性,然后要注意这个也必然会影响到系统的可用性。举个例子,如果系统里大多数节点都宕机了,那在TTL时间内整个系统都处于全局不可用状态(全局不可用的意思就是在获取不到任何锁)。

其他疑问

  • 如果客户端 长期阻塞导致锁过期,那么它接下来访问的共享资源就不安全了,因为没有了锁的保护。这个问题在Redlock中依然存在。

扩展锁来是的算法更可靠

如果客户端做的工作都是由一些小的步骤组成,那么就有可能使用更小的默认锁有效时间,而且扩展这个算法来实现一个锁扩展机制。基本上,客户端如果在执行计算期间发现锁快要超时了,客户端可以给所有服务实例发送一个Lua脚本让服务端延长锁的时间,只要这个锁的key还存在而且值还等于客户端获取时的那个值。 客户端应当只有在失效时间内无法延长锁时再去重新获取锁(基本上这个和获取锁的算法是差不多的) 然而这个并不会对从本质上改变这个算法,所以最大的重新获取锁数量应该被设置成合理的大小,不然性能必然会受到影响。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值