hello,大家好,我是小黑,又和大家見面啦~~
在配置中心中,有一個經典的 pub/sub 場景:某個配置項發生變更之後,需要實時的同步到各個服務端節點,同時推送給客戶端叢集。
在之前實現的簡易版配置中心中是通過 redis 的 pub/sub 來實現的。這種實現雖然簡單,但卻強依賴了 redis。
配置中心作為一個基礎元件,如果能儘可能的減少外部依賴,那對使用方來說一定是更友好的。那麼,有沒有可能不使用 MQ 來實現 pub/sub 的場景呢?答案是肯定的。
基於 DB 的 pub/sub 方案
Apollo 在實現上述場景時,並沒有選用基於 MQ 來進行實現,而是通過資料庫實現了一個簡單的訊息佇列。示意圖如下:
大致實現方式如下:
- Admin Service 在配置釋出後會往 ReleaseMessage 表插入一條訊息記錄
- Config Service 中有一個執行緒會每秒掃描一次 ReleaseMessage 表,看是否有新的訊息記錄(怎麼判斷是不是新訊息呢,怎麼保證每個 client 不會重複消費呢?)
- Config Service 如果發現有新的訊息記錄,就會通知給客戶端(怎麼保證通知給每個客戶端呢?每個 Config Service 都通知,不會重複通知嗎?)
下面,就讓我們帶著這幾個問題來學習一下原始碼吧。(畫外音:思路比原始碼更重要)
DatabaseMessageSender
Admin Service 在配置釋出後會呼叫 DatabaseMessageSender#sendMessage
方法,該方法主要做了兩件事情:
- 建立 ReleaseMessage ,然後將其儲存到資料庫中
- 記錄當前儲存的 ReleaseMessage Id,將其放到
DatabaseMessageSender#toClean
佇列中。
為什麼要記錄當前儲存的 ReleaseMessage Id 呢?
在 DatabaseMessageSender
中有個定時任務,會去清除比當前 ID 小的 ReleaseMessage。
ReleaseMessageScanner
Config Service 中通過 ReleaseMessageScanner
元件會每秒(預設配置下)掃描一次 ReleaseMessage 表,來獲取最新的訊息。
有了這個基於 DB 的 pub/sub,Admin Service 在配置釋出之後,每個 Config Service 都會通過 DB 來感知到這個訊息,然後再通知給客戶端。
那 Config Service 又是如何通知客戶端的呢?
基於長輪詢的實時訊息
在 Apollo 的設計中,配置發生更新之後,並不是服務端主動推給客戶端的,而且客戶端通過長輪詢的方式向服務端詢問是否有配置發生了變更。大致思路為:如果在 60 秒內沒有該客戶端關心的配置釋出,那麼會返回 Http 狀態碼 304 給客戶端;如果有該客戶端關心的配置釋出,請求就會立即返回,客戶端從返回的結果中獲取到配置變化的 namespace 後,會立即請求 Config Service 獲取該 namespace 的最新配置。
客戶端的相關程式碼在 RemoteConfigLongPollService#doLongPollingRefresh
,程式碼比較簡單,感興趣的同學可以自行查閱。
這裡我們重點看一下服務端是如何實現的。
在傳統的 servlet
模型中,每個請求都是由某個執行緒處理的,如果一個請求處理的時間較長,那麼這種基於執行緒池的同步模型很快就會把所有執行緒耗盡,導致伺服器無法響應新的請求。
在 servlet 3.0
中引入了非同步支援,允許對一個請求進行非同步處理,工作執行緒在此期間不會被阻塞,可以繼續處理傳入的客戶端請求。
從 Spring 3.2 開始,可以使用 DeferredResult
來實現非同步處理。使用 DeferredResult
時,可以設定超時,超時之後自動返回超時錯誤響應。同時,可以在另一個執行緒中,可以呼叫其 setResult()
寫入結果返回。
在 Apollo 客戶端長輪詢的地址為 /notifications/v2
,對應的服務端程式碼為 NotificationControllerV2
。
在 NotificationControllerV2
中就使用了 Spring 的 DeferredResult
來實現的。本文重在解決問題的思路,就不展示原始碼了,感興趣的同學可以自己閱讀一下原始碼。不過,小黑同學寫了一個簡單的 demo 來幫助我們理解一下 DeferredResult
的使用。
@Slf4j
@RestController
public class DeferredResultDemoController {
private final Multimap<String, DeferredResult<String>> deferredResults = ArrayListMultimap.create();
@GetMapping("/info")
public DeferredResult<String> info(String key) {
// 設定 1 秒超時時間,設定超時是返回的結果
DeferredResult<String> result = new DeferredResult<>(1000L, "key not change");
// 將 result 放到 deferredResults 中, key 即為當前請求所關心的配置項
deferredResults.put(key, result);
// 如果超時,移除當前 DeferredResult,並列印日誌,同時返回 DeferredResult 構造器中傳入的結果
result.onTimeout(() -> {
deferredResults.remove(key, result);
log.info("time out key not change");
});
// 如果完成了,則從 deferredResults 中移除當前 DeferredResult
result.onCompletion(() -> deferredResults.remove(key, result));
return result;
}
@PostConstruct
public void init() {
new Thread(() -> {
while (true) {
try {
TimeUnit.MILLISECONDS.sleep(700);
} catch (InterruptedException e) {
log.info(e.getMessage(), e);
}
// 定時任務,模擬配置更新
// 當 hello key 發生變更之後,從 deferredResults 獲取到相關的 DeferredResult,通過 setResult 方法設定返回結果,同時移除 deferredResults
if (deferredResults.containsKey("hello")) {
Collection<DeferredResult<String>> results = deferredResults.removeAll("hello");
results.forEach(stringDeferredResult -> stringDeferredResult.setResult("hello key change :" + System.currentTimeMillis()));
}
}
}).start();
}
}