Apollo 8 — ConfigService 异步轮询接口的实现

源码

Apollo 长轮询的实现,是通过客户端轮询 /notifications/v2 接口实现的。具体代码在 com.ctrip.framework.apollo.configservice.controller.NotificationControllerV2.java。

这个类也是实现了 ReleaseMessageListener 监控,表明他是一个消息监听器,当有新的消息时,就会调用他的 hanlderMessage 方法。这个具体我们后面再说。

该类只有一个 rest 接口: pollNotification 方法。返回值是 DeferredResult,这是 Spring 支持 Servlet 3 的一个类,关于异步同步的不同,可以看笔者的另一篇文章 异步 Servlet 和同步 Servlet 的性能测试

该接口提供了几个参数:

  1. appId appId
  2. cluster 集群名称
  3. notificationsAsString 通知对象的 json 字符串
  4. dataCenter,idc 属性
  5. clientIp 客户端 IP, 非必传,为了扩展吧估计

大家有么有觉得少了什么? namespace 。

当然,没有 namespace 这个重要的参数是不存在的。

参数在 notificationsAsString 中。客户端会将自己所有的 namespace 传递到服务端进行查询。

是时候上源码了。

@RequestMapping(method = RequestMethod.GET)
  public DeferredResult<ResponseEntity<List<ApolloConfigNotification>>> pollNotification(
      @RequestParam(value = "appId") String appId,// appId
      @RequestParam(value = "cluster") String cluster,// default
      @RequestParam(value = "notifications") String notificationsAsString,// json 对象 List<ApolloConfigNotification>
      @RequestParam(value = "dataCenter", required = false) String dataCenter,// 基本用不上, idc 属性
      @RequestParam(value = "ip", required = false) String clientIp) {

    List<ApolloConfigNotification> notifications =// 转换成对象
          gson.fromJson(notificationsAsString, notificationsTypeReference);
          
    // Spring 的异步对象: timeout 60s, 返回304
    DeferredResultWrapper deferredResultWrapper = new DeferredResultWrapper();
    Set<String> namespaces = Sets.newHashSet();
    Map<String, Long> clientSideNotifications = Maps.newHashMap();
    Map<String, ApolloConfigNotification> filteredNotifications = filterNotifications(appId, notifications);// 过滤一下名字
    // 循环
    for (Map.Entry<String, ApolloConfigNotification> notificationEntry : filteredNotifications.entrySet()) {
      // 拿出 key
      String normalizedNamespace = notificationEntry.getKey();
      // 拿出 value
      ApolloConfigNotification notification = notificationEntry.getValue();
      /* 添加到 namespaces Set */
      namespaces.add(normalizedNamespace);
      // 添加到 client 端的通知, key 是 namespace, values 是 messageId
      clientSideNotifications.put(normalizedNamespace, notification.getNotificationId());
      // 如果不相等, 记录客户端名字
      if (!Objects.equals(notification.getNamespaceName(), normalizedNamespace)) {
        // 记录 key = 标准名字, value = 客户端名字
        deferredResultWrapper.recordNamespaceNameNormalizedResult(notification.getNamespaceName(), normalizedNamespace);
      }
    }// 记在 namespaces 集合, clientSideNotifications 也put (namespace, notificationId)

    // 组装得到需要观察的 key,包括公共的.
    Multimap<String, String> watchedKeysMap =
        watchKeysUtil.assembleAllWatchKeys(appId, cluster, namespaces, dataCenter);// namespaces 是集合
    // 得到 value; 这个 value 也就是 appId + cluster + namespace
    Set<String> watchedKeys = Sets.newHashSet(watchedKeysMap.values());
    // 从缓存得到最新的发布消息
    List<ReleaseMessage> latestReleaseMessages =// 根据 key 从缓存得到最新发布的消息.
        releaseMessageService.findLatestReleaseMessagesGroupByMessages(watchedKeys);

    /* 如果不关闭, 这个请求将会一直持有一个数据库连接. 影响并发能力. 这是一个 hack 操作*/
    entityManagerUtil.closeEntityManager();
    // 计算出新的通知
    List<ApolloConfigNotification> newNotifications =
        getApolloConfigNotifications(namespaces, clientSideNotifications, watchedKeysMap,
            latestReleaseMessages);
    // 不是空, 理解返回结果, 不等待
    if (!CollectionUtils.isEmpty(newNotifications)) {
      deferredResultWrapper.setResult(newNotifications);
    } else {
      // 设置 timeout 回调:打印日志
      deferredResultWrapper
          .onTimeout(() -> logWatchedKeys(watchedKeys, "Apollo.LongPoll.TimeOutKeys"));
      // 设置完成回调:删除 key
      deferredResultWrapper.onCompletion(() -> {
        //取消注册
        for (String key : watchedKeys) {
          deferredResults.remove(key, deferredResultWrapper);
        }
      });

      //register all keys 注册
      for (String key : watchedKeys) {
        this.deferredResults.put(key, deferredResultWrapper);
      }
    }
    // 立即返回
    return deferredResultWrapper.getResult();/** @see DeferredResultHandler 是关键 */
  }

