1.概述
上篇文章主要介绍了服务发布注册流程。从上篇文章可知,sofa-registry为了避免数据分片对DataServer连接的压力,加了一层代理(SessionServer)。这篇文章主要介绍一下sofa注册中心SessionServer的实现原理。后面顺带说一下data层的扩所容。整个框架的源码分析就可以告一段落了。
2.关注点
我们主要关注以下几点:
- 对Subscriber的订阅处理流程
- SessionServer与DataServer的推拉模型
- SessionServer的缓存,以及如何维护缓存一致性
- SessionServer一致性哈希选取DataServer
- dataserver如何实现平滑扩容
3.源码解析
我们针对每一个关注点,进行详细的源码跟踪。首先我们应该知道的是,session作为会话层,会和dataservcer通信,保证数据变化时及时同步数据。会和metaserver通信,同步节点信息。也会和客户端通信,提供注册服务。
SessionServerConfiguration
@Bean(name = "serverHandlers")
public Collection<AbstractServerHandler> serverHandlers() {
Collection<AbstractServerHandler> list = new ArrayList<>();
list.add(publisherHandler());
list.add(subscriberHandler());
list.add(watcherHandler());
list.add(clientNodeConnectionHandler());
list.add(cancelAddressRequestHandler());
list.add(syncConfigHandler());
return list;
}
@Bean(name = "dataClientHandlers")
public Collection<AbstractClientHandler> dataClientHandlers() {
Collection<AbstractClientHandler> list = new ArrayList<>();
list.add(dataNodeConnectionHandler());
list.add(dataChangeRequestHandler());
list.add(dataPushRequestHandler());
return list;
}
@Bean(name = "metaClientHandlers")
public Collection<AbstractClientHandler> metaClientHandlers() {
Collection<AbstractClientHandler> list = new ArrayList<>();
list.add(metaNodeConnectionHandler());
list.add(nodeChangeResultHandler());
list.add(notifyProvideDataChangeHandler());
return list;
}
上面就是它提供的处理器。其中serverHandlers是为客户端提供的服务处理器,dataClientHandlers是用来处理dataserver的请求。 metaClientHandlers则是处理metaserver的请求。不同的点就是serverHandlers是作为服务端。后两者时作为客户端。 ok,那我们继续关注核心点。
SessionServer一致性哈希选取DataServer
首先我们应该要知道节点信息如何而来。其实有两个途径。
1.session启动的时候
private void registerSessionNode(URL leaderUrl) {
URL clientUrl = new URL(NetUtil.getLocalAddress().getHostAddress(), 0);
SessionNode sessionNode = new SessionNode(clientUrl,
sessionServerConfig.getSessionServerRegion());
Object ret = sendMetaRequest(sessionNode, leaderUrl);
if (ret instanceof NodeChangeResult) {
NodeChangeResult nodeChangeResult = (NodeChangeResult) ret;
NodeManager nodeManager = NodeManagerFactory.getNodeManager(nodeChangeResult
.getNodeType());
//update data node info
nodeManager.updateNodes(nodeChangeResult);
LOGGER.info("Register MetaServer Session Node success!get data node list {}",
nodeChangeResult.getNodes());
}
}
session启动的时候会注册自己到metaserver,同时metaserver会返回所有的dataNode。session收到请求后会调用updateNodes方法建立hash存储结构。其实就是通过ConsistentHash实现的一致性哈希。
2.NodeChangeResultHandler处理metaserver的节点变化请求
通过这两个方式,可以保证节点的一致性。这里其实有个问题就是如何做到平滑扩所容问题。后期我们会专门研究这方面。
算法实现
通过SortedMap。以及MD5哈希算法。当然实现了带虚拟节点的一致性hash。
public ConsistentHash(HashFunction hashFunction, int numberOfReplicas, Collection<T> nodes) {
this.realNodes = new HashSet<>();
this.hashFunction = hashFunction;
this.numberOfReplicas = numberOfReplicas;
for (T node : nodes) {
addNode(node);
}
}
private void addNode(T node) {
realNodes.add(node);
for (int i = 0; i < numberOfReplicas; i++) {
// The string addition forces each replica to have different hash
circle.put(hashFunction.hash(node.getNodeName() + SIGN + i), node);
}
}
通过numberOfReplicas,实现了副本为numberOfReplicas的一致性hash。
public T getNodeFor(Object key) {
if (circle.isEmpty()) {
return null;
}
int hash = hashFunction.hash(key);
T node = circle.get(hash);
if (node == null) {
// inexact match -- find the next value in the circle
SortedMap<Integer, T> tailMap = circle.tailMap(hash);
hash = tailMap.isEmpty() ? circle.firstKey() : tailMap.firstKey();
node = circle.get(hash);
}
return node;
}
获取节点的方式也很简单,先对key进行hash,如果没有命中,顺时针找对应节点即可。通过circle.tailMap完美解决环问题。
当然,我们需要注意的一点是,一致性hash并不能保证数据的高可用。如果多个session的data节点数据同步不及时,也会导致数据写入到不同的节点。也就是平滑扩缩容问题。后期我们研究它是如何做到的。
SessionServer的缓存,以及如何维护缓存一致性
对于session维持缓存是非常有必要的,因为如果订阅数据不发生变化。session是不需要频繁向dataServer请求数据。减小对dataServer的压力。当然缓存的大小是需要控制的。所以这里使用了LoadingCache的数据结构。通过对key设置过期时间,定期从datServer拉去数据更新缓存。当缓存数据更新之后,dataServer会通知session,及时更新缓存。
设置缓存
public SessionCacheService() {
this.readWriteCacheMap = CacheBuilder.newBuilder().maximumSize(1000L)
.expireAfterWrite(31000, TimeUnit.MILLISECONDS).build(new CacheLoader<Key, Value>() {
@Override
public Value load(Key key) {
return generatePayload(key);
}
});
}
通过generatePayload从dataServer拉取缓存。
DataChangeRequestHandler
当订阅数据有变化,dataServer会通知session。session先会让缓存失效。
//update cache when change
sessionCacheService.invalidate(new Key(KeyType.OBJ, DatumKey.class.getName(), new DatumKey(
dataChangeRequest.getDataInfoId(), dataChangeRequest.getDataCenter())));
然后判断版本是否最新。如果不是最新拉取数据,并通知订阅者。这个的前提就是,session得保存一份活跃的订阅者列表。
public void execute() {
Map<String/*dataCenter*/, Datum> datumMap = getDatumsCache();
if (datumMap != null && !datumMap.isEmpty()) {
PushTaskClosure pushTaskClosure = getTaskClosure(datumMap);
for (ScopeEnum scopeEnum : ScopeEnum.values()) {
Map<InetSocketAddress, Map<String, Subscriber>> map = getCache(fetchDataInfoId,
scopeEnum);
if (map != null && !map.isEmpty()) {
for (Entry<InetSocketAddress, Map<String, Subscriber>> entry : map.entrySet()) {
Map<String, Subscriber> subscriberMap = entry.getValue();
if (subscriberMap != null && !subscriberMap.isEmpty()) {
List<String> subscriberRegisterIdList = new ArrayList<>(
subscriberMap.keySet());
//select one row decide common info
Subscriber subscriber = subscriberMap.values().iterator().next();
//remove stopPush subscriber avoid push duplicate
evictReSubscribers(subscriberMap.values());
fireReceivedDataMultiPushTask(datumMap, subscriberRegisterIdList,
scopeEnum, subscriber, subscriberMap, pushTaskClosure);
}
}
}
}
pushTaskClosure.start();
} else {
LOGGER.error("Get publisher data error,which dataInfoId:{}", fetchDataInfoId);
}
}
getDatumsCache会获取新的数据。
getCache方法根据变化的dataInfo获取所有的订阅者缓存。然后遍历通知。
Subscriber的订阅处理流程
case SUBSCRIBER:
Subscriber subscriber = (Subscriber) storeData;
sessionInterests.add(subscriber);
sessionRegistryStrategy.afterSubscriberRegister(subscriber);
1.首先就是保存注册信息
2.sessionRegistryStrategy.afterSubscriberRegister方法处理后续订阅逻辑。 其实就是获取订阅信息,然后push给订阅者。
注意:这里并没有从缓存获取。不知道为何,提个issue问了一下
Emmm,其实就是为了返回新一点的数据。
SessionServer与DataServer的推拉模型
如果有节点变化,dataServer会通知所有的SessionServer。 每个session在启动的时候,都会向所有的data节点注册,建立连接,所有data拥有所有活跃session的连接。 每当节点变化的时候通知session,具体实现在SessionServerNotifier中。
SessionServerNotifier
public void notify(Datum datum, Long lastVersion) {
DataChangeRequest request = new DataChangeRequest(datum.getDataInfoId(),
datum.getDataCenter(), datum.getVersion());
List<Connection> connections = sessionServerConnectionFactory.getSessionConnections();
for (Connection connection : connections) {
doNotify(new NotifyCallback(connection, request));
}
}
private void doNotify(NotifyCallback notifyCallback) {
Connection connection = notifyCallback.connection;
DataChangeRequest request = notifyCallback.request;
try {
//check connection active
if (!connection.isFine()) {
LOGGER
.info(String
.format(
"connection from sessionServer(%s) is not fine, so ignore notify, retryTimes=%s,request=%s",
connection.getRemoteAddress(), notifyCallback.retryTimes, request));
return;
}
Server sessionServer = boltExchange.getServer(dataServerConfig.getPort());
sessionServer.sendCallback(sessionServer.getChannel(connection.getRemoteAddress()),
request, notifyCallback, dataServerConfig.getRpcTimeout());
} catch (Exception e) {
LOGGER.error(String.format(
"invokeWithCallback failed: sessionServer(%s),retryTimes=%s, request=%s",
connection.getRemoteAddress(), notifyCallback.retryTimes, request), e);
onFailed(notifyCallback);
}
}
onFailed是在发送失败的时候执行的回调。因为如果失败,我们有必要进行重试。否则会导致一段时间session节点脏数据情况。 session处理DataChangeRequest请求在上面已经介绍过(DataChangeRequestHandler),不再赘述。
平滑扩缩容
我们知道,sofa是通过一致性hash进行数据分片存储的。节点变化面临两个问题
- 扩容过程中,数据的读写情况。
- 解决数据重分配同步
节点扩容时,数据读写
为了保证节点扩容过程中数据的高可用。对于写操作是禁止的。当数据同步完成之后,写操作才被允许。每个节点都会有一个状态,在扩所容结束后,状态会变更为WOREKING
if (forwardService.needForward()) {
CommonResponse response = new CommonResponse();
response.setSuccess(false);
response.setMessage("Request refused, Server status is not working");
return response;
}
对于读请求,会被转发到拥有该数据分片的数据节点。这里其实就是获取到一致性hash的下一个节点。然后重定向即可。
if (forwardService.needForward()) {
try {
return forwardService.forwardRequest(dataInfoId, request);
} catch (Exception e) {
//省略
}
}
节点所容,数据读写
节点所容的时候,数据的读写会被请求到下一个节点。所以切换是平滑稳定的。
节点变更数据同步
数据同步的前提是,data节点要实时了解节点变更情况。那么dataserver如何得知其他data节点变动?
1.ConnectionRefreshTask
在data启动的时候,会有定时任务。会发布DataServerChangeEvent事件。
2.ServerChangeHandler
每当有节点上下线,metaserver会发送NodeChangeResult请求,通知dataserver。同样,dataserver收到请求后会发布DataServerChangeEvent事件。
所以到这里,我们只需要关注DataServerChangeEvent事件处理流程。
DataServerChangeEvent
这个方法逻辑如下:
迭代变化节点的dataCenter
- 和新节点建立连接
- 移除下线节点
- 如果是本地机房节点变更,则发布一个本地节点变更事件(LocalDataServerChangeEvent)。
节点变化,本地机房处理逻辑(LocalDataServerChangeEventHandler)
if (LocalServerStatusEnum.WORKING == dataNodeStatus.getStatus()) {
//if local server is working, compare sync data
notifyToFetch(event, changeVersion);
} else {
dataServerCache.checkAndUpdateStatus(changeVersion);
//if local server is not working, notify others that i am newer
notifyOnline(changeVersion);
dataServerCache.updateItem(event.getLocalDataServerMap(),
event.getLocalDataCenterversion(),
dataServerConfig.getLocalDataCenter());
}
如果当前节点在运行(非变化的节点)。执行notifyToFetch。
如果当前节点是新节点,通知其他节点,上线。
notifyToFetch
这个方法首先会根据hash环计算出变更节点(扩容场景下,变更节点是新节点,缩容场景下,变更节点是下线节点在 Hash 环中的后继节点)所负责的数据分片范围和其备份节点。
然后遍历自身节点内存数据,过滤出属于更节点的分片范围的数据项(getToBeSyncMap方法)。然后向变更节点发送。 NotifyFetchDatumRequest请求,通知变更节点和其副本节点拉取数据。具体代码较长,自行研究。
LocalDataServerCleanHandler
主要用来清空脏数据。所谓脏数据,也就是不属于当前节点分区的dataInfo。主要应对节点扩所容的时,导致数据重分配的case。
总结
整个实现方面相对简单,但从整体架构来讲,这层代理还是非常有必要的。通过缓存可以减少网络io,以及对dataServer的压力。整个框架采用task-listener-strategy来实现逻辑处理,代码可读性高,值得借鉴学习。