
阿里妹導讀
本文對比了幾種常用快取的特點,主要介紹了基於Guava的本地快取和基於Tair的分散式快取,包含快速入門和深入原理兩部分,並在最後提供了使用快取時需要注意的事項。
一、引言
在現代系統中,快取作為增速減壓的神兵利器被廣泛使用,然而,快取使用不當可能對系統造成嚴重影響。在大促期間,身邊就出現一些由於快取使用不當產生的後果:
-
案例一:壓測流量脈衝至100%時,此時B端進行了配置變更,快取失效。大量請求瞬間湧向資料庫,且執行了無索引的查詢,最終拖垮了資料庫。 -
案例二:在使用Tair快取管理配置資訊時,預釋出環境進行了錯誤的配置修改。由於快取未進行預釋出與線上環境的隔離,錯誤配置直接影響了線上資料。
面對上述案例,我認為非常有必要深入學習和理解快取機制,以確保在實際使用中具備更完善的應對策略,避免類似問題的發生。
本文對比了幾種常用快取的特點,主要介紹了基於Guava的本地快取和基於Tair的分散式快取,包含快速入門和深入原理兩部分,並在最後提供了使用快取時需要注意的事項。如果有任何錯誤或建議,歡迎批評指正~
二、快取的演進
快取技術經歷了從本地快取到分散式快取,再到多級快取的不斷演進。當開發人員發現系統性能瓶頸來自頻繁的資料庫操作時,透過將資料暫時快取在本地,能夠顯著提升響應速度。當請求命中快取時,可以直接返回結果;如果沒有命中,則再進行資料庫查詢。這種基於本地快取的最佳化顯著提高了系統的整體效能。在Java專案中,有眾多框架提供了強大的本地快取支援,如Guava Cache、Ehcache和Caffeine Cache等。
隨著業務叢集的擴充套件,新的問題也隨之出現:當多個節點同時存在時,因本地快取各自在節點內獨立構建,導致各業務節點間的資料一致性難以保證。為了解決這一難題,開發團隊提出了集中式快取的構想,讓所有業務節點共享同一份快取資料,從而高效解決了節點間資料不一致的問題。這一想法催生了分散式快取的誕生,現如今,業界已經有多種成熟的集中式快取解決方案,比如集團大規模使用的快取記憶體Tair,以及大家熟知的Redis和Memcache等。
將本地快取改成分散式快取有效解決了快取不一致問題和單機容量限制問題。但是,如果在系統中頻繁地使用分散式快取,網路IO互動次數的增加,可能並不能達到效能提成的目的。因此,我們可以將本地快取與集中式快取結合起來使用,取長補短,實現效果最大化。如圖所示,演示了多級快取的互動流程:

具體而言:
-
對於一些變更頻率比較高的資料,採用集中式快取,這樣能夠確保所有節點在資料變化後即時更新,從而保證資料的一致性。 -
對於一些極少變更的資料(如系統配置項)或者是一些對短期一致性要求不高的資料(如使用者暱稱、簽名等)則採用本地快取,可以顯著降低對遠端集中式快取的網路IO次數,提高系統性能。
三、本地快取
1. 快取框架對比
Java專案中,幾種廣泛使用的高效能框架包括Guava Cache、Ehcache、Caffeine Cache等。
-
Guava:由Google團隊開源的Java核心增強庫,涵蓋集合、併發原語、快取、IO、反射等多種工具。其效能和穩定性有保障,廣泛應用於各類專案中。 -
Caffeine:基於Java 8實現的新一代快取工具,可視作Guava Cache的升級版。雖然功能上兩者相似,但Caffeine在效能表現和命中率上全面超越Guava Cache,卓越的表現令人矚目。 -
Ehcache:一個純Java的程序內快取框架,以其快速和輕量著稱。同時,它支援記憶體和磁碟的二級快取,讓它在功能上更加豐富,且擴充套件性極強。
下表總結了三個框架之間的對比,幫助大家在選擇本地快取解決方案時找到最合適的框架。

