分布式锁实现原理之深入redis、浅谈zookeeper与mysql

分布式锁实现原理之深入redis、浅谈zookeeper与mysql

回顾

在上一篇中https://blog.csdn.net/rekingman/article/details/104035265应用篇中,我们讲解了如何使用分布式锁。但是很多东西会用是一回事,我们还要弄懂原理,才能用得更好。


redis分布式锁

redis分布式锁的发展

起初

  • 使用setnx命令,设置一个key和一个value
    • 如果key对应的value不存在,设置value,则获得锁
    • 如果value存在,则获取锁失败或者采用自旋去获取

在这里插入图片描述

  • 存在问题
    • 如果一个进程获取lock之后,由于宕机,并未执行del命令,那么锁不能有限时间释放,会炸

引入expire

在这里插入图片描述

  • 在最初的基础上引入了expire机制,即键值对超时失效机制
    • 如果超过有效时间,那么会自动释放
  • 存在问题
    • 起初setnx与expire是分开设置的,是不具备原子性的
      • 意味着在setnx之后,如果系统宕机,那么expire执行不了,仍然出现上述问题

redis 支持lua

  • redis在2.6之后支持lua脚本,但是lua脚本的执行是原子性的,刚好可以满足上述的要求
  • 存在问题
    • 锁过期问题,即能否在有效时间内执行完任务,否则待锁自动释放,则会出现实例不安全问题。
    • 还有一个问题是性能的问题,即由于redis是单线程多路IO复用设计,lua脚本执行进行解析和原子调用,是会阻塞整个redis实例的,当然,基于内存使得它的问题不是那么大。

redis pub/sub机制

  • 发布/订阅机制
    • 通过订阅某个channel,然后当某个锁释放的时候,由redis直接通知那些等待获得锁的进程、线程,提高性能;而不是让那些期待获取锁的线程去做死循环等待。
    • 原理问题
      • publish只占用一个发布连接
      • sub会占用多个连接,除非在应用层进行多路复用,因此,使用这种机制要求连接数要够高。

讲了redis作为分布式锁的发展过程,接下来讲讲redission组件中分布式锁的实现原理。


redission分布式锁原理

redission是目前比较稳定且社会活跃度高的java-redis组件,从性能和功能上都是比较完整、丰富的。

扫描二维码关注公众号,回复: 8986978 查看本文章

可重入独占锁

  • redission的锁是基于java锁接口规范去实现的,无论使用或者阅读源码都是比较容易的

以下是尝试获取锁的代码实现。

public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
        long time = unit.toMillis(waitTime);
        long current = System.currentTimeMillis();
        long threadId = Thread.currentThread().getId();
       	 //尝试获取锁,如果没取到锁,则获取锁的剩余超时时间
  //之所以需要threadId,是因为需要判断是否是当前线程持有,实现重入锁
        Long ttl = tryAcquire(leaseTime, unit, threadId);
        // lock acquired
        if (ttl == null) {
            return true;
        }
        
        time -= System.currentTimeMillis() - current;
        if (time <= 0) {
            acquireFailed(threadId);
            return false;
        }
        
        current = System.currentTimeMillis();
        RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
        if (!await(subscribeFuture, time, TimeUnit.MILLISECONDS)) {
            if (!subscribeFuture.cancel(false)) {
                subscribeFuture.onComplete((res, e) -> {
                    if (e == null) {
                        unsubscribe(subscribeFuture, threadId);
                    }
                });
            }
            acquireFailed(threadId);
            return false;
        }

        try {
            time -= System.currentTimeMillis() - current;
            if (time <= 0) {
                acquireFailed(threadId);
                return false;
            }
    //进入死循环,反复去调用tryAcquire尝试获取锁,ttl为null时就是别的线程已经unlock了
            while (true) {
                long currentTime = System.currentTimeMillis();
                ttl = tryAcquire(leaseTime, unit, threadId);
                // lock acquired:other thread release lock.
                if (ttl == null) {
                    return true;
                }

                time -= System.currentTimeMillis() - currentTime;
                if (time <= 0) {
                    acquireFailed(threadId);
                    return false;
                }

                // waiting for message
                currentTime = System.currentTimeMillis();
              //如果锁被释放,那么redis就会publish到对应的channel,所有的subscribe就会给信号量加1,激活同一JVM内的线程去竞争信号量,获取到信号量的线程就能够解除阻塞,去竞争锁。
                if (ttl >= 0 && ttl < time) {
                        //这里是采用semaphore机制,如果获取不到信号量,则会阻塞
                    getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
                } else {
                        //这里是采用semaphore机制,如果获取不到信号量,则会阻塞
                    getEntry(threadId).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
                }

                time -= System.currentTimeMillis() - currentTime;
                if (time <= 0) {
                    acquireFailed(threadId);
                    return false;
                }
            }
        } finally {
            unsubscribe(subscribeFuture, threadId);
        }
//        return get(tryLockAsync(waitTime, leaseTime, unit));
    }
  • redisLUA脚本-获取与释放锁的实现
//尝试获取锁
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
        internalLockLeaseTime = unit.toMillis(leaseTime);

        return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
                  "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; " +
                  "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; " +
                  "return redis.call('pttl', KEYS[1]);",
                    Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
    }
//lua脚本释放重入锁
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
        return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
                "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
                    "return nil;" +
                "end; " +
                "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
                "if (counter > 0) then " +
                    "redis.call('pexpire', KEYS[1], ARGV[2]); " +
                    "return 0; " +
                "else " +
                    "redis.call('del', KEYS[1]); " +
                    "redis.call('publish', KEYS[2], ARGV[1]); " +
                    "return 1; "+
                "end; " +
                "return nil;",
                Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
    }

//lua脚本强制释放锁,直接删除key
@Override
    public RFuture<Boolean> forceUnlockAsync() {
        cancelExpirationRenewal(null);
        return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
                "if (redis.call('del', KEYS[1]) == 1) then "
                + "redis.call('publish', KEYS[2], ARGV[1]); "
                + "return 1 "
                + "else "
                + "return 0 "
                + "end",
                Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE);
    }
  • 总结
    • 独占式可重入锁的实现还是相对简单的,就是redis有限时间键的操作,以及利用hash结构
      • 如果key不存在,则设置key : val(uuid:threadId) :counter
      • 如果key存在,则利用hincrby 更新对应key下的val与counter,并刷新时间
    • 为了提高性能,利用信号量与pub/sub机制,当锁释放,则发布信息,所有订阅连接就会激活对应的lock的信号量,去激活阻塞线程竞争锁。
      • 为何要采用信号量机制
      • 如果不采用信号量机制,意味着每个阻塞的线程都必须去订阅redis的通道,但是我们都知道,每个订阅都会消耗一个redis连接,在这种情况下,如果不利用复用的思想,那么redis的连接数毫无疑问支撑不了大型的分布式系统。
    • 为了实现安全性
      • redis会在心跳时间内启动定时的renew任务去刷新锁的期限,避免任务执行未完全从而出现的安全问题。

读写锁

  • 读写锁也是基于redis的hash结构去实现的,但是由于需要实现读写分离,因此,逻辑上的处理会更加复杂一些,下面进行源码的分析

读锁实现

@Override
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
    internalLockLeaseTime = unit.toMillis(leaseTime);

    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
                                          //key[1]=resourcesName
                                          //key[2]= suffixName(getName(), getLockName(threadId)) + ":rwlock_timeout"; 
                                          //argv[2]=uuid:threadId
                                          //argv[1]=expireTime
                                          //argv[3]=uuid:threadId:write
                                          
                            "local mode = redis.call('hget', KEYS[1], 'mode'); " +
                            "if (mode == false) then " +
                              "redis.call('hset', KEYS[1], 'mode', 'read'); " +
                              "redis.call('hset', KEYS[1], ARGV[2], 1); " +
                                 //设置一个{lock}:6ebc68ab-79ce-42d7-9c7d-00401fab055e:1:rwlock_timeout:1 对象,注意结尾是:1
                              "redis.call('set', KEYS[2] .. ':1', 1); " +
                              "redis.call('pexpire', KEYS[2] .. ':1', ARGV[1]); " +
                              "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                              "return nil; " +
                            "end; " +
                            "if (mode == 'read') or (mode == 'write' and redis.call('hexists', KEYS[1], ARGV[3]) == 1) then " +
                         //如果当前是读锁,或者写锁也是由当前实例当前线程获取的写锁
                              "local ind = redis.call('hincrby', KEYS[1], ARGV[2], 1); " + 
                              "local key = KEYS[2] .. ':' .. ind;" +
                              "redis.call('set', key, 1); " +
                              "redis.call('pexpire', key, ARGV[1]); " +
                              "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                              "return nil; " +
                            "end;" +
                            "return redis.call('pttl', KEYS[1]);",
                    Arrays.<Object>asList(getName(), getReadWriteTimeoutNamePrefix(threadId)), 
                    internalLockLeaseTime, getLockName(threadId), getWriteLockName(threadId));
}
  • 分析
    • 获取读写锁key下的mode对应的val
      • 如果是false,则可以设置锁
      • 如果是read模式,或者是write模式并且是当前线程的write模式,则允许实现重入。

写锁实现

@Override
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
    internalLockLeaseTime = unit.toMillis(leaseTime);

    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
                        "local mode = redis.call('hget', KEYS[1], 'mode'); " +
                        "if (mode == false) then " +
                              "redis.call('hset', KEYS[1], 'mode', 'write'); " +
                              "redis.call('hset', KEYS[1], ARGV[2], 1); " +
                              "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                              "return nil; " +
                          "end; " +
                          "if (mode == 'write') then " +
                                          //当前客户端同线程  即 ARGV[2]为uuid:threadId
                              "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                                  "redis.call('hincrby', KEYS[1], ARGV[2], 1); " + 
                                  "local currentExpire = redis.call('pttl', KEYS[1]); " +
                                  "redis.call('pexpire', KEYS[1], currentExpire + ARGV[1]); " +
                                  "return nil; " +
                              "end; " +
                            "end;" +
                            "return redis.call('pttl', KEYS[1]);",
                    Arrays.<Object>asList(getName()), 
                    internalLockLeaseTime, getLockName(threadId));
}
  • 分析
    • 根据key去获取模式
      • 如果没有存在,则设置写锁
      • 如果存在,且是当前客户端同线程,则实现重入,并且利用pttl获取剩余时间更新返回
  • 总结
    • 写锁实现相对简单,直接获取模式,如果不存在则设置写锁,类似于可重入独占锁
    • 读锁复杂一些
      • 如果是读锁,那么则对读锁对应的uuid:threadId进行重入(不存在则是加1)
      • 如果是写锁,那么如果是对应的uuid:threadId,则也可以进行重入操作

应用

  • 读锁
    • 多个实例中的多个线程都可以对资源进行“读”共享访问
    • 或者当前实例中的当前线程在对资源获取了“写”独占访问之后允许获取用获取读锁的方式再次获取该写锁(获取之后全局仍然是写锁)
  • 写锁
    • 只允许一个实例中的一个线程对资源进行“写”独占访问,可重入

一个应用场景就是分布式应用层的缓存锁,读共享,写独占。另外一个应用就是数据库的指定用户资源数据读写分离锁,当对某个用户的资料进行更新,获取对应用户的写锁;浏览资料时,获取对应用户的读锁。


联锁

  • 将几个锁分组联合操作管理,是红锁的父类。

红锁

  • 假设有5个redis节点,这些节点之间既没有主从,也没有集群关系。客户端用相同的key和随机值在5个节点上请求锁,请求锁的超时时间应小于锁自动释放时间。当在3个(超过半数)redis上请求到锁的时候,才算是真正获取到了锁。如果没有获取到锁,则把部分已锁的redis释放掉。
public class RedissonRedLock extends RedissonMultiLock {

    /**
     * 一个红锁由多个独立实例的锁创建。
     * Creates instance with multiple {@link RLock} objects.
     * Each RLock object could be created by own Redisson instance.
     *
     * @param locks - array of locks
     */
    public RedissonRedLock(RLock... locks) {
        super(locks);
    }

    @Override
    protected int failedLocksLimit() {
        return locks.size() - minLocksAmount(locks);
    }
    
    protected int minLocksAmount(final List<RLock> locks) {
        return locks.size()/2 + 1;
    }

    @Override
    protected long calcLockWaitTime(long remainTime) {
        return Math.max(remainTime / locks.size(), 1);
    }
    
    @Override
    public void unlock() {
        unlockInner(locks);
    }

}
  • 应用场景
    • 红锁可以避免异步数据丢失、脑裂问题
      • 性能消耗更大,但是更加稳定

公平锁

  • 实现原理是利用redis的列表存放实例线程请求,然后按照请求顺序获取锁(如果线程超时,则从列表中移出)
  • 应用场景
    • 用于避免某节点出现饥饿问题

浅谈mysql、zk分布式锁

以上我们深入了解了redis实现分布式锁的原理,接下来我们将继续讲解mysql、zk实现分布式锁的方式。

mysql

  • 利用数据库表,按照redis实现的方式,同样存储锁名、客户端线程名称、重入数量等来实现分布式锁。
    • 由于db的操作是I/O操作,速度上比内存操作要慢很多,因此并不常用。

zookeeper

  • 利用zk创建node的分布式一致性特征,来实现分布式锁
  • 以前zk与redis相比,有一个区别就是zk可以通过注册监听器来监听节点删除(锁释放时间),但是现在redis也可以通过pub/sub机制来实现。不过呢,如果redis锁的一个实例挂掉了,只能等待超时释放锁;而zk的客户端挂掉,由于创建的节点类型属于临时节点,那么会立刻释放锁(也存在一个问题,如果出现网络波动,那么很明显就会出现安全性问题了)

总之,凡事皆有利弊,至于如何取舍,则需要根据具体的项目情况去选择了。

发布了57 篇原创文章 · 获赞 32 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/rekingman/article/details/104107133