prev:ZooKeeper 3 Watch机制与其源码分析
ZooKeeper是一个 服务器-客户端结构的应用,客户端与服务器之间需要建立一个连接,而这个连接就是一个会话。客户端与服务端的交互操作中都离不开会话的相关的操作。
会话的数据结构
维护一个会话需要一些信息,主要由4部分构成:
- sessionId 会话 ID
会话的唯一标识。由服务器生成,以64位数字表示,并分配给客户端 - timeout
会话的超时时间。会话的超时时间就是指一次会话从发起后到被服务器关闭的时长。 - isClosing
用于标记一个会话是否已经被关闭。如果服务器检查到一个会话已经因为超时等原因失效时, ZooKeeper 会在该会话的 isClosing 属性值标记为关闭,再之后就不对该会话进行操作了。 - owner
(ZooKeeper 3.8 org.apache.zookeeper.server SessionTrackerImpl.java 56行)
会话的状态
在 ZooKeeper 服务的运行过程中,会话会经历不同的状态变化。而这些状态包括:正在连接(CONNECTING)、已经连接(CONNECTIED)、正在重新连接(RECONNECTING)、已经重新连接(RECONNECTED)、会话关闭(CLOSE)等。
服务端会话处理
整体流程
图源:糖爸的架构师之路
会话创建
服务端会话的管理主要是SessionTrackerImpl进行的(org.apache.zookeeper.server SessionTrackerImpl.java)
会话会被维护在两个map中:
/**
* 版本:ZooKeeper 3.8.0
* org.apache.zookeeper.server
* SessionTrackerImpl.java
* 51行
*/
public class SessionTrackerImpl extends ZooKeeperCriticalThread implements SessionTracker {
// 保存id到session的映射
protected final ConcurrentHashMap<Long, SessionImpl> sessionsById = new ConcurrentHashMap<Long, SessionImpl>();
// 存放过期队列,使用bucket来维护会话,每一个bucket对应一个某时间范围内过期的会话
private final ExpiryQueue<SessionImpl> sessionExpiryQueue;
// 保存session到超时时间的映射
protected final ConcurrentMap<Long, Integer> sessionsWithTimeout;
}
在SessionTrackerImpl的构造函数中,可以看到用initializeNextSession()方法来生成一个会话ID,该会话ID会作为一个唯一的标识符:
/**
* 版本:ZooKeeper 3.8.0
* org.apache.zookeeper.server
* SessionTrackerImpl.java
* 110行
*/
public SessionTrackerImpl(SessionExpirer expirer, ConcurrentMap<Long, Integer> sessionsWithTimeout, int tickTime, long serverId, ZooKeeperServerListener listener) {
super("SessionTracker", listener);
this.expirer = expirer;
this.sessionExpiryQueue = new ExpiryQueue<SessionImpl>(tickTime);
this.sessionsWithTimeout = sessionsWithTimeout;
this.nextSessionId.set(initializeNextSessionId(serverId));
for (Entry<Long, Integer> e : sessionsWithTimeout.entrySet()) {
trackSession(e.getKey(), e.getValue());
}
EphemeralType.validateServerId(serverId);
}
SessionTracker会为该会话分配一个sessionID,并将其注册到sessionsById和sessionsWithTimeout中去,同时进行会话的激活。
会话管理——分桶策略
分桶策略指的是将超时时间相近的会话放到同一个桶中来进行管理,以减少管理的复杂度。
本文的第一个代码块中,可以看到维护了一个过期队列:
private final ExpiryQueue<SessionImpl> sessionExpiryQueue;
这个队列的类型是ExpiryQueue,其内部使用以下数据结构来维护过期:
/**
* 版本:ZooKeeper 3.8.0
* org.apache.zookeeper.server
* ExpiryQueue.java
* 37行
*/
// 维护Session对象与其过期时间的映射关系,key是Session,value是过期时间
private final ConcurrentHashMap<E, Long> elemMap = new ConcurrentHashMap<E, Long>();
/**
* The maximum number of buckets is equal to max timeout/expirationInterval,
* so the expirationInterval should not be too small compared to the
* max timeout that this expiry queue needs to maintain.
*/
// 维护过期时间和在该时间过期的session,key是过期时间,value是session集合
private final ConcurrentHashMap<Long, Set<E>> expiryMap = new ConcurrentHashMap<Long, Set<E>>();
// 下一个过期的时间点
private final AtomicLong nextExpirationTime = new AtomicLong();
// 过期时间间隔,默认2000,单位毫秒
private final int expirationInterval;
对于一个新创建的会话而言,其会话创建完毕后,就会为其预期计算过期时间,即:
E
x
p
i
r
a
t
i
o
n
T
i
m
e
=
C
u
r
r
e
n
t
T
i
m
e
+
S
e
s
s
i
o
n
T
i
m
e
o
u
t
ExpirationTime = CurrentTime + SessionTimeout
ExpirationTime=CurrentTime+SessionTimeout
因为是预期过期时间,所以并不一定就在这个时间过期,因为ZooKeeper的Leader服务器在运行期间会每隔Expirationlnterval毫秒的时间,定期地进行会话超时检查。
因此,会话过期的时间实际上是:
/**
* 版本:ZooKeeper 3.8.0
* org.apache.zookeeper.server
* ExpiryQueue.java
* 53行
* 计算会话实际的过期时间
*/
private long roundToNextInterval(long time) {
return (time / expirationInterval + 1) * expirationInterval;
}
举个例子,例如我们使用默认的过期检查间隔时间2000毫秒,假设目前时间为0,Session A的过期时间为1000,Session B的过期时间为2000ms,Session C的过期时间为3000,Session D的过期时间为3200,那么:
- Session A和Session B都会落在2000ms这个时间(expiryMap的key=2000对应的Set里面)
- Session C和Session D都会落在4000ms这个时间(expiryMap的key=4000对应的Set里面)
会话探活(激活)
为了客户端这边防止会话过期,要保持客户端会话的有效性,也就是说,需要一种探活机制来实现客户端服务器之间的会话保持。ZooKeeper的探活机制是在客户端和服务器之间采用心跳机制:
- 客户端会在会话超时时间过期范围内向服务端发送PING请求来保持会话的有效性
- 服务端需要不断地接收来自客户端的这个心跳检测,并且需要重新激活对应的客户端会话
这个重新激活的过程称为TouchSession,它的具体的实现在SessionTrackerImpl.touch()中:
/**
* 版本:ZooKeeper 3.8.0
* org.apache.zookeeper.server
* SessionTrackerImpl.java
* 179行
* Session激活
*/
public synchronized boolean touchSession(long sessionId, int timeout) {
// 从map里面获取id对应的Session
SessionImpl s = sessionsById.get(sessionId);
// 查不到Session或Session过期
if (s == null) {
logTraceTouchInvalidSession(sessionId, timeout);
return false;
}
if (s.isClosing()) {
logTraceTouchClosingSession(sessionId, timeout);
return false;
}
// 更新Session过期时间,具体实现如下
updateSessionExpiry(s, timeout);
return true;
}
/**
* 版本:ZooKeeper 3.8.0
* org.apache.zookeeper.server
* SessionTrackerImpl.java
* 196行
* Session过期时间更新到sessionExpiryQueue
*/
private void updateSessionExpiry(SessionImpl s, int timeout) {
logTraceTouchSession(s.sessionId, timeout, "");
sessionExpiryQueue.update(s, timeout);
}
sessionExpiryQueue的update逻辑如下:
/**
* 版本:ZooKeeper 3.8.0
* org.apache.zookeeper.server
* ExpiryQueue.java
* 84行
* 向ExpiryQueue中更新或新增数据
*/
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;
}
// 从expiryMap中获取相应时间过期的所有会话,如2000ms的所有过期会话
// 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;
}
}
// 把Session添加到桶里
set.add(elem);
// Map the elem to the new expiry time. If a different previous
// mapping was present, clean up the previous expiry bucket.
// 过期时间维护到emeMap中
prevExpiryTime = elemMap.put(elem, newExpiryTime);
if (prevExpiryTime != null && !newExpiryTime.equals(prevExpiryTime)) {
Set<E> prevSet = expiryMap.get(prevExpiryTime);
// 如果Session还在旧桶,删掉
if (prevSet != null) {
prevSet.remove(elem);
}
}
return newExpiryTime;
}
超时处理
如果会话在过了预期过期时间后,仍然没有被激活,就需要将超时的Session移除。
在SessionTracker中有一个单独的线程,进行会话超时检查。由于采用了分桶的逻辑,超时检查就很简单了,到了相应时间后,没有被移除的会话都是需要执行超时处理逻辑的。
/**
* 版本:ZooKeeper 3.8.0
* org.apache.zookeeper.server
* SessionTrackerImpl.java
* 158行
* 起一个线程处理过期Session
*/
@Override
public void run() {
try {
while (running) {
// 获取最近的过期时间的时长,如果大于0就sleep()
long waitTime = sessionExpiryQueue.getWaitTime();
if (waitTime > 0) {
Thread.sleep(waitTime);
continue;
}
// 取出要过期的会话
for (SessionImpl s : sessionExpiryQueue.poll()) {
ServerMetrics.getMetrics().STALE_SESSIONS_EXPIRED.add(1);
// isClosing置为true
setSessionClosing(s.sessionId);
// 具体过期处理逻辑
expirer.expire(s);
}
}
} catch (InterruptedException e) {
handleException(this.getName(), e);
}
LOG.info("SessionTrackerImpl exited loop!");
}
这里具体的过期处理逻辑是发送一个关闭请求:
/**
* 版本:ZooKeeper 3.8.0
* org.apache.zookeeper.server
* ZooKeeperServer.java
* 604行
* 过期释放的具体执行:发送Session关闭
*/
private void close(long sessionId) {
Request si = new Request(null, sessionId, 0, OpCode.closeSession, null, null);
submitRequest(si);
}
参考
聊聊Zookeeper的会话(中篇)
09 创建会话:避开日常开发的那些“坑”
ZooKeeper Programmer’s Guide
ZooKeeper会话的创建过程