目前,我們的公司專案中廣泛應用了Guava Cache框架。本文將以Guava Cache為例,進行深入介紹。
2. Guava Cache 快速入手
Step 1 :引入依賴
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.4.0-jre</version>
</dependency>
Step 2 :容器建立
使用CacheBuilder及其提供的各種方法構建快取容器(常見屬性方法見附錄)。
Cache<String, String> cache = CacheBuilder.newBuilder()
.initialCapacity(1000) // 初始容量
.maximumSize(10000L) // 設定最大容量
.expireAfterWrite(30L, TimeUnit.MINUTES) // 設定寫入過期時間
.concurrencyLevel(8) // 設定最大併發寫操作執行緒數
.refreshAfterWrite(1L, TimeUnit.MINUTES) // 設定自動重新整理資料時間
.recordStats() // 開啟快取執行情況統計
.build(new CacheLoader<String, User>() {
@Override
public User load(String key) throws Exception {
return userDao.getUser(key);
}
});
Step 3 :快取使用
-
設定快取
// 1. put 往快取中新增key-value鍵值對
cache.put("key1", "value1");
// 2. putAll 批次往快取中新增key-value鍵值對
Map<String, String> bulkData = new HashMap<>();
bulkData.put("key2", "Value2");
bulkData.put("key3", "Value3");
cache.putAll(bulkData);
-
獲取快取
// 1. get 查詢指定key對應的value值,如果快取中沒匹配,則基於給定的Callable邏輯去獲取資料回填快取中並返回
String value1 = cache.get("key1", new Callable<String>() {
@Override
public String call() throws Exception {
// 模擬從資料庫或其他資料來源獲取資料
return"Value from Callable for key1";
}
});
// 2. getIfPresent 如果快取中存在指定的key,返回對應的value,否則返回null
String value2 = cache.getIfPresent("key2");
// 3. getAllPresent 針對傳入的key列表,返回快取中存在的對應value值列表
List<String> keys = Arrays.asList("key1", "key2", "key3");
Map<String, String> presentValues = cache.getAllPresent(keys);
-
刪除快取
//1. invalidate - 從快取中刪除指定的記錄
cache.invalidate("key1");
// 2. invalidateAll - 從快取中批次刪除指定記錄,如果無引數,則清空所有快取
cache.invalidateAll(Arrays.asList("key2", "key3"));
-
其他方法
// 1. size 獲取快取容器中的總記錄數
Long size = cache.size();
// 2. stats 獲取快取容器當前的統計資料
CacheStats stats = cache.stats();
// 3. asMap 將快取中的資料轉換為ConcurrentHashMap格式返回
Map<String, String> cacheMap = cache.asMap();
// 4. cleanUp - 清理所有的已過期的資料
cache.cleanUp();
3. Guava Cache 原始碼解析
3.1 快取資料結構
Guava Cache 的核心類 LocalCache 採用了與 ConcurrentHashMap 類似的資料結構,都是由多個相對獨立的 segment 組成。且各segment相對獨立,互不影響,所以能支援並行操作。segment 繼承自 ReentrantLock,有效地減少了鎖的粒度,從而提升了併發效能。每個 segment 包含一個雜湊表和五個佇列。
對於每一個segment放大如下:

這五個佇列分別是:key和value的引用佇列、讀佇列、寫佇列、訪問佇列以及最近使用佇列。
快取資料儲存在table中,其型別為AtomicReferenceArray。該陣列中的每個元素都是一個ReferenceEntry,每個entry包含了key及其hash值、value,以及指向下一個entry的指標。
3.2 get 方法原始碼
當快取過期時,為了防止大量的執行緒同時請求資料來源載入資料並生成快取項,造成 “快取擊穿” ,Guava在載入過程中實現了併發控制。當多個執行緒請求一個不存在或過期的快取項時,只有一個執行緒會執行load方法。而其他執行緒則會根據構建時設定的expireAfterWrite 或expireAfterAccess 屬性採取不同的處理策略。具體而言:
-
如果設定了expireAfterWrite 或expireAfterAccess ,其他執行緒將被阻塞,直到快取項生成完畢。 -
如果選擇了refreshAfterWrite ,其他執行緒將不會被阻塞,而是直接返回舊的快取值。
下面程式碼為 get 方法的入口,透過 hash 值獲取相應的段(segment ),然後從該段中提取具體的值。
LocalCache#get
V get(K key, CacheLoader<? super K, V> loader) throws ExecutionException {
int hash = hash(checkNotNull(key));
// 根據 hash 獲取對應的 segment 然後從 segment 獲取具體值
return segmentFor(hash).get(key, hash, loader);
}
get方法的具體執行流程主要分為三步:
-
第一步:根據expireAfterAccess和expireAfterWrite判斷值否過期,若未設定或者未過期,返回非空value。 -
第二步:若value不為空,根據refreshAfterWrite設定的引數,判斷否需要非同步重新整理。如果判斷到loading中就會返回舊值。 -
第三步:如果之前沒有寫入過資料或者資料已經過期或者資料不是在載入中,則會呼叫lockedGetOrLoad。此時會加鎖,並根據是否處於載入狀態來決定是阻塞等待還是直接獲取資料。
整體執行流程如下:

