【轉】認識長輪詢:配置中心是如何實現推送的?

一 前言

傳統的靜態配置方式想要修改某個配置時,必須重新啓動一次應用,如果是數據庫連接串的變更,那可能還容易接受一些,但如果變更的是一些運行時實時感知的配置,如某個功能項的開關,重啓應用就顯得有點大動干戈了。配置中心正是爲了解決此類問題應運而生的,特別是在微服務架構體系中,更傾向於使用配置中心來統一管理配置。

配置中心最核心的能力就是配置的動態推送,常見的配置中心如 Nacos、Apollo 等都實現了這樣的能力。在早期接觸配置中心時,我就很好奇,配置中心是如何做到服務端感知配置變化實時推送給客戶端的,在沒有研究過配置中心的實現原理之前,我一度認爲配置中心是通過長連接來做到配置推送的。事實上,目前比較流行的兩款配置中心:Nacos 和 Apollo 恰恰都沒有使用長連接,而是使用的長輪詢。本文便是介紹一下長輪詢這種聽起來好像已經是上個世紀的技術,老戲新唱,看看能不能品出別樣的韻味。文中會有代碼示例,呈現一個簡易的配置監聽流程。

二 數據交互模式

衆所周知,數據交互有兩種模式:Push(推模式)和 Pull(拉模式)。

推模式指的是客戶端與服務端建立好網絡長連接,服務方有相關數據,直接通過長連接通道推送到客戶端。其優點是及時,一旦有數據變更,客戶端立馬能感知到;另外對客戶端來說邏輯簡單,不需要關心有無數據這些邏輯處理。缺點是不知道客戶端的數據消費能力,可能導致數據積壓在客戶端,來不及處理。

拉模式指的是客戶端主動向服務端發出請求,拉取相關數據。其優點是此過程由客戶端發起請求,故不存在推模式中數據積壓的問題。缺點是可能不夠及時,對客戶端來說需要考慮數據拉取相關邏輯,何時去拉,拉的頻率怎麼控制等等。

三 長輪詢與輪詢

在開頭,重點介紹一下長輪詢(Long Polling)和輪詢(Polling)的區別,兩者都是拉模式的實現。

“輪詢”是指不管服務端數據有無更新,客戶端每隔定長時間請求拉取一次數據,可能有更新數據返回,也可能什麼都沒有。配置中心如果使用「輪詢」實現動態推送,會有以下問題:

  • 推送延遲。客戶端每隔 5s 拉取一次配置,若配置變更發生在第 6s,則配置推送的延遲會達到 4s。
  • 服務端壓力。配置一般不會發生變化,頻繁的輪詢會給服務端造成很大的壓力。
  • 推送延遲和服務端壓力無法中和。降低輪詢的間隔,延遲降低,壓力增加;增加輪詢的間隔,壓力降低,延遲增高。

“長輪詢”則不存在上述的問題。客戶端發起長輪詢,如果服務端的數據沒有發生變更,會 hold 住請求,直到服務端的數據發生變化,或者等待一定時間超時纔會返回。返回後,客戶端又會立即再次發起下一次長輪詢。配置中心使用「長輪詢」如何解決「輪詢」遇到的問題也就顯而易見了:

  • 推送延遲。服務端數據發生變更後,長輪詢結束,立刻返回響應給客戶端。
  • 服務端壓力。長輪詢的間隔期一般很長,例如 30s、60s,並且服務端 hold 住連接不會消耗太多服務端資源。

以 Nacos 爲例的長輪詢流程如下:

可能有人會有疑問,爲什麼一次長輪詢需要等待一定時間超時,超時後又發起長輪詢,爲什麼不讓服務端一直 hold 住?主要有兩個層面的考慮,一是連接穩定性的考慮,長輪詢在傳輸層本質上還是走的 TCP 協議,如果服務端假死、fullgc 等異常問題,或者是重啓等常規操作,長輪詢沒有應用層的心跳機制,僅僅依靠 TCP 層的心跳保活很難確保可用性,所以一次長輪詢設置一定的超時時間也是在確保可用性。除此之外,在配置中心場景,還有一定的業務需求需要這麼設計。在配置中心的使用過程中,用戶可能隨時新增配置監聽,而在此之前,長輪詢可能已經發出,新增的配置監聽無法包含在舊的長輪詢中,所以在配置中心的設計中,一般會在一次長輪詢結束後,將新增的配置監聽給捎帶上,而如果長輪詢沒有超時時間,只要配置一直不發生變化,響應就無法返回,新增的配置也就沒法設置監聽了。

