【高级篇】Redis深入理解与实践指南(十四)之Redis分布式锁之Jedis与Redisson

150 阅读16分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第16天,点击查看活动详情

介绍

流行的分布式锁实现方式有数据库、Memcached、Redis、文件系统、ZooKeeper,主流的实现是Zookpeer与Redis分布式锁。

因Redis高性能、部署简单被广泛采用,那么今天分享下,如何用Redis实现分布式锁。

一、一个可靠的、高可用的分布式锁需要满足以下几点

互斥性:任意时刻只能有一个客户端拥有锁,不能被多个客户端获取

安全性:锁只能被持有该锁的客户端删除,不能被其它客户端删除

非死锁:获取锁的客户端因为某些原因而宕机,而未能释放锁,其它客户端也就无法获取该锁,需要有机制来避免该类问题的发生

高可用:当部分节点宕机,客户端仍能获取锁或者释放锁

二、利用单节点Redis实现分布式锁

利用单节点Redis实现分布式锁是最常用的一种方式,虽然没有考虑高可用,但是实现简单、成本低廉而被很多中小型企业所采用。

网上很多文章说采用setnx实现分布式锁,但是setnx命令无法原子性的设置锁的自身过期时间,也就是说执行setnx命令时我们无法同时设置其过期时间,那么就会出现死锁,例如:客户端A刚执行完setnx,这时候客户端A挂掉了,没有完成给锁设置过期时间,此时就产生了死锁,所有的客户端再也无法获得该锁,这种情况一般采用Lua脚本来实现(因为Redis执行Lua脚本是原子性的),其实从 Redis 2.6.12 版本开始set命令完全可以替代setnx命令,我们看官网的set命令参数

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

参数说明

EX second :设置键的过期时间为 second 秒。 SET key value EX second 效果等同于 SETEX key second value 。

PX millisecond :设置键的过期时间为 millisecond 毫秒。 SET key value PX millisecond 效果等同于 PSETEX key millisecond value 。

NX :只在键不存在时,才对键进行设置操作。 SET key value NX 效果等同于 SETNX key value 。

XX :只在键已经存在时,才对键进行设置操作。

例如:SET key value NX PX 30000 这个命令的作用是在只有这个key不存在的时候才会设置这个key的值(NX选项的作用),超时时间设为30000毫秒(PX选项的作用)

那么我们用set命令带上EX或者PX、以及NX参数就满足了上面提到的互斥性(加锁)、死锁(自动过期)两个要求。

那么如何满足安全性这个要求呢?

比如:客户端A拿到锁并设置了锁的过期时间为10S,但是由于某种原因客户端A执行时间超过了10S,此时锁自动过期,那么客户端B拿到了锁,然后客户端A此时正好执行完毕删除锁,但是此时删除的是客户端B加的锁,如何防止这种不安全的情况发生呢?

方案一:使用Jedis

我们可以让获得锁的线程开启一个守护线程,用来给自己的锁“续期”。

当过去了9S,客户端A还没执行完,这时候守护线程会执行expire指令,把锁再“续期”10S,守护线程从第9S开始执行,每9秒执行一次。

当客户端A执行完任务,会显式关掉守护线程。

如果客户端A忽然宕机,由于A线程和守护线程在同一个进程,守护线程也会停下。这把锁到了超时的时候,没人给它续期,也就自动释放了。

方案二:使用Redisson

我们也可以在加锁的时候把set的value值设置成一个唯一标识,标识这个锁是谁加的锁,在删除锁的时候判断是不是自己加的那把锁,如果不是则不删除。

注意:这里隐含了一个新的问题,判断是不是自己加的锁和释放锁是两个独立操作,不是原子性,所以我们需要使用Lua脚本执行判断和释放锁。

三、提高Redis分布式锁的高可用性

在大型的应用中,一般Redis服务都是集群形式,主从复制、Cluster等,由于Slave同步Master是异步的,所以会出现客户端A在Master上加锁,此时Master宕机,Slave没有完成锁的同步,Slave变为Master,客户端B此时可以完成加锁操作,如何解决该问题呢?

官方给出了Redlock算法,大致意思如下:

在分布式版本的算法里我们假设我们有N个Redis Master节点,这些节点都是完全独立的,我们不用任何复制或者其他隐含的分布式协调算法(如果您采用的是Redis Cluster集群此方案可能不适用,因为Redis Cluster是按哈希槽 (hash slot)的方式来分配到不同节点上的,明显存在分布式协调算法)。

我们把N设成5,因此我们需要在不同的计算机或者虚拟机上运行5个master节点来保证他们大多数情况下都不会同时宕机。一个客户端需要做如下操作来获取锁:

1、获取当前时间(单位是毫秒)。

2、轮流用相同的key和随机值(客户端的唯一标识)在N个节点上请求锁,在这一步里,客户端在每个master上请求锁时,会有一个和总的锁释放时间相比小的多的超时时间。比如如果锁自动释放时间是10秒钟,那每个节点锁请求的超时时间可能是5-50毫秒的范围,这个可以防止一个客户端在某个宕掉的master节点上阻塞过长时间,如果一个master节点不可用了,我们应该尽快尝试下一个master节点。

3、客户端计算第二步中获取锁所花的时间,只有当客户端在大多数master节点上成功获取了锁(在这里是3个),而且总共消耗的时间不超过锁释放时间,这个锁就认为是获取成功了。

4、如果锁获取成功了,那现在锁自动释放时间就是最初的锁释放时间减去之前获取锁所消耗的时间。

5、如果锁获取失败了,不管是因为获取成功的锁不超过一半(N/2+1)还是因为总消耗时间超过了锁释放时间,客户端都会到每个master节点上释放锁,即便是那些他认为没有获取成功的锁。

虽然说RedLock算法可以解决单点Redis分布式锁的高可用问题,但如果集群中有节点发生崩溃重启,还是会出现锁的安全性问题。具体出现问题的场景如下:

假设一共有A, B, C, D, E,5个Redis节点,设想发生了如下的事件序列:

1、客户端1成功锁住了A, B, C,获取锁成功(但D和E没有锁住)

2、节点C崩溃重启了,但客户端1在C上加的锁没有持久化下来,丢失了

3、节点C重启后,客户端2锁住了C, D, E,获取锁成功

这样,客户端1和客户端2同时获得了锁(针对同一资源)。针对这样场景,解决方式也很简单,也就是让Redis崩溃后延迟重启,并且这个延迟时间大于锁的过期时间就好。这样等节点重启后,所有节点上的锁都已经失效了。也不存在以上出现2个客户端获取同一个资源的情况了。

总之用Redis集群实现分布式锁要考虑的特殊情况比较多,尤其是服务器比较多的情况下,需要多测试。

多语言实现方式

最后给出各种语言实现Redlock算法的代码,感兴趣的朋友可以深入学习下:

Redlock-py (Python 实现)

github.com/SPSCommerce…

Redlock-php (PHP 实现)

github.com/ronnylt/red…

PHPRedisMutex (PHP 更完整的实现)

github.com/php-lock/lo…

Redsync.go (Go 实现)

github.com/hjr265/reds…

Redisson (Java 实现)

github.com/redisson/re…

Redis::DistLock (Perl 实现)

github.com/sbertrang/r…

Redlock-cpp (C++ 实现)

github.com/jacket-code…