對應的get方法程式碼部分如下:
Segment#get
V get(K key, int hash, CacheLoader<? super K, V> loader) throws ExecutionException {
checkNotNull(key);
checkNotNull(loader);
try {
// count 表示在這個segment中存活的專案個數
if (count != 0) {
// 獲取segment中的元素, 包含正在load的資料
ReferenceEntry<K, V> e = getEntry(key, hash);
if (e != null) {
// 獲取當前時間,納秒
long now = map.ticker.read();
// 獲取快取值,判斷是否過期,過期刪除快取並返回null
V value = getLiveValue(e, now); // 拿到沒過期存活的資料
if (value != null) {
// 記錄訪問時間
recordRead(e, now);
// 記錄快取命中一次
statsCounter.recordHits(1);
// 重新整理快取並返回快取值
return scheduleRefresh(e, key, hash, value, now, loader);
}
// 走到這步,說明取出來的值 value == null, 可能是過期了,也有可能正在重新整理
ValueReference<K, V> valueReference = e.getValueReference();
// 如果此時value正在loading,那麼此時等待重新整理結果
if (valueReference.isLoading()) {
return waitForLoadingValue(e, key, valueReference);
}
}
}
// 走到這說明值為null或者過期,需要加鎖進行載入
return lockedGetOrLoad(key, hash, loader);
} catch (ExecutionException ee) {
Throwable cause = ee.getCause();
if (cause instanceof Error) {
thrownew ExecutionError((Error) cause);
} elseif (cause instanceof RuntimeException) {
thrownew UncheckedExecutionException(cause);
}
throw ee;
} finally {
postReadCleanup();
}
}
getLiveValue 方法用於判斷快取是否過期。如果快取已過期,則會刪除相應的快取並返回 null;如果快取未過期,則返回當前的快取值。
Segment#getLiveValue
V getLiveValue(ReferenceEntry<K, V> entry, long now){
//被GC回收了
if (entry.getKey() == null) {
//
tryDrainReferenceQueues();
return null;
}
V value = entry.getValueReference().get();
//被GC回收了
if (value == null) {
tryDrainReferenceQueues();
return null;
}
//判斷是否過期
if (map.isExpired(entry, now)) {
tryExpireEntries(now);
return null;
}
return value;
}
//isExpired,判斷Entry是否過期
boolean isExpired(ReferenceEntry<K, V> entry, long now){
checkNotNull(entry);
//如果配置了expireAfterAccess,用當前時間跟entry的accessTime比較
if (expiresAfterAccess() && (now - entry.getAccessTime() >= expireAfterAccessNanos)) {
returntrue;
}
//如果配置了expireAfterWrite,用當前時間跟entry的writeTime比較
if (expiresAfterWrite() && (now - entry.getWriteTime() >= expireAfterWriteNanos)) {
returntrue;
}
returnfalse;
}
scheduleRefresh 方法會根據 refreshAfterWrite 引數的設定,判斷是否需要進行非同步重新整理。
Segment#scheduleRefresh
V scheduleRefresh(
ReferenceEntry<K, V> entry,
K key,
int hash,
V oldValue,
long now,
CacheLoader<? super K, V> loader) {
if (
// 配置了重新整理策略 refreshAfterWrite
map.refreshes()
// 到重新整理時間了
&& (now - entry.getWriteTime() > map.refreshNanos)
// 沒在 loading
&& !entry.getValueReference().isLoading()) {
// 開始重新整理
V newValue = refresh(key, hash, loader, true);
if (newValue != null) {
return newValue;
}
}
return oldValue;
}
如果之前沒有寫入過資料 || 資料已經過期 || 資料不是在載入中,則會呼叫lockedGetOrLoad 方法。
Segment#lockedGetOrLoad
V lockedGetOrLoad(K key, int hash, CacheLoader<? super K, V> loader) throws ExecutionException {
ReferenceEntry<K, V> e;
ValueReference<K, V> valueReference = null;
LoadingValueReference<K, V> loadingValueReference = null;
boolean createNewEntry = true;
// 要對 segment 寫操作 ,先加鎖
lock();
try {
long now = map.ticker.read();
preWriteCleanup(now);
// 這裡基本就是 HashMap 的程式碼,如果沒有 segment 的陣列下標衝突了就拉一個連結串列
int newCount = this.count - 1;
AtomicReferenceArray<ReferenceEntry<K, V>> table = this.table;
int index = hash & (table.length() - 1);
ReferenceEntry<K, V> first = table.get(index);
for (e = first; e != null; e = e.getNext()) {
K entryKey = e.getKey();
if (e.getHash() == hash
&& entryKey != null
&& map.keyEquivalence.equivalent(key, entryKey)) {
valueReference = e.getValueReference();
//如果value在載入中則不需要重複建立entry
if (valueReference.isLoading()) {
createNewEntry = false;
} else {
V value = valueReference.get();
// 如果快取項為 null 資料已經被刪除,通知對應的 queue
if (value == null) {
enqueueNotification(
entryKey, hash, value, valueReference.getWeight(), RemovalCause.COLLECTED);
// 這個是 double check 如果快取項過期 資料沒被刪除,通知對應的 queue
} elseif (map.isExpired(e, now)) {
// This is a duplicate check, as preWriteCleanup already purged expired
// entries, but let's accommodate an incorrect expiration queue.
enqueueNotification(
entryKey, hash, value, valueReference.getWeight(), RemovalCause.EXPIRED);
// 再次看到的時候這個位置有值了直接返回
} else {
recordLockedRead(e, now);
statsCounter.recordHits(1);
return value;
}
// immediately reuse invalid entries
writeQueue.remove(e);
accessQueue.remove(e);
this.count = newCount; // write-volatile
}
break;
}
}
// 沒有 loading ,建立一個 loading 節點
if (createNewEntry) {
loadingValueReference = new LoadingValueReference<>();
if (e == null) {
e = newEntry(key, hash, first);
e.setValueReference(loadingValueReference);
table.set(index, e);
} else {
e.setValueReference(loadingValueReference);
}
}
} finally {
unlock();
postWriteCleanup();
}
//同步載入資料。
if (createNewEntry) {
try {
synchronized (e) {
return loadSync(key, hash, loadingValueReference, loader);
}
} finally {
statsCounter.recordMisses(1);
}
} else {
// The entry already exists. Wait for loading.
return waitForLoadingValue(e, key, valueReference);
}
}
透過分析get的流程圖和原始碼,我們可以瞭解到在執行get時,會先檢查快取是否過期,然後再判斷是否需要執行refresh。如果快取已過期,系統將優先呼叫load方法,此時會阻塞其他執行緒。相反,當快取未過期但時間超過refresh設定時,系統將非同步執行reload,同時返回舊值。因此,推薦的配置是將refresh設定為小於expire。這樣的設定不僅可以確保在長時間未訪問快取後獲取到最新的值,還能避免因refresh返回舊值而導致的資料不一致問題。
3.3 put 方法原始碼
put的操作相對簡單。在執行put操作時,Guava首先會計算出key的hash值,隨後根據該hash值確定資料應寫入哪個Segment。接著,系統會對選定的Segment進行加鎖,以確保安全地執行寫入操作。
@Override
public V put(K key, V value){
// ... 省略部分邏輯
int hash = hash(key);
return segmentFor(hash).put(key, hash, value, false);
}
@Nullable
V put(K key, int hash, V value, boolean onlyIfAbsent){
lock();
try {
// ... 省略具體邏輯
} finally {
unlock();
postWriteCleanup();
}
}
四、分散式快取
1. 快取選型對比
目前三種比較成熟的分散式快取對比如下:
特性 / 框架
|
Tair
|
Redis
|
Memcached
|
資料結構支援
|
基於 Redis,支援其所有資料結構,並擴充套件了更多功能
|
支援多種資料結構,如字串、雜湊、列表、集合、有序集合等
|
僅支援簡單的鍵值對儲存
|
持久化
|
支援持久化,確保資料安全性
|
支援 RDB 和 AOF 持久化
|
不支援持久化
|
高可用性
|
內建高可用性機制,提供自動故障轉移和資料備份
|
支援主從複製、哨兵和叢集模式
|
透過客戶端實現簡單複製
|
分散式支援
|
原生支援分散式架構,易於大規模擴充套件
|
透過 Cluster 模式實現分散式
|
通常依賴客戶端進行分片,分散式支援較為基礎
|
效能
|
效能與 Redis 相當,同時提供更多最佳化和企業級特性
|
單執行緒模型效能出色,適合複雜操作
|
高效能,適用於簡單的快取讀取和寫入
|
擴充套件性
|
支援大規模水平擴充套件,適應企業級業務增長需求
|
水平擴充套件有限,需透過叢集管理進行擴充套件
|
易於水平擴充套件,適合大規模分散式快取需求
|
社群支援
|
社群較小,但由阿里巴巴維護,提供企業級支援
|
擁有廣泛的社群和豐富的文件資源
|
社群活躍,資源豐富
|
優點
|
採用分散式叢集架構,具備自動容災及故障遷移能力,對儲存層做了抽象,底層方便切換不同的儲存引擎,採用一致性雜湊演算法將key分散在Q個桶中,並將桶放到不同的dataserver上,保證資料平衡,tair高可用比較強,容災性比redis強,支援多種叢集結構,支援跨機房資料分佈
|
非常豐富的資料結構而且都是原子性操作、高速讀寫、支援事務,支援aof、rdb兩種持久化機制,擁有豐富特性,訂閱釋出 Pub / Sub 功能、Key 過期策略、事務、支援多個 DB、計數、支援叢集和資料備份
|
簡潔,靈活,多執行緒非阻塞io效率高,所有支援多種語言api,且在併發下用cas保證一致性
|
缺點
|
文件不全,社群不活躍,單節點上效能沒有redis高,不能對key實現模糊查詢,單條資料不能太大key建議1k以下,value不能超過1M,建議10k以下
|
3.0以前不支援叢集,單執行緒無法充分利用多核伺服器CPU,事務支援較弱,rdb每次都是寫全量資料,成本高,aof追加導致log特別大
|
結構單一,資料在記憶體重啟會丟失,資料大小受記憶體限制
|
本文以公司廣泛使用的高效能、高擴充套件、高可靠Tair為例展開介紹。
2. Tair 快速入手
以MDB/LDB為例展開介紹,Tair接入大概需要四步。
Step 1 :申請Tair例項
申請地址:http://tiddo.alibaba-inc.com/saas-tair/#/InstanceGroupList

