本文想跟大家聊聊分布式锁的一些常见解决方案;在展开分布式锁之前,我们先聊聊锁的概念。

锁是一种有效的防止资源竞争的机制;例如:张三出门的时候,为了防止小偷进自己家,会锁上自己家的大门。电商场景中,为了防止商品超卖,会对库存资源加锁。

知识脑图

单机锁

在单机环境中,我们往往使用应用程序的开发语言本身提供的锁机制就能很好的处理多线程带来的安全问题;例如 synchronized 关键字,以及并发工具包下的Lock。

伪代码演示

/**
* 锁住方法
*/
public void synchronized lock(){

}

/**
* 锁住资源
*/
Lock lock = new ReentrantLock();

public void  lock(){
	lock.lcok();
    //资源操作
    lock.unlock();
}

但是,随着微服务的拆分,集群化的部署;这个单机锁,在分布式环境中的就显得苍白无力,捉襟见肘;为此便诞生了分布式锁。

分布式锁之MySQL 版

MySQL版的分布式锁,仅用于演示功能,不建议生产环境使用;原因很简单数据库是稀缺资源,使用不当或者服务的下线可能会造成死锁等情况;而且数据库相较于Redis ,Zookeeper 等并发能力有着明显的差距。

锁的核心是锁住共享资源,然后再由得锁者在操作完毕后解锁。

我们可以通过如下方案实现:

//手动开启事务
BEGIN ;
select * from table_x where id =XXX for update;
update table_x set xxx=xxx;
//手动提交事务
COMMIT;

分布式锁之Redis 版

SETNX ?

提到Redis版的分布式锁,有的同学可能会脱口而出 setnx ;但是你仔细想想合适吗?

加入你在使用了setnx 之后,在解锁之前;服务重启或者宕机了;这样就会造成死锁,想要解锁,就必须人工介入;是多么的得不偿失。

聪明的同学可能已经想到,在setnx 之后,再使用一个 expire ;给锁加一个过期时间;不就可以不用担心死锁的问题了吗?

想法还是不错的,不过一个指令被增加问两个指令;并且这两个指令不是原子级别的操作,所以还是可能会出现只有setnx 执行成功,expire没有执行的情况。

set key value NX EX times

在关注到人们这样的需求以后,redis 的作者使得 set 指令可以增加NX EX 两个参数,并且还是原子级别的操作。

到这里以后,你千万不要沾沾自喜;你需要思考几个问题?

  1. 操作完成之后,该如何解锁呢?del key 吗?

  2. 别人把我的锁解开了怎么办?(得锁者解锁)

  3. 我自己锁住以后,下次还能不能继续锁?或者我会不会被自己锁住?(锁重入问题)

  4. 假如过期时间到了,我的操作还没有执行完怎么办?(锁续期问题)

不想不知道,一想吓一跳;原来还有这么多问题等着我呢?别着急,我们一个个解决。

如何解锁?如何防止被人解锁

我们把问题1和问题2一起合并来看,首先我们得确认是自己的锁,我们也之解自己的锁;如何才能确定是自己的锁呢?就是约定一个“暗号”。

set key 暗号 nx ex 时间

就是解锁前,我们先使用get 一下,看看value 值是不是我们约定的暗号;如果是则放心的解锁del,如果不是就不做任何处理;吃过上面“原子性”亏的同学会想到get 和del 一起不是原子性的呀;的确,redis 目前也没有这方面的支持;但是redis 支持lua 脚本;为了保证这两个操作的原子性,我们便需要用到它。

解锁lua脚本

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

以上脚本需要传入两个参数,一个是 key值,一个期望的value值。
jedis 执行

        Long result = (Long)jedis.eval(RELEASE_LOCK_LUA,
                    Arrays.asList(RS_DISTLOCK_NS+lockName),
                    Arrays.asList(lockerId.get()));

如何不锁自己,实现可重入?

解决了前两个问题以后,便是问题3,它的本质是锁的可重入问题;我们需要做的就是在设置锁的时候,先查看是否有锁,如果有锁是不是自己设置的,如果是返回true 可以继续执行;如果不是返回false,获取锁失败。以此便可以解决锁的重入问题。(例如记录加锁者的线程号等)

如何过期时间到了怎么办?

过期时间到了,我们的操作还没有执行完;如果锁没有及时续期,那么锁失效以后,便失去了分布式锁的意义。为此我们可以设计一个守护线程,在锁快要到期的时候完成一下续期,保证锁的所有权还在我们的手中。也有人将这种方案叫做“看门狗”

 /*看门狗线程*/
    private Thread expireThread;
    //通过delayDog 避免无谓的轮询,减少看门狗线程的轮序次数   阻塞延迟队列   刷1  没有刷2
    private static DelayQueue<ItemVo<LockItem>> delayDog = new DelayQueue<>();
    //续锁逻辑:判断是持有锁的线程才能续锁
    private final static String DELAY_LOCK_LUA =
            "if redis.call('get',KEYS[1])==ARGV[1] then\n" +
                    "        return redis.call('pexpire', KEYS[1],ARGV[2])\n" +
                    "    else return 0 end";
    private class ExpireTask implements Runnable{

        @Override
        public void run() {
            System.out.println("看门狗线程已启动......");
            while(!Thread.currentThread().isInterrupted()) {
                try {
                    LockItem lockItem = delayDog.take().getData();//只有元素快到期了才能take到  0.9s
                    Jedis jedis = null;
                    try {
                        jedis = jedisPool.getResource();
                        Long result = (Long)jedis.eval(DELAY_LOCK_LUA,
                                Arrays.asList(RS_DISTLOCK_NS+lockItem.getKey ()),
                                Arrays.asList(lockItem.getValue(),LOCK_TIME_STR));
                        if(result.longValue()==0L){
                            System.out.println("Redis上的锁已释放,无需续期!");
                        }else{
                            delayDog.add(new ItemVo<>((int)LOCK_TIME,
                                    new LockItem(lockItem.getKey(),lockItem.getValue())));
                            System.out.println("Redis上的锁已续期:"+LOCK_TIME);
                        }
                    } catch (Exception e) {
                        throw new RuntimeException("锁续期失败!",e);
                    } finally {
                        if(jedis!=null) jedis.close();
                    }
                } catch (InterruptedException e) {
                    System.out.println("看门狗线程被中断");
                    break;
                }
            }
            System.out.println("看门狗线程准备关闭......");
        }
    }
    
    @Override
    public boolean tryLock() {
        Thread t=Thread.currentThread();
        /*说明本线程正在持有锁*/
        if(ownerThread==t) {
            return true;
        }else if(ownerThread!=null){/*说明本进程中有别的线程正在持有分布式锁*/
            return false;
        }
        Jedis jedis = null;
        try {
            jedis = jedisPool.getResource();
            /*每一个锁的持有人都分配一个唯一的id,也可采用snowflake算法*/
            String id = UUID.randomUUID().toString();

            SetParams params = new SetParams();
            params.px(LOCK_TIME); //加锁时间1s
            params.nx();
            synchronized (this){
                if ((ownerThread==null)&&
                        "OK".equals(jedis.set(RS_DISTLOCK_NS+lockName,id,params))) {
                    lockerId.set(id);
                    setOwnerThread(t);
                    if(expireThread == null){//看门狗线程启动
                        expireThread = new Thread(new ExpireTask(),"expireThread");
                        expireThread.setDaemon(true);
                        expireThread.start();
                    }
                    //往延迟阻塞队列中加入元素(让看门口可以在过期之前一点点的时间去做锁的续期)
                    delayDog.add(new ItemVo<>((int)LOCK_TIME,new LockItem(lockName,id)));
                    System.out.println(Thread.currentThread().getName()+"已获得锁----");
                    return true;
                }else{
                    System.out.println(Thread.currentThread().getName()+"无法获得锁----");
                    return false;
                }
            }
        } catch (Exception e) {
            throw new RuntimeException("分布式锁尝试加锁失败!",e);
        } finally {
            jedis.close();
        }
    }

redis 主从哨兵方案

以上的redis 分布式锁已经变得健壮了,但是还有一个问题,如果redis 宕机了怎么办?

我们可以把redis 从单机模式升级问主从哨兵模式,当master 节点宕机以后,哨兵节点会选举新的从节点成为主节点,保证集群的服务能力。

redission 红锁方案

哨兵模式下的主节点写入有单点问题,所以redission 便有了一个redlock方案。方案的实施是这样的,搭建一群redis,并非分片的cluster的redis集群;各redis节点之间互不相关。

假如有三个节点A,B,C;在获取分布式锁时,分别到这些节点去获取,超过半数节点获取成功则认为获取成功;同时为了避免每个服务获取时访问接单顺序不一致,造成相互之间的互锁资源而导致死锁;每个服务去获取所时需要固定访问顺序A,B,C。

这样使得与redis 节点之间的交互次数变多,运维成本陡然增加;所以不建议使用此方案。

Zookeeper 分布式锁方案

zookeeper 版的分布式锁原理时利用多线程去zookeeper 去创建临时节点的思路来实现的。

临时节点方案

获取锁的步骤:

  1. 争抢锁,只有一个线程能够成功在zookeeper创建临时节点。

  2. 采用临时节点的目的时为了避免创建节点的线程或者服务出现问题,造成死锁。

  3. 获得锁的线程,进行资源的操作。

  4. 资源使用完毕,以后释放锁。

这里有一个小的问题:别的线程该如何知道锁已经释放了呢?

①. 主动轮询,轮询之间有时间差,可能会造成延迟,并且轮询会加重zookeeper的压力

②. watch 机制,锁释放以后,通知监听器

通过watch机制看似可以解决“锁释放通知的问题”?但是如果高并发的情况下,很多线程都在watch,这无疑也会加重zookeeper的负担,造成羊群效应。

临时顺序节点方案

为了避免羊群效应,便有了临时顺序节点的方案。

改进点时:最小节点获得锁,为获得锁的按顺序创建节点,并监听上一个节点。这样做同时还增加了一个好处,实现了类似公平锁的机制;先到的可以先得到锁。

屏幕截图_20221216_102259

总结

以上便是常见的分布式锁实现机制,生产环境中不推荐使用MySQL 版,以及redission的redlock ;至于需要用redis 还是 zookeeper 去实现分布式锁;这便需要您根据实际的业务场景;如果您的业务场景对一致性要求较高,推荐使用zookeeper版的分布式锁,但是这种方案的性能相较于redis 版的会差一些。