redis 分布式锁简单实践

redis 分布式锁简单实践

参考:

1. 分布式锁

  • 分布式锁要满足的几个条件:
  • 系统是一个分布式系统(关键是分布式,单机的可以使用ReentrantLock或者synchronized代码块来实现)
  • 共享资源(各个系统访问同一个资源,资源的载体可能是传统关系型数据库或者NoSQL)
  • 同步访问(即有很多个进程同事访问同一个共享资源。没有同步访问,谁管你资源竞争不竞争)
  • 所以分布式需要满足的特性:
  • 在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行;
  • 高可用的获取锁与释放锁;
  • 高性能的获取锁与释放锁;
  • 具备可重入特性;
  • 具备锁失效机制,防止死锁;
  • 具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。

通常为了保证数据的最终一致性,需要来实现分布式锁。一般有以下方法来实现:

基于数据库实现分布式锁;
基于缓存(Redis等)实现分布式锁;
基于Zookeeper实现分布式锁;

2. 基于redis实现分布式锁的基本思想

  • 选用Redis实现分布式锁原因:

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

  • 使用命令介绍:

(1)SETNX

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

(2)expire

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

(3)delete

delete key:删除key

在使用Redis实现分布式锁的时候,主要就会使用到这三个命令。

  • 实现思想:

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

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

(3)释放锁的时候,通过UUID判断是不是该锁,若是该锁,则执行delete进行锁释放。

3. jedisLock—redis分布式锁实现

方法一:

package redisLock;

import redis.clients.jedis.Jedis;

/**
 * @author xingzhou
 * @version 1.0.0
 * @ClassName JedisLock.java
 * @Description TODO
 * @createTime 2022年04月04日 20:58:00
 */
public class JedisLock {
    Jedis jedis;
    /**
     * redis key
     */
    String lockKey;
    /**
     * 过期时间
     */
    int expireMsecs;
    /**
     * 超时时间
     */
    int timeoutMsecs;
    /**
     * redis锁状态
     */
    boolean locked;

    public JedisLock(Jedis jedis, String lockKey) {
        this.expireMsecs = 60000;
        this.timeoutMsecs = 10000;
        this.locked = false;
        this.jedis = jedis;
        this.lockKey = lockKey;
    }

    public JedisLock(Jedis jedis, String lockKey, int timeoutMsecs) {
        this(jedis, lockKey);
        this.timeoutMsecs = timeoutMsecs;
    }

    public JedisLock(Jedis jedis, String lockKey, int timeoutMsecs, int expireMsecs) {
        this(jedis, lockKey, timeoutMsecs);
        this.expireMsecs = expireMsecs;
    }

    public JedisLock(String lockKey) {
        this((Jedis) null, lockKey);
    }

    public JedisLock(String lockKey, int timeoutMsecs) {
        this((Jedis) null, lockKey, timeoutMsecs);
    }

    public JedisLock(String lockKey, int timeoutMsecs, int expireMsecs) {
        this((Jedis) null, lockKey, timeoutMsecs, expireMsecs);
    }

    public String getLockKey() {
        return this.lockKey;
    }

    public synchronized boolean acquire() throws InterruptedException {
        return this.acquire(this.jedis);
    }

    /**
     * 获得 lock.
     * 实现思路: 主要是使用了redis 的setnx命令,缓存了锁.
     * reids缓存的key是锁的key,所有的共享, value是锁的到期时间(注意:这里把过期时间放在value了,没有时间上设置其超时时间)
     * 执行过程:
     * 1.通过setnx尝试设置某个key的值,成功(当前没有这个锁)则返回,成功获得锁
     * 2.锁已经存在则获取锁的到期时间,和当前时间比较,超时的话,则设置新的值
     */