需要重點關注的引數:
-
ConfigID:用來唯一表示一個Tair叢集,一般存放在diamond中。configID方式Tair已經不推薦使用,因為採用configID方式初始化時,由於線上線下叢集往往不同,需要維護線上線下不同的配置項。 -
UserName:新版配置,表示一個例項組,所有環境的同一使用者空間使用username方式,線上線下統一
Step 2 :引入依賴
<dependency>
<groupId>com.taobao.tair</groupId>
<artifactId>tair-mc-client</artifactId>
<version>4.4.12</version>
</dependency>
JAR包說明:MDB&LDB JAVA客戶端有2個包,tair-mc-client和tair-client。tair-client是MDB&LDB客戶端和服務端服務互動的核心庫,tair-mc-client依賴tair-client,引入Diamond中介軟體,透過Diamond上的叢集配置,決定客戶端連結具體哪個MDB/LDB叢集。因此,tair-mc-client是具有容災功能的客戶端。
官方強制要求使用者直接依賴tair-mc-client,而不要直接依賴tair-client庫。
Step 3 :初始化
MultiClusterTairManager mcTairManager = new MultiClusterTairManager();
mcTairManager.setUserName("申請到的例項組名稱");
mcTairManager.setDynamicConfig(true);
// mcTairManager.setTimeout(500); // 單位為 ms,預設 2000 ms
mcTairManager.init();
Step 4 :快取使用
// get方法
// namespace,申請時分配的namespace,
// key大小不超過1k
Result<DataEntry> result = tairManager.get(NAME_SPACE, key);
// put方法,寫入資料,設定版本號和過期時間
// 當version為0時,表示強制更新。
// 當version為非0時,則判斷服務端的version和客戶端傳入的version是否一致,不一致則返回
ResultCode code = tairManager.put(NAME_SPACE, key, value, 0, expireTime);
// delete方法,刪除本叢集指定key。
// 目前不支援批次刪除key
ResultCode code = tairManager.delete(NAME_SPACE, key);
3. Tair 原理解析
3.1 整體架構
Tair共有四個模組,包括三個必選模組:client、configserver和dataserver。一個可選模組:invalidserver。

