首页 > 技术文章 > Redis实现分布式锁

jasonZh 2018-08-23 11:16 原文

  目前几乎很多大型网站及应用都是分布式部署的,分布式场景中的数据一致性问题一直是一个比较重要的话题。分布式的CAP理论告诉我们“任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。”所以,很多系统在设计之初就要对这三者做出取舍。在互联网领域的绝大多数的场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证“最终一致性”,只要这个最终时间是在用户可以接受的范围内即可。

  在很多场景中,我们为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。

  分布式锁在很多应用场景下是非常有效的手段,比如当运行在多个机器上的不同进程需要访问同一个竞争资源的时候,那么就会涉及到进程对资源的加锁和释放,这样才能保证数据的安全访问。分布式锁实现的方案有很多,比如基于ZooKeeper实现、或者基于Mysql实现等等,今天我们来一起看看如何基于Redis实现分布式锁服务。

一、何为分布式锁:

  分布式锁是控制分布式系统之间同步访问共享资源的一种方式。在分布式系统中,常常需要协调他们的动作。如果不同的系统或是同一个系统的不同主机之间共享了一个或一组资源,那么访问这些资源的时候,往往需要互斥来防止彼此干扰来保证一致性,在这种情况下,便需要使用到分布式锁。

  我们来假设一个最简单的秒杀场景:数据库里有一张表,column分别是商品ID,和商品ID对应的库存量,秒杀成功就将此商品库存量-1。现在假设有1000个线程来秒杀两件商品,500个线程秒杀第一个商品,500个线程秒杀第二个商品。我们来根据这个简单的业务场景来解释一下分布式锁。

  通常具有秒杀场景的业务系统都比较复杂,承载的业务量非常巨大,并发量也很高。这样的系统往往采用分布式的架构来均衡负载。那么这1000个并发就会是从不同的地方过来,商品库存就是共享的资源,也是这1000个并发争抢的资源,这个时候我们需要将并发互斥管理起来。这就是分布式锁的应用。

  而key-value存储系统,如redis,因为其一些特性,是实现分布式锁的重要工具。

二、分布式锁的要点:

1、任何一个时间点必须只能够有一个客户端拥有锁。 
2、不能够有死锁,也就是最终客户端都能够获得锁,尽管可能会经历失败。 
3、错误容忍性要好,只要有大部分的redis实例存活客户端就应该能够获得锁。

三、使用分布式锁要满足的几个条件:

1、系统是一个分布式系统(关键是分布式,单机的可以使用ReentrantLock或者synchronized代码块来实现); 
2、共享资源(各个系统访问同一个资源,资源的载体可能是传统关系型数据库或者NoSQL); 
3、同步访问(即有很多个进程同时访问同一个共享资源。没有同步访问,谁管你资源竞争不竞争)。

四、应用场景实例:

  多台服务器要访问redis全局缓存的资源,如果不使用分布式锁就会出现问题。

long N=0L;
    //N从redis获取值
    if(N<5){
        N++//N写回redis
}

  上面的代码主要实现以下功能:

  从redis获取值N,对数值N进行边界检查,如果小于5就自增1,然后写回到redis中。这种应用场景很常见,像秒杀,全局递增ID,IP访问限制等。以IP访问限制来说,恶意攻击者可能发起无限次访问,并发量比较大,分布式环境下对N的边界检查就不可靠,因为从redis读的N可能已经是脏数据了,传统的加锁做法(如Java的synchronized和Lock)也没用,因为这是分布式环境,这时我们就要考虑使用分布式锁了。

五、使用redis的setNX实现分布式锁:

  分布式锁的基本原理:用一个状态值表示锁,对锁的占用和释放通过状态值来标识。

  redis实现分布式锁的原理:redis为单进程单线程模式,采用队列模式将并发访问变成串行访问,且多客户端对redis的连接并不存在竞争关系。redis的SETNX命令可以方便的实现分布式锁。

  1、基本命令:

  1)、setNX(SET IF NOT EXISTS)

  语法:SETNX key value
  将key的值设为value,当且仅当key不存在;若给定的key已经存在,则SETNX不做任何动作。

  返回值:设置成功返回1;设置失败返回0.

  示例:

redis> EXISTS job                # job 不存在
(integer) 0
redis> SETNX job "programmer"    # job 设置成功
(integer) 1
redis> SETNX job "code-farmer"   # 尝试覆盖 job ,失败
(integer) 0
redis> GET job                   # 没有被覆盖
"programmer"

  所以我们使用执行下面的命令:

SETNX lock.foo <current Unix time + lock timeout + 1>

  (current Unix time + lock timeout为该锁的到期时间)

返回1,则该客户端获得锁,把lock.foo的键值设置为时间值表示该键已经被锁定,该客户端最后可以通过DEL lock.foo来释放锁。

返回0,表明该锁已经被其它客户端取得,这时我们可以先返回或进行重试等对方完成或等待锁超时。

  2)、getSET 
  语法:GETSET key value 
  将给定key的值设为value,并返回key的旧值(old value)。当key存在但不是字符串类型时,返回一个错误。 
  返回值:返回给定key的旧值。当key没有旧值时,也即是key不存在时,返回nil。

  3)、get 
  语法:GET key 
  返回值:当key不存在时,返回nil,否则返回key的值;如果key不是字符串类型,返回一个错误。

  4)、expire 
  语法:expire key timeout 
  为key设置一个超时时间,单位为second,超过这个时间锁会自动释放,避免死锁。

  5)、delete 
  语法:delete key 
  删除key

  2、解决死锁:

  上面的锁定逻辑有一个问题:如果一个持有锁的客户端失败或者崩溃了不能释放锁,该怎么解决?

  我们可以通过锁对应的时间戳来判断这种情况是否发生了,如果当前的时间已经大于lock.foo的值(锁的到期时间),说明该锁已经失效,可以被重新使用。

  发生这种情况时,可不能简单的通过DEL删除锁,然后再SETNX一次(删除锁的操作应该是锁拥有者来执行的,这里只需要等它超时即可),当多个客户端检测到锁超时后都会尝试去释放它,这里就可能出现一个竞态条件,让我们来模拟一下这个场景:

C0操作超时了,但它还持有着锁,C1和C2读取lock.foo检查时间戳,先后发现超时了。

C1发送DEL lock.foo 
C1发送SETNX lock.foo并且成功了;

C2发送DEL lock.foo 
C2发送SETNX lock.foo并且成功了;

这样一来,C1,C2都拿到锁,就出问题了。

  幸好这种问题是可以避免的,让我们来看看C3这个客户端是怎样做的:

C3发送SETNX lock.foo想要获得锁,由于C0还持有锁,所以redis返回给C3一个0;

C3发送GET lock.foo以检查锁是否超时了,如果没有超时,则等待或者重试;

反之,如果已经超时,C3通过下面的操作来尝试获得锁: 
GETSET lock.fool

SET resource_name my_random_value NX PX 30000

  SET NX命令只会在key不存在的时候给key赋值,PX命令通知redis保存这个key为30000MS。 
  key的值会被设置为my_random_value。这个值在多个客户端和锁中必须是唯一的,我们使用random value是为了方便安全地释放锁,通过下面的脚本为申请成功的锁解锁:

if redis.call("get",KEY[1]) == argv[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

  如果key对应的value一致,则删除这个key。通过这个方式释放锁是为了避免client释放其它client申请的锁。

  例如:

1、Client A 获得了一个锁 
2、当尝试释放锁的请求发送给Redis时被阻塞,没有及时到达Redis 
3、锁定时间超时,Redis认为锁的租约到期,释放了这个锁 
4、client B 重新申请到了这个锁 
5、client A的解锁请求到达,将Client B锁定的key解锁 
6、Client C 也获得了锁 
7、Client B client C 同时持有锁

  通过执行上面脚本的方式释放锁,Client的解锁操作只会解锁自己曾经加锁的资源。

  官方推荐通从 /dev/urandom/中取20个byte作为随机数或者采用更加简单的方式, 例如使用RC4加密算法在/dev/urandom中得到一个种子(Seed),然后生成一个伪随机流。

  也可以用更简单的使用时间戳+客户端编号的方式生成随机数,这种方式的安全性较差一些,但是对于绝大多数的场景来说也已经足够安全了。

  PX 操作后面的参数代表的是这key的存活时间,称作锁过期时间。当资源被锁定超过这个时间,锁将自动释放。

  获得锁的客户端如果没有在这个时间窗口内完成操作,就可能会有其他客户端获得锁,引起争用问题。

  通过上面的两个操作,我们可以完成获得锁和释放锁操作。如果这个系统不宕机,那么单点的锁服务已经足够安全,接下来我们开始把场景扩展到分布式系统。

六、基于Redlock算法在redis集群上实现分布式锁

  在分布式环境下,假设我们有N个master,这些节点都是独立的,因此我们没有配置复制策略。上面我们已经学会了如何在单机环境下获取锁和释放锁,我们假设的更具体一些,N=5,为了能获取锁,客户端的步骤为:

1、得到本地时间

2、Client使用相同的key和随机数,按照顺序在每个Master实例中尝试获得锁。在获得锁的过程中,为每一个锁操作设置一个快速失败时间(如果想要获得一个10秒的锁, 那么每一个锁操作的失败时间设为5-50ms)。这样可以避免客户端与一个已经故障的Master通信占用太长时间,通过快速失败的方式尽快的与集群中的其他节点完成锁操作。

3、客户端计算出与master获得锁操作过程中消耗的时间,当且仅当Client获得锁消耗的时间小于锁的存活时间,并且在一半以上的master节点中获得锁。才认为client成功的获得了锁。

4、如果已经获得了锁,Client执行任务的时间窗口是锁的存活时间减去获得锁消耗的时间。

5、如果Client获得锁的数量不足一半以上,或获得锁的时间超时,那么认为获得锁失败。客户端需要尝试在所有的master节点中释放锁, 即使在第二步中没有成功获得该Master节点中的锁,仍要进行释放操作。

  RedLock能保证锁同步吗?

  这个算法成立的一个条件是:即使集群中没有同步时钟,各个进程的时间流逝速度也要大体一致,并且误差与锁存活时间相比是比较小的。实际应用中的计算机也能满足这个条件:各个计算机中间有几毫秒的时钟漂移(clock drift)。

  失败重试机制

  如果一个Client无法获得锁,它将在一个随机延时后开始重试。使用随机延时的目的是为了与其他申请同一个锁的Client错开申请时间,减少脑裂(split brain)发生的可能性。

  三个Client同时尝试获得锁,分别获得了2,2,1个实例中的锁,三个锁请求全部失败。

  一个client在全部Redis实例中完成的申请时间越短,发生脑裂的时间窗口越小。所以比较理想的做法是同时向N个Redis实例发出异步的SET请求。当Client没有在大多数Master中获得锁时,立即释放已经取得的锁时非常必要的。(PS.当极端情况发生时,比如获得了部分锁以后,client发生网络故障,无法再释放锁资源。那么其他client重新获得锁的时间将是锁的过期时间)。无论Client认为在指定的Master中有没有获得锁,都需要执行释放锁操作。

七、RedLock算法安全性分析

  我们将从不同的场景分析RedLock算法是否足够安全。首先我们假设一个client在大多数的Redis实例中取得了锁,那么:

1、每个实例中的锁的剩余存活时间相等为TTL。

2、每个锁请求到达各个Redis实例中的时间有差异。

3、第一个锁成功请求最先在T1后返回,最后返回的请求在T2后返回。(T1,T2都小于最大失败时间)

4、并且每个实例之间存在时钟漂移CLOCK_DRIFT(Time Drift)。

  于是,最先被SET的锁将在TTL-(T2-T1)-CLOCK_DIRFT后自动过期,其他的锁将在之后陆续过期。所以可以得到结论:所有的key这段时间内是同时被锁住的。在这段时间内,一半以上的Redis实例中这个key都处在被锁定状态,其他的客户端无法获得这个锁。

八、锁的可用性分析(Liveness)

  分布式锁系统的可用性主要依靠以下三种机制

1、锁的自动释放(key expire),最终锁将被释放并且被再次申请。

2、客户端在未申请到锁以及申请到锁并完成任务后都将进行释放锁的操作,所以大部分情况下都不需要等待到锁的自动释放期限,其他client即可重新申请到锁。

3、假设一个Client在大多数Redis实例中申请锁请求所成功花费的时间为Tac。那么如果某个Client第一次没有申请到锁,需要重试之前,必须等待一段时间T。T需要远大于Tac。 因为多个Client同时请求锁资源,他们有可能都无法获得一半以上的锁,导致脑裂双方均失败。设置较久的重试时间是为了减少脑裂产生的概率。

  如果一直持续的发生网络故障,那么没有客户端可以申请到锁。分布式锁系统也将无法提供服务直到网络故障恢复为止。

九、性能,故障恢复与文件同步

  用户使用redis作为锁服务的主要优势是性能。其性能的指标有两个:

1、加锁和解锁的延迟

2、每秒可以进行多少加锁和解锁操作

  所以,在客户端与N个Redis节点通信时,必须使用多路发送的方式(multiplex),减少通信延时。

  为了实现故障恢复还需要考虑数据持久化的问题。我们还是从某个特定的场景分析:

Redis实例的配置不进行任何持久化,集群中5个实例 M1,M2,M3,M4,M5

client A获得了M1,M2,M3实例的锁。

此时M1宕机并重启。

由于没有进行持久化,M1重启后不存在任何KEY

client B获得M4,M5和重启后的M1中的锁。

此时client A 和Client B 同时获得锁

  如果使用AOF的方式进行持久化,情况会稍好一些。例如我们可以向某个实例发送shutdown和restart命令。即使节点被关闭,EX设置的时间仍在计算,锁的排他性仍能保证。但当Redis发生电源瞬断的情况又会遇到有新的问题出现。如果Redis配置中的进行磁盘持久化的时间是每分钟进行,那么会有部分key在重新启动后丢失。

  如果为了避免key的丢失,将持久化的设置改为Always,那么性能将大幅度下降。

  另一种解决方案是在这台实例重新启动后,令其在一定时间内不参与任何加锁。在间隔了一整个锁生命周期后,重新参与到锁服务中。这样可以保证所有在这台实例宕机期间内的key都已经过期或被释放。

  延时重启机制能够保证Redis即使不使用任何持久化策略,仍能保证锁的可靠性。但是这种策略可能会牺牲掉一部分可用性。例如集群中超过半数的实例都宕机了,那么整个分布式锁系统需要等待一整个锁有效期的时间才能重新提供锁服务。

十、 使锁算法更加可靠:锁续约

  如果Client进行的工作耗时较短,那么可以默认使用一个较小的锁有效期,然后实现一个锁续约机制。

  当一个Client在工作计算到一半时发现锁的剩余有效期不足。可以向Redis实例发送续约锁的Lua脚本。如果Client在一定的期限内(时间与申请锁的耗时接近)成功的续约了半数以上的实例,那么续约锁成功。

  为了提高系统的可用性,每个Client申请锁续约的次数需要有一个最大限制,避免其不断续约造成该key长时间不可用。

十一、Java代码实现:

实现思想(使用jedis来连接redis):

1、获取锁的时候,使用setnx加锁,并使用expire命令为锁添加一个超时时间,超过该时间则自动释放锁。锁的value值为一个随机生成的UUID,通过该value值在释放锁的时候进行判断;

2、获取锁的时候还设置一个获取的超时时间,若超过这个时间则放弃获取锁;

3、释放锁的时候,通过UUID来判断是不是该锁,若是该锁,则执行delete操作进行锁释放。

分布式锁的核心代码如下:

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.Transaction;
import redis.clients.jedis.exceptions.JedisException;
import java.util.List;
import java.util.UUID;

public class DistributedLock {
    private final JedisPool jedisPool;
    public DistributedLock(JedisPool jedisPool) {
        this.jedisPool = jedisPool;
    }
    /**
     * 加锁
     * @param locaName  锁的key
     * @param acquireTimeout  获取超时时间
     * @param timeout   锁的超时时间
     * @return 锁标识
     */
    public String lockWithTimeout(String locaName,
                                  long acquireTimeout, long timeout) {
        Jedis conn = null;
        String retIdentifier = null;
        try {
            // 获取连接
            conn = jedisPool.getResource();
            // 随机生成一个value
            String identifier = UUID.randomUUID().toString();
            // 锁名,即key值
            String lockKey = "lock:" + locaName;
            // 超时时间,上锁后超过此时间则自动释放锁
            int lockExpire = (int)(timeout / 1000);
            // 获取锁的超时时间,超过这个时间则放弃获取锁
            long end = System.currentTimeMillis() + acquireTimeout;
            while (System.currentTimeMillis() < end) {
                if (conn.setnx(lockKey, identifier) == 1) {
                    conn.expire(lockKey, lockExpire);
                    // 返回value值,用于释放锁时间确认
                    retIdentifier = identifier;
                    return retIdentifier;
                }
                // 返回-1代表key没有设置超时时间,为key设置一个超时时间
                if (conn.ttl(lockKey) == -1) {
                    conn.expire(lockKey, lockExpire);
                }
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        } catch (JedisException e) {
            e.printStackTrace();
        } finally {
            if (conn != null) {
                conn.close();
            }
        }
        return retIdentifier;
    }
    /**
     * 释放锁
     * @param lockName 锁的key
     * @param identifier    释放锁的标识
     * @return
     */
    public boolean releaseLock(String lockName, String identifier) {
        Jedis conn = null;
        String lockKey = "lock:" + lockName;
        boolean retFlag = false;
        try {
            conn = jedisPool.getResource();
            while (true) {
                // 监视lock,准备开始事务
                conn.watch(lockKey);
                // 通过前面返回的value值判断是不是该锁,若是该锁,则删除,释放锁
                if (identifier.equals(conn.get(lockKey))) {
                    Transaction transaction = conn.multi();
                    transaction.del(lockKey);
                    List<Object> results = transaction.exec();
                    if (results == null) {
                        continue;
                    }
                    retFlag = true;
                }
                conn.unwatch();
                break;
            }
        } catch (JedisException e) {
            e.printStackTrace();
        } finally {
            if (conn != null) {
                conn.close();
            }
        }
        return retFlag;
    }
}

测试:

  模拟秒杀服务,在其中配置了jedis线程池,在初始化的时候传给分布式锁,供其使用。使用50个线程模拟秒杀一个商品,使用–运算符来实现商品的减少,从结果有序性可以看出是否为加锁状态。

import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;

public class Service {
    private static JedisPool pool = null;
    static {
        JedisPoolConfig config = new JedisPoolConfig();
        // 设置最大连接数
        config.setMaxTotal(200);
        // 设置最大空闲数
        config.setMaxIdle(8);
        // 设置最大等待时间
        config.setMaxWaitMillis(1000 * 100);
        // 在borrow一个jedis实例时,是否需要验证,若为true,则所有jedis实例均是可用的
        config.setTestOnBorrow(true);
        pool = new JedisPool(config, "127.0.0.1", 6379, 3000);
    }
    DistributedLock lock = new DistributedLock(pool);
    int n = 500;
    public void seckill() {
        // 返回锁的value值,供释放锁时候进行判断
        String indentifier = lock.lockWithTimeout("resource", 5000, 1000);
        System.out.println(Thread.currentThread().getName() + "获得了锁");
        System.out.println(--n);
        lock.releaseLock("resource", indentifier);
    }
}

//模拟线程进行秒杀
public class ThreadA extends Thread {
    private Service service;
    public ThreadA(Service service) {
        this.service = service;
    }
    @Override
    public void run() {
        service.seckill();
    }
}

public class Test {
    public static void main(String[] args) {
        Service service = new Service();
        for (int i = 0; i < 50; i++) {
            ThreadA threadA = new ThreadA(service);
            threadA.start();
        }
    }
}

 

推荐阅读