sofa-registry源码分析(SessionServer核心实现以及dataServer平滑扩容)

539 阅读5分钟

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来实现逻辑处理,代码可读性高,值得借鉴学习。