四 配置中心長輪詢設計

上文的圖中,介紹了長輪詢的流程,本節會詳解配置中心長輪詢的設計細節。

客戶端發起長輪詢

客戶端發起一個 HTTP 請求,請求信息包含配置中心的地址,以及監聽的 dataId(本文出於簡化說明的考慮,認爲 dataId 是定位配置的唯一鍵)。若配置沒有發生變化,客戶端與服務端之間一直處於連接狀態。

服務端監聽數據變化

服務端會維護 dataId 和長輪詢的映射關係,如果配置發生變化,服務端會找到對應的連接,爲響應寫入更新後的配置內容。如果超時內配置未發生變化,服務端找到對應的超時長輪詢連接,寫入 304 響應。

304 在 HTTP 響應碼中代表“未改變”,並不代表錯誤。比較契合長輪詢時,配置未發生變更的場景。

客戶端接收長輪詢響應

首先查看響應碼是 200 還是 304,以判斷配置是否變更,做出相應的回調。之後再次發起下一次長輪詢。

服務端設置配置寫入的接入點

主要用配置控制檯和 client 發佈配置,觸發配置變更。

這幾點便是配置中心實現長輪詢的核心步驟,也是指導下面章節代碼實現的關鍵。但在編碼之前,仍有一些其他的注意點需要實現闡明。

配置中心往往是爲分佈式的集羣提供服務的,而每個機器上部署的應用,又會有多個 dataId 需要監聽,實例級別 * 配置數是一個不小的數字,配置中心服務端維護這些 dataId 的長輪詢連接顯然不能用線程一一對應,否則會導致服務端線程數爆炸式增長。一個 Tomcat 也就 200 個線程,長輪詢也不應該阻塞 Tomcat 的業務線程,所以需要配置中心在實現長輪詢時,往往採用異步響應的方式來實現。而比較方便實現異步 HTTP 的常見手段便是 Servlet3.0 提供的 AsyncContext 機制。

Servlet3.0 並不是一個特別新的規範,它跟 Java 6 是同一時期的產物。例如 SpringBoot 內嵌的 Tomcat 很早就支持了 Servlet3.0,你無需擔心 AsyncContext 機制不起作用。

SpringMVC 實現了 DeferredResult 和 Servlet3.0 提供的 AsyncContext 其實沒有多大區別,我並沒有深入研究過兩個實現背後的源碼,但從使用層面上來看,AsyncContext 更加的靈活,例如其可以自定義響應碼,而 DeferredResult 在上層做了封裝,可以快速的幫助開發者實現一個異步響應,但沒法細粒度地控制響應。所以下文的示例中,我選擇了 AsyncContext。

五 配置中心長輪詢實現

1 客戶端實現

@Slf4j
public class ConfigClient {

    private CloseableHttpClient httpClient;
    private RequestConfig requestConfig;

    public ConfigClient() {
        // ① httpClient 客戶端超時時間要大於長輪詢約定的超時時間
        this.requestConfig = RequestConfig.custom().setSocketTimeout(40000).build();
        this.httpClient = HttpClientBuilder.create().setDefaultRequestConfig(this.requestConfig).build();
    }

    @SneakyThrows
    public void longPolling(String url, String dataId) {
        String endpoint = url + "?dataId=" + dataId;
        HttpGet request = new HttpGet(endpoint);
        CloseableHttpResponse response = httpClient.execute(request);
        switch (response.getStatusLine().getStatusCode()) {
            case 200: {
                BufferedReader rd = new BufferedReader(new InputStreamReader(response.getEntity().getContent()));
                StringBuilder result = new StringBuilder();
                String line;
                while ((line = rd.readLine()) != null) {
                    result.append(line);
                }
                response.close();
                String configInfo = result.toString();
                log.info("dataId: [{}] changed, receive configInfo: {}", dataId, configInfo);
                longPolling(url, dataId);
                break;
            }
            // ② 304 響應碼標記配置未變更
            case 304: {
                log.info("longPolling dataId: [{}] once finished, configInfo is unchanged, longPolling again", dataId);
                longPolling(url, dataId);
                break;
            }
            default: {
                throw new RuntimeException("unExcepted HTTP status code");
            }
        }

    }