    public synchronized boolean acquire(Jedis jedis) throws InterruptedException {
        int timeout = this.timeoutMsecs;

        while (timeout >= 0) {
            long expires = System.currentTimeMillis() + (long) this.expireMsecs + 1L;
            //锁到期时间
            String expiresStr = String.valueOf(expires);
            if (jedis.setnx(this.lockKey, expiresStr) == 1L) {
                this.locked = true;
                return true;
            }
            /**
             * 判断是否为空,不为空的情况下,如果被其他线程设置了值,则第二个条件判断是过不去的
             * 获取上一个锁到期时间,并设置现在的锁到期时间,
             * 只有一个线程才能获取上一个线上的设置时间,因为jedis.getSet是同步的
             */
            String currentValueStr = jedis.get(this.lockKey);
            if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
                /**
                 * 判断是否为空,不为空的情况下,如果被其他线程设置了值,则第二个条件判断是过不去的
                 *  lock is expired
                 */
                String oldValueStr = jedis.getSet(this.lockKey, expiresStr);
                if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
                    /**
                     *   防止误删(覆盖,因为key是相同的)了他人的锁——这里达不到效果,这里值会被覆盖,但是因为什么相差了很少的时间,所以可以接受
                     *   [分布式的情况下]:如过这个时候,多个线程恰好都到了这里,但是只有一个线程的设置值和当前值相同,他才有权利获取锁
                     *  lock acquired
                     */

                    this.locked = true;
                    return true;
                }
            }
            /**
             延迟100 毫秒,  这里使用随机时间可能会好一点,可以防止饥饿进程的出现,即,当同时到达多个进程,
             只会有一个进程获得锁,其他的都用同样的频率进行尝试,后面有来了一些进行,也以同样的频率申请锁,这将可能导致前面来的锁得不到满足.
             使用随机的等待时间可以一定程度上保证公平性
             */
            timeout -= 100;
            Thread.sleep(100L);
        }

        return false;
    }

    public synchronized void release() {
        this.release(this.jedis);
    }

    public synchronized void release(Jedis jedis) {
        if (this.locked) {
            jedis.del(new String[]{this.lockKey});
            this.locked = false;
        }

    }
}

方法二:

package redisLock;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.params.SetParams;
import java.util.UUID;

/**
 * @author xingzhou
 * @version 1.0.0
 * @ClassName JedisLock_02.java
 * @Description TODO
 * @createTime 2022年04月04日 21:21:00
 */
public class JedisLock_02 {
    private Jedis jedis;
    /**
     * 锁key
     */
    private String key;
    /**
     * 锁超时时间,毫秒为单位
     */
    private int expire;
    /**
     * 获取锁等待时间,毫秒为单位
     */
    private int timeout;
    /**
     * 是否占有锁
     */
    private volatile boolean locked = false;
    /**
     * 唯一标识
     */
    private UUID uuid;
    /**
     * 线程等待时间
     */
    private static final int DEFAULT_ACQUIRY_RESOLUTION_MILLIS = 100;

    /**
     * 删除key的lua脚本
     */
    private static final String LUA_DEL_SCRIPT = "if redis.call('GET',KEYS[1]) == ARGV[1] then return redis.call('DEL',KEYS[1]) else return 0 end";


    public JedisLock_02(Jedis videoJedis, String key, int timeout, int expire) {
        this.jedis = videoJedis;
        this.key = key;
        this.timeout = timeout;
        this.expire = expire;
        this.uuid = UUID.randomUUID();
    }

    /**
     * set值
     * 说明:这个命令仅在不存在key的时候才能被执行成功(NX选项),并且这个key有一个自动失效时间(PX属性)
     * 这个key的值是一个唯一值,这个值在所有的客户端必须是唯一的,所有同一key的获取者(竞争者)这个值都不能一样。
     *
     * @param value
     * @return
     */
    public String setNX(final String value) {
        SetParams params = new SetParams();
        params.ex(this.expire);
        return jedis.set(key, value, params);
    }

    /**
     * 获取锁
     *
     * @return
     */
    public synchronized boolean lock() throws InterruptedException {
        long timeout = this.timeout;
        while (timeout > 0) {
            //获取锁,返回OK则代表获取锁成功
            if ("OK".equals(this.setNX(this.getLockValue(Thread.currentThread().getId())))) {
                this.locked = true;
                return true;
            }
            timeout -= DEFAULT_ACQUIRY_RESOLUTION_MILLIS;
            Thread.sleep(DEFAULT_ACQUIRY_RESOLUTION_MILLIS);
        }
        return false;
    }

    /**
     * 释放锁
     * 说明:通过key和唯一value值删除
     *
     * @return
     */
    public synchronized void release() {
        if (this.locked) {
            long result = (long) this.jedis.eval(LUA_DEL_SCRIPT, 1, this.key, this.getLockValue(Thread.currentThread().getId()));
            if (result > 0) {
                this.locked = false;
            }
        }

    }

    /**
     * 判断当前线程是否还继续拥有锁
     * 说明:该方法主要用来判断操作时间已经超过key的过期时间,可以用来做业务过滚
     *
     * @return
     */
    public boolean checkTimeOut() {
        if (this.locked) {
            String value = this.jedis.get(this.key);
            if (this.getLockValue(Thread.currentThread().getId()).equals(value)) {
                return true;
            }
        }
        return false;
    }

