分布式锁种类及Java实现方式

分布式锁种类

分布式锁的常见实现方式有四种:

基于 MySQL 的悲观锁来实现分布式锁,这种方式使用的最少,因为这种实现方式的性能不好,且容易造成死锁;
基于 Memcached 实现分布式锁,可使用 add 方法来实现,如果添加成功了则表示分布式锁创建成功;
基于 Redis 实现分布式锁,这也是本课时要介绍的重点,可以使用 setnx 方法来实现;
基于 ZooKeeper 实现分布式锁,利用 ZooKeeper 顺序临时节点来实现。

由于 MySQL 的执行效率问题和死锁问题,所以这种实现方式会被我们先排除掉,而 Memcached 和 Redis 的实现方式比较类似,但因为 Redis 技术比较普及,所以会优先使用 Redis 来实现分布式锁,而 ZooKeeper 确实可以很好的实现分布式锁。但此技术在中小型公司的普及率不高,尤其是非 Java 技术栈的公司使用的较少,如果只是为了实现分布式锁而重新搭建一套 ZooKeeper 集群,显然实现成本和维护成本太高,所以综合以上因素,我们本文会采用 Redis 来实现分布式锁。

之所以可以使用以上四种方式来实现分布式锁,是因为以上四种方式都属于程序调用的“外部系统”,而分布式的程序是需要共享“外部系统”的,这就是分布式锁得以实现的基本前提。

单机锁

单机锁,就是单线程锁。
大体可分为以下几类:

悲观锁,是数据对外界的修改采取保守策略,它认为线程很容易把数据修改掉,因此在整个数据被修改的过程中都会采取锁定状态,直到一个线程使用完,其他线程才可以继续使用,典型应用是 synchronized;
乐观锁,和悲观锁的概念恰好相反,乐观锁认为一般情况下数据在修改时不会出现冲突,所以在数据访问之前不会加锁,只是在数据提交更改时,才会对数据进行检测,典型应用是 ReadWriteLock 读写锁;
可重入锁,也叫递归锁,指的是同一个线程在外面的函数获取了锁之后,那么内层的函数也可以继续获得此锁,在 Java 语言中 ReentrantLock 和 synchronized 都是可重入锁;
独占锁和共享锁,只能被单线程持有的锁叫做独占锁,可以被多线程持有的锁叫共享锁,独占锁指的是在任何时候最多只能有一个线程持有该锁,比如 ReentrantLock 就是独占锁;而 ReadWriteLock 读写锁允许同一时间内有多个线程进行读操作,它就属于共享锁。
单机锁之所以不能应用在分布式系统中是因为,在分布式系统中,每次请求可能会被分配在不同的服务器上,而单机锁是在单台服务器上生效的。如果是多台服务器就会导致请求分发到不同的服务器,从而导致锁代码不能生效,因此会造成很多异常的问题,那么单机锁就不能应用在分布式系统中了。

使用 Redis 实现分布式锁

使用 Redis 实现分布式锁主要需要使用 setnx 方法,也就是 set if not exists(不存在则创建),redis执行脚本如下;

127.0.0.1:6379> setnx lock true
(integer) 1 #创建锁成功
#逻辑业务处理...
127.0.0.1:6379> del lock
(integer) 1 #释放锁

当执行 setnx 命令之后返回值为 1 的话,则表示创建锁成功,否则就是失败。释放锁使用 del 删除即可,当其他程序 setnx 失败时,则表示此锁正在使用中,这样就可以实现简单的分布式锁了。

但是以上代码有一个问题,就是没有设置锁的超时时间,因此如果出现异常情况,会导致锁未被释放,而其他线程又在排队等待此锁就会导致程序不可用。

有人可能会想到使用 expire 来设置键值的过期时间来解决这个问题,例如以下代码:

127.0.0.1:6379> setnx lock true
(integer) 1 #创建锁成功
127.0.0.1:6379> expire lock 30 #设置锁的(过期)超时时间为 30s
(integer) 1 
#逻辑业务处理...
127.0.0.1:6379> del lock
(integer) 1 #释放锁

但这样执行仍然会有问题,因为 setnx lock true 和 expire lock 30 命令是非原子的,也就是一个执行完另一个才能执行。但如果在 setnx 命令执行完之后,发生了异常情况,那么就会导致 expire 命令不会执行,因此依然没有解决死锁的问题。

