集锦

Posted 禅与计算机程序设计艺术

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了集锦相关的知识,希望对你有一定的参考价值。

前言

近两年来微服务变得越来越热门,越来越多的应用部署在分布式环境中,在分布式环境中,数据一致性是一直以来需要关注并且去解决的问题,分布式锁也就成为了一种广泛使用的技术,常用的分布式实现方式为Redis,Zookeeper,其中基于Redis的分布式锁的使用更加广泛。

但是在工作和网络上看到过各个版本的Redis分布式锁实现,每种实现都有一些不严谨的地方,甚至有可能是错误的实现,包括在代码中,如果不能正确的使用分布式锁,可能造成严重的生产环境故障,本文主要对目前遇到的各种分布式锁以及其缺陷做了一个整理,并对如何选择合适的Redis分布式锁给出建议。

分布式锁一般有三种实现方式:1. 数据库乐观锁;2. 基于Redis的分布式锁;3. 基于ZooKeeper的分布式锁。本篇博客将介绍第二种方式,基于Redis实现分布式锁。虽然网上已经有各种介绍Redis分布式锁实现的博客,然而他们的实现却有着各种各样的问题,为了避免误人子弟,本篇博客将详细介绍如何正确地实现Redis分布式锁。


可靠性

首先,为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:

  • 互斥性。在任意时刻,只有一个客户端能持有锁。

  • 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。

  • 具有容错性。只要大部分的Redis节点正常运行,客户端就可以加锁和解锁。

  • 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。


代码实现1

组件依赖

首先我们要通过Maven引入Jedis开源组件,在pom.xml文件加入下面的代码:

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>2.9.0</version>
</dependency>

加锁代码

正确姿势

Talk is cheap, show me the code。先展示代码,再带大家慢慢解释为什么这样实现:

public class RedisTool 

    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";

    /**
     * 尝试获取分布式锁
     * @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;

    

可以看到,我们加锁就一行代码:jedis.set(String key, String value, String nxxx, String expx, int time),这个set()方法一共有五个形参:

第一个为key,我们使用key来当锁,因为key是唯一的。

第二个为value,我们传的是requestId,很多童鞋可能不明白,有key作为锁不就够了吗,为什么还要用到value?原因就是我们在上面讲到可靠性时,分布式锁要满足第四个条件解铃还须系铃人,通过给value赋值为requestId,我们就知道这把锁是哪个请求加的了,在解锁的时候就可以有依据。requestId可以使用UUID.randomUUID().toString()方法生成。

第三个为nxxx,这个参数我们填的是NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作;

第四个为expx,这个参数我们传的是PX,意思是我们要给这个key加一个过期的设置,具体时间由第五个参数决定。

第五个为time,与第四个参数相呼应,代表key的过期时间。

总的来说,执行上面的set()方法就只会导致两种结果:

  1. 当前没有锁(key不存在),那么就进行加锁操作,并对锁设置个有效期,同时value表示加锁的客户端。

  2. 已有锁存在,不做任何操作。

心细的童鞋就会发现了,我们的加锁代码满足我们可靠性里描述的三个条件。首先,set()加入了NX参数,可以保证如果已有key存在,则函数不会调用成功,也就是只有一个客户端能持有锁,满足互斥性。其次,由于我们对锁设置了过期时间,即使锁的持有者后续发生崩溃而没有解锁,锁也会因为到了过期时间而自动解锁(即key被删除),不会发生死锁。

最后,因为我们将value赋值为requestId,代表加锁的客户端请求标识,那么在客户端在解锁的时候就可以进行校验是否是同一个客户端。由于我们只考虑Redis单机部署的场景,所以容错性我们暂不考虑。

错误示例1

比较常见的错误示例就是使用jedis.setnx()和jedis.expire()组合实现加锁,代码如下:

public static void wrongGetLock1(Jedis jedis, String lockKey, String requestId, int expireTime) 

    Long result = jedis.setnx(lockKey, requestId);
    if (result == 1) 
        // 若在这里程序突然崩溃,则无法设置过期时间,将发生死锁
        jedis.expire(lockKey, expireTime);
    

setnx()方法作用就是SET IF NOT EXIST,expire()方法就是给锁加一个过期时间。乍一看好像和前面的set()方法结果一样,然而由于这是两条Redis命令,不具有原子性,如果程序在执行完setnx()之后突然崩溃,导致锁没有设置过期时间。那么将会发生死锁。网上之所以有人这样实现,是因为低版本的jedis并不支持多参数的set()方法。

错误示例2

这一种错误示例就比较难以发现问题,而且实现也比较复杂。实现思路:使用jedis.setnx()命令实现加锁,其中key是锁,value是锁的过期时间。

执行过程:

  1. 通过setnx()方法尝试加锁,如果当前锁不存在,返回加锁成功。

  2. 如果锁已经存在则获取锁的过期时间,和当前时间比较,如果锁已经过期,则设置新的过期时间,返回加锁成功。

代码如下:

public static boolean wrongGetLock2(Jedis jedis, String lockKey, int expireTime) 

    long expires = System.currentTimeMillis() + expireTime;
    String expiresStr = String.valueOf(expires);

    // 如果当前锁不存在,返回加锁成功
    if (jedis.setnx(lockKey, expiresStr) == 1) 
        return true;
    

    // 如果锁存在,获取锁的过期时间
    String currentValueStr = jedis.get(lockKey);
    if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) 
        // 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间
        String oldValueStr = jedis.getSet(lockKey, expiresStr);
        if (oldValueStr != null && oldValueStr.equals(currentValueStr)) 
            // 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才有权利加锁
            return true;
        
    

    // 其他情况,一律返回加锁失败
    return false;

那么这段代码问题在哪里?1. 由于是客户端自己生成过期时间,所以需要强制要求分布式下每个客户端的时间必须同步。2. 当锁过期的时候,如果多个客户端同时执行jedis.getSet()方法,那么虽然最终只有一个客户端可以加锁,但是这个客户端的锁的过期时间可能被其他客户端覆盖。3. 锁不具备拥有者标识,即任何客户端都可以解锁。

解锁代码

正确姿势

还是先展示代码,再带大家慢慢解释为什么这样实现:

public class RedisTool 

    private static final Long RELEASE_SUCCESS = 1L;

    /**
     * 释放分布式锁
     * @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;

    

可以看到,我们解锁只需要两行代码就搞定了!第一行代码,我们写了一个简单的Lua脚本代码,上一次见到这个编程语言还是在《黑客与画家》里,没想到这次居然用上了。第二行代码,我们将Lua代码传到jedis.eval()方法里,并使参数KEYS[1]赋值为lockKey,ARGV[1]赋值为requestId。eval()方法是将Lua代码交给Redis服务端执行。

那么这段Lua代码的功能是什么呢?其实很简单,首先获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)。那么为什么要使用Lua语言来实现呢?因为要确保上述操作是原子性的。关于非原子性会带来什么问题,可以阅读【解锁代码-错误示例2】 。那么为什么执行eval()方法可以确保原子性,源于Redis的特性,下面是官网对eval命令的部分解释:

简单来说,就是在eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令。

错误示例1

最常见的解锁代码就是直接使用jedis.del()方法删除锁,这种不先判断锁的拥有者而直接解锁的方式,会导致任何客户端都可以随时进行解锁,即使这把锁不是它的。

public static void wrongReleaseLock1(Jedis jedis, String lockKey) 
    jedis.del(lockKey);

错误示例2

这种解锁代码乍一看也是没问题,甚至我之前也差点这样实现,与正确姿势差不多,唯一区别的是分成两条命令去执行,代码如下:

public static void wrongReleaseLock2(Jedis jedis, String lockKey, String requestId) 

    // 判断加锁与解锁是不是同一个客户端
    if (requestId.equals(jedis.get(lockKey))) 
        // 若在此时,这把锁突然不是这个客户端的,则会误解锁
        jedis.del(lockKey);
    

如代码注释,问题在于如果调用jedis.del()方法的时候,这把锁已经不属于当前客户端的时候会解除他人加的锁。那么是否真的有这种场景?答案是肯定的,比如客户端A加锁,一段时间之后客户端A解锁,在执行jedis.del()之前,锁突然过期了,此时客户端B尝试加锁成功,然后客户端A再执行del()方法,则将客户端B的锁给解除了。


如果你的项目中Redis是多机部署的,那么可以尝试使用Redisson实现分布式锁,这是Redis官方提供的Java组件。

Redis实现分布式锁的7种方案,及正确使用姿势!

前言

日常开发中,秒杀下单、抢红包等等业务场景,都需要用到分布式锁。而Redis非常适合作为分布式锁使用。本文将分七个方案展开,跟大家探讨Redis分布式锁的正确使用方式。如果有不正确的地方,欢迎大家指出哈,一起学习一起进步。

  • 什么是分布式锁

  • 方案一:SETNX + EXPIRE

  • 方案二:SETNX + value值是(系统时间+过期时间)

  • 方案三:使用Lua脚本(包含SETNX + EXPIRE两条指令)

  • 方案四:SET的扩展命令(SET EX PX NX)

  • 方案五:SET EX PX NX  + 校验唯一随机值,再释放锁

  • 方案六: 开源框架~Redisson

  • 方案七:多机实现的分布式锁Redlock

什么是分布式锁

分布式锁其实就是,控制分布式系统不同进程共同访问共享资源的一种锁的实现。如果不同的系统或同一个系统的不同主机之间共享了某个临界资源,往往需要互斥来防止彼此干扰,以保证一致性。

我们先来看下,一把靠谱的分布式锁应该有哪些特征:

  • 「互斥性」: 任意时刻,只有一个客户端能持有锁。

  • 「锁超时释放」:持有锁超时,可以释放,防止不必要的资源浪费,也可以防止死锁。

  • 「可重入性」:一个线程如果获取了锁之后,可以再次对其请求加锁。

  • 「高性能和高可用」:加锁和解锁需要开销尽可能低,同时也要保证高可用,避免分布式锁失效。

  • 「安全性」:锁只能被持有的客户端删除,不能被其他客户端删除

Redis分布式锁方案一:SETNX + EXPIRE

提到Redis的分布式锁,很多小伙伴马上就会想到setnxexpire命令。即先用setnx来抢锁,如果抢到之后,再用expire给锁设置一个过期时间,防止锁忘记了释放。

SETNX 是SET IF NOT EXISTS的简写.日常命令格式是SETNX key value,如果 key不存在,则SETNX成功返回1,如果这个key已经存在了,则返回0。

假设某电商网站的某商品做秒杀活动,key可以设置为key_resource_id,value设置任意值,伪代码如下:

if(jedis.setnx(key_resource_id,lock_value) == 1) //加锁
    expire(key_resource_id,100); //设置过期时间
    try 
        do something  //业务请求
    catch()
  
  finally 
       jedis.del(key_resource_id); //释放锁
    

但是这个方案中,setnxexpire两个命令分开了,「不是原子操作」。如果执行完setnx加锁,正要执行expire设置过期时间时,进程crash或者要重启维护了,那么这个锁就“长生不老”了,「别的线程永远获取不到锁啦」

Redis分布式锁方案二:SETNX + value值是(系统时间+过期时间)

为了解决方案一,「发生异常锁得不到释放的场景」,有小伙伴认为,可以把过期时间放到setnx的value值里面。如果加锁失败,再拿出value值校验一下即可。加锁代码如下:

long expires = System.currentTimeMillis() + expireTime; //系统时间+设置的过期时间
String expiresStr = String.valueOf(expires);

// 如果当前锁不存在,返回加锁成功
if (jedis.setnx(key_resource_id, expiresStr) == 1) 
        return true;
 
// 如果锁已经存在,获取锁的过期时间
String currentValueStr = jedis.get(key_resource_id);

// 如果获取到的过期时间,小于系统当前时间,表示已经过期
if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) 

     // 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间(不了解redis的getSet命令的小伙伴,可以去官网看下哈)
    String oldValueStr = jedis.getSet(key_resource_id, expiresStr);
    
    if (oldValueStr != null && oldValueStr.equals(currentValueStr)) 
         // 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才可以加锁
         return true;
    

        
//其他情况,均返回加锁失败
return false;

这个方案的优点是,巧妙移除expire单独设置过期时间的操作,把「过期时间放到setnx的value值」里面来。解决了方案一发生异常,锁得不到释放的问题。但是这个方案还有别的缺点:

  • 过期时间是客户端自己生成的(System.currentTimeMillis()是当前系统的时间),必须要求分布式环境下,每个客户端的时间必须同步。

  • 如果锁过期的时候,并发多个客户端同时请求过来,都执行jedis.getSet(),最终只能有一个客户端加锁成功,但是该客户端锁的过期时间,可能被别的客户端覆盖

  • 该锁没有保存持有者的唯一标识,可能被别的客户端释放/解锁。

Redis分布式锁方案三:使用Lua脚本(包含SETNX + EXPIRE两条指令)

实际上,我们还可以使用Lua脚本来保证原子性(包含setnx和expire两条指令),lua脚本如下:

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

加锁代码如下:

String lua_scripts = "if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then" +
            " redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end";   
Object result = jedis.eval(lua_scripts, Collections.singletonList(key_resource_id), Collections.singletonList(values));
//判断是否成功
return result.equals(1L);

这个方案,跟方案二对比,你觉得哪个更好呢?

Redis分布式锁方案方案四:SET的扩展命令(SET EX PX NX)

除了使用,使用Lua脚本,保证SETNX + EXPIRE两条指令的原子性,我们还可以巧用Redis的SET指令扩展参数!(SET key value[EX seconds][PX milliseconds][NX|XX]),它也是原子性的!

SET key value[EX seconds][PX milliseconds][NX|XX]

  • NX :表示key不存在的时候,才能set成功,也即保证只有第一个客户端请求才能获得锁,而其他客户端请求只能等其释放锁,才能获取。

  • EX seconds :设定key的过期时间,时间单位是秒。

  • PX milliseconds: 设定key的过期时间,单位为毫秒

  • XX: 仅当key存在时设置值

伪代码demo如下:

if(jedis.set(key_resource_id, lock_value, "NX", "EX", 100s) == 1) //加锁
    try 
        do something  //业务处理
    catch()
  
  finally 
       jedis.del(key_resource_id); //释放锁
    

但是呢,这个方案还是可能存在问题:

  • 问题一:「锁过期释放了,业务还没执行完」。假设线程a获取锁成功,一直在执行临界区的代码。但是100s过去后,它还没执行完。但是,这时候锁已经过期了,此时线程b又请求过来。显然线程b就可以获得锁成功,也开始执行临界区的代码。那么问题就来了,临界区的业务代码都不是严格串行执行的啦。

  • 问题二:「锁被别的线程误删」。假设线程a执行完后,去释放锁。但是它不知道当前的锁可能是线程b持有的(线程a去释放锁时,有可能过期时间已经到了,此时线程b进来占有了锁)。那线程a就把线程b的锁释放掉了,但是线程b临界区业务代码可能都还没执行完呢。

方案五:SET EX PX NX  + 校验唯一随机值,再删除

既然锁可能被别的线程误删,那我们给value值设置一个标记当前线程唯一的随机数,在删除的时候,校验一下,不就OK了嘛。伪代码如下:

if(jedis.set(key_resource_id, uni_request_id, "NX", "EX", 100s) == 1) //加锁
    try 
        do something  //业务处理
    catch()
  
  finally 
       //判断是不是当前线程加的锁,是才释放
       if (uni_request_id.equals(jedis.get(key_resource_id))) 
        jedis.del(lockKey); //释放锁
        
    

在这里,「判断是不是当前线程加的锁」「释放锁」不是一个原子操作。如果调用jedis.del()释放锁的时候,可能这把锁已经不属于当前客户端,会解除他人加的锁。

为了更严谨,一般也是用lua脚本代替。lua脚本如下:

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

Redis分布式锁方案六:Redisson框架

方案五还是可能存在「锁过期释放,业务没执行完」的问题。有些小伙伴认为,稍微把锁过期时间设置长一些就可以啦。其实我们设想一下,是否可以给获得锁的线程,开启一个定时守护线程,每隔一段时间检查锁是否还存在,存在则对锁的过期时间延长,防止锁过期提前释放。

当前开源框架Redisson解决了这个问题。我们一起来看下Redisson底层原理图吧:

只要线程一加锁成功,就会启动一个watch dog看门狗,它是一个后台线程,会每隔10秒检查一下,如果线程1还持有锁,那么就会不断的延长锁key的生存时间。因此,Redisson就是使用Redisson解决了「锁过期释放,业务没执行完」问题。

Redis分布式锁方案七:多机实现的分布式锁Redlock+Redisson

前面六种方案都只是基于单机版的讨论,还不是很完美。其实Redis一般都是集群部署的:

如果线程一在Redis的master节点上拿到了锁,但是加锁的key还没同步到slave节点。恰好这时,master节点发生故障,一个slave节点就会升级为master节点。线程二就可以获取同个key的锁啦,但线程一也已经拿到锁了,锁的安全性就没了。

为了解决这个问题,Redis作者 antirez提出一种高级的分布式锁算法:Redlock。Redlock核心思想是这样的:

搞多个Redis master部署,以保证它们不会同时宕掉。并且这些master节点是完全相互独立的,相互之间不存在数据同步。同时,需要确保在这多个master实例上,是与在Redis单实例,使用相同方法来获取和释放锁。

我们假设当前有5个Redis master节点,在5台服务器上面运行这些Redis实例。

RedLock的实现步骤:如下

  • 1.获取当前时间,以毫秒为单位。

  • 2.按顺序向5个master节点请求加锁。客户端设置网络连接和响应超时时间,并且超时时间要小于锁的失效时间。(假设锁自动失效时间为10秒,则超时时间一般在5-50毫秒之间,我们就假设超时时间是50ms吧)。如果超时,跳过该master节点,尽快去尝试下一个master节点。

  • 3.客户端使用当前时间减去开始获取锁时间(即步骤1记录的时间),得到获取锁使用的时间。当且仅当超过一半(N/2+1,这里是5/2+1=3个节点)的Redis master节点都获得锁,并且使用的时间小于锁失效时间时,锁才算获取成功。(如上图,10s> 30ms+40ms+50ms+4m0s+50ms)

  • 如果取到了锁,key的真正有效时间就变啦,需要减去获取锁所使用的时间。

  • 如果获取锁失败(没有在至少N/2+1个master实例取到锁,有或者获取锁时间已经超过了有效时间),客户端要在所有的master节点上解锁(即便有些master节点根本就没有加锁成功,也需要解锁,以防止有些漏网之鱼)。

简化下步骤就是:

  • 按顺序向5个master节点请求加锁

  • 根据设置的超时时间来判断,是不是要跳过该master节点。

  • 如果大于等于3个节点加锁成功,并且使用的时间小于锁的有效期,即可认定加锁成功啦。

  • 如果获取锁失败,解锁!

Redisson实现了redLock版本的锁,有兴趣的小伙伴,可以去了解一下哈~

各个版本的Redis分布式锁

V1.0


 
tryLock()
    SETNX Key 1
    EXPIRE Key Seconds

release()
  DELETE Key

这个版本应该是最简单的版本,也是出现频率很高的一个版本,首先给锁加一个过期时间操作是为了避免应用在服务重启或者异常导致锁无法释放后,不会出现锁一直无法被释放的情况。

这个方案的一个问题在于每次提交一个Redis请求,如果执行完第一条命令后应用异常或者重启,锁将无法过期,一种改善方案就是使用Lua脚本(包含SETNX和EXPIRE两条命令),但是如果Redis仅执行了一条命令后crash或者发生主从切换,依然会出现锁没有过期时间,最终导致无法释放。

另外一个问题在于,很多同学在释放分布式锁的过程中,无论锁是否获取成功,都在finally中释放锁,这样是一个锁的错误使用,这个问题将在后续的V3.0版本中解决。

针对锁无法释放问题的一个解决方案基于GETSET命令来实现

V1.1 基于GETSET


 
tryLock()
    NewExpireTime=CurrentTimestamp+ExpireSeconds
    if(SETNX Key NewExpireTime Seconds)
         oldExpireTime = GET(Key)
          if( oldExpireTime < CurrentTimestamp)
              NewExpireTime=CurrentTimestamp+ExpireSeconds
              CurrentExpireTime=GETSET(Key,NewExpireTime)
              if(CurrentExpireTime == oldExpireTime)
                return 1;
              else
                return 0;
              
          
    

release()
        DELETE key
    

思路:

(1)SETNX(Key,ExpireTime)获取锁

(2)如果获取锁失败,通过GET(Key)返回的时间戳检查锁是否已经过期

(3)GETSET(Key,ExpireTime)修改Value为NewExpireTime

(4)检查GETSET返回的旧值,如果等于GET返回的值,则认为获取锁成功

注意:这个版本去掉了EXPIRE命令,改为通过Value时间戳值来判断过期

问题:

(1)在锁竞争较高的情况下,会出现Value不断被覆盖,但是没有一个Client获取到锁

(2)在获取锁的过程中不断的修改原有锁的数据,设想一种场景C1,C2竞争锁,C1获取到了锁,C2锁执行了GETSET操作修改了C1锁的过期时间,如果C1没有正确释放锁,锁的过期时间被延长,其它Client需要等待更久的时间

V2.0 基于SETNX


 
tryLock()
    SETNX Key 1 Seconds

release()
  DELETE Key

Redis 2.6.12版本后SETNX增加过期时间参数,这样就解决了两条命令无法保证原子性的问题。但是设想下面一个场景:

(1)C1成功获取到了锁,之后C1因为GC进入等待或者未知原因导致任务执行过长,最后在锁失效前C1没有主动释放锁

(2)C2在C1的锁超时后获取到锁,并且开始执行,这个时候C1和C2都同时在执行,会因重复执行造成数据不一致等未知情况

(3)C1如果先执行完毕,则会释放C2的锁,此时可能导致另外一个C3进程获取到了锁

大致的流程图

存在问题:(1)由于C1的停顿导致C1 和C2同都获得了锁并且同时在执行,在业务实现间接要求必须保证幂等性

(2)C1释放了不属于C1的锁

V3.0


 
tryLock()
    SETNX Key UnixTimestamp Seconds

release()
    EVAL(
      //LuaScript
      if redis.call("get",KEYS[1]) == ARGV[1] then
          return redis.call("del",KEYS[1])
      else
          return 0
      end
    )

这个方案通过指定Value为时间戳,并在释放锁的时候检查锁的Value是否为获取锁的Value,避免了V2.0版本中提到的C1释放了C2持有的锁的问题;另外在释放锁的时候因为涉及到多个Redis操作,并且考虑到Check And Set 模型的并发问题,所以使用Lua脚本来避免并发问题。

存在问题:

如果在并发极高的场景下,比如抢红包场景,可能存在UnixTimestamp重复问题,另外由于不能保证分布式环境下的物理时钟一致性,也可能存在UnixTimestamp重复问题,只不过极少情况下会遇到。

V3.1


 
tryLock()
    SET Key UniqId Seconds

release()
    EVAL(
      //LuaScript
      if redis.call("get",KEYS[1]) == ARGV[1] then
          return redis.call("del",KEYS[1])
      else
          return 0
      end
    )

Redis 2.6.12后SET同样提供了一个NX参数,等同于SETNX命令,官方文档上提醒后面的版本有可能去掉SETNX, SETEX, PSETEX,并用SET命令代替,另外一个优化是使用一个自增的唯一UniqId代替时间戳来规避V3.0提到的时钟问题。

这个方案是目前最优的分布式锁方案,但是如果在Redis集群环境下依然存在问题:

由于Redis集群数据同步为异步,假设在Master节点获取到锁后未完成数据同步情况下Master节点crash,此时在新的Master节点依然可以获取锁,所以多个Client同时获取到了锁

分布式Redis锁:Redlock

V3.1的版本仅在单实例的场景下是安全的,针对如何实现分布式Redis的锁,国外的分布式专家有过激烈的讨论, antirez提出了分布式锁算法Redlock,在distlock话题下可以看到对Redlock的详细说明,下面是Redlock算法的一个中文说明(引用)

假设有N个独立的Redis节点

1、获取当前时间(毫秒数)。

2、按顺序依次向N个Redis节点执行获取锁的操作。这个获取操作跟前面基于单Redis节点的获取锁的过程相同,包含随机字符串my_random_value,也包含过期时间(比如PX 30000,即锁的有效时间)。

为了保证在某个Redis节点不可用的时候算法能够继续运行,这个获取锁的操作还有一个超时时间(time out),它要远小于锁的有效时间(几十毫秒量级)。客户端在向某个Redis节点获取锁失败以后,应该立即尝试下一个Redis节点。

这里的失败,应该包含任何类型的失败,比如该Redis节点不可用,或者该Redis节点上的锁已经被其它客户端持有(注:Redlock原文中这里只提到了Redis节点不可用的情况,但也应该包含其它的失败情况)。

3、计算整个获取锁的过程总共消耗了多长时间,计算方法是用当前时间减去第1步记录的时间。如果客户端从大多数Redis节点(>= N/2+1)成功获取到了锁,并且获取锁总共消耗的时间没有超过锁的有效时间(lock validity time),那么这时客户端才认为最终获取锁成功;否则,认为最终获取锁失败。

4、如果最终获取锁成功了,那么这个锁的有效时间应该重新计算,它等于最初的锁的有效时间减去第3步计算出来的获取锁消耗的时间。

5、如果最终获取锁失败了(可能由于获取到锁的Redis节点个数少于N/2+1,或者整个获取锁的过程消耗的时间超过了锁的最初有效时间),那么客户端应该立即向所有Redis节点发起释放锁的操作(即前面介绍的Redis Lua脚本)。

6、释放锁:对所有的Redis节点发起释放锁操作

然而Martin Kleppmann针对这个算法提出了质疑,提出应该基于fencing token机制(每次对资源进行操作都需要进行token验证)

(1)Redlock在系统模型上尤其是在分布式时钟一致性问题上提出了假设,实际场景下存在时钟不一致和时钟跳跃问题,而Redlock恰恰是基于timing的分布式锁

(2)另外Redlock由于是基于自动过期机制,依然没有解决长时间的gc pause等问题带来的锁自动失效,从而带来的安全性问题。

接着antirez又回复了Martin Kleppmann的质疑,给出了过期机制的合理性,以及实际场景中如果出现停顿问题导致多个Client同时访问资源的情况下如何处理。

针对Redlock的问题,基于Redis的分布式锁到底安全给出了详细的中文说明,并对Redlock算法存在的问题提出了分析。

不论是基于SETNX版本的Redis单实例分布式锁,还是Redlock分布式锁,都是为了保证以下特性

(1)安全性:在同一时间不允许多个Client同时持有锁

(2)活性 死锁:锁最终应该能够被释放,即使Client端crash或者出现网络分区(通常基于超时机制) 容错性:只要超过半数Redis节点可用,锁都能被正确获取和释放

所以在开发或者使用分布式锁的过程中要保证安全性和活性,避免出现不可预测的结果。

另外每个版本的分布式锁都存在一些问题,在锁的使用上要针对锁的实用场景选择合适的锁,通常情况下锁的使用场景包括:

(1)Efficiency(效率):只需要一个Client来完成操作,不需要重复执行,这是一个对宽松的分布式锁,只需要保证锁的活性即可;

(2)Correctness(正确性):多个Client保证严格的互斥性,不允许出现同时持有锁或者对同时操作同一资源,这种场景下需要在锁的选择和使用上更加严格,同时在业务代码上尽量做到幂等

在Redis分布式锁的实现上还有很多问题等待解决,我们需要认识到这些问题并清楚如何正确实现一个Redis 分布式锁,然后在工作中合理的选择和正确的使用分布式锁。

Redisson分布式锁实现原理的总结了一张图

加个“星标”,欢迎来撩

前言

分布式锁相信大家一定不会陌生, 想要用好或者自己写个分布式锁却没那么简单

想要达到上述的条件, 一定要 掌握分布式锁的应用场景, 以及分布式锁的不同实现, 不同实现之间有什么区别

这很重要!

能够帮助自己更好掌握原理

分布式锁场景

如果想真正了解分布式锁, 需要结合一定场景; 举个例子, 某夕夕上抢购 AirPods Pro 的 100 元优惠券

如果使用下面这段代码当作抢购优惠券的后台程序, 我们一起看一下, 可能存在什么样的问题

很明显的就是这段流程在并发场景下并不安全, 会导致优惠券发放超过预期, 类似电商抢购超卖问题

想一哈有什么方式可以避免这种分布式下超量问题?

互斥加锁, Java 中互斥锁的语义就是 同一时间, 只允许一个客户端对资源进行操作

比如 Java 中的关键字 Synchronized, 以及 JUC Lock 包下的 ReentrantLock 都可以实现互斥锁

JVM 锁

如图所示, 加入 JVM synchronized 锁确实可以解决单机下并发问题

但是生产环境为了保证服务高可用, 起码要 部署两台服务, 这样的话 synchronized 就不起作用了, 因为它的 作用域只是单个 JVM

分布式情况下只能通过 分布式锁 来解决多个服务资源共享的问题了

如果死磕单服务, 那没的说, 分布式锁就是浮云 ☁️

分布式锁

分布式锁的定义:

保证同一时间只能有一个客户端对共享资源进行操作

比对刚才举的例子, 不论部署多少台优惠券服务, 只会有 一台服务能够对优惠券数量进行增删操作

另外有几点要求也是必须要满足的: 

1、不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁

2、具有容错性只要大部分的Redis节点正常运行,客户端就可以加锁和解锁

3、解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了

分布式锁实现大致分为三种, Redis、Zookeeper、数据库, 文章以 Redis 展开分布式锁的讨论

分布式锁演进史

先来构思下分布式锁实现思路

首先我们必须保证同一时间只有一个客户端(部署的优惠券服务)操作数量加减

其次本次 客户端操作完成后, 需要让 其它客户端继续执行

1、客户端一存放一个标志位, 如果添加成功, 操作减优惠券数量操作

2、客户端二添加标志位失败, 本次减库存操作失败(或继续尝试获取等)

3、客户端一优惠券操作完成后, 需要将标志位释放, 以便其余客户端对库存进行操作

第一版 setnx

向 Redis 中添加一个 lockKey 锁标志位, 如果添加成功则能够继续向下执行扣减优惠券数量操作, 最后再释放此标志位

由于使用的是 Spring 提供的 Redis 封装的 Start 包, 所有有些命令与 Redis 原生命令不相符

setIfAbsent(key, val) -> setnx(key, val)

加了简单的几行代码, 一个简单的分布式锁的雏形就出来了

第二版 expire

上面第一版基于 setnx 命令实现分布式锁的缺陷也是很明显的, 那就是 一定情况下可能发生死锁

画个图, 举个例子说明哈

上图说明, 线程1在成功获取锁后, 执行流程时异常结束, 没有执行释放锁操作, 这样就会 产生死锁


如果方法执行异常导致的线程被回收, 那么可以将解锁操作放到 finally 块中

但是还有存在死锁问题, 如果获得锁的线程在执行中, 服务被强制停止或服务器宕机, 锁依然不会得到释放

这种极端情况下我们还是要考虑的, 毕竟不能只想着服务没问题对吧

对 Redis 的 锁标志位加上过期时间 就能很好的防止死锁问题, 继续更改下程序代码

虽然 小红旗处 对分布式锁添加了过期时间, 但依然无法避免极端情况下的死锁问题

那就是如果在客户端加锁成功后, 还没有设置过期时间时宕机

如果想要避免添加锁时死锁, 那就对添加锁标志位 & 添加过期时间命令 保证一个原子性, 要么一起成功, 要么一起失败

第三版 set

我们的添加锁原子命令就要登场了, 从 Redis 2.6.12 版本起, 提供了可选的 字符串 set 复合命令

SET key value [expiration EX seconds|PX milliseconds] [NX|XX]

可选参数如下:

  • EX: 设置超时时间,单位是秒

  • PX: 设置超时时间,单位是毫秒

  • NX: IF NOT EXIST 的缩写,只有 KEY不存在的前提下 才会设置值

  • XX: IF EXIST 的缩写,只有在 KEY存在的前提下 才会设置值

继续完善分布式锁的应用程序, 代码如下:

我使用的 2.0.9.RELEASE 版本的 SpringBoot, RedisTemplate 中不支持 set 复合命令, 所以临时换个 Jedis 来实现

加锁以及设置过期时间确实保证了原子性, 但是这样的分布式锁就没有问题了么?

我们根据图片以及流程描述设想一下这个场景

1、线程一获取锁成功, 设置过期时间五秒, 接着执行业务逻辑

2、接着线程一获取锁后执行业务流程, 执行的时间超过了过期时间, 锁标志位过期进行释放, 此时线程二获取锁成功

3、然鹅此时线程一执行完业务后, 开始执行释放锁的流程, 然后顺手就把线程二获取的锁释放了


如果线上真的发生上述问题, 那真的是xxx, 更甚者可能存在线程一将线程二的锁释放掉之后, 线程三获取到锁, 然后线程二执行完将线程三的锁释放


第四版 verify value

事当如今, 只能创建辨别客户端身份的唯一值了, 将加锁及解锁归一化, 上代码~

这一版的代码相当于我们添加锁标志位时, 同时为每个客户端设置了 uuid 作为锁标志位的 val, 解锁时需要判断锁的 val 是否和自己客户端的相同, 辨别成功才会释放锁

但是上述代码执行业务逻辑如果抛出异常, 锁只能等待过期时间, 我们可以将解锁操作放到 finally 块

大眼一看, 上上下下实现了四版分布式锁, 也该没问题了吧


真相就是: 解锁时,  由于判断锁和删除标志位并不是原子性的, 所以可能还是会存在误删

1、线程一获取锁后, 执行流程balabala... 判断锁也是自家的, 这时 CPU 转头去做别的事情了, 恰巧线程一的锁过期时间到了

2、线程二此时顺理成章的获取到了分布式锁, 执行业务逻辑balabala...

3、线程一再次分配到时间片继续执行删除操作


解决这种非原子操作的方式只能 将判断元素值和删除标志位当作一个原子操作

第五版 lua

很不友好的是, del 删除操作并没有提供原子命令, 所以我们需要想点办法

Redis在 2.6 推出了脚本功能, 允许开发者使用 Lua 语言编写脚本传到 Redis 中执行

使用 Lua 脚本有什么好处呢?

1、减少网络开销

原本我们需要向 Redis 服务请求多次命令, 可以将命令写在 Lua 脚本中, 这样执行只会发起一次网络请求

2、原子操作

Redis 会将 Lua 脚本中的命令当作一个整体执行, 中间不会插入其它命令

3、复用(大家自己探索哈)

客户端发送的脚步会存储 Redis 中, 其他客户端可以复用这一脚本而不需要使用代码完成相同的逻辑

那我们编写一个简单的 Lua 脚本实现原子删除操作

重点就在 Lua 脚本这一块, 重点说一下这块的逻辑

script 脚本就是我们在 Redis 中执行的 Lua 脚本, 后面跟的两个 List 分别是 KEYS、ARGV

cache.eval(script, Lists.newArrayList(lockKey), Lists.newArrayList(lockValue));

KEYS[1]: lockKey

ARGV[1]: lockValue

代码不是很多, 也比较简单, 就是在 Java 中代码实现的逻辑放到了一个 Lua 脚本中

# 获取 KEYS[1] 对应的 Val
local cliVal = redis.call('get', KEYS[1])
# 判断 KEYS[1] 与 ARGV[1] 是否保持一致
if(cliVal == ARGV[1]) then 
  # 删除 KEYS[1]
  redis.call('del', KEYS[1]) 
  return 'OK' 
else
  return nil 
end

到了这种程度, 已经可以放到一些并发量不大的项目中生产使用了

TODO 列表

虽然上述代码已经很大程度上解决了分布式锁可能存在的一些问题

但是下述列出的问题部分就不是客户端代码范畴内的事情了

  • 如何实现可重入锁

  • 如何解决代码执行锁超时

  • 主从节点同步数据丢失导致锁丢失问题

上述问题等下一篇介绍 Redisson 源码时会一一说明, 顺道向大家推出一款 Redis 官方推荐的客户端: Redisson

Jedis... 等客户端平常使用是绰绰有余的, 但是在功能上还是和 Redisoon 比不了

并不是推荐一定要用 Redisson, 根据不同场景选用不同客户端

Redisson 就是为分布式提供 各种不同锁以及多样化的技术支持, 感兴趣的小伙伴可以看一下 Giuhub 上的介绍, 挺详细的

下一篇文章会详细介绍 Redisson 分布式锁的加锁、解锁原理

看了 Redisson 的源码后简直了... 然后对之前项目的分布式锁做了重构, 在原有基础上增加了如下功能

  • 保证了加锁、解锁之间的原子性

  • 可重入的分布式锁

  • 分布式锁自动延期功能

文末总结

本篇文章从最简单的分布式锁说起, 一步一步的讲述存在的问题以及解决方式, 最终得到个基本可用的分布式锁

但是事无绝对, 对于分布式锁的应用, 还是推荐在 代码以及数据库表中添加兜底策略, 乐观锁等措施


【更多阅读】

……

以上是关于集锦的主要内容,如果未能解决你的问题,请参考以下文章

前端面试常见问题集锦

软件测试概念集锦

软件测试概念集锦

软件测试概念集锦

阿里前端经典react面试题集锦

面试时候可以问的问题集锦