服務通常需要考慮速度和容量限制,增強系統的魯棒性。
背景
筆者曾負責過某公司內公眾號服務開發。公眾號介面服務接收到使用者的推送請求後會構造公眾號訊息並寫入訊息佇列,路由服務非同步接收到訊息後進行訊息儲存後,再交由推送服務向使用者推送訊息。基本流程如下圖所示:
訊息儲存過程:- 路由服務發起訊息儲存請求,並將訊息快取到本地;
- 儲存服務成功儲存訊息後非同步傳送成功通知;
- 路由服務接收到成功通知後從本地快取獲取訊息內容後進行後續推送處理;
問題
若儲存服務異常,系統會出現什麼問題?
- 路由服務使用local cache臨時儲存訊息。當儲存服務異常時,若不加限制,路由服務極有可能導致記憶體溢位,路由服務不可用;
- 路由服務發起訊息儲存請求為非同步過程,很有可能會一直消費MQ裡的訊息,導致儲存服務承受更大的服務壓力。同時會存在訊息可能丟失的風險;
方案
基於訊號量實現限制容量的本地快取。容量大小為訊號量個數,當路由服務發起訊息儲存請求時,訊號量減1。當路由服務接收到儲存成功通知後,訊號量加1。
- 儲存服務正常時,容量限制機制不會起作用,服務效能不會受到影響;
- 儲存服務異常時,本地快取的容量會越來越小。最後再無可用的訊號量時,服務會阻塞等待。此時不再對訊息佇列進行消費。既避免了服務OOM的狀況,也降低了服務繼續惡化的可能;
實現
基於訊號量實現的限容資料結構BlockingHashMap
public class BlockingHashMap<K, V> {
private static final int DEFAULT_MAX_AVAILABLE = 1000;
private final ConcurrentHashMap<K, V> inmap = new ConcurrentHashMap<>(DEFAULT_MAX_AVAILABLE);
private Semaphore sem;
public BlockingHashMap() {
this(DEFAULT_MAX_AVAILABLE);
}
public BlockingHashMap(int permits) {
sem = new Semaphore(permits);
}
public V put(K key, V value) {
boolean wasAdded = false;
try {
sem.acquire();
V v = inmap.putIfAbsent(key, value);
if (v != null) {
return v;
}
wasAdded = true;
} catch (Exception e) {
} finally {
if (!wasAdded) {
// 若新增失敗,需要釋放訊號量
sem.release();
}
}
return value;
}
public V remove(K key) {
V value = inmap.remove(key);
if (value != null) {
// 只有當成功移除元素時才釋放訊號量
sem.release();
}
return value;
}
}
複製程式碼
總結
基於訊號量實現固定容量的本地快取,簡單有效。