这个问题在 Redis 2.6.12 之前一直没有得到有效的处理,当时的解决方案是在客户端进行原子合并操作,于是就诞生了很多客户端类库来解决此原子问题,不过这样就增加了使用的成本。因为你不但要添加 Redis 的客户端,还要为了解决锁的超时问题,需额外的增加新的类库,这样就增加了使用成本,但这个问题在 Redis 2.6.12 版本中得到了有效的处理。

在 Redis 2.6.12 中我们可以使用一条 set 命令来执行键值存储,并且可以判断键是否存在以及设置超时时间了,如下代码所示:

127.0.0.1:6379> set lock true ex 30 nx
OK #创建锁成功

其中,ex 是用来设置超时时间的,而 nx 是 not exists 的意思,用来判断键是否存在。如果返回的结果为“OK”则表示创建锁成功,否则表示此锁有人在使用。

锁超时

从上面的内容可以看出,使用 set 命令之后好像一切问题都解决了,但在这里我要告诉你,其实并没有。例如,我们给锁设置了超时时间为 10s,但程序的执行需要使用 15s,那么在第 10s 时此锁因为超时就会被释放,这时候线程二在执行 set 命令时正常获取到了锁,于是在很短的时间内 2s 之后删除了此锁,这就造成了锁被误删的情况,如下图所示:

在这里插入图片描述

锁被误删的解决方案是在使用 set 命令创建锁时,给 value 值设置一个归属标识。例如,在 value 中插入一个 UUID,每次在删除之前先要判断 UUID 是不是属于当前的线程,如果属于再删除,这样就避免了锁被误删的问题。

注意:在锁的归属判断和删除的过程中,不能先判断锁再删除锁,如下代码所示:

if(uuid.equals(uuid)){ // 判断是否是自己的锁
	del(luck); // 删除锁
}

应该把判断和删除放到一个原子单元中去执行,因此需要借助 Lua 脚本来执行,在 Redis 中执行 Lua 脚本可以保证这批命令的原子性,它的实现代码如下:

大概思路:

  1. 获取锁的时候,使用 setnx(SETNX key val:当且仅当 key 不存在时, set 一个 key为 val 的字符串,返回 1;若 key 存在,则什么都不做,返回 0)加锁,锁的 value值为一个随机生成的 UUID,在释放锁的时候进行判断。 并使用 expire 命令为锁添加一个超时时间,超过该时间则自动释放锁。
  2. 获取锁的时候调用 setnx, 如果返回 0,则该锁正在被别人使用,返回 1 则成功获取锁。 还设置一个获取的超时时间,若超过这个时间则放弃获取锁。
  3. 释放锁的时候,通过 UUID 判断是不是该锁,若是该锁, 则执行 delete 进行锁释放。
/**
 * 
 * 基于Codis分布式线程安全锁
 */
public class CodisDistLock {

    private static final Logger LOGGER = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
    private static final String SET_IF_NOT_EXIST = "NX";
    private static final String SET_WITH_EXPIRE_TIME = "EX";
    //是否释放锁成功
    private static final String LOCK_SUCCESS = "OK";
    private String key;
    private String value;
    private CodisImpl codisImpl;
    //超时时间20s
    private long TIMEOUT_SECOND = 20L;
    //等待锁5s超时
    long waitLockTimeoutMsecs = 5000;
    //成功标识
    private boolean successFlag = false;

    /**
     * 创建redis分布式锁
     *
     * @param codisImpl codisImpl
     * @param key       key
     */
    public CodisDistLock(CodisImpl codisImpl, String key) {
        if (key == null) {
            throw new RuntimeException("lockName must not null !");
        }
        this.key = key;
        this.value = java.util.UUID.randomUUID().toString();
        this.codisImpl = codisImpl;
    }

    /**
     * 创建redis分布式锁
     *
     * @param codisImpl     codisImpl
     * @param key           key
     * @param timeoutSecond 超时时间
     */
    public CodisDistLock(CodisImpl codisImpl, String key, long timeoutSecond) {
        if (key == null) {
            throw new RuntimeException("lockName must not null !");
        }
        this.key = key;
        this.value = java.util.UUID.randomUUID().toString();
        this.TIMEOUT_SECOND = timeoutSecond;
        this.codisImpl = codisImpl;
    }