    public static void main(String[] args) {
        // httpClient 會打印很多 debug 日誌,關閉掉
        Logger logger = (Logger) LoggerFactory.getLogger("org.apache.http");
        logger.setLevel(Level.INFO);
        logger.setAdditive(false);

        ConfigClient configClient = new ConfigClient();
        // ③ 對 dataId: user 進行配置監聽
        configClient.longPolling("http://127.0.0.1:8080/listener", "user");
    }
}

主要有三個注意點:

  • RequestConfig.custom().setSocketTimeout(40000).build() :httpClient 客戶端超時時間要大於長輪詢約定的超時時間。很好理解,不然還沒等服務端返回,客戶端會自行斷開 HTTP 連接。
  • response.getStatusLine().getStatusCode() == 304 :前文介紹過,約定使用 304 響應碼來標識配置未發生變更,客戶端繼續發起長輪詢。
  • configClient.longPolling("http://127.0.0.1:8080/listener", "user"):在示例中,我們處於簡單考慮,僅僅啓動一個客戶端,對單一的 dataId:user 進行監聽(注意,需要先啓動 server 端)。

2 服務端實現

@RestController
@Slf4j
@SpringBootApplication
public class ConfigServer {

    @Data
    private static class AsyncTask {
        // 長輪詢請求的上下文,包含請求和響應體
        private AsyncContext asyncContext;
        // 超時標記
        private boolean timeout;

        public AsyncTask(AsyncContext asyncContext, boolean timeout) {
            this.asyncContext = asyncContext;
            this.timeout = timeout;
        }
    }

    // guava 提供的多值 Map,一個 key 可以對應多個 value
    private Multimap<String, AsyncTask> dataIdContext = Multimaps.synchronizedSetMultimap(HashMultimap.create());

    private ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat("longPolling-timeout-checker-%d").build();
    private ScheduledExecutorService timeoutChecker = new ScheduledThreadPoolExecutor(1, threadFactory);

    // 配置監聽接入點
    @RequestMapping("/listener")
    public void addListener(HttpServletRequest request, HttpServletResponse response) {

        String dataId = request.getParameter("dataId");

        // 開啓異步
        AsyncContext asyncContext = request.startAsync(request, response);
        AsyncTask asyncTask = new AsyncTask(asyncContext, true);

        // 維護 dataId 和異步請求上下文的關聯
        dataIdContext.put(dataId, asyncTask);

        // 啓動定時器,30s 後寫入 304 響應
        timeoutChecker.schedule(() -> {
            if (asyncTask.isTimeout()) {
                dataIdContext.remove(dataId, asyncTask);
                response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
                asyncContext.complete();
            }
        }, 30000, TimeUnit.MILLISECONDS);
    }

    // 配置發佈接入點
    @RequestMapping("/publishConfig")
    @SneakyThrows
    public String publishConfig(String dataId, String configInfo) {
        log.info("publish configInfo dataId: [{}], configInfo: {}", dataId, configInfo);
        Collection<AsyncTask> asyncTasks = dataIdContext.removeAll(dataId);
        for (AsyncTask asyncTask : asyncTasks) {
            asyncTask.setTimeout(false);
            HttpServletResponse response = (HttpServletResponse) asyncTask.getAsyncContext().getResponse();
            response.setStatus(HttpServletResponse.SC_OK);
            response.getWriter().println(configInfo);
            asyncTask.getAsyncContext().complete();
        }
        return "success";
    }

    public static void main(String[] args) {
        SpringApplication.run(ConfigServer.class, args);
    }

}

對上述實現的一些說明:

  • @RequestMapping("/listener") ,配置監聽接入點,也是長輪詢的入口。在獲取 dataId 之後,使用 request.startAsync 將請求設置爲異步,這樣在方法結束後,不會佔用 Tomcat 的線程池。
  • dataIdContext.put(dataId, asyncTask) 會將 dataId 和異步請求上下文給關聯起來,方便配置發佈時,拿到對應的上下文。注意這裏使用了一個 guava 提供的數據結構 Multimap<String, AsyncTask> dataIdContext ,它是一個多值 Map,一個 key 可以對應多個 value,你也可以理解爲 Map<String,List> ,但使用 Multimap 維護起來可以更方便地處理一些併發邏輯。至於爲什麼會有多值,很好理解,因爲配置中心的 Server 端會接受來自多個客戶端對同一個 dataId 的監聽。
  • timeoutChecker.schedule() 啓動定時器,30s 後寫入 304 響應。再結合之前客戶端的邏輯,接收到 304 之後,會重新發起長輪詢,形成一個循環。
  • @RequestMapping("/publishConfig") ,配置發佈的入口。配置變更後,根據 dataId 一次拿出所有的長輪詢,爲之寫入變更的響應,同時不要忘記取消定時任務。至此,完成了一個配置變更後推送的流程。

