天天看點

認識長輪詢:配置中心是如何實作推送的?

認識長輪詢:配置中心是如何實作推送的?

作者 | kiritomoe

來源 | 阿裡技術公衆号

一 前言

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

配置中心最核心的能力就是配置的動态推送,常見的配置中心如 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() {
        this.httpClient = HttpClientBuilder.create().build();
        // ① httpClient 用戶端逾時時間要大于長輪詢約定的逾時時間
        this.requestConfig = RequestConfig.custom().setSocketTimeout(40000).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。用戶端列印長輪詢的日志如下:

22:18:09.185 [main] INFO moe.cnkirito.demo.ConfigClient - longPolling dataId: [user] once finished, configInfo is unchanged, longPolling again
22:18:39.197 [main] INFO moe.cnkirito.demo.ConfigClient - longPolling dataId: [user] once finished, configInfo is unchanged, longPolling again           

釋出一條配置:

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

服務端列印日志如下:

2021-01-24 22:18:50.801  INFO 73301 --- [nio-8080-exec-6] moe.cnkirito.demo.ConfigServer           : publish configInfo dataId: [user], configInfo: helloworld           

用戶端接受配置推送:

22:18:50.806 [main] INFO moe.cnkirito.demo.ConfigClient - dataId: [user] changed, receive 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 的用戶端。

那麼長連接配接是不是真的就不适合用于配置中心場景呢?有人可能會認為維護一條長連接配接會消耗大量資源,而長輪詢可以提升系統的吞吐量,而在配置中心場景,這一假設并沒有實際的壓測資料能夠論證,benchmark everything!please~

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

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

八 總結

本文介紹了長輪詢、輪詢、長連接配接這幾種資料互動模型的差異性。

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

通過一個簡單的 demo,實作了長輪詢配置實時推送的過程示範,本文的 demo 示例存放在:

https://github.com/lexburner/longPolling-demo