-
configServer :註冊中心,感知儲存dataserver節點
-
透過維護和dataserver心跳來獲知叢集中存活節點的資訊
-
根據存活節點的資訊來構建資料在叢集中的分佈表
-
提供資料分佈表的查詢服務
-
排程dataserver之間的資料遷移、複製
-
dataServer:資料儲存節點
-
提供儲存引擎
-
接受client的put/get/remove等操作
-
執行資料遷移,複製等
-
外掛:在接受請求的時候處理一些自定義功能
-
訪問統計
-
client:客戶端呼叫
-
在應用端提供訪問Tair叢集的介面
-
更新並快取資料分佈表和invalidserver地址等
-
LocalCache,避免過熱資料訪問影響tair叢集服務
-
流控
-
invalidServer:控制同一組內資料一致性
-
接收來自client的invalid/hide等請求後,對屬於同一組的叢集(雙機房獨立叢集部署方式)做delete/hide操作,保證同一組叢集的一致
-
叢集斷網之後的,髒資料清理。
-
訪問統計
3.2 底層儲存引擎對比
注意,由於MDB&LDB是由Tair團隊研發並維護多年,集團使用者通常將MDB&LDB稱為Tair。由於現在MDB&LDB已經不在Tair團隊,因此不再掛Tair品牌名稱。僅稱為MDB&LDB。Tair表示對外的雲原生記憶體資料庫Tair,也就是Redis企業版。不過,為了方便介紹,本文仍然將三者放在一起對比:
從效能上來說,MDB > LDB > RDB,但是相應的從資料可靠性來說MDB < LDB < RDB。所以MDB的使用場景一般要有兜底的方案,比如查不到mdb快取資料時透過查詢其他資料來源可以獲取。
特性 / 框架
|
MDB
|
LDB
|
RDB
|
產品特性
|
Memcache,Tair 1.0產品,Tair最早的一款產品,專注於記憶體型KV極速快取。
|
levelDB,Tair 1.0產品,Tair第二款產品,專注於持久化型KV快取記憶體。
|
Tair 3.0產品,同時服務集團內部和公有云使用者,全面支援業務上雲。
|
支援資料結構
|
Key-Value
|
Key-Value
|
String,List,Zset,Hmap,Set等複雜資料結構。
|
優點
|
高讀寫效能,適用容量小,讀寫QPS高(萬級別)的快取場景。
|
LDB 適用於確實有持久化需求,讀寫QPS較高(萬級別)的應用場景。
|
有豐富的資料結構,支援Redis原生的資料結構,也支援自研的高階資料結構。
|
缺點
|
類似於Memcache,由於是記憶體型產品,因此無法保證資料的安全性,
|
LDB目前線上使用的SSD機型成本較高
|
不適合大吞吐場景,即快取Key不應過大,
分散式支援較為基礎
|
應用場景
|
可接受資料丟失場景,比如經典快取場景,臨時可丟失資料場景。
|
持久化KV場景,比如黑白名單,離線資料線上化等
|
應用場景覆蓋新零售,遊戲,教育,文化產品,製造業,交通物流,網際網路社交等行業。
|
管控平臺
|
Tiddo 1.0
|
Tiddo 1.0
|
諾曼底
|
3.3 Tair的資料一致性—Version支援
在PUT介面中,有一個version引數,用於解決併發更新同一資料的問題。通常,更新資料的過程是先進行GET獲取資料,然後修改後再PUT回系統。如果多個客戶端同時獲取同一份資料並進行修改,後到達的修改可能會覆蓋先儲存的內容,從而導致資料丟失。為防止這種情況,設定了version引數。
伺服器端的version初始值為0,第一次PUT時version增加至1,後續每次更新時version再加1。每次GET請求時,伺服器會返回當前資料的版本,只有當更新介面中傳入的version與當前版本相同時,更新才會成功。如果在GET與更新之間資料已被更改,導致版本不一致,更新會失敗並返回ResultCode.VERERROR。原始碼的處理流程如下:

注意:如果應用不關心併發更新的一致性,呼叫客戶端介面時,version必須不傳或者傳入0。
3.4 Tair的負載均衡
tair 的分佈採用的是一致性雜湊演算法,用來解決分散式中的平衡性、分散性和一致性。對於所有的 key,分到 Q 個桶中,桶是負載均衡和資料遷移的基本單位。config server 根據一定的策略把每個桶指派到不同的 data server 上,因為資料按照 key 做 hash 演算法,所以可以認為每個桶中的資料基本是平衡的,保證了桶分佈的均衡性, 就保證了資料分佈的均衡性。
具體說,首先計算 Hash(key),然後透過一些運算,比如取模運算,得到 key 所對應的 bucket,然後再去 config server 查詢該 bucket 對應的 data server,再與相應的 data server 進行通訊。也就是說,config server 維護了一張由 bucket 對映到 data server 的對照表。如:

這裡共 6 個 bucket,由兩臺機器負責,每臺機器負責 3 個 bucket。客戶端將 key hash 後,對 6 取模,找到負責的資料節點,然後和其直接通訊。表的大小(行數)通常會遠大於叢集的節點數,這和 consistent hash 中的虛擬節點很相似。假設我們加入了一臺新的機器: 192.168.10.3,tair 會自動調整對照表,將部分 bucket 交由新的節點負責,比如新的表很可能類似下表:

3.5 熱點雜湊策略
由於Tair訪問方式是客戶端對請求的Key進行類一致性 Hash 計算後,再透過資料路由表查表定位到某臺DataServer(資料節點伺服器)進行讀寫的,所以對相同Key的讀寫請求必然固定對映到相同的DataServer上,如圖。

此時DataServer單節點的讀寫效能便成了單Key的讀寫效能瓶頸,且無法透過簡單的水平擴充套件來解決。針對此問題,解決方案分為三部分:熱點識別、讀熱點方案和寫熱點方案。
熱點識別
DataServer收到客戶端的請求後,每個處理請求的工作執行緒(Worker Thread)用來統計熱點的資料結構都是ThreadLocal的資料結構,完全無鎖化設計。熱點獲取使用精心設計的多級加權LRU鏈和HashMap組合的資料結構,在保證服務端請求處理效率的前提進行請求的全統計。
讀熱點方案
在DataServer中,設立了一個HotZone,用於儲存熱點資料。在客戶端初始化時,會獲取雜湊的機器配置資訊,並隨機選擇一臺DataServer作為固定的熱點資料讀寫HotZone。當資料被識別為熱點時,客戶端會優先從HotZone進行讀取;如果讀取失敗,則會按照原有路由方式訪問源DataServer。與此同時,系統會透過非同步方式將新資料更新到HotZone,從而實現HotZone與源DataServer之間的二級快取。
這種機制有效地將熱點資料的查詢壓力分散到多個HotZone,顯著提升了系統的水平擴充套件能力。