    /**
     * 锁的value值(唯一值)
     * 说明:value的值必须是唯一主要是为了更安全的释放锁,
     * 释放锁的时候使用脚本告诉Redis:只有key存在并且存储的值和我指定的值一样才能告诉我删除成功
     *
     * @param threadId
     * @return
     */
    public String getLockValue(Long threadId) {
        return this.uuid.toString() + "_" + threadId;
    }

}

4. 通过redisson简单实现redis分布式锁

redisson官网:https://redisson.org/
redisson文档:https://github.com/redisson/redisson/wiki/2.-配置方法

  • 引入redisson客户端
<dependency>
        <groupId>org.redisson</groupId>
        <artifactId>redisson</artifactId>
         <version>2.7.0</version>
 </dependency>

4.1 Redisson原理分析

1、加锁机制
线程去获取锁,获取成功: 执行lua脚本,保存数据到redis数据库。

线程去获取锁,获取失败: 一直通过while循环尝试获取锁,获取成功后,执行lua脚本,保存数据到redis数据库。

2、watch dog自动延期机制
简单理解就是:

在一个分布式环境下,假如一个线程获得锁后,突然服务器宕机了,那么这个时候在一定时间后这个锁会自动释放,你也可以设置锁的有效时间(不设置默认30秒),这样的目的主要是防止死锁的发生。

但在实际开发中会有下面一种情况--锁失效之后,业务逻辑还没结束

            //设置锁1秒过去
            redissonLock.lock("redisson", 1);
            /**
             * 业务逻辑需要咨询2秒
             */
            redissonLock.release("redisson");
    
          /**
           * 线程1 进来获得锁后,线程一切正常并没有宕机,但它的业务逻辑需要执行2秒,这就会有个问题,在 线程1 执行1秒后,这个锁就自动过期了,
           * 那么这个时候 线程2 进来了。那么就存在 线程1和线程2 同时在这段业务逻辑里执行代码,这当然是不合理的。
           * 而且如果是这种情况,那么在解锁时系统会抛异常,因为解锁和加锁已经不是同一线程了,具体后面代码演示。
           */

所以这个时候 看门狗就出现了,它的作用就是 线程1 业务还没有执行完,时间就过了,线程1 还想持有锁的话,就会启动一个watch dog后台线程,不断的延长锁key的生存时间。

注意 正常这个看门狗线程是不启动的,还有就是这个看门狗启动后对整体性能也会有一定影响,所以不建议开启 看门狗

3、为啥要用lua脚本呢?
这个不用多说,主要是如果你的业务逻辑复杂的话,通过封装在lua脚本中发送给redis,而且redis是单线程的,这样就保证这段复杂业务逻辑执行的 原子性

4、可重入加锁机制
Redisson可以实现可重入加锁机制的原因,我觉得跟两点有关:

1、Redis存储锁的数据类型是 Hash类型
2、Hash数据类型的key值包含了当前线程信息。

  1. Redis分布式锁的缺点

Redis分布式锁会有个缺陷,就是在Redis哨兵模式下:

客户端1 对某个 master节点 写入了redisson锁,此时会异步复制给对应的 slave节点。但是这个过程中一旦发生 master节点宕机,主备切换,slave节点从变为了 master节点。

这时 客户端2 来尝试加锁的时候,在新的master节点上也能加锁,此时就会导致多个客户端对同一个分布式锁完成了加锁。

这时系统在业务语义上一定会出现问题, 导致各种脏数据的产生 。

缺陷 在哨兵模式或者主从模式下,如果 master实例宕机的时候,可能导致多个客户端同时完成加锁。

4.2 redisson实践

引入maven依赖

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

4.2.1 获取锁实例

我们先来看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;
}

4.2.2 加锁

当我们调用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,等待锁释放的消息,然后重新尝试获取锁。流程如下:

  • 获取锁

获取锁的过程是怎样的呢?接下来就要看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判断,如果锁已存在,并且锁的是当前线程,则证明是重入锁,加锁成功
  • 如果锁已存在,但锁的不是当前线程,则证明有其他线程持有锁。返回当前锁的过期时间,加锁失败

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

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

4.2.3 解锁

我们通过调用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并发布锁释放的消息,解锁成功

至此,Redisson中的可重入锁的逻辑,就分析完了。

posted @ 2022-04-04 22:43  行舟QAQ  阅读(80)  评论(0编辑  收藏  举报