基于Consul互斥锁Lock的实现

背景

最近给apache shardingsphere 贡献了一个基于consul做集群模式的注册中心,已经被亮哥merge到5.2.1的版本,支持对互斥锁,可重入锁,超时锁的实现,以及支持类似zk的临时节点和watch机制,支持新增,删除,修改三种事件,感兴趣的同学可以去到apache shardingsphere的源码,这篇文章主要是总结下,基于consul 实现互斥锁的背后的原理的知识点。

Consul Session 机制

要明白Consul实现锁,我们需要知道consul的两个核心特性,consul的session机制和阻塞查询,先放一段客户端创建session的代码,这样好理解:

private String createSession(final String lockName) {
            NewSession session = new NewSession();
            session.setName(lockName);
            // lock was released by force while session is invalid
            session.setBehavior(Session.Behavior.RELEASE);
          session.setTtl(consulProperties.getValue(ConsulPropertyKey.TIME_TO_LIVE_SECONDS));
            return consulClient.sessionCreate(session, null).getValue();
        }

Session Behavior

consul session 的两个核心特性是behaviorttl, behavior 有两种类型,分别是Release 和 Delete,聪明的同学一看就知道release就是用来实现lock机制的,delete 就是用来实现类型zk的临时节点机制的,当session 的ttl失效时,consul server就会根据behavior来做对应的action,我们这里是实现锁创建的session,所以指定为release。

Session Ttl

ttl 机制是consul用来判断这个session 是否还有效,就类似租约机制一个道理,我们如果要让这个session 有校,就要定期的更新ttl,在consul这里叫renew session。

Session Id

上面的代码我们创建一个session,consul会返回给我们一个session id,这个session id有啥用呢,就是我们创建key value的时候,我们可以带上这个session id,那个这个key value就是和这个session 绑定的,session 过期那这个对应的key value 就会被consul 有策略来处理掉,策略就是上面提供的release和delete两种,对应的代码实现如下:

String sessionId = createSession(lockPath);
//acquire session 代表获取锁
putParams.setAcquireSession(sessionId);
//写key value时带上 session id
Response response = consulClient.setKVValue(lockPath, DEFAULT_CONSUL_LOCK_VALUE, putParams);
if (response.getValue()) {
        // lock success
        lockSessionMap.set(sessionId);
        SESSION_FLUSH_EXECUTOR.scheduleAtFixedRate(() -> consulClient.renewSession(sessionId, QueryParams.DEFAULT), 5L, 10L, TimeUnit.SECONDS);
        return true;
} else {
  //获取锁失败,如果是互斥锁,则需要继续wait,等待锁释放,下面会讲到
}

绑定key value的session consul会保证只有一个session id会操作成功,其他的都会失败,这个是实现互斥锁的关键,你可以理解每个线程获取锁时,都会对应一个session id,锁就是一个key,获取锁成功后,需要定时给session 续约,也就是定期调用renew session,除非主动释放锁,否则如果进程挂了,这个锁就不能释放,会导致一直等待,类似死锁了。

另外获取锁成功后,需要把session id 放到线程上下文中,作用是用来释放锁的时候,需要用的这个session id的,下面释放锁会用到。

释放锁

释放锁的实现相对简单,就是set key 操作时,指定release 参数的值为获取锁时候的session id,代码如下:

public void unlock() {
            try {
                PutParams putParams = new PutParams();
                //从线程上下文获取session id,我们前面获取锁成功时,会放进去。
                String sessionId = lockSessionMap.get();
                putParams.setReleaseSession(sessionId);
                String lockPath = CONSUL_ROOT_PATH + CONSUL_PATH_SEPARATOR + lockName;
                consulClient.setKVValue(lockPath, DEFAULT_CONSUL_UNLOCK_VALUE, putParams);
                //消费对应的session
                consulClient.sessionDestroy(sessionId, null);
                // CHECKSTYLE:OFF
            } catch (final Exception ex) {
                // CHECKSTYLE:ON
                log.error("EtcdRepository unlock error, lockName: {}", lockName, ex);
            } finally {
                lockSessionMap.remove();
            }
        }

有的同学看到这里就有疑问了,这里只是简单的释放锁,并没看到通知那些在等待锁释放的线程,这里其实时通过consul 来通知的,consul 有个阻塞查询机制,如果对应的key value有发生变更,则会通知阻塞在这个key value上的查询客户端,释放锁也是一个变更操作,consul 对变更的这个key 的版本会变更,客户端
可以通过这个版本判断是否真的发生了变更。

等待锁

等待锁分两种,一种是一直等待,值得有人释放锁,这种一般是分布式锁,选举的场景,我们平时一般用的都是try lock,带有时间参数的,不可能一直等,所以需要指定超时时间,如果指定的时间内获取不到锁,就超时退出,不再等待。

Consul 阻塞查询

通过consul实现锁等待,是通过consul的阻塞查询机制实现的,我们通过consul做动态的实时变更也是通过这个方式,我们指定一个阻塞的时间,比如30s,如果30s内没有发生变更,consul则会阻塞,当前的http请求,不响应,如果30s内有变更,则响应,如果还没有变更则返回当前的key的value数据,需要客户端自己判断是超时返回了,还是真正的发生了变更,基于篇幅的原因,这里只讲有时间范围的锁等待,下面放上关键的代码:

private long doWaitRelease(final String key, final long valueIndex, final long waitTime) {
            long currentIndex = valueIndex;
            if (currentIndex < 0) {
                currentIndex = 0;
            }
            AtomicBoolean running = new AtomicBoolean(true);
            long waitCostTime = 0L;
            long now = System.currentTimeMillis();
            long deadlineWaitTime = now + waitTime;
            long blockWaitTime = waitTime;
            while (running.get()) {
                //每次重新获取锁时,需要检查等待时间是否已经达到,如果已经达到则退出,不再获取锁。
                long startWaitTime = System.currentTimeMillis();
                if (startWaitTime >= deadlineWaitTime) {
                    // wait time is reached max
                    return waitTime;
                }
                //阻塞查询,有人释放了锁,则这里会返回,当然如果在超时时间到达后,还没有对应的锁释放,则超时返回。
                RawResponse rawResponse = ((ShardingSphereConsulClient) consulClient).getRawClient().makeGetRequest("/v1/kv/" + key, null, new ShardingSphereQueryParams(blockWaitTime, currentIndex));
                Response response = warpRawResponse(rawResponse);
                Long index = response.getConsulIndex();
                //计算本次阻塞的时间总和。
                waitCostTime += System.currentTimeMillis() - startWaitTime;
                blockWaitTime -= waitCostTime;
                if (null != index && index >= currentIndex) {
                    if (currentIndex == 0) {
                        //这里是consul 有个bug,第一次会立即返回。
                        currentIndex = index;
                        continue;
                    }
                    currentIndex = index;
                    GetValue getValue = response.getValue();
                    if (null == getValue || null == getValue.getValue()) {
                        return waitCostTime;
                    }
                    if (!key.equals(getValue.getKey())) {
                        continue;
                    }
                    return waitCostTime;
                }
                if (null != index) {
                    currentIndex = 0;
                }
            }
            return -1;
        }

等待锁释放的关键是阻塞试查询,这里我重写了一个客户端,默认的开源阻塞时间只支持秒为单位,consul 服务端是支持毫秒级别的,我们平时也基本都是毫秒的单位,秒级别太大,没有人会用,所以需要重写,阻塞的时间blockWaitTime就是try lock指定的超时时间。

上面说过,这个阻塞查询get request操作,返回结果时有可能真的有人释放锁,也可能是超时返回了,因为这里阻塞的时间就是超时时间,所以这里判断的index 是大于等于,因为超时返回时等于时间已经到,即waitCostTime 就是blockWaitTime,则不再去获取锁,直接返回,如果这中间锁被释放,外面的代码会尝试去重新获取锁,如果没有获取到,则继续这个wait的逻辑,如果还没有获取到,则需要重新计算阻塞查询的时间,不能还是用之前的那个时间,否则会一直等待的问题,下面放上获取锁的完整代码:

                long lockTime = timeoutMillis;
                PutParams putParams = new PutParams();
                String lockPath = CONSUL_ROOT_PATH + CONSUL_PATH_SEPARATOR + lockName;
                while (true) {
                    //获取锁
                    String sessionId = createSession(lockPath);
                    //acquire session 绑定sessionid
                    putParams.setAcquireSession(sessionId);
                    Response response = consulClient.setKVValue(lockPath, DEFAULT_CONSUL_LOCK_VALUE, putParams);
                    if (response.getValue()) {
                        // lock success
                        lockSessionMap.set(sessionId);
                        SESSION_FLUSH_EXECUTOR.scheduleAtFixedRate(() -> consulClient.renewSession(sessionId, QueryParams.DEFAULT), 5L, 10L, TimeUnit.SECONDS);
                        return true;
                    }
                    // lock failed,exist race so retry
                    // block query if value is change so return
                    consulClient.sessionDestroy(sessionId, null);
                    long waitTime = doWaitRelease(lockPath, response.getConsulIndex(), lockTime);
                    if (waitTime < lockTime) {
                        //这里需要重新计算wait的时间,考虑多人或者多线程获取锁的情况
                        lockTime = lockTime - waitTime;
                        continue;
                    }
                    return false;
                }

上面总体说明了基于consul 实现互斥锁的原理,同样也能做为分布式锁来用,主要是依赖consul的session 机制,阻塞查询,版本等机制来实现,非超时锁,即一直等待,直到获取到锁的代码实现没有分析,如果你感兴趣,可以参考apache shardingsphere 5.2.1版本的集群模式consul repository模块的完整代码。

你可能感兴趣的:(基于Consul互斥锁Lock的实现)