Redlock-cs (C#/.NET 实现)

github.com/kidfashion/…

node-redlock (NodeJS 实现). Includes support for lock extension

github.com/mike-marcac…

总结:对于Redis分布式锁需要根据自己的实际情况进行选择是单机还是高可用的集群形式,如果都不满足不妨关注我,下一篇我会讲解用ZooKeeper怎么实现分布式锁。

特性

分布式锁,是一种思想,它的实现方式有很多。比如,我们将沙滩当做分布式锁的组件,那么它看起来应该是这样的:

  • 加锁

在沙滩上踩一脚,留下自己的脚印,就对应了加锁操作。其他进程或者线程,看到沙滩上已经有脚印,证明锁已被别人持有,则等待。

  • 解锁

把脚印从沙滩上抹去,就是解锁的过程。

  • 锁超时

为了避免死锁,我们可以设置一阵风,在单位时间后刮起,将脚印自动抹去。

分布式锁的实现有很多,比如基于数据库、memcached、Redis、系统文件、zookeeper等,它们的核心的理念跟上面的过程大致相同。

两大实现方案

1. 使用Jedis实现

首先,我们在pom文件中,引入Jedis。在这里,笔者用的是最新版本,注意由于版本的不同,API可能有所差异。

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

加锁的过程很简单,就是通过SET指令来设置值,成功则返回;否则就循环等待,在timeout时间内仍未获取到锁,则获取失败。

@Service
public class RedisLock {
​
    Logger logger = LoggerFactory.getLogger(this.getClass());
​
    private String lock_key = "redis_lock"; //锁键
​
    protected long internalLockLeaseTime = 30000;//锁过期时间
​
    private long timeout = 999999; //获取锁的超时时间
​
    
    //SET命令的参数 
    SetParams params = SetParams.setParams().nx().px(internalLockLeaseTime);
​
    @Autowired
    JedisPool jedisPool;
​
    
    /**
     * 加锁
     * @param id
     * @return
     */
    public boolean lock(String id){
        Jedis jedis = jedisPool.getResource();
        Long start = System.currentTimeMillis();
        try{
            for(;;){
                //SET命令返回OK ,则证明获取锁成功
                String lock = jedis.set(lock_key, id, params);
                if("OK".equals(lock)){
                    return true;
                }
                //否则循环等待,在timeout时间内仍未获取到锁,则获取失败
                long l = System.currentTimeMillis() - start;
                if (l>=timeout) {
                    return false;
                }
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }finally {
            jedis.close();
        }
    }
}

解锁我们通过jedis.eval来执行一段LUA就可以。将锁的Key键和生成的字符串当做参数传进来。

    /**
     * 解锁
     * @param id
     * @return
     */
    public boolean unlock(String id){
        Jedis jedis = jedisPool.getResource();
        String script =
                "if redis.call('get',KEYS[1]) == ARGV[1] then" +
                        "   return redis.call('del',KEYS[1]) " +
                        "else" +
                        "   return 0 " +
                        "end";
        try {
            Object result = jedis.eval(script, Collections.singletonList(lock_key), 
                                    Collections.singletonList(id));
            if("1".equals(result.toString())){
                return true;
            }
            return false;
        }finally {
            jedis.close();
        }
    }

最后,我们可以在多线程环境下测试一下。我们开启1000个线程,对count进行累加。调用的时候,关键是唯一字符串的生成。这里,笔者使用的是Snowflake算法。

@Controller
public class IndexController {
​
    @Autowired
    RedisLock redisLock;
    
    int count = 0;
    
    @RequestMapping("/index")
    @ResponseBody
    public String index() throws InterruptedException {
​
        int clientcount =1000;
        CountDownLatch countDownLatch = new CountDownLatch(clientcount);
​
        ExecutorService executorService = Executors.newFixedThreadPool(clientcount);
        long start = System.currentTimeMillis();
        for (int i = 0;i<clientcount;i++){
            executorService.execute(() -> {
            
                //通过Snowflake算法获取唯一的ID字符串
                String id = IdUtil.getId();
                try {
                    redisLock.lock(id);
                    count++;
                }finally {
                    redisLock.unlock(id);
                }
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();
        long end = System.currentTimeMillis();
        logger.info("执行线程数:{},总耗时:{},count数为:{}",clientcount,end-start,count);
        return "Hello";
    }
}

至此,单节点Redis的分布式锁的实现就已经完成了。比较简单,但是问题也比较大,最重要的一点是,锁不具有可重入性。

2. 使用Redisson

Redisson是架设在Redis基础上的一个Java驻内存数据网格(In-Memory Data Grid)。充分的利用了Redis键值数据库提供的一系列优势,基于Java实用工具包中常用接口,为使用者提供了一系列具有分布式特性的常用工具类。使得原本作为协调单机多线程并发程序的工具包获得了协调分布式多机多线程并发系统的能力,大大降低了设计和研发大规模分布式系统的难度。同时结合各富特色的分布式服务,更进一步简化了分布式环境中程序相互之间的协作。

① 可重入锁

上面我们自己实现的Redis分布式锁,其实不具有可重入性。那么下面我们先来看看Redisson中如何调用可重入锁:

在这里,我们使用的是Redisson的版本为3.10.1

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.10.1</version>
</dependency>

首先,通过配置获取RedissonClient客户端的实例,然后getLock获取锁的实例,进行操作即可。

public static void main(String[] args) {
​
    Config config = new Config();
    config.useSingleServer().setAddress("redis://127.0.0.1:6379");
    config.useSingleServer().setPassword("redis1234");
    
    final RedissonClient client = Redisson.create(config);  
    RLock lock = client.getLock("lock1");
    
    try{
        lock.lock();
    }finally{
        lock.unlock();
    }
}
② 获取锁实例

我们先来看RLock lock = client.getLock("lock1"); 这句代码就是为了获取锁的实例,然后我们可以看到它返回的是一个RedissonLock对象。

public RLock getLock(String name) {
    return new RedissonLock(connectionManager.getCommandExecutor(), name);
}

RedissonLock构造方法中,主要初始化一些属性。

public RedissonLock(CommandAsyncExecutor commandExecutor, String name) {
    super(commandExecutor, name);
    //命令执行器
    this.commandExecutor = commandExecutor;
    //UUID字符串
    this.id = commandExecutor.getConnectionManager().getId();
    //内部锁过期时间
    this.internalLockLeaseTime = commandExecutor.
                getConnectionManager().getCfg().getLockWatchdogTimeout();
    this.entryName = id + ":" + name;
}
③ 加锁

当我们调用lock方法,定位到lockInterruptibly。在这里,完成了加锁的逻辑。

public void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException {
    
    //当前线程ID
    long threadId = Thread.currentThread().getId();
    //尝试获取锁
    Long ttl = tryAcquire(leaseTime, unit, threadId);
    // 如果ttl为空,则证明获取锁成功
    if (ttl == null) {
        return;
    }
    //如果获取锁失败,则订阅到对应这个锁的channel
    RFuture<RedissonLockEntry> future = subscribe(threadId);
    commandExecutor.syncSubscription(future);
​
    try {
        while (true) {
            //再次尝试获取锁
            ttl = tryAcquire(leaseTime, unit, threadId);
            //ttl为空,说明成功获取锁,返回
            if (ttl == null) {
                break;
            }
            //ttl大于0 则等待ttl时间后继续尝试获取
            if (ttl >= 0) {
                getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
            } else {
                getEntry(threadId).getLatch().acquire();
            }
        }
    } finally {
        //取消对channel的订阅
        unsubscribe(future, threadId);
    }
    //get(lockAsync(leaseTime, unit));
}

如上代码,就是加锁的全过程。先调用tryAcquire来获取锁,如果返回值ttl为空,则证明加锁成功,返回;如果不为空,则证明加锁失败。这时候,它会订阅这个锁的Channel,等待锁释放的消息,然后重新尝试获取锁。流程如下:

img

获取锁

获取锁的过程是怎样的呢?接下来就要看tryAcquire方法。在这里,它有两种处理方式,一种是带有过期时间的锁,一种是不带过期时间的锁。

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

接着往下看,tryLockInnerAsync方法是真正执行获取锁的逻辑,它是一段LUA脚本代码。在这里,它使用的是hash数据结构。

<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit,     
                            long threadId, RedisStrictCommand<T> command) {
​
        //过期时间
        internalLockLeaseTime = unit.toMillis(leaseTime);
​
        return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
                  //如果锁不存在,则通过hset设置它的值,并设置过期时间
                  "if (redis.call('exists', KEYS[1]) == 0) then " +
                      "redis.call('hset', KEYS[1], ARGV[2], 1); " +
                      "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                      "return nil; " +
                  "end; " +
                  //如果锁已存在,并且锁的是当前线程,则通过hincrby给数值递增1
                  "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                      "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                      "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                      "return nil; " +
                  "end; " +
                  //如果锁已存在,但并非本线程,则返回过期时间ttl
                  "return redis.call('pttl', KEYS[1]);",
        Collections.<Object>singletonList(getName()), 
                internalLockLeaseTime, getLockName(threadId));
    }

这段LUA代码看起来并不复杂,有三个判断:

  • 通过exists判断,如果锁不存在,则设置值和过期时间,加锁成功
  • 通过hexists判断,如果锁已存在,并且锁的是当前线程,则证明是重入锁,加锁成功
  • 如果锁已存在,但锁的不是当前线程,则证明有其他线程持有锁。返回当前锁的过期时间,加锁失败
img

加锁成功后,在redis的内存数据中,就有一条hash结构的数据。Key为锁的名称;field为随机字符串+线程ID;值为1。如果同一线程多次调用lock方法,值递增1。

127.0.0.1:6379> hgetall lock1
1) "b5ae0be4-5623-45a5-8faa-ab7eb167ce87:1"
2) "1"
④ 解锁

我们通过调用unlock方法来解锁。

public RFuture<Void> unlockAsync(final long threadId) {
    final RPromise<Void> result = new RedissonPromise<Void>();
    
    //解锁方法
    RFuture<Boolean> future = unlockInnerAsync(threadId);
​
    future.addListener(new FutureListener<Boolean>() {
        @Override
        public void operationComplete(Future<Boolean> future) throws Exception {
            if (!future.isSuccess()) {
                cancelExpirationRenewal(threadId);
                result.tryFailure(future.cause());
                return;
            }
            //获取返回值
            Boolean opStatus = future.getNow();
            //如果返回空,则证明解锁的线程和当前锁不是同一个线程,抛出异常
            if (opStatus == null) {
                IllegalMonitorStateException cause = 
                    new IllegalMonitorStateException("
                        attempt to unlock lock, not locked by current thread by node id: "
                        + id + " thread-id: " + threadId);
                result.tryFailure(cause);
                return;
            }
            //解锁成功,取消刷新过期时间的那个定时任务
            if (opStatus) {
                cancelExpirationRenewal(null);
            }
            result.trySuccess(null);
        }
    });
​
    return result;
}

然后我们再看unlockInnerAsync方法。这里也是一段LUA脚本代码。

protected RFuture<Boolean> unlockInnerAsync(long threadId) {
    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, EVAL,
    
            //如果锁已经不存在, 发布锁释放的消息
            "if (redis.call('exists', KEYS[1]) == 0) then " +
                "redis.call('publish', KEYS[2], ARGV[1]); " +
                "return 1; " +
            "end;" +
            //如果释放锁的线程和已存在锁的线程不是同一个线程,返回null
            "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
                "return nil;" +
            "end; " +
            //通过hincrby递减1的方式,释放一次锁
            //若剩余次数大于0 ,则刷新过期时间
            "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
            "if (counter > 0) then " +
                "redis.call('pexpire', KEYS[1], ARGV[2]); " +
                "return 0; " +
            //否则证明锁已经释放,删除key并发布锁释放的消息
            "else " +
                "redis.call('del', KEYS[1]); " +
                "redis.call('publish', KEYS[2], ARGV[1]); " +
                "return 1; "+
            "end; " +
            "return nil;",
    Arrays.<Object>asList(getName(), getChannelName()), 
        LockPubSub.unlockMessage, internalLockLeaseTime, getLockName(threadId));
​
}

如上代码,就是释放锁的逻辑。同样的,它也是有三个判断:

  • 如果锁已经不存在,通过publish发布锁释放的消息,解锁成功
  • 如果解锁的线程和当前锁的线程不是同一个,解锁失败,抛出异常
  • 通过hincrby递减1,先释放一次锁。若剩余次数还大于0,则证明当前锁是重入锁,刷新过期时间;若剩余次数小于0,删除key并发布锁释放的消息,解锁成功
img

至此,Redisson中的可重入锁的逻辑,就分析完了。但值得注意的是,上面的两种实现方式都是针对单机Redis实例而进行的。如果我们有多个Redis实例,请参阅Redlock算法。该算法的具体内容,请参考redis.cn/topics/dist…

总结

可以根据Jedis订制自己的Redis分布式锁实现,如果是考虑到使用功能已经比较完善的框架,建议选型Redisson

欢迎点赞关注评论,感谢观看ヾ(◍°∇°◍)ノ゙