Zookeeper Session源码

我们说客户端与服务端建立连接交互的时候会创建一个 Session 与之对应,那假设客户端请求来了,服务端是如何处理的?Session 又是如何创建出来的?

我们先来看第一个问题:服务端如何处理客户端发来的请求?

一、如何处理请求

所谓的请求全称是网络请求,涉及到网络就少不了 Socket 通信,ZooKeeper 采取的是 NIO 的方式,提供了一个 NIOServerCnxn 实例来维护每一个客户端的连接,也就是说客户端与服务端的通信都是靠 NIOServerCnxn 这个类来处理的,无非就干两件事:接收客户端的请求以及处理请求(将请求体从网络 I/O 中读出来做处理)。这个处理类的核心方法是doIO(),也就是如下:

public class NIOServerCnxn extends ServerCnxn {
    void doIO(SelectionKey k) throws InterruptedException {
        // ...
    }
}

核心处理类结构已经清晰了,那我们接下来就分析 doIO() 这个方法就好了,我上面说这个方法无非就干两件事:接收请求、处理请求。那如何接收请求呢?请求又分为两种:创建连接的请求和发送数据的请求。

我们逐个分析:

  • 何为创建连接的请求?其实很好理解,就是第一次客户端和服务端通信的时候要建立连接,建立完连接才能发送数据进行真正请求。
  • 何为发送数据的请求?上面创建完连接了才能真正发送数据给服务端。

一言以蔽之就是:客户端要想和服务端通信必须先建立连接才能发送数据,且连接只需要建立一次即可。所以我们会有一个变量: initialized 代表是否初始化完成,也就是代表是否已经建立过连接了。代码如下:

public void 接收请求() {
    if (!initialized) {
        // 还没初始化,那就建立连接
        readConnectRequest();
    } else {
        // 初始化完了,那就发送数据
        readRequest();
    }
}

我们继续分析:readConnectRequest(),也就是如何创建连接?首先我们能明确一点的是创建完连接后肯定要把 initialized 状态变为 true,代表已经建立完成。我们先把这块代码写下:

private void readConnectRequest() throws IOException, InterruptedException, ClientCnxnLimitException {
    // 省略真正建立连接代码
    
    initialized = true;
}

在开始真正建立连接之前,我们肯定都知道一点:客户端会传输一些数据给服务端,但是网络传输都是靠字节数组,所以服务端接收到数据后第一件事就是拿到字节数组进行反序列化,反序列化成一个对象,我们叫这个对象为:ConnectRequest。我们继续完善下代码:

public void 建立连接() {
    // 拿到客户端发来的字节数组
    BinaryInputArchive bia = BinaryInputArchive.getArchive(new ByteBufferInputStream(incomingBuffer));
    ConnectRequest connReq = new ConnectRequest();
    // 反序列化成ConnectRequest对象
    connReq.deserialize(bia, "connect");
}

我们在上一 Session 原理篇的时候说过 Session 是有生命周期的,带时效性的,也就是有过期时间的。那这个过期时间肯定是客户端和服务端建立连接的时候通过客户端发过去的,所以我们反序列化出来的对象里还会有 sessionTimeout 字段,如下:

// 基本验证
int sessionTimeout = connReq.getTimeOut();
int minSessionTimeout = getMinSessionTimeout();
if (sessionTimeout < minSessionTimeout) {
    sessionTimeout = minSessionTimeout;
}
int maxSessionTimeout = getMaxSessionTimeout();
if (sessionTimeout > maxSessionTimeout) {
    sessionTimeout = maxSessionTimeout;
}

很简单的一些验证,就好比我们业务代码中分页一样,都会判断不能小于 1、不能大于最大条数等验证。这个过期时间传给 Server 后,并不是真正的过期时间,因为我们在上一篇中也讲解过了,真实过期时间会被计算为 tickTime 的倍数

有了上面这些参数后我们就可以真正创建 Session 了:

long id = createSession(cnxn, passwd, sessionTimeout);

在开始如何创建 Session 之前,我们先画个流程图:

session-12.png

现在创建 Session 的时机和前置条件都搞懂了,那创建 Session 都需要经过哪些步骤呢?

二、如何创建 Session

其实创建 Session 的原理我们在上一篇都讲解得很清楚了,按照大步骤来划分的话无非就是下面这四步:

  • 按照一定规则为客户端生成 SessionId;
  • Session 的创建以及过期机制;
  • 每次正常 CRUD 以及定时心跳 Ping 都会重新刷新 Session 的过期时间,我们称这个过程为 Session 的激活;
  • Session 到期后如何回收。