寫熱點方案
對於寫熱點,一致性問題使多級快取方案難以適用。使用本地快取並非同步更新遠端資料庫時,如果業務機器宕機,則已寫資料可能丟失。此外,本地快取導致某應用機器的更新對其他機器不可見,從而延長資料不一致的時間。因此,寫熱點採用服務端請求合併的方法(類似Group Commit機制)。
熱點Key的寫請求由IO執行緒轉發到專門的合併執行緒,後者在一定時間內合併寫請求,然後由定時執行緒按預設週期提交請求。合併過程中請求結果不返回客戶端,待寫入成功後再統一返回,避免了一致性問題及假寫情況。合併週期可在服務端配置並動態修改。
五、快取注意事項
多級快取的運用能夠顯著提升系統性能和響應速度,但其設計與實現並不是一件簡單的事情。在實施過程中,需關注一些關鍵因素,以確保快取的有效性和穩定性。其中兩個比較關鍵的問題則為快取一致性問題和快取併發性問題。
1. 快取一致性問題
在多級快取架構的應用中,資料一致性問題至關重要。由於不同級別的快取可能儲存相同資料的不同副本,資料更新時,必須有效同步各個快取層。對此,我們需要關注以下兩個關鍵點:
-
追求最終一致性而非強一致性:在資料庫與快取的讀寫流程中,推薦的旁路型快取策略是,首先更新資料庫,然後刪除相關快取。這一方法能有效確保資料的最終一致性,並降低極端情況下資料不一致的風險。 -
快取必須要有過期時間:為快取設定過期時間不僅可以提高命中率,更是一種有效的兜底策略。當資料庫與快取資料出現不一致時,只有在資料不一致的短時間內,不同的資料狀態存在。這一機制也有助於保證最終一致性。
2. 快取併發問題
在高併發場景下,可能會有大量請求繞過快取,直接訪問資料庫,造出資料庫癱瘓等,例如致快取雪崩、快取穿透、快取擊穿等問題。
快取雪崩
當大量甚至全部的快取資料在短時間內集體失效,這樣會導致大量的請求無法命中快取而直接流轉到了下游模組,導致系統癱瘓,也即快取雪崩。
解決方案:
-
從應用架構角度,可以透過限流、降級、熔斷等手段來降低影響,也可以透過多級快取來避免這種災難。 -
從快取的角度考慮,可以透過將快取過期的時間隨機打散來有效的避免資料集中失效,或者可以設計延遲失效策略,分散對後端儲存的訪問壓力。
快取擊穿
少量快取失效的時候恰好失效的資料遭遇大併發量的請求,導致這些請求全部湧入資料庫中。
解決方案:
-
為熱點資料設定一個過期時間續期的操作,比如每次請求的時候自動將過期時間續期一下。 -
藉助分散式鎖來防止快取擊穿問題的出現。當快取不可用時,僅持鎖的執行緒負責從資料庫中查詢資料並寫入快取中,其餘請求重試時先嚐試從快取中獲取資料,避免所有的併發請求全部同時打到資料庫上。 -
設定熱點資料永遠不過期,定時去更新最新資料。
快取穿透
大量請求的 key 是不合理的,根本不存在於快取中,也不存在於資料庫中 。這就導致這些請求直接到了資料庫上,根本沒有經過快取這一層,對資料庫造成了巨大的壓力。如某個駭客故意製造一些非法的 key 發起大量請求,導致大量請求落到資料庫,結果資料庫上也沒有查到對應的資料。
解決方案:
-
快取無效 key
-
使用布隆過濾器
六、附錄
CacheBuilder中常見屬性方法
方法
|
含義說明
|
newBuilder
|
構造出一個Builder例項類
|
initialCapacity
|
待建立的快取容器的初始容量大小(記錄條數)
|
maximumSize
|
指定此快取容器的最大容量(最大快取記錄條數)
|
maximumWeight
|
指定此快取容器的最大容量(最大比重值),需結合weighter方可體現出效果
|
weighter
|
入參為一個函式式介面,用於指定每條存入的快取資料的權重佔比情況。
需要與maximumWeight結合使用
|
expireAfterWrite
|
設定過期策略,按照資料寫入時間進行計算(非自動失效,需有任意put/replace之類方法才會掃描過期失效資料))
|
expireAfterAccess
|
設定過期策略,按照資料最後訪問時間來計算(非自動失效,需有任意get/put之類方法才會掃描過期失效資料)
|
refreshAfterWrite
|
設定更新策略,資料寫入後多久重新整理 (非同步重新整理,非自動失效,需有任意put、replace之類方法才會掃描過期失效資料。但區別是會開一個非同步執行緒進行重新整理,重新整理過程中訪問返回舊資料)
|
concurrencyLevel
|
用於控制快取的併發處理能力,同時支援多少個執行緒併發寫入操作
|
recordStats
|
設定開啟此容器的資料載入與快取命中情況統計
|
引用
-
MBD/LDB文件:
https://alidocs.dingtalk.com/i/nodes/N7dx2rn0JbxOaqnACmlYG4YQWMGjLRb3
-
Tair(Redis企業版)產品文件:
https://alidocs.dingtalk.com/i/nodes/Qnp9zOoBVBDEydnQUONBD3qn81DK0g6l
-
深入理解快取原理與實戰設計:
https://juejin.cn/column/7140852038258147358
通義萬相文字繪圖與人像美化
利用通義萬相AIGC在Web服務中實現影像生成,包括文字到影像、塗鴉轉換、人像風格重塑以及人物寫真建立等功能,加快創作流程,提高創意效率。
點選閱讀原文檢視詳情。