3 啓動配置監聽

先啓動 ConfigServer,再啓動 ConfigClient。客戶端打印長輪詢的日誌如下:

發佈一條配置:

curl -X GET "localhost:8080/publishConfig?dataId=user&configInfo=helloworld"

服務端打印日誌如下:

客戶端接受配置推送:

六 實現細節思考

爲什麼需要定時器返回 304?

上述的實現中,服務端採用了一個定時器,在配置未發生變更時,定時返回 304,客戶端接收到 304 之後,重新發起長輪詢。在前文,已經解釋過了爲什麼需要超時後重新發起長輪詢,而不是由服務端一直 hold,直到配置變更再返回,但可能有讀者還會有疑問,爲什麼不由客戶端控制超時,服務端去除掉定時器,這樣客戶端超時後重新發起下一次長輪詢,這樣的設計不是更簡單嗎?無論是 Nacos 還是 Apollo 都有這樣的定時器,而不是靠客戶端控制超時,這樣做主要有兩點考慮:

  • 和真正的客戶端超時區分開。
  • 僅僅使用異常(Exception)來表達異常流,而不應該用異常來表達正常的業務流。304 不是超時異常,而是長輪詢中配置未變更的一種正常流程,不應該使用超時異常來表達。

客戶端超時需要單獨配置,且需要比服務端長輪詢的超時要長。正如上述的 demo 中客戶端超時設置的是 40s,服務端判斷一次長輪詢超時是 30s。這兩個值在 Nacos 中默認是 30s 和 29.5s,在 Apollo 中默認是是 90s 和 60s。

長輪詢包含多組 dataId

在上述的 demo 中,一個 dataId 會發起一次長輪詢,在實際配置中心的設計中肯定不能這樣設計,一般的優化方式是,一批 dataId 組成一個組批量包含在一個長輪詢任務中。在 Nacos 中,按照 3000 個 dataId 爲一組包裝成一個長輪詢任務。

七 長輪詢和長連接

講完實現細節,本文最核心的部分已經介紹完了。再回到最前面提到的數據交互模式上提到的推模型和拉模型,其實在寫這篇文章時,我曾經問過交流羣中的小夥伴們“配置中心實現動態推送的原理”,他們中絕大多數人認爲是長連接的推模型。然而事實上,主流的配置中心幾乎都是使用了本文介紹的長輪詢方案,這又是爲什麼呢?

我也翻閱了不少博客,顯然他們給出的理由並不能說服我,我嘗試着從自己的角度分析了一下這個既定的事實:

  • 長輪詢實現起來比較容易,完全依賴於 HTTP 便可以實現全部邏輯,而 HTTP 是最能夠被大衆接受的通信方式。
  • 長輪詢使用 HTTP,便於多語言客戶端的編寫,大多數語言都有 HTTP 的客戶端。

那麼長連接是不是真的就不適合用於配置中心場景呢?有人可能會認爲維護一條長連接會消耗大量資源,而長輪詢可以提升系統的吞吐量,而在配置中心場景,這一假設並沒有實際的壓測數據能夠論證。

另外,翻閱了一下 Nacos 2.0 的 milestone,我發現了一個有意思的規劃,Nacos 的註冊中心(目前是短輪詢 + udp 推送)和配置中心(目前是長輪詢)都有計劃改造爲長連接模式。

再回過頭來看,長輪詢實現已經將配置中心這個組件支撐的足夠好了,替換成長連接,一定需要找到合適的理由纔行。

八 總結

本文介紹了長輪詢、輪詢、長連接這幾種數據交互模型的差異性。

分析了 Nacos 和 Apollo 等主流配置中心均是通過長輪詢的方式實現配置的實時推送的。實時感知建立在客戶端拉的基礎上,因爲本質上還是通過 HTTP 進行的數據交互,之所以有“推”的感覺,是因爲服務端 hold 住了客戶端的響應體,並且在配置變更後主動寫入了返回 response 對象再進行返回。



原文鏈接:認識長輪詢:配置中心是如何實現推送的?

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章