天天看點

配置中心是如何實作推送的?

一  前言

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

配置中心最核心的能力就是配置的動态推送,常見的配置中心如 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<AsyncTask>> ,但使用 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