背景
tcp閘道器出現了記憶體洩漏的現象,經排查後發現是一個java原生記憶體快取導致的。
Map<String, String> belongCache = new ConcurrentHashMap<>();
該記憶體快取作為兜底快取使用,主要邏輯是讀取redis使用者身份資訊後,有則更新到記憶體快取,沒有則從記憶體快取中獲取快取資料。
該記憶體快取直接使用了ConcurrentHashMap實現,尋找效率較高,而且執行緒安全。但是功能比較簡單,且無過期和淘汰能力只能手動淘汰,存在記憶體洩露問題。
因此,需要對記憶體快取進行一次升級,新增淘汰策略,以解決記憶體洩漏問題,故收集了市面上三種常用的記憶體快取元件/框架資訊,以做選型參考。
GuavaCache
前面說到,ConcurrentHashMap主要的缺點是功能簡單沒有過期和淘汰機制,那麼為了解決這些問題,Google提供了一套JVM本地快取框架GuavaCache,底層實現的資料結構類似於ConcurrentHashMap,但是進行了更多的能力擴充,包括快取過期時間、快取容量設定、多種淘汰策略、快取監控統計等。
被動淘汰
過期機制
① 基於建立時間expireAfterWrite
public Cache<String, User> createUserCache() {
return CacheBuilder.newBuilder()
.expireAfterWrite(30L, TimeUnit.MINUTES)
.build();
}
② 基於最後一次訪問時間expireAfterAccess
public Cache<String, User> createUserCache() {
return CacheBuilder.newBuilder()
.expireAfterAccess(30L, TimeUnit.MINUTES)
.build();
}
注: 基於過期機制的被動淘汰,其實現類似redis的惰性刪除(無獨立清理執行緒),在get請求時觸發一次cleanUp操作(tryLock佛系搶鎖以應對高併發場景)。
注: GuavaCache底層也採用了ConcurrentHashMap一樣的分段鎖機制,執行清理時,會且僅會針對當前查詢記錄所在的Segment分片執行清理操作。
重新整理機制
public LoadingCache<String, User> createUserCache() {
return CacheBuilder.newBuilder().refreshAfterWrite(30L, TimeUnit.MINUTES)
.build(newCacheLoader<String, User>() {
@Override
public User load(String key) throwsException {
log.info(key + "使用者快取更新,嘗試CacheLoader回源查詢並回填...");
returnuserDao.getUser(key);
}
});
}
過期機制和重新整理機制對比
① expire
優勢: 有效防止快取擊穿問題,且阻塞等待的方式可以保證業務層面獲取到的快取資料的強一致性。
劣勢: 高併發場景下,如果回源的耗時較長,會導致多個讀執行緒被阻塞等待,影響整體的併發效率
適用場景: 資料極少變更,或者對變更的感知訴求不強,且併發請求同一個key的競爭壓力不大
② refresh(非同步refresh)
優勢: 可以最大限度的保證查詢操作的執行效率,避免過多的執行緒被阻塞等待。
劣勢: 多個執行緒併發請求同一個key對應的快取值拿到的結果可能不一致,在對於一致性要求特別嚴苛的業務場景下可能會引發問題
適用場景: 資料無需過期,但是可能會被修改,需要及時感知並更新快取資料
結合使用: 資料需要過期,也需要在有效期內儘可能保證資料的更新一致性
均不使用: 資料需要永久儲存,且不會變更
注: expire和refresh不是互斥關係,其實是互補關係,即同時設定expire和refresh。不過需要注意的是,refresh的時間設定應小於expire。
引用機制
核心是利用JVM虛擬機器的GC機制來達到資料清理的目的。按照JVM的GC原理,當一個物件不再被引用之後,便會執行一系列的標記清除邏輯,並最終將其回收釋放。實際使用的較少,下面是三種支援的回收機制。
① weakKeys
採用弱引用方式儲存key值內容,當key物件不再被引用的時候,由GC進行回收
② weakValues
採用弱引用方式儲存value值內容,當value物件不再被引用的時候,由GC進行回收
③ softValues
採用軟引用方式儲存value值內容,當記憶體容量滿時基於LRU策略進行回收
容量限制機制
淘汰條件
① 基於快取記錄條數maximumSize
public Cache<String, User> createUserCache() {
return CacheBuilder.newBuilder()
.maximumSize(10000L)
.build();
}
② 基於快取記錄權重maximumWeight+weigher
public Cache<String, User> createUserCache() {
return CacheBuilder.newBuilder()
.maximumWeight(10000L)
.weigher((key, value) -> (int) Math.ceil(instrumentation.getObjectSize(value) / 1024L))
.build();
}
我們透過計算value物件的位元組數(byte)來計算其權重資訊,每1kb的位元組數作為1個權重,整個快取容器的總權重限制為1w,這樣就可以實現將快取記憶體佔用控制在10000*1k≈10M左右
淘汰策略
① FIFO: 先進先出: First In First Out
② LRU: 最近最久未使用: Least Recent Used
③ LFU: 最近最少頻率使用: Least Frequency Used
注: 淘汰策略在此先不做展開,下文討論Caffeine時W-TinyLFU將會進行詳細分析
主動淘汰
介面 | 含義 |
---|---|
invalidate(key) | 刪除指定的記錄 |
invalidateAll(keys) | 批次刪除給定的記錄 |
invalidateAll() | 清空整個快取容器 |
其他
支援整合資料來源
① Callable模式 -> Cache
cache.get(userId, () -> {
log.info(userId + "使用者快取不存在,嘗試回源查詢並回填...");
return userDao.getUser(userId);
});
② CacheLoader模式 -> LoadingCache
CacheBuilder.newBuilder().build(newCacheLoader<String, User>() {
@Override
publicUserload(Stringkey) throwsException {
log.info(key + "使用者快取不存在,嘗試CacheLoader回源查詢並回填...");
return userDao.getUser(key);
}
});
注: 兩種模式可以同時存在 -> Callable優先順序高,作為特定場景使用,CacheLoader作為通用場景使用,也可以任務是兜底場景。
支援更新鎖定
快取擊穿: 高併發量場景下,少量快取恰好失效遭遇大量請求,導致這些請求全部湧入資料庫
一般解決方案: 分散式鎖
Guava Cache解決方案: 併發鎖定機制 -> 同一時刻僅允許一個請求回源獲取資料並回填到快取中,其餘請求則阻塞等待。
支援監控
為什麼要監控 -> 關注快取的命中率
監控關鍵指標 -> 快取資料的載入或者命中情況統計
如何開啟監控 -> 快取容器建立時,透過recordStats()開啟
如何檢視統計 -> 使用cache.stats()獲取統計資料CacheStats
指標含義 | 說明 |
---|---|
hitCount | 命中快取次數 |
missCount | 沒有命中快取次數(查詢時記憶體中沒有) |
loadSuccessCount | 回源載入的時候載入成功次數 |
loadExceptionCount | 回源載入但是載入失敗的次數 |
totalLoadTime | 回源載入操作總耗時 |
evictionCount | 刪除記錄的次數 |
Caffeine
Caffeine是基於Google Guava Cache設計經驗上改進的成果,眾多的特性與設計思路都完全沿用了Guava Cache相同的邏輯,且提供的介面與使用風格也與Guava Cache無異。因此,以上GuavaCache提供的能力Caffeine基本都有,在此不做贅述,以下主要探討Caffeine相較於GuavaCache的改進之處。
基礎資料結構層面最佳化
Caffeine基於java8開發,Caffeine參照java8對ConcurrentHashMap底層由連結串列切換為紅黑樹、以及廢棄分段鎖邏輯的最佳化,提升了Hash衝突時的查詢效率以及併發場景下的處理效能。
非同步並行能力的全面支援
完美適配java8的並行程式設計場景,提供了全套的Async非同步處理機制,可以支援業務在非同步並行流水線處理場景中使用以獲得更好的體驗。
① 非同步Callable -> AsyncCache
public static void main(String[] args) {
AsyncCache<String, User> asyncCache = Caffeine.newBuilder().buildAsyn();
CompletableFuture<User> userCompletableFuture = asyncCache.get("123", s -> userDao.getUser(s));
}
② 非同步CacheLoader -> AsyncLoadingCache
public static void main(String[] args) {
try {
AsyncLoadingCache<String, User> asyncLoadingCache =
Caffeine.newBuilder().maximumSize(1000L).buildAsync(key -> userDao.getUser(key));
CompletableFuture<User> userCompletableFuture = asyncLoadingCache.get("123");
} catch (Exception e) {
e.printStackTrace();
}
}
③ 非同步AsyncCacheLoader -> AsyncLoadingCache
public static void main(String[] args) {
try {
AsyncLoadingCache<String, User> asyncLoadingCache =
Caffeine.newBuilder().maximumSize(1000L).buildAsync(
(key, executor) -> CompletableFuture.supplyAsync(() -> userDao.getUser(key), executor)
);
CompletableFuture<User> userCompletableFuture = asyncLoadingCache.get("123");
} catch (Exception e) {
e.printStackTrace();
}
}
注: 非同步AsyncCacheLoader是非同步CacheLoader的另一個版本,區別在於非同步AsyncCacheLoader使用的是 buildAsync的過載版本,允許傳入一個支援非同步並行處理的AsyncCacheLoader物件。
資料淘汰策略的最佳化
W-TinyLFU演算法,提供了更佳的熱點資料留存效果,提供了近乎完美的熱點資料命中率,以及更低消耗的過程維護,接下來將重點介紹這一部分。
其他淘汰策略
FIFO: First In First Out
先進先出: 先進去的快取最先淘汰
優點: 實現非常簡單
缺點: 快取命中率(hit rate)並不理想,通用快取基本上不考慮。
LRU: Least Recent Used
最近最久未使用: 把最近訪問過的快取項保留了下來
優點:
實現簡單,根據區域性性原理,一般情況下LRU的命中率不錯,而針對訪問頻繁的熱點資料,命中率非常好。
缺點:
對於週期性、偶發性的訪問資料,有大機率可能造成快取汙染,也就是置換出去了熱點資料,把這些偶發性資料留下了,從而導致LRU的資料命中率急劇下降,因此無法處理大量的稀疏流量。
稀疏流量: sparse burst: 即短時間內使用幾次後面就不被使用了
LFU:Least Frequency Used
最近最少頻率使用: 把訪問頻率高的快取項保留下來,同時考慮時間因素
優點:
可以有效地保護快取,相對於LRU來說有更好的快取命中率。
缺點:
1、需要為每一個快取項維護其頻率統計資訊,每一次訪問都需要更新相應的統計資訊,因此需要額外的空間和時間開銷。
2、無法處理稀疏流量(sparse burst)場景。因為稀疏流量只有少量的訪問次數,在比較訪問頻率決定去留時處於劣勢,可能導致稀疏流量快取項頻繁被淘汰,造成快取汙染,進而導致訪問稀疏流量經常無法命中。
W-TinyLFU: Window Cache - Tiny Least Frequency Used
結合了LRU和LFU的優點, 實現了高命中、低記憶體佔用的效果。
主要結構
① TinyLFU: 用於估算統計各個key值的請求頻率
② Window Cache: 其本質就是一個LRU快取
③ SLRU(Segmented LRU,即分段 LRU): 包括一個名為 protected 和一個名為 probation 的快取區,透過增加一個快取區(即 Window Cache),當有新的記錄插入時,會先在 window Cache區呆一下,就可以避免 sparse bursts 問題。
TinyLFU上文提到了LFU的兩個缺點,為了解決LFU的兩個缺點提出了對應的兩個解決方案。
問題
① 如何減少訪問頻率的儲存和記錄的更新,所帶來的空間和時間的開銷
② 如果提升對區域性熱點資料的 演算法命中率
方案
① Count–Min Sketch 演算法
② “新鮮度”機制(Freshness Mechanism)
核心演算法: Count–Min Sketch
Caffeine採用Count-Min Sketch演算法來統計LFU頻率,該演算法借鑑了boomfilter的思想,只不過hash key對應的value不是表示存在的true或false標誌,而是一個計數器。
它會對快取key進行四次hash(seed不同),將hash值對應的計數器加一。計數器只有4bit,所以計數器最大隻能計數到15,超過15則不再往上增加計數。
因為bloomfilter存在positive false的問題(hash衝突),快取項的頻率值取四個計數器的最小值(Count-Min的含義)。當所有計數器值的和超過設定的閾值(預設是快取項最大數量的10倍),所有計數器值減半。
// FrequencySketch原始碼(caffenie版本: 2.9.3)
// 預設的4個種子
static final long[] SEED = { // A mixture of seeds from FNV-1a, CityHash, and Murmur3
0xc3a5c85c97cb3127L, 0xb492b66fbe98f273L, 0x9ae16a3b2f90404fL, 0xcbf29ce484222325L};
static final long RESET_MASK = 0x7777777777777777L;
static final long ONE_MASK = 0x1111111111111111L;
// sampleSize = (maximumSize == 0) ? 10 : (10 * maximum);
int sampleSize;
// tableMask = Math.max(0, table.length - 1)
// table長度一般為2的n次方, tableMask值為tabel陣列長度-1(掩碼)
// 可以透過&操作來模擬取餘操作,進而根據hash值快速得到table對應的index值
int tableMask;
// 儲存計數頻率的一維陣列
long[] table;
int size;
/**
* Increments the popularity of the element if it does not exceed the maximum (15). The popularity
* of all elements will be periodically down sampled when the observed events exceeds a threshold.
* This process provides a frequency aging to allow expired long term entries to fade away.
*
* @param e the element to add
*/
public void increment(@NonNull E e) {
if (isNotInitialized()) {
return;
}
// 怕一次hash不夠均勻, 呼叫spread方法再打散一次
int hash = spread(e.hashCode());
// 取低2位作為隨機值,往左移動兩位得到一個小於16的值(0000、0100、1000、1100)
int start = (hash & 3) << 2;
// Loop unrolling improves throughput by 5m ops/s
// 根據hash值和4個不同種子(SEED)得到table的下標index
int index0 = indexOf(hash, 0);
int index1 = indexOf(hash, 1);
int index2 = indexOf(hash, 2);
int index3 = indexOf(hash, 3);
// 根據index和start(+1, +2, +3)的值,把table[index]對應的等分追加1
// 前兩位: 0000、0100、1000、1100 -> 補全後兩位: 00、01、10、11
boolean added = incrementAt(index0, start);
added |= incrementAt(index1, start + 1);
added |= incrementAt(index2, start + 2);
added |= incrementAt(index3, start + 3);
// size是所有記錄的頻率統計和,即每個記錄加1,這個size都會加1
// sampleSize是一個閾值,值為maximumSize的10倍
if (added && (++size == sampleSize)) {
reset();
}
}
/**
* Applies a supplemental hash function to a given hashCode, which defends against poor quality
* hash functions.
*/
int spread(int x) {
x = ((x >>> 16) ^ x) * 0x45d9f3b;
x = ((x >>> 16) ^ x) * 0x45d9f3b;
return (x >>> 16) ^ x;
}
/**
* Returns the table index for the counter at the specified depth.
*
* @param item the element's hash
* @param i the counter depth
* @return the table index
*/
int indexOf(int item, int i) {
long hash = (item + SEED[i]) * SEED[i];
hash += (hash >>> 32);
return ((int) hash) & tableMask;
}
/**
* Increments the specified counter by 1 if it is not already at the maximum value (15).
*
* @param i the table index (16 counters)
* @param j the counter to increment
* @return if incremented
*/
boolean incrementAt(int i, int j) {
// j表示16個等分的下標,offset相當於在64位中的下標
// 4位: 0~15 -> 6位: 0~63 -> 實際offset取值: 0~60(預留了4位給mask掩碼)
int offset = j << 2;
// Caffeine把頻率統計最大定為15,即0xfL
// mask是在64位中的掩碼 -> 1111+0000...(0~60個0)
long mask = (0xfL << offset);
// 如果table[index]要計算的4bit不等於15,就追加1,否則不追加
if ((table[i] & mask) != mask) {
table[i] += (1L << offset);
return true;
}
return false;
}
/** Reduces every counter by half of its original value. */
void reset() {
int count = 0;
for (int i = 0; i < table.length; i++) {
// 16個counter中頻次為奇數的個數
// 最低一位為1 -> (下面>>>1再&RESET_MASK) -> 被抹掉的1的個數
count += Long.bitCount(table[i] & ONE_MASK);
// table[i] >>> 1,整體右移1位,相當於除2,每個counter的高位是上一個bit的低位,可能為1
// & RESET_MASK,抹去新counter的最高位,保留低三位。最終實現每個counter除2
table[i] = (table[i] >>> 1) & RESET_MASK;
}
// 新size = 老size/2 - 奇數資料/4
// 除以4是因為每增加1個頻次 -> 實際加了4次1
// 結合reset方法和incrementAt可以發現,size的值並不完全準確,可能會有誤差,就像boomfilter一樣並不追求百分百的準確
size = (size >>> 1) - (count >>> 2);
}
/**
* Returns the estimated number of occurrences of an element, up to the maximum (15).
*
* @param e the element to count occurrences of
* @return the estimated number of occurrences of the element; possibly zero but never negative
*/
@NonNegative
public int frequency(@NonNull E e) {
if (isNotInitialized()) {
return 0;
}
int hash = spread(e.hashCode());
int start = (hash & 3) << 2;
int frequency = Integer.MAX_VALUE;
for (int i = 0; i < 4; i++) {
int index = indexOf(hash, i);
// 讀操作同寫操作
int count = (int) ((table[index] >>> ((start + i) << 2)) & 0xfL);
// 取最小的count
frequency = Math.min(frequency, count);
}
return frequency;
}
注:
table陣列每個元素大小是64bit,每個計數器大小為4bit,那麼每個table元素有16個計數器。
這16個計數器分為4個group,每個group包含4個計數器,等於bloom hash函式的個數。
4個hash計數器在相應table元素內計數器的偏移不一樣,也可以有效降低hash衝突。
保鮮機制
由於計數器大小隻有4bit,極大地降低了LFU頻率統計對儲存空間的要求。
同時,計數器統計上限是15,並在計數總和達到閾值時所有計數器值減半,相當於引入計數飽和和衰減機制,可以有效解決短時間內突發大流量不能有效淘汰的問題。
比如出現了一個突發熱點事件,它的訪問量是其他事件的成百上千倍,但是該熱點事件很快冷卻下去,傳統的LFU淘汰機制會讓該事件的快取長時間地保留在快取中而無法淘汰掉,雖然該型別事件已經訪問量非常小了。
Window Cache + SLRU
TinyLFU解決了LFU列出的第一個問題,但是並沒有解決第二個問題。於是在TinyLFU演算法基礎上引入一個基於LRU的Window Cache,這個新的演算法叫就叫做W-TinyLFU(Window-TinyLFU)。
W-TinyLFU將快取儲存空間分為兩個大的區域:Window Cache(1%)和Main Cache(99%)
Window Cache是一個標準的LRU Cache,Main Cache則是一個SLRU(Segmemted LRU)cache。
Main Cache進一步劃分為Protected Cache(保護區)(80%)和Probation Cache(觀察區)(20%)兩個區域,這兩個區域都是基於LRU的Cache。
注: 這些cache區域的大小會動態調整
寫入流程
有新的快取項寫入快取時,會先寫入Window Cache區域。
當Window Cache空間滿時,最舊的快取項會被移出Window Cache。
如果Probation Cache未滿,從Window Cache移出的快取項會直接寫入Probation Cache;
如果Probation Cache已滿,則會根據TinyLFU演算法確定從Window Cache移出的快取項是丟棄(淘汰)還是寫入Probation Cache。
Probation Cache中的快取項如果訪問頻率達到一定次數,會提升到Protected Cache;
如果Protected Cache也滿了,最舊的快取項也會移出Protected Cache,然後根據TinyLFU演算法確定是丟棄(淘汰)還是寫入Probation Cache。
淘汰機制
從Window Cache或Protected Cache移出的快取項稱為Candidate
Probation Cache中最舊的快取項稱為Victim
如果Candidate快取項的訪問頻率大於Victim快取項的訪問頻率,則淘汰掉Victim。
如果Candidate小於或等於Victim的頻率,那麼如果Candidate的頻率小於5,則淘汰掉Candidate;
否則,則在Candidate和Victim兩者之中隨機地淘汰一個。
總結
caffeine綜合了LFU和LRU的優勢,將不同特性的快取項存入不同的快取區域
最近剛產生的快取項進入Window區,不會被淘汰;
訪問頻率高的快取項進入Protected區,也不會淘汰;
介於這兩者之間的快取項存在Probation區,當快取空間滿了時,Probation區的快取項會根據訪問頻率判斷是保留還是淘汰;
透過這種機制,平衡了【訪問頻率】和【訪問時間新鮮程度】兩個維度因素,儘量將新鮮的訪問頻率高的快取項保留在快取中。
同時在維護快取項訪問頻率時,引入【計數器飽和】和【衰減機制】,即節省了儲存資源,也能較好的處理稀疏流量、短時超熱點流量等傳統LRU和LFU無法很好處理的場景。
其他改進點
CleanUp非同步處理
獲取快取請求中的惰性刪除,最佳化後會新開一個執行緒非同步處理,不再阻塞主執行緒。
新增expireAfter功能
可以基於個性化定製的邏輯來實現過期處理(可以定製基於新增、讀取、更新等場景的過期策略,甚至支援為不同記錄指定不同過期時間)
① expireAfterCreate
② expireAfterUpdate
③ expireAfterRead
Ehcache
支援多級快取
① 支援堆外快取
② 支援磁碟快取
③ 支援叢集快取
堆內快取 < 堆外快取 < 磁碟快取 < 叢集快取
組合:
堆內快取 + 堆外快取
堆內快取 + 堆外快取 + 磁碟快取
堆內快取 + 堆外快取 + 叢集快取
堆內快取 + 磁碟快取
堆內快取 + 叢集快取
注:
1、堆內快取一定要有
2、磁碟快取與叢集快取不能同時存在
注: 除了堆內快取屬於JVM堆內部,可以直接透過引用的方式進行訪問,其餘幾種型別都屬於JVM外部的資料互動,所以對這部分資料的讀寫時,需要先進行序列化與反序列化,因此要求快取的資料物件一定要支援序列化與反序列化。
支援快取持久化
支援使用磁碟來對快取內容進行持久化儲存,上面已經介紹在此不再贅述。
支援分散式快取
Ehcache自帶叢集解決方案,透過相應的配置可以讓本地快取變身叢集版本,以此來應付分散式場景下各個節點快取資料不一致的問題,並且由於資料都快取在程序內部,所以也可以避免集中是快取頻繁在業務流程中頻繁網路互動的弊端。
① RMI組播方式: 一種點對點(P2P)的通訊互動機制
② JMS訊息方式: 基於釋出訂閱模式,預設使用ActiveMQ,也可以切換為Kafka或者RabbitMQ等
③ Cache Server模式: 一個獨立的集中式快取,類似Redis
④ JGroup方式: 和RMI有點類似
⑤ Terracotta方式: 一個JVM層專門負責做分散式節點間協同處理的平臺框架
其他
更靈活和細粒度的過期時間設定
Ehcache不僅支援快取容器物件級別統一的過期時間設定,還會支援為容器中每一條快取記錄設定獨立過期時間,允許不同記錄有不同的過期時間,類似redis。
同時支援JCache與SpringCache規範
Ehcache作為一個標準化構建的通用快取框架,同時支援了JAVA目前業界最為主流的兩大快取標準,即官方的JSR107標準以及使用非常廣泛的Spring Cache標準,這樣使得業務中可以基於標準化的快取介面去呼叫,避免了Ehcache深度耦合到業務邏輯中去。
總結
比較項 | ConcurrentHashMap | Ehcache | Guava Cache | Caffeine |
---|---|---|---|---|
讀寫效能 | 很好,分段鎖 | 好 | 好 | 很好 |
淘汰演算法 | 無 | LFU,LRU,FIFO | LFU,LRU,FIFO | W-TinyLFU |
功能豐富度 | 功能簡單 | 功能豐富 | 功能豐富 | 同GuavaCache |
工具大小 | jdk自帶 | 一般 | 較小 | 一般 |
是否持久化 | 否 | 是 | 否 | 否 |
是否支援叢集 | 否 | 是 | 否 | 否 |
選型建議 | 需要一個執行緒安全的鍵值儲存,不需要快取特性(例如淘汰策略) | ①本地快取資料量較大記憶體不足需要使用磁碟等快取的 ②需要在JVM之間共享快取資料 | 快取需求不復雜,並且已經在使用Guava庫 | 對效能有較高要求,並且需要複雜的快取過期策略 |
思考
本地快取的設計邊界與定位
Ehcache的整體綜合功能是最強大的,整體定位偏向於大而全,但導致在各個細分場景下表現不夠極致:
相比Caffeine:略顯臃腫, 因為提供了很多額外的功能,比如使用磁碟快取、比如支援多節點間叢集組網等;
相比Redis: 先天不足,畢竟是本地快取,縱使支援了多種組網模式,仍無法媲美集中式快取在分散式場景下的體驗。
參考
https://juejin.cn/column/7140852038258147358