現在大部分系統使用的都是分佈式緩存系統Redis。 但在一些場景下,比如緩存單元很大,單元數不多,變化很小,加載時間很長,如算法模型。 這個時候使用本地緩存比Redis的效率要高很多,但是又要保證集羣中各個機器的緩存的一致性,不然就會出現請求耗時不穩定的情況,也有可能出現相同的請求不同服務器返回的結果不一致。
本文介紹了一個簡單的實現集羣中同步各服務器本地緩存的方案。
實現思路:
- 集羣各個節點通過Redis的pub/sub機制實現簡單的消息隊列,把緩存的變化廣播給集羣中所有節點。
- 因爲緩存單元的數據本身很大,但是數量並不多,所以只把緩存數據的id保存在Redis的set中。
整個過程分成兩個階段:初始同步與廣播同步
初始同步
程序啓動時,一開始沒有緩存任何模型數據,進入初始同步階段。流程如下:
初始同步
監聽緩存變更事件
獲取緩存事件後,並不立即操作,後續再順序處理該事件
下面一些操作都用redis命令演示,實際項目中,使用的是jedis
redis> subscribe channel.model
獲取緩存的數據id
一般從redis讀取緩存的模型id列表
redis> smembers cache.models
緩存所有模型數據
根據上一步讀到的id列表,緩存所有模型數據
一般是從數據庫或分佈式文件系統中加載模型
增量更新
如果到緩存模型數據結束,有監聽到緩存變更事件,則依次響應該事件
完成增量更新後,節點接入下一個階段:廣播同步
廣播同步
集羣中的每個節點都訂閱頻道channel.model
, 接收緩存變更的消息(增、刪、改);也在主動變更後,往頻道channel.model
發佈消息來廣播給其他節點。消息分爲以下三種類型:
- 新增緩存
一般是請求第一次到達,或者是模型生成後,收到HTTP更新消息,就會預加載模型文件。
redis> publish channel.model add:1
- 更新緩存
redis> publish channel.model update:1
- 刪除緩存
不僅僅是用戶邏輯觸發緩存的刪除,更大的可能是因爲緩存策略需要刪除長期不使用的緩存。
比如我們常用的Gauva Cache。設置如下:
CacheBuilder.newBuilder()
.maximumSize(1000)
.expireAfterAccess(1, TimeUnit.DAYS)
.removalListener((RemovalListener<Integer, Model>) notification -> {
final RemovalCause cause = notification.getCause();
switch (cause) {
//緩存到期
case EXPIRED:
//緩存大小限制
case SIZE:
//緩存被垃圾回收
case COLLECTED:
//如果是緩存到期等原因被刪除,則需要通知分佈式環境下的其他機器也要刪除
distCacheManager.removeFromCache(notification.getKey());
break;
//緩存顯示刪除(這裏沒有調用是避免事件循環)
case EXPLICIT:
//緩存顯示替換(這了沒有調用是避免事件循環)
case REPLACED:
break;
default:
log.error("there should not be [{}]", cause);
}
redis> publish channel.model delete:1
優缺點
優點:
- 實現簡單:基於廣泛使用的Redis,沒有引入其他組件,而且實現邏輯也很簡單
缺點:
- 在一些極端情況下,會出現緩存的更新不及時。比如模型更新後,收到請求的進程本地更新後返回結果,因爲消息是異步的,可能還沒達到Redis時,進程就掛掉了。
- 當模型更新時,各個進程中緩存的模型在很短的時間內存在不一致的情況。 會影響部分用戶。不過這種情況是完全可以接受的。
注意事項
- 因爲所有節點都訂閱了同一頻道
channel.model
,也會接聽到自身廣播的事件,所以節點在響應事件時,可以做冪等處理 - Java程序使用Jedis實現頻道訂閱,訂閱調用是阻塞的,所以需要使用單獨的線程來執行,不能阻塞主幹流程
- Jedis頻道訂閱線程可能會與Redis斷開連接,需要捕捉異常,並重新訂閱