本篇不打算详细剖析上面这四步,那样会太占用篇幅,所以我把这四点放到下篇单独讲解,本篇讲解整个脉络,也可以称之为“框架”。比如我们上面讲解了服务端是如何处理请求的,然后现在我们假设 Session 已经创建完成了,“框架”如下:

public void proces () {
    // 1. 接收请求
    // 2. 处理请求
    // 3. 创建Session
    //   3.1 生成SessionId
    //   3.2 Session过期机制
    //   3.3 Session激活
    //   3.4 Session回收
}

上面已经为我们提供好了如何激活 Session 的方法,但是什么时候触发这个方法呢?我们也说了是在正常 CRUD 或者心跳 Ping 的时候会进行激活,在剖析这块代码之前我们先时光倒流,回忆一下最开始我们讲如何处理请求的流程:

public void 接收请求() {
    if (!initialized) {
        // 还没初始化,那就建立连接
        readConnectRequest();
    } else {
        // 初始化完了,那就发送数据
        readRequest();
    }
}

如果没建立过连接,那么就建立连接readConnectRequest();如果之前建立过连接,那就是正常地发送数据(可能是正常的 CRUD,也可能是心跳 Ping 请求)。所以大家肯定突然明白一个事情,那就是我们调用 Session 激活方法的入口就是这里——readRequest(),接下来我们一起看下是如何调用的吧~

ZooKeeper 这部分的做法是异步处理的,也就是先将接收到的请求放到一个队列里,然后起个线程消费队列去激活 Session,伪代码如下:

private final LinkedBlockingQueue<Request> submittedRequests = new LinkedBlockingQueue<Request>();

protected void readRequest() throws IOException {
    // 放到队列里,异步消费处理
    submittedRequests.add(request);
}

刚也说了会有个线程异步消费去激活 Session,逻辑很清晰明了,不做过多解释,我们贴下代码:

// 线程
public class RequestThrottler extends ZooKeeperCriticalThread {
    @Overwrite
    public void run() {
        try {
            while (true) {
                if (killed) {
                    break;
                }
                // 消费队列,取出request请求
                Request request = submittedRequests.take();
                if (request != null) {
                    // 激活Session的方法
                    sessionTracker.touchSession(request);
                }
            }
        } catch (InterruptedException e) {
            LOG.error("Unexpected interruption", e);
        }
    }
}

上面代码是源码的简易版,只保留了核心代码以及省去了方法间的调用链。服务端处理客户端请求的整个流程就是这样子了,最后我们简单总结下本篇内容。

三、总结

本篇主要讲解了服务端是如何处理客户端请求以及何时激活 Session 的。我们总结一段伪代码以及一张流程图做收尾:

public void proces () {
    // 1. 接收请求
    // 2. 处理请求
    // 3. 如果之前没创建过连接,则创建
    // 4. 创建Session
    //   4.1 生成SessionId
    //   4.2 Session过期机制
    //   4.3 Session激活
    //   4.4 Session回收
    // 5.如果之间创建过连接,则直接处理数据包,无需重复创建。
    //   5.1 激活Session
    // 6. 响应客户端
}

session-13.png

我们带着以下问题进行剖析。

  1. 我们讲到 SessionId 这个概念,说 SessionId 标记出这个 Session 的唯一性,但是 SessionId 是根据什么算法或者什么规则生成的呢?
  2. 我们讲到 Session 是有过期机制的,也就是 Session 带有效期的,还提及到最好设置成 tickTime 的整数倍,即使没设置成整数倍,那么实际过期时间也是倍数的,那么 Session 的过期机制是怎么实现的呢?
  3. 我们还说了两种刷新 Session 过期时间的机制:正常业务操作(CRUD)以及心跳机制,这块是如何刷新的呢?
  4. 最后我们在篇末解答原理篇留下的两个思考题:如果客户端宕机了,那么服务端怎么处理这个 Session?如果服务端宕机了,那么客户端该怎么办?

话不多说,我们先来分析第一个问题:SessionId 的生成规则是什么呢?

一、SessionId 生成规则是什么?

在讨论具体细节之前,我们先贴出完整源码,然后我们展开细节研究:

public static long initializeNextSessionId(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;
}

可以发现源码很短,但是很多位运算,不慌,我们把它拆开一个一个去剖析。首先我们能看到一句代码:Time.currentElapsedTime(),这句代码很简单,就是获取当前系统时间戳,源码如下:

public static long currentElapsedTime() {
    return System.nanoTime() / 1000000;
}

我们假设当前系统时间戳是1648019855000,其二进制为:

0000 0000 0000 0000 0000 0001 0111 1111 1011 0101 1010 0011 0101 0110 1001 1000

然后将其左移 24 位Time.currentElapsedTime() << 24,左移就是整体向左移动,然后低位补 0,如下:

0111 1111 1011 0101 1010 0011 0101 0110 1001 1000 0000 0000 0000 0000 0000 0000

我们继续分析,又将结果无符号右移了 8 位(Time.currentElapsedTime() << 24) >>> 8,得到的二进制如下:

0000 0000 0111 1111 1011 0101 1010 0011 0101 0110 1001 1000 0000 0000 0000 0000

到这里我们就已经得到了 nextSid 的二进制,但是源码又将 id 向左移 56 位,这里的 id 就是 sid,也就是 Zooeeper 配置文件 myid 里的值。假设该值为 2,2 的 64 位二进制如下:

0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0010

那么,将 2 向左移 56 位id << 56得到如下二进制:

0000 0010 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000

现在 nextSid 和id << 56我们都有了,那么源码中的这段代码nextSid | (id << 56)就很容易解了:

0000 0000 0111 1111 1011 0101 1010 0011 0101 0110 1001 1000 0000 0000 0000 0000
|
0000 0010 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000   
=
0000 0010 0111 1111 1011 0101 1010 0011 0101 0110 1001 1000 0000 0000 0000 0000

通过以上几步,就完成了一个 SessionId 的计算过程,其实稍微懂一点位运算的同学都应该能看懂上面的运算过程,原理可以用一句话来概括:高 8 位确定了所在机器,中间 40 位使用当前时间戳保证单机环境唯一性,最后低 16 位都是 0 可用于并发自增。

算法一步步推导并不复杂,但是这套算法当中有很多疑问,比如:

  1. 为什么是左移 24 位?左移 23 位不行吗?
  2. 为什么要无符号右移(>>>) 8 位?有符号右移(>>)不行吗?
  3. 这套算法在实际生产环境中集群能部署最大节点数是多少个?
  4. 假设并发了,那么能支持最大的 qps 是多少?

我们一个一个来看,先看第一个问题:为什么是左移 24 位?左移 23 位不行吗?

1. 左移 24 位的原因

这个问题并不复杂,我们还是以我们上面推导的数字举例,假设时间戳是1648019855000,二进制为:

0000 0000 0000 0000 0000 0001 0111 1111 1011 0101 1010 0011 0101 0110 1001 1000

将其左移 24 位后的二进制如下:

0111 1111 1011 0101 1010 0011 0101 0110 1001 1000 0000 0000 0000 0000 0000 0000

可以发现高位只有一个符号位 0 了,如果我们不是左移 24 位,而是左移 23 位的话,那么二进制如下:

1011 1111 1101 1010 1101 0001 1010 1011 0100 1100 0000 0000 0000 0000 0000 0000

会发现最高位变成 1 了,而最高位代表符号位,1 代表负数,所以这就出现了负数的情况,而后面的无符号右移 8 位等运算改变不了符号位,因此就无法清晰地从 SessionId 当中分辨出 myid 的值了,这就是左移 24 位的重要原因。

那为什么是无符号右移而不是有符号右移呢?

2. 无符号右移(>>>)的由来

无符号和有符号的区别在于无符号移动不会移动最高位(符号位),不够位数的话就补 0。所以就不难想到了,其实 ZooKeeper 采取无符号右移就是防止出现负数。那又有疑问了:上面左移 24 位就是防止出现负数,这次又为何要再次无符号右移呢?是因为左移 24 位不能 100% 保证都是正数,假设现在到了 2022 年 04 月 07 日,那么这个日期的时间戳左移 24 位后高位就是 1,也就是左移 24 位后是负数,因此有了右移的操作,如果采取有符号右移的话依然可能是负数,所以采取的是无符号右移。

这在 ZooKeeper 早版本当中是一个 bug,因为在 3.4.6 版本之前用的是有符号右移,后来在 3.4.6 版本中修复了,感兴趣的同学可以去看下。

接下来我们再看第三个疑问:这套算法在实际生产环境中集群能部署最大节点数是多少个?

3. 此算法能部署多少个节点?