注释写了很多了,再简单说说逻辑:

  1. 解析 JSON 字符串为 List< ApolloConfigNotification> 对象。
  2. 创建 Spring 异步对象。
  3. 处理过滤 namespace。
  4. 根据 namespace 生成需要监听的 key,格式为 appId + cluster + namespace,包括公共 namespace。并获取最新的 Release 信息。
  5. 关闭 Spring 实例管理器,释放数据库资源。
  6. 根据刚刚得到的 ReleaseMessage,和客户端的 ReleaseMessage 的版本进行对比,生成新的配置通知对象集合。
  7. 如果不是空 —— 立即返回给客户端,结束此次调用。如果没有,进入第 8 步。
  8. 设置 timeout 回调方法 —— 打印日志。再设置完成回调方法:删除注册的 key。
  9. 对客户端感兴趣的 key 进行注册,这些 key 都对应着 deferredResultWrapper 对象,可以认为他就是客户端。
  10. 返回 Spring 异步对象。该请求将被异步挂起。

Apollo 的 DeferredResultWrapper 保证了 Spring 的 DeferredResult 对象,泛型内容是 List<ApolloConfigNotification>, 构造这个对象,默认的 timeout 是 60 秒,即挂起 60 秒。同时,对 setResult 方法进行包装,加入了对客户端 key 和服务端 key 的一个映射(大小写不一致) 。

我们刚刚说,Apollo 会将这些 key 注册起来。那么什么时候使用呢,异步对象被挂起,又是上面时候被唤醒呢?

答案就在 handleMessage 方法里。我们刚刚说他是一个监听器,当消息扫描器扫描到新的消息时,会通知所有的监听器,也就是执行 handlerMessage 方法。方法内容如下:

@Override
public void handleMessage(ReleaseMessage message, String channel) {

  String content = message.getMessage();
  if (!Topics.APOLLO_RELEASE_TOPIC.equals(channel) || Strings.isNullOrEmpty(content)) {
    return;
  }
  String changedNamespace = retrieveNamespaceFromReleaseMessage.apply(content);

  //create a new list to avoid ConcurrentModificationException 构造一个新 list ,防止并发失败
  List<DeferredResultWrapper> results = Lists.newArrayList(deferredResults.get(content));

  // 创建通知对象
  ApolloConfigNotification configNotification = new ApolloConfigNotification(changedNamespace, message.getId());
  configNotification.addMessage(content, message.getId());

  //do async notification if too many clients 如果有大量的客户端(100)在等待,使用线程池异步处理
  if (results.size() > bizConfig.releaseMessageNotificationBatch()) {
    // 大量通知批量处理
    largeNotificationBatchExecutorService.submit(() -> {
      for (int i = 0; i < results.size(); i++) { // 循环
        /*
         * 假设一个公共 Namespace 有10W 台机器使用,如果该公共 Namespace 发布时直接下发配置更新消息的话,
         * 就会导致这 10W 台机器一下子都来请求配置,这动静就有点大了,而且对 Config Service 的压力也会比较大。
         * 即"惊群效应"
         */
        if (i > 0 && i % bizConfig.releaseMessageNotificationBatch() == 0) {// 如果处理了一批客户端,休息一下(100ms)
            TimeUnit.MILLISECONDS.sleep(bizConfig.releaseMessageNotificationBatchIntervalInMilli());
        }
        results.get(i).setResult(configNotification);// 通知每个等待的 HTTP 请求
      }
    });
    return;
  }

  // 否则,同步处理
  for (DeferredResultWrapper result : results) {
    result.setResult(configNotification);
  }
}

笔者去除了一些日志和一些数据判断。大致的逻辑如下:

  1. 消息类型必须是 “apollo-release”。然后拿到消息里的 namespace 内容。
  2. 根据 namespace 从注册器里拿出 Spring 异步对象集合
  3. 创建通知对象。
  4. 如果有超过 100 个客户端在等待,那么就使用线程池批量执行通知。否则就同步慢慢执行。
  5. 每处理 100 个客户端就休息 100ms,防止发生惊群效应,导致大量客户端调用配置获取接口,引起服务抖动。
  6. 循环调用 Spring 异步对象的 setResult 方法,让其立即返回。

具体的流程图如下:

其中,灰色区域是扫描器的异步线程,黄色区域是接口的同步线程。他们共享 deferredResults 这个线程安全的 Map,实现异步解耦和实时通知客户端。

总结

好了,这就是 Apollo 的长轮询接口,客户端会不断的轮询服务器,服务器会 Hold住 60 秒,这是通过 Servlet 3 的异步 + NIO 来实现的,能够保持万级连接(Tomcat 默认 10000)。

通过一个线程安全的 Map + 监听器,让扫描器线程和 HTTP 线程共享 Spring 异步对象,即实现了消息实时通知,也让应用程序实现异步解耦。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,053评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,527评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,779评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,685评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,699评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,609评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,989评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,654评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,890评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,634评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,716评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,394评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,976评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,950评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,191评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,849评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,458评论 2 342

推荐阅读更多精彩内容