深入浅出Zookeeper源码(三):会话管理

本文首发于泊浮目的简书:https://www.jianshu.com/u/204b8aaab8ba

版本 日期 备注
1.0 2020.3.29 文章首发
1.1 2020.4.18 改进小结部分
1.2 2020.5.4 修复笔误部分
1.4 2020.7.21 段落重新排版,增强语义
1.5 2020.8.6 增加题图
1.6 2021.6.22 标题从深入浅出Zookeeper(三):会话管理变更为深入浅出Zookeeper源码(三):会话管理

前言

我们知道zookeeper是一个分布式协同系统。在一个大型的分布式系统中,必然会有大量的client来连接zookeeper。那么zookeeper是如何管理这些session的生命周期呢?带着这个问题,我们进入今天的正文。

image

Session管理者:SessionTracker

我们先来看看session相关的核心类——位于服务端的SessionTracker的抽象定义:

/**
 * This is the basic interface that ZooKeeperServer uses to track sessions. The
 * standalone and leader ZooKeeperServer use the same SessionTracker. The
 * FollowerZooKeeperServer uses a SessionTracker which is basically a simple
 * shell to track information to be forwarded to the leader.
 */
public interface SessionTracker {
    public static interface Session {
        long getSessionId();
        int getTimeout();
        boolean isClosing();
    }
    public static interface SessionExpirer {
        void expire(Session session);

        long getServerId();
    }

    long createSession(int sessionTimeout);

    /**
     * Add a global session to those being tracked.
     * @param id sessionId
     * @param to sessionTimeout
     * @return whether the session was newly added (if false, already existed)
     */
    boolean addGlobalSession(long id, int to);

    /**
     * Add a session to those being tracked. The session is added as a local
     * session if they are enabled, otherwise as global.
     * @param id sessionId
     * @param to sessionTimeout
     * @return whether the session was newly added (if false, already existed)
     */
    boolean addSession(long id, int to);

    /**
     * @param sessionId
     * @param sessionTimeout
     * @return false if session is no longer active
     */
    boolean touchSession(long sessionId, int sessionTimeout);

    /**
     * Mark that the session is in the process of closing.
     * @param sessionId
     */
    void setSessionClosing(long sessionId);

    /**
     *
     */
    void shutdown();

    /**
     * @param sessionId
     */
    void removeSession(long sessionId);

    /**
     * @param sessionId
     * @return whether or not the SessionTracker is aware of this session
     */
    boolean isTrackingSession(long sessionId);

    /**
     * Checks whether the SessionTracker is aware of this session, the session
     * is still active, and the owner matches. If the owner wasn't previously
     * set, this sets the owner of the session.
     *
     * UnknownSessionException should never been thrown to the client. It is
     * only used internally to deal with possible local session from other
     * machine
     *
     * @param sessionId
     * @param owner
     */
    public void checkSession(long sessionId, Object owner)
            throws KeeperException.SessionExpiredException,
            KeeperException.SessionMovedException,
            KeeperException.UnknownSessionException;

    /**
     * Strictly check that a given session is a global session or not
     * @param sessionId
     * @param owner
     * @throws KeeperException.SessionExpiredException
     * @throws KeeperException.SessionMovedException
     */
    public void checkGlobalSession(long sessionId, Object owner)
            throws KeeperException.SessionExpiredException,
            KeeperException.SessionMovedException;

    void setOwner(long id, Object owner) throws SessionExpiredException;

    /**
     * Text dump of session information, suitable for debugging.
     * @param pwriter the output writer
     */
    void dumpSessions(PrintWriter pwriter);

    /**
     * Returns a mapping of time to session IDs that expire at that time.
     */
    Map<Long, Set<Long>> getSessionExpiryMap();
}

大致可以看到,该interface定义对会话一系列的控制方法:比如会话的创建、激活及删除等等。

那么我们来看下其SessionTrackerImpl实现中比较重要的接口和成员变量以及方法。

会话的属性与状态

接下来我们来看看一个会话实例会包含哪些属性,话不多说,直接看接口定义:

    public static interface Session {
        long getSessionId();
        int getTimeout();
        boolean isClosing();
    }

我们可以看到,在服务端,仅仅记录了client这样的三个属性:sessionId,timeout,isClosing。

但在client,还会更复杂一点。比如session的状态就有好多个:

    @InterfaceAudience.Public
    public enum States {
        CONNECTING, ASSOCIATING, CONNECTED, CONNECTEDREADONLY,
        CLOSED, AUTH_FAILED, NOT_CONNECTED;

        public boolean isAlive() {
            return this != CLOSED && this != AUTH_FAILED;
        }

        /**
         * Returns whether we are connected to a server (which
         * could possibly be read-only, if this client is allowed
         * to go to read-only mode)
         * */
        public boolean isConnected() {
            return this == CONNECTED || this == CONNECTEDREADONLY;
        }
    }

通常情况下,因为网络闪断或其他原因,client会出现和server断开的情况。所幸的是,zkClient会自动重连,这时client会变为connecting,直到连上服务器,则变connected。如果会话超时、权限检查失败或client退出程序等异常情况,则客户端会变成close状态。

重要成员变量

    protected final ConcurrentHashMap<Long, SessionImpl> sessionsById =
        new ConcurrentHashMap<Long, SessionImpl>();

    private final ExpiryQueue<SessionImpl> sessionExpiryQueue;

    private final ConcurrentMap<Long, Integer> sessionsWithTimeout;
  • 第一个sessionsById很显然,就是通过session的id与session本体做映射的一个字典。
  • 第二个sessionExpiryQueue,听名字像是一个过期队列,没错,不过里面使用了分桶策略 ,稍后我们会做分析。
  • 第三个sessionsWithTimeout,名字说明一切。用于标示session的超时时间,k是sessionId,v是超时时间。该数据结构和Zk的内存数据库相连通,会被定期持久化到快照里去。

会话管理

会话的创建

要谈会话管理,必然要谈到会话是怎么创建的,不然则显得有些空洞。这里不会赘述client的初始化过程。无论如何,我们需要一个链接,毕竟不能让会话基于空气建立:

  1. 我们的client会随机选一个我们提供的地址,然后委托给ClientCnxnSocket去创建与zk之间的TCP链接。
  2. 接下来SendThread(Client的网络发送线程)构造出一个ConnectRequest请求(代表客户端与服务器创建一个会话)。同时,Zookeeper客户端还会进一步将请求包装成网络IO的Packet对象,放入请求发送队列——outgoingQueue中去。
  3. ClientCnxnSocket从outgoingQueue中取出Packet对象,将其序列化成ByteBuffer后,向服务器进行发送。
  4. 服务端的SessionTracker为该会话分配一个sessionId,并发送响应。
  5. Client收到响应后,此时此刻便明白自己没有初始化,因此会用readConnectResult方法来处理请求。
  6. ClientCnxnSocket会对接受到的服务端响应进行反序列化,得到ConnectResponse对象,并从中获取到Zookeeper服务端分配的会话SessionId。
  7. 通知SendThread,更新Client会话参数(比如重要的connectTimeout),并更新Client状态;另外,通知地址管理器HostProvider当前成功链接的服务器地址。

这就是会话的大致创建流程了,当然我们还省去了SyncConnected-None的事件通知逻辑,因为这在本篇的内容里并不重要。

相关源码:SessionId的分配

    /**
     * Generates an initial sessionId. High order byte is serverId, next 5
     * 5 bytes are from timestamp, and low order 2 bytes are 0s.
     */
    public static long initializeNextSession(long id) {
        long nextSid;
        nextSid = (Time.currentElapsedTime() << 24) >>> 8;
        nextSid =  nextSid | (id <<56);
        if (nextSid == EphemeralType.CONTAINER_EPHEMERAL_OWNER) {
            ++nextSid;  // this is an unlikely edge case, but check it just in case
        }
        return nextSid;
    }

简单来说,前7位确定了所在的机器,后57位使用当前时间的毫秒表示进行随机。

会话过期检查

会话过期检查是通过SessionTrackerImpl.run来做的,这是一个线程的核心方法——显然,zk的session过期检查是通过一个线程来做的。

简单来说,ExpiryQueue会根据时间将会要过期的sessions进行归档。比如在12:12:54将会有session1、session2、session3会过期,12:12:55会有session4、session5、session6会过期,那么时间会作为一个k,而对应的过期sessions会被作为一个数组,用字典将它们映射起来:

key value
12:12:54 [session1,session2,session3]
12:12:55 [session4,session5,session6]

当然,实际中间隔不会是1s,这里为了便于表达,才这么写的。真实的情况是,zk会计算每个session的过期时间,并将其归档到对应的会话桶中。

  • 计算一个会话的过期时间大致为:CurrentTime+SessionTimeout(见ExpiryQueue的update)。
  • 而归档到Zk的时间节点为:(会话过期时间/ExpirationInterval+1) * ExpirationInterval。

为了便于理解,我们可以举几个例子,Zk默认的间隔时间是2000ms:

  • 比如我们计算出来一个sessionA在3000ms后过期,那么其会坐落在(3000/2000+1)*2000=4000ms这个key里。
  • 比如我们计算出来一个sessionB在1500ms后过期,那么其会坐落在(1500/2000+1)*2000=2000ms这个key里。
0 2000ms 4000ms 6000ms 8000ms
sessionB sessionA

这样线程就不用遍历所有的会话去逐一检查它们的过期时间了,有点妙。在这里,也可以简单的讲一下会话清理步骤:

  1. 标记会话为isClosing。这样在会话清理期间接收到客户端的新请求也无法继续处理了。
  2. 发起关闭会话请求给PrepRequestProcessor,使其在整个Zk集群里生效。
  3. 收集需要清理的临时节点 ——在上面提到过sessionsWithTimeout和内存数据库是共通的。
  4. 发起“节点删除”请求,这个事务会被发到outstandingChanges中去。
  5. 删除临时节点,该逻辑由FinalRequestProcessor触发Zk内存数据库(见FinalRequestProcessor.processRequest)。
  6. 移除会话。从sessionsByIdsessionExpiryQueuesessionsWithTimeout中移除。
  7. 关闭ServerCnxn:从ServerCnxnFactory找出对应的ServerCnxn,将其关闭(见FinalRequestProcessor.closeSession)。

从这里可以了解到,Zk临时节点的自动回收基于会话管理机制。

相关源码:SessionTrackerImpl.run

    @Override
    public void run() {
        try {
            while (running) {
                long waitTime = sessionExpiryQueue.getWaitTime();
                if (waitTime > 0) {
                    Thread.sleep(waitTime);
                    continue;
                }

                for (SessionImpl s : sessionExpiryQueue.poll()) {
                    setSessionClosing(s.sessionId);
                    expirer.expire(s);
                }
            }
        } catch (InterruptedException e) {
            handleException(this.getName(), e);
        }
        LOG.info("SessionTrackerImpl exited loop!");
    }

逻辑很简单。去sessionExpiryQueue里看一下离最近的过期时间还要多久,有的话就等一会儿。

接下来是标记成Closing,并开始做使过期操作。

我们接着看expirer.expire

  public void expire(Session session) {
        long sessionId = session.getSessionId();
        LOG.info("Expiring session 0x" + Long.toHexString(sessionId)
                + ", timeout of " + session.getTimeout() + "ms exceeded");
        close(sessionId);
    }

跳向close:

  private void close(long sessionId) {
        Request si = new Request(null, sessionId, 0, OpCode.closeSession, null, null);
        setLocalSessionFlag(si);
        submitRequest(si);
    }

就是build一个新的请求,然后set本地的flag。关键方法是submitRequest:

 public void submitRequest(Request si) {
        if (firstProcessor == null) {
            synchronized (this) {
                try {
                    // Since all requests are passed to the request
                    // processor it should wait for setting up the request
                    // processor chain. The state will be updated to RUNNING
                    // after the setup.
                    while (state == State.INITIAL) {
                        wait(1000);
                    }
                } catch (InterruptedException e) {
                    LOG.warn("Unexpected interruption", e);
                }
                if (firstProcessor == null || state != State.RUNNING) {
                    throw new RuntimeException("Not started");
                }
            }
        }
        try {
            touch(si.cnxn);
            boolean validpacket = Request.isValid(si.type);
            if (validpacket) {
                firstProcessor.processRequest(si);
                if (si.cnxn != null) {
                    incInProcess();
                }
            } else {
                LOG.warn("Received packet at server of unknown type " + si.type);
                new UnimplementedRequestProcessor().processRequest(si);
            }
        } catch (MissingSessionException e) {
            if (LOG.isDebugEnabled()) {
                LOG.debug("Dropping request: " + e.getMessage());
            }
        } catch (RequestProcessorException e) {
            LOG.error("Unable to process request:" + e.getMessage(), e);
        }
    }

第一段逻辑是等待Processor的chain准备好。接下来是激活一下会话,但会话如果已经被移除或超时,则会抛出异常。这个情况很正常,因为client的session和这里的移除请求并不是同时做的。

接下来则是提交移除会话的请求。

会话激活

从上面看来,session似乎是到了事先计算好的时间就会过期。其实并非如此——client会通过发送请求or心跳请求来保持会话的有效性,即延迟超时时间。这个过程一般叫做touchSession(没错,代码里也是这么叫的)。我们来简单的讲一下流程:

  1. 检查该会话是否被关闭,如果关闭,则不再激活。
  2. 计算新的超时时间(参考上面提到的会话超时计算方法,也可以看ExpiryQueue.update)
  3. 迁移会话(从老桶到新桶)

相关源码:SessionTrackerImpl.touch

    synchronized public boolean touchSession(long sessionId, int timeout) {
        SessionImpl s = sessionsById.get(sessionId);

        if (s == null) {
            logTraceTouchInvalidSession(sessionId, timeout);
            return false;
        }

        if (s.isClosing()) {
            logTraceTouchClosingSession(sessionId, timeout);
            return false;
        }

        updateSessionExpiry(s, timeout);
        return true;
    }

获取和校验逻辑不再赘述。直接跳向核心方法ExpiryQueue.update:

    /**
     * Adds or updates expiration time for element in queue, rounding the
     * timeout to the expiry interval bucketed used by this queue.
     * @param elem     element to add/update
     * @param timeout  timout in milliseconds
     * @return         time at which the element is now set to expire if
     *                 changed, or null if unchanged
     */
    public Long update(E elem, int timeout) {
        Long prevExpiryTime = elemMap.get(elem);
        long now = Time.currentElapsedTime();
        Long newExpiryTime = roundToNextInterval(now + timeout);

        if (newExpiryTime.equals(prevExpiryTime)) {
            // No change, so nothing to update
            return null;
        }

        // First add the elem to the new expiry time bucket in expiryMap.
        Set<E> set = expiryMap.get(newExpiryTime);
        if (set == null) {
            // Construct a ConcurrentHashSet using a ConcurrentHashMap
            set = Collections.newSetFromMap(
                new ConcurrentHashMap<E, Boolean>());
            // Put the new set in the map, but only if another thread
            // hasn't beaten us to it
            Set<E> existingSet = expiryMap.putIfAbsent(newExpiryTime, set);
            if (existingSet != null) {
                set = existingSet;
            }
        }
        set.add(elem);

        // Map the elem to the new expiry time. If a different previous
        // mapping was present, clean up the previous expiry bucket.
        prevExpiryTime = elemMap.put(elem, newExpiryTime);
        if (prevExpiryTime != null && !newExpiryTime.equals(prevExpiryTime)) {
            Set<E> prevSet = expiryMap.get(prevExpiryTime);
            if (prevSet != null) {
                prevSet.remove(elem);
            }
        }
        return newExpiryTime;
    }

逻辑非常简单。计算最新的过期时间,并放置到新的归档区间里,再移除掉老归档区间里的会话实例。

小结

在本文中,笔者和大家一起了剖析了zk的session管理机制。有些点我们在以后设计系统时可以借鉴一番:

  • 会话的状态变化主要由client维护,server端保存的状态较少,一定程度上减少了server端的压力。
  • 分桶策略在这种大量client会话场景下显得非常有用,显著提升了会话超时的清理效率。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,298评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,701评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,078评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,687评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,018评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,410评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,729评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,412评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,124评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,379评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,903评论 1 257
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,268评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,894评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,014评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,770评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,435评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,312评论 2 260

推荐阅读更多精彩内容