回忆下这句话:高 8 位确定了所在机器,中间 40 位使用当前时间戳保证单机环境唯一性,最后低 16 位都是 0 可用于并发自增。 能部署多少个节点取决于什么?取决于高 8 位,因为高 8 位代表的是机器,那就简单了,8 位最大数的二进制是0111 1111,转换成 10 进制是 127,因此能部署最大节点数是 127 个。

有同学会问:为什么高位是 0?高位是 1 不行吗?也就是1111 1111,这样最大能部署 255 个节点,算上 0 就是 256 个节点。其实也行,只是不建议,高 8 位就是 sid,如果 sid 小于 0 的话那生成的 SessionId 也是负数,这样的 SessionId 就无法看出高位代表机器了,也就没多大意义了。那为什么不设置成 0 呢?因为 0 左移 56 位还是 0,恰巧 256 左移 56 位也是 0,重复了。因此 sid 取值建议是 [1,127] 之间,也就是高 8 位建议最大值是0111 1111,十进制 127。

一个 ZooKeeper 集群部署 127 个节点已经是很足够了。

再来看最后一个问题:这套算法能支持多少 qps 呢?

4. 此算法能支持最大的 qps 是多少?

还是离不开这句话:高 8 位确定了所在机器,中间 40 位使用当前时间戳保证单机环境唯一性,最后低 16 位都是 0 可用于并发自增。 部署多少节点我们说了是看高 8 位,中间位是时间戳,那自然要看低 16 位了,低 16 位是同时间戳的并发数,我们算下 16 位能支持多少并发,16 位最大值是1111 1111 1111 1111,十进制就是:65535,也就是一毫秒能生成 65535 个 sessionId,一秒能生成 65535000 (6553.5 万)个,所以最大的 qps 是 6553.5 万。理论上来讲,单机六千多万的 qps 能支持所有业务场景了。

最后,我们再来看个好玩的,因为我发现了一个注释 bug,何为注释 bug?

5. 注释 bug

我们先贴下源码:

/**
 * Returns time in milliseconds as does System.currentTimeMillis(),
 * but uses elapsed time from an arbitrary epoch more like System.nanoTime().
 * The difference is that if somebody changes the system clock,
 * Time.currentElapsedTime will change but nanoTime won't. On the other hand,
 * all of ZK assumes that time is measured in milliseconds.
 * @return The time in milliseconds from some arbitrary point in time.
 */
public static long currentElapsedTime() {
    return System.nanoTime() / 1000000;
}

首先我们原封不动地翻译一下上面这段注释:

System.currentTimeMillis() 一样,以毫秒为单位返回时间,但使用从任意时期经过的时间,更像 System.nanoTime()。不同之处在于,如果有人更改系统时钟, Time.currentElapsedTime 会更改,但 nanoTime 不会。另一方面,所有 ZK 都假设时间以毫秒为单位。 @return 从某个任意时间点开始的时间(以毫秒为单位)。

bug 就出现在第三、四行注释:

The difference is that if somebody changes the system clock,Time.currentElapsedTime will change but nanoTime won't.
不同之处在于,如果有人更改系统时钟, Time.currentElapsedTime 会更改,但 nanoTime 不会。

我们发现获取当前时间戳用的是 System.nanoTime() / 1000000,而不是System.currentTimeMillis(),这二者区别在于nanoTime()不受时钟影响,而currentTimeMillis()和时钟强关联,也就是说currentTimeMillis()得到的毫秒值可以转成日期类型,和系统时钟强关联的,所以如果时钟回拨,那么currentTimeMillis()也会随之变动。但是第三、四行注释写的却是 “如果有人更改系统时钟,Time.currentElapsedTime 会更改”,这不是 bug 吗?注释写错啦,应该是System.currentTimeMillis() will change,而不是Time.currentElapsedTime will change

SessionId 的生成规则我们告一段落,接下来我们看第二个核心问题:Session 的过期机制。

二、Session 的过期机制

我们在讲 Session 原理的时候有讲到 Session 是有过期机制的,也就是 Session 带有效期的,还提及到最好设置成tickTime的整数倍,即使没设置成整数倍,那么实际过期时间也会自动计算成tickTime的整数倍,那么这块是怎么实现的呢?

首先,我们需要有一个 SessionId 和 Session 的映射关系,像这种 key-value 的我们都可以设计成 Map 结构:

public class SessionTrackerImpl extends ZooKeeperCriticalThread implements SessionTracker {
    // key:SessionId,value:Session对象
    protected final ConcurrentHashMap<Long, SessionImpl> sessionsById = new ConcurrentHashMap<Long, SessionImpl>();
}