    /**
     * 创建redis分布式锁
     *
     * @param codisImpl     codisImpl
     * @param key           key
     * @param timeoutSecond  锁超时时间
     * @param waitLockTimeoutSecond 等待锁超时时间(最大等待时间)
     */
    public CodisDistLock(CodisImpl codisImpl, String key, long timeoutSecond, long waitLockTimeoutSecond) {
        if (key == null) {
            throw new RuntimeException("lockName must not null !");
        }
        this.key = key;
        this.value = java.util.UUID.randomUUID().toString();
        this.TIMEOUT_SECOND = timeoutSecond;
        this.codisImpl = codisImpl;
        this.waitLockTimeoutMsecs = waitLockTimeoutSecond * 1000;
    }

    /**
     * 获取并释放锁
     *
     * @param runnable
     */
    public void runIfAcquired(Runnable runnable, String expMsg) {
        if (runnable == null) {
            throw new NullPointerException("runnable must not null!");
        }
        try {
            if (acquire()) {
                runnable.run();
            }
        } catch (Exception ex) {
            LOGGER.error(expMsg, ex);
        } finally {
            //执行完毕,释放锁
            if (this.successFlag) {
                release();
            }
        }
    }

    /**
     * 获取等待锁并释放锁
     *
     * @param runnable
     */
    public void runIfWaitAcquired(Runnable runnable, String expMsg) {
        if (runnable == null) {
            throw new NullPointerException("runnable must not null!");
        }
        try {
            if (waitAcquire()) {
                runnable.run();
            }
        } catch (Exception ex) {
            LOGGER.error(expMsg, ex);
        } finally {
            //执行完毕,释放锁
            if (this.successFlag) {
                release();
            }
        }
    }


    /**
     * 获取锁
     *
     * @return
     */
    public boolean acquire() {
        try {
            String status = codisImpl.set(this.key, this.value, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, this.TIMEOUT_SECOND);
            if (LOCK_SUCCESS.equals(status)) {
                this.successFlag = true;
                LOGGER.info("获取分布式锁成功,KEY:"+this.key);
            }else{
                LOGGER.info("获取分布式锁失败,KEY:"+this.key);
            }
        } catch (Exception ex) {
            LOGGER.error("get lock failed:" + key, ex);
        }
        return this.successFlag;
    }

    /**
     * 成功标识
     * @return
     */
    public boolean successFlag(){
        return this.successFlag;
    }

    /**
     * 等待锁获取
     * @return
     */
    public boolean waitAcquire() {
        long timeout = waitLockTimeoutMsecs;
        do {
            boolean result = acquire();
            if (result) {
                return true;
            }
            if (timeout > 0) {
                timeout -= 1000;
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    LOGGER.warn("获取等待锁中断异常", e);
                    Thread.currentThread().interrupt();
                }catch (Exception e){
                    LOGGER.error("获取等待锁异常", e);
                }
            }
        } while (timeout > 0);
        return false;
    }

/**
 * 释放分布式锁
 * @param jedis Redis客户端
 * @param lockKey 锁的 key
 * @param flagId 锁归属标识
 * @return 是否释放成功
 */
public static boolean unLock(Jedis jedis, String lockKey, String flagId) {
    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(flagId));
    if ("1L".equals(result)) { // 判断执行结果
        return true;
    }
    return false;
}
    /**
     * 原子性释放锁,只能由当前锁定客户端删除
     * @return
     */
    public void release(){
        try {
            String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
            Long result = (Long)codisImpl.eval(script, this.key, this.value);
            if (result == null || result != 1) {
                LOGGER.error("release lock failed:" + this.key + " 已自动超时,可能已被其他线程重新获取锁");
            }
            LOGGER.info("成功释放分布式锁,KEY:"+this.key);
        }catch (Exception ex){
            LOGGER.error("释放锁:" + this.key + "失败!", ex);
        }
    }
}

其中,Collections.singletonList() 方法是将 String 转成 List,因为 jedis.eval() 最后两个参数要求必须是 List 类型。

Q:锁超时可以通过两种方案来解决:

把执行耗时的方法从锁中剔除,减少锁中代码的执行时间,保证锁在超时之前,代码一定可以执行完;
把锁的超时时间设置的长一些,正常情况下我们在使用完锁之后,会调用删除的方法手动删除锁,因此可以把超时时间设置的稍微长一些。但到底多长合适呢:)

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值