背景
Zookeeper作为一个服务器,需要管理与客户端的连接和会话,如接受连接,网络IO,和过期清理等,此文章就介绍下Zookeeper的过期策略.
注:目前网络上的资料介绍的都是会话的过期策略,在最新版Zookeeper中,将过期策略的代码抽象为ExpiryQueue
类,除了会话,连接也用此类进行过期处理,但为了行文方便,下文中一律使用会话进行介绍
注:在下文中使用过期时间表示会话或连接何时过期,对应的英文为expiration time
,用unix时间戳表示.超时时间表示会话或连接的超时时间,对应的英文为timeout
,如5000ms
简单的会话清理策略
在介绍分桶策略之前,我先介绍一种比较简略的会话清理策略.
数据结构
使用3个TreeMap
作为数据结构,如下:
- timeoutMap:其key是session,value是该会话的timeout(用于保存会话的timeout)
- elementMap:其key是session,value是该回话的过期时间(expiration time)(过期时间都是在当前时间之后的)
- expiryMap:其key是过期时间,value是原定在该过期时间过期的session
更新会话过期时间
客户端与服务端通信过程中更新上述3个数据结构,流程如下:
- 会话初始化时将其加入到timeoutMap,保存该会话的timeout
- 每当服务端与该会话进行通信时,更新会话的过期时间,将其过期时间修改为currentTime+timeout,包括2个步骤
- 通过elementMap获取该session的prevExpiryTime,根据prevExpiryTime在expiryMap删除其对应的session
- 修改elementMap,将session对应的过期时间修改为currentTime+timeout
清理过期会话
后台启动一个清理线程清理过期的session,线程中无限循环,执行工作如下:
- 执行定时的wait()操作,等待到当前时间为expiryMap中最小的key(使用TreeMap的原因),清理该key对应的session.
可能有同学对清理线程中执行的操作不是非常理解,由于expireMap的key是过期时间,当前时间到了过期时间,对应的session就要被清除.若在此之前,session与服务端进行了通信,就会更新expiryMap
,删除该session原本的过期时间,该session就不会被清理线程清理.
优缺点
缺点:
- 若有多个session在同一时间过期,则
exipryMap
中只保存了最后一个调用put(time,session)方法的session的信息
解决方案:将exipryMap
的value修改为Set<Session>
- 若存在大量会话,则清理线程会在短时间内执行大量的wait()操作,虽然不知道会有什么不足,但总感觉有问题,比如系统能在短时间内调度大量的wait()操作吗?
优点:
- 实时性高
分桶策略
为了解决上述策略的缺点2,就引入了分桶策略,其将过期时间在某个时间段内的会话将看做一组,在每组最后的时间点,即下图中expiration time n,批量清理此组的过期会话.此时间段的长度可适当调整,若过大,则效率较高,实时性不足;若过小,则效率较低,实时性高.
具体实现
改进
ExpiryQueue
的实现思路与上文基本相同,其改进如下:
expiryMap
的value修改为SetexpiryMap
使用ConCurrentHashMap
,使用nextExpirationTime
存放下一次进行会话超时检查的时间- 没有使用
timeoutMap
存放会话的超时时间,每次更新会话过期时间时将timeout当做参数调用update(E elem, int timeout)
- 使用泛型,被清理的元素可以是
SessionImpl
,也可以是NIOServerCnxn
- 增加了
expirationInterval
属性,用于存放分桶策略中的两次超时时间的间隔(也即分组时间段长度)
update
/**
* 为队列中的元素添加或更新过期时间,将超时四舍五入到此队列使用的到期时间间隔。
* <p>
* 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();
//2.计算该会话新的的过期时间
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.
//3.将会话迁移到新bucket上
Set<E> set = expiryMap.get(newExpiryTime);
if (set == null) {
// Construct a ConcurrentHashSet using a ConcurrentHashMap
set = Collections.newSetFromMap(
new ConcurrentHashMap<>());
// 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.
//4.从旧bucket上删除该回话
//获取该会话旧的过期时间,根据旧的过期时间在expiryMap获取在该过期时间过期的session集合,从该集合中删除该会话
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;
}
poll
/**
* 将即将超时的元素集合从{@link #expiryMap}中删除,此方法需要通过检查{@link #getWaitTime()}来频繁地调用,
* 否则将会有大量已超时的元素堆积在{@link #expiryMap}中
* <p>
* Remove the next expired set of elements from expireMap. This method needs
* to be called frequently enough by checking getWaitTime(), otherwise there
* will be a backlog of empty sets queued up in expiryMap.
*
* @return next set of expired elements, or an empty set if none are
* ready
*/
public Set<E> poll() {
long now = Time.currentElapsedTime();
long expirationTime = nextExpirationTime.get();
if (now < expirationTime) {
return Collections.emptySet();
}
Set<E> set = null;
long newExpirationTime = expirationTime + expirationInterval;
if (nextExpirationTime.compareAndSet(
expirationTime, newExpirationTime)) {
set = expiryMap.remove(expirationTime);
}
if (set == null) {
return Collections.emptySet();
}
return set;
}
remove
/**
* Removes element from the queue.
*
* @param elem element to remove
* @return time at which the element was set to expire, or null if
* it wasn't present
*/
public Long remove(E elem) {
Long expiryTime = elemMap.remove(elem);
if (expiryTime != null) {
Set<E> set = expiryMap.get(expiryTime);
if (set != null) {
set.remove(elem);
// We don't need to worry about removing empty sets,
// they'll eventually be removed when they expire.
}
}
return expiryTime;
}
连接和会话
虽然连接和会话都可通过ExpiryQueue
进行清理,但其相关配置不同
方式 | expirationInterval配置 | 默认值 |
---|---|---|
连接 | 使用ticktime 作为expirationInterval值 | 3000ms |
会话 | 通过系统属性zookeeper.nio.sessionlessCnxnTimeout 配置 | 10000ms |
总结
- 本文介绍了Zookeeper中
NIOServerCnxn
和SessionImpl
的过期策略,使用分桶策略将会话分组,在保证实时性的同时提高了效率 - 为何在存在会话过期清理的情况下,还需对连接进行过期清理?
个人认为会话和连接还是两个层次的概念,会话过期清理是一定需要实现的,而连接的过期清理应该属于对网络IO的一种优化,在NIOServerCnxnFactory
使用ExpiryQueue
进行会话过期清理,但NettyServerCnxnFactory
便没有显式的使用ExpiryQueue