但是我们每个 Session 什么时候过期呢?或者说我们如何去找哪些 Session 快过期了呢?一样地需要 Map 来存储,key 是过期时间,value 是 Session,但一个过期时间对应一个 Session 这合理吗?显然不合理,应该对应一组,因为很可能多个 Session 设置的过期时间都一样,所以我们给它设计成Map<Long, Set<E>>结构来存储:

// key是过期时间,value是Session集合(会话桶)
private final ConcurrentHashMap<Long, Set<E>> expiryMap = new ConcurrentHashMap<Long, Set<E>>();

比如下图:

session-4.png

这对应到我们设计的 expiryMap 就是:key:1235000;value:SessionId=222 的 Session 对象(add 到 Set 里)。key 代表过期时间,计算公式为:

key = 当前时间 + 设置的Session过期时间

但 key 真的是这样设计的吗?不是的,我们在讲 Session 原理篇的时候说过如果设置的过期时间不和tickTime成倍数关系,那么此 Session 实际的过期时间也会自动换算成tickTime的倍数,那这个算法是什么呢?我们看下源码:

private long roundToNextInterval(long time) {
    // expirationInterval 就是 tickTime,默认是2000毫秒,也就是每隔2s进行一次Session检查。
    return (time / expirationInterval + 1) * expirationInterval;
}

上面注释我说明了expirationInterval的含义,但是入参 time 是什么呢?入参就是我们上面 key 的计算公式当前时间 + 设置的Session过期时间,因此完整 key 的计算公式就成下面这样了:

key = 当前时间 + 设置的Session过期时间
key = (key / expirationInterval + 1) * expirationInterval

这里可能有的同学看起来比较绕,为了便于理解,我们代数法举几个例子,假设 ZooKeeper 的 tickTime 为 2000ms(也就是 ExpirationInterval 是 2000ms),那么如下:

  • 假设 Session1 设置的过期时间是 1900ms,也就是 1900ms 后过期,那么代入到公式里得到的 Map 的 key 就是(1900/2000 + 1) * 2000 = 2000ms
  • 假设 Session2 设置的过期时间是 3000ms,也就是 3000ms 后过期,那么代入到公式里得到的 Map 的 key 就是(3000/2000 + 1) * 2000 = 4000ms
  • 假设 Session3 设置的过期时间是 5000ms,也就是 5000ms 后过期,那么代入到公式里得到的 Map 的 key 就是(5000/2000 + 1) * 2000 = 6000ms

为什么不精确计算而是要取整数倍呢?因为如果精确计算的话那这个 Map 就"爆炸"了,离散度太高了,比如你设置的 1ms 过期,他设置的 2ms 过期……以此类推,那么这个 Map 存储的数据太离散了,也不易于每次任务扫描,所以索性按照tickTime的倍数来,这样大大降低了 key 的离散度

数据准备完了,我们是不是需要一个定时任务去扫描这个 Map,然后剔除过期的 key,释放 Session 断开连接?我们立马能想到定时任务扫描整个 Map,然后遍历取出 key 逐个对比当前时间,看看是否过期了,如果过期了那么就从 Map 中剔除且释放 Session 断开连接。但是如果这次任务扫描完发现没有要过期的 Session,那么下次又重新调起任务遍历 Map 了,这样显然不是很合适。我们可以新增一个字段叫“下一个过期时间点”,默认是当前tickTime执行时间,然后在每次任务执行的时候就判断“下一个过期时间点”是否小于等于当前时间,如果小于等于就直接释放 Session 断开连接;如果大于当前时间,那就阻塞等待 “下一个过期时间点”减去当前时间这个差值,这样看起来完美了许多。

我们简单画个流程图说明下:

session-7.png

但是这个“下一个过期时间点”的值什么时候更新呢?其实更新时机也很清晰了,就是当我们定时任务执行后如果发现“下一个过期时间点”小于等于当前时间的话肯定会释放 Session 断开连接,这时候就可以更新这个“下一个过期时间点”的值,更新的新值计算公式也很简单粗暴了,直接用tickTime + 现在的过期时间点即可。我们继续完善下流程图:

create-8.png

上面说了一大堆知识理论,我们现在梳理下,整理下源码该怎么写。首先有提及到 Session 过期时间桶的概念,是用一个 Map 来存储的,如下:

// key是过期时间,value是Session集合(会话桶)
private final ConcurrentHashMap<Long, Set<E>> expiryMap = new ConcurrentHashMap<Long, Set<E>>();

接着我们又提到tickTime以及“下一个过期时间点”的概念,“下一个过期时间点”我们称为nextExpirationTime,我们这些代码封装到一个类里:

public class ExpiryQueue<E> {
    // 保存过期时间与在此过期时间点要过期的会话集合的映射关系。
    // key是过期时间,value是Session集合(会话桶)
    private final ConcurrentHashMap<Long, Set<E>> expiryMap = new ConcurrentHashMap<Long, Set<E>>();
    // 下一个过期的时间点
    private final AtomicLong nextExpirationTime = new AtomicLong();
    // 过期时间间隔
    private final int expirationInterval;
    
    // 计算过期时间的,tickTime的整数倍。 tickTime就是expirationInterval
    private long roundToNextInterval(long time) {
        return (time / expirationInterval + 1) * expirationInterval;
    }
}

现在万事俱备只欠东风了,这里的万事具备指得是基础结构我们已经涉及完了,只欠东风指的是还差个定时任务去扫描。我们搞一个SessionTrackerImpl类来管理 Session,如下:

public class SessionTrackerImpl extends ZooKeeperCriticalThread implements SessionTracker {
    // key:SessionId,value:Session对象
    protected final ConcurrentHashMap<Long, SessionImpl> sessionsById = new ConcurrentHashMap<Long, SessionImpl>();
    // 过期队列。用于维护会话的过期,并且使用bucket来维护会话,每一个bucket对应一个某时间范围内过期的会话。
    private final ExpiryQueue<SessionImpl> sessionExpiryQueue;
}

所以我们的定时任务也放到这个类里来,我们弄个线程就好了,这个类也继承了ZooKeeperCriticalThread这个 Thread 类,所以重写下 run 方法即可。

@Override
public void run() {
    try {
        while (running) {
            // 1. 获取下一个过期时间点
            // 2. 如果下一个过期时间点大于当前时间,则阻塞等待:当前时间 - 下一个过期时间点
            // 3. 如果下一个过期时间点小于等于当前时间,则剔除Session,断开链接,更新下一个过期时间点等操作。
        }
    } catch (InterruptedException e) {}
}

上面注释已经很清楚了,我就不画图了,我们只需要按部就班地分析这三行注释就好了。先来看第一个问题:如何获取下一个过期时间点?

1. 获取下一个过期时间点的算法

我们上面已经说过基本原理了,首先下一个过期时间点(后面统称为nextExpirationTime),默认是当前 tickTime 执行时间(过期时间轴的 current ),我们也已经知道了如何计算过期时间点(也就是上面分析的 Map 的 key),那就很简单了,我们默认直接调用roundToNextInterval()方法传递进去当前时间戳就好了,如下:

public ExpiryQueue(int expirationInterval) {
    // 首先下一个过期时间点(后面统称为`nextExpirationTime`),默认是当前tickTime执行时间,也就是前面图中时间轴横坐标的current
    nextExpirationTime.set(roundToNextInterval(Time.currentElapsedTime()));
}

那我们回到正题,如何获取nextExpirationTime?简直不要太简单,nextExpirationTime是原子类(AtomicLong),直接调用 get 方法就好了,如下:

// 返回expirationTime 和 当前时间的差值
public long getWaitTime() {
    // 获取当前时间戳
    long now = Time.currentElapsedTime();
    // 获取下一个过期时间点
    long expirationTime = nextExpirationTime.get();
    // 如果当前时间小于下一个过期时间那就返回差值,反之返回0
    return now < expirationTime ? (expirationTime - now) : 0L;
}

很简单,通俗点说就是:如果到达过期时间了,那就返回 0,然后调用方释放 Session 等,假设没到达过期时间,就返回差值,然后调用方 sleep(差值)。

第一步搞定了,我们继续分析第二步:如果下一个过期时间点大于当前时间,则阻塞等待:当前时间 - 下一个过期时间点,这一步是不是太简单了?我们getWaitTime()都已经获取到差值了,直接判断是不是大于 0,如果大于 0 的话就sleep(差值)就好了,不多说,贴出源码:

// 返回差值,上面刚讲的
long waitTime = sessionExpiryQueue.getWaitTime();
// 如果差值大于0,直接sleep(差值) 阻塞等待,到点后自动释放锁然后continue进入下一次循环
if (waitTime > 0) {
    Thread.sleep(waitTime);
    continue;
}

我们完善下流程图:

session-9.png

现在还差最后一步,就是如果差值不大于 0,也就是已经到达过期时间点了,这时候我们需要剔除 Session,断开连接,更新下一个过期时间点等操作,这该怎么做呢?接下来我们一起分析下这块内容~

2. 如何让 Session 过期?

这块涉及的内容就相对多一点了,我们先梳理下这个方法都需要做什么事:

  • 首先,需要更新下一次过期时间点,也就是更新nextExpirationTime
  • 其次,我们需要将过期的这些 Session 从我们的 expiryMap 中移除(也就是我们的会话桶,key 是过期时间,value 是 Session 集合);
  • 最后,我们要释放 Session 对应的连接,也就是将连接断开,释放资源

这三步都很清晰,也并不复杂,首先如何更新nextExpirationTime呢?nextExpirationTimeAutomicLong类型,原子类,学过一点点多线程的同学都该知道 CAS 机制,而 JDK 自带的原子类底层实现就采取了 CAS 机制,所以更新nextExpirationTime变得十分简单:

public Set<E> poll() {
    // 1. 先取出下一次过期时间点
    long expirationTime = nextExpirationTime.get();
    // 2. 然后让下一次过期时间点 + tickTime(周期执行的时间)的结果成为我们最新下一次过期时间
    long newExpirationTime = expirationTime + expirationInterval;
    // 3. CAS将最新下一次过期时间更新到nextExpirationTime中
    if (nextExpirationTime.compareAndSet(expirationTime, newExpirationTime)) {
        // 4. 从Session会话桶中移除key,也就意味着这个key对应的所有Session都被移除了
        set = expiryMap.remove(expirationTime);
    }
    // 5. 将移除的Session集合返回
    return set;
}

第二步将过期的这些 Session 从我们的 expiryMap 中移除,我们已经在上面源码注释中写得很明白了,就一句代码:expiryMap.remove(expirationTime);

上面源码当中还有一个引人深思的问题:更新nextExpirationTime以及将过期的 Session 从会话桶中移除,这都很好理解,但是最后为什么要将被移除的 Session 集合返回呢?返回过期的 Session 有什么意义?当然有意义!不返回的话怎么知道要断开哪些 Session 对应的客户端连接?所以返回的目的其实是服务于第三步:释放 Session 对应的连接。这一步我不做过多剖析,会很占用篇幅,感兴趣的同学可以自行查看,无非就是放到队列异步处理以及网络通信那点事。在 Leader 选举的时候我们从 0 到 1 的剖析了选举网络通信流程,大同小异。

目前为止,Session 的初始化以及 Session 过期被回收的原理我们都剖析完成,继续完善下我们流程图:

session-10.png

我们还剩下一个问题:我们之前还说了两种刷新 Session 过期时间的机制:正常业务操作(CRUD)以及心跳机制,这块是如何刷新的呢?

三、Session 激活

Session 激活的含义也很简单,就是正常 CRUD 以及心跳的时候会将 Session 的过期时间重新计算一下,上一篇已经讲解过调用 Session 激活的时机了,本篇直入正题,老规矩,先梳理流程后剖析源码。

假设现在一个心跳请求过来了,Session 激活的方法都需要做什么呢?

  • 首先为了严谨性,肯定是要先判断该 Session 是否已经被关闭了,如果被关闭了,那就不用再激活了。
  • 其次如果 Session 没被关闭,那就重新计算下 Session 过期时间,计算公式也很简单,我们之前剖析过roundToNextInterval(),也就是计算出来每次都是tickTime的倍数那个方法。
  • 最后我们将新计算出来的过期时间放到 Session 桶中(expiryMap)。

原理我们已经搞懂了,那接下来就看看如何实现的吧,先来看第一步:检测 Session 是否已经关闭。如下:

public synchronized boolean touchSession(long sessionId, int timeout) {
    // 先根据SessionId获取到Session
    SessionImpl s = sessionsById.get(sessionId);
    // 然后判断是否已经close了,如果close了那么直接返回即可,不做其他逻辑处理
    if (s.isClosing()) {
        return false;
    }
    // 如果没关闭,则调用updateSessionExpiry(s, timeout)
    update(s, timeout);
    return true;
}

第一步很简单,我们接着看第二步:重新计算 Session 过期时间,也就是重新计算 Session 桶的 key。

public Long update(E elem, int timeout) {
    long now = Time.currentElapsedTime();
    // 重新计算Session过期时间,也就是重新计算Session桶的key
    return roundToNextInterval(now + timeout);
}

是的,就这么简单,roundToNextInterval()方法我们前面已经剖析过了,最终返回的结果其实就是tickTime的倍数。既然有了新的过期时间了(expiryMap会话桶里的 key),那紧接着第三步将其 put 进去就好了。

public Long update(E elem, int timeout) {
    long now = Time.currentElapsedTime();
    // 重新计算Session过期时间,也就是重新计算Session桶的key
    Long newExpiryTime = roundToNextInterval(now + timeout);
    
    Set<E> set = expiryMap.get(newExpiryTime);
    if (set == null) {
        // 如果新计算出来的key在Session桶里还没有创建过,那就初始化一个
        set = Collections.newSetFromMap(new ConcurrentHashMap<E, Boolean>());
        // 以newExpiryTime为key,set为value放到Session桶里
        Set<E> existingSet = expiryMap.putIfAbsent(newExpiryTime, set);
    }
    // 给桶内set集合添加Session
    set.add(elem);
    
    return newExpiryTime;
}

Session 激活原理也很简单,就一句话:重新计算过期时间然后重新放到 Session 桶里,计算过期时间的算法就是tickTime的倍数。

四、答疑

  1. 如果客户端宕机了,那么服务端怎么处理这个 Session?
  2. 如果服务端宕机了,那么客户端该怎么办?

两个问题都不复杂,先看第一种情况:如果客户端宕机了,那么服务端就检测不到客户端的心跳(Ping),因此会随着tickTime时间的周期执行给回收 Session 以及关闭连接。

那第二种情况呢?如果客户端没宕机,服务端挂了,这会出现什么情况?如果 ZooKeeper 服务端是单机部署,那么恭喜你,此连接就会随着下次客户端发心跳的时候发现服务端不可用而断开。如果 ZooKeeper 服务端是集群部署,那么客户端发心跳检测到当前节点不可用后,会尝试给其他节点发心跳建立连接,所以影响不是很大滴。

五、总结

  • 客户端要想跟服务端建立连接,必须创建 Session 且产生 SessionId 与之一一对应,也就是客户端和服务端之间的通信必须建立在一个 Session 的基础之上。
  • Session 具有顺序性,这就意味着同⼀个 Session 的请求会以 FIFO(先进先出) 的顺序执⾏。通常情况⼀个客户端只打开⼀个 Session,因此客户端请求将全部以 FIFO 顺序执⾏
  • SessionId 的生成规则:高 8 位确定了所在机器,中间 40 位使用当前时间戳保证单机环境唯一性,最后低 16 位都是 0 可用于并发自增。由此也可推断出最大部署 127 个节点以及最大支持的 qps 是 65535000(6553.5 万)个。
  • Session 的过期机制,过期时间巧妙采取与tickTime成倍数计算,减轻了 Session 桶的离散度,也采取了提前计算下一个到期时间点的思路来进行优化
  • 客户端每次 CRUD 操作以及定时心跳 Ping 请求都会重新计算 Session 的过期时间,因为连接的创建和销毁是十分消耗性能的,所以靠心跳来维活
  • 如果客户端宕机了,那么服务端就检测不到客户端的心跳(Ping),因此会随着tickTime时间的周期执行给回收 Session 以及关闭连接。
  • 如果 ZooKeeper 服务端是单机部署,那么此连接就会随着下次客户端发心跳的时候发现服务端不可用而断开。如果 ZooKeeper 服务端是集群部署,那么客户端发心跳检测到当前节点不可用后,会尝试给其他节点发心跳建立连接。

最后补充一下 Session 数据结构的设计:巧妙采取了几个 Map。首先是SessionIdSessionImpl的 Map,维护 SessionId 与 Session 的映射关系:

public class SessionTrackerImpl extends ZooKeeperCriticalThread implements SessionTracker {
    // key:SessionId,value:Session对象
    protected final ConcurrentHashMap<Long, SessionImpl> sessionsById = new ConcurrentHashMap<Long, SessionImpl>();
}

其次采取了另一个 Map 维护 Session 的过期时间,成为 Session 会话桶,以过期时间为 key,Session 集合为 Value。巧妙地采取了将过期时间都计算为 tickTime 倍数的方式来规避桶离散度过高的问题,如下:

// key是过期时间,value是Session集合(会话桶)
private final ConcurrentHashMap<Long, Set<E>> expiryMap = new ConcurrentHashMap<Long, Set<E>>();

整体流程就是:开个线程去扫描过期的 key,如果到达过期时间了就移除 Session 且断开连接等操作,如果没到达过期时间那就阻塞等待一定时间。

posted @ 2023-04-05 11:53  Dazzling!  阅读(65)  评论(0编辑  收藏  举报