過期機制
只要是快取,就必定有過期機制,guava 快取過期分為以下三種:
- expireAfterAccess: 資料在指定時間內沒有被訪問(讀或寫),則為過期資料,當沒有資料或者讀到過期資料時,只允許一個執行緒更新新資料時,其他執行緒阻塞等待該執行緒更新完成後,取最新的資料。
建構函式:
public CacheBuilder<K, V> expireAfterAccess(long duration, TimeUnit unit) {
...
this.expireAfterAccessNanos = unit.toNanos(duration);
return this;
}
建構函式設定了變數expireAfterAccessNanos
的值。
- expireAfterWrite:資料在指定時間內沒有被更新(寫入),則為過期資料,當沒有資料或者讀到過期資料時,只允許一個執行緒更新新資料時,其他執行緒阻塞等待該執行緒更新完成後,取最新的資料。
建構函式:
public CacheBuilder<K, V> expireAfterWrite(long duration, TimeUnit unit) {
...
this.expireAfterWriteNanos = unit.toNanos(duration);
return this;
}
建構函式設定了變數expireAfterWriteNanos
的值。
- refreshAfterWrite:資料在指定時間內沒有被更新(寫入),則為過期資料,當有執行緒正在更新(寫入)新資料時,其他執行緒返回舊資料。
建構函式:
public CacheBuilder<K, V> refreshAfterWrite(long duration, TimeUnit unit) {
...
this.refreshNanos = unit.toNanos(duration);
return this;
}
建構函式設定了變數refreshNanos
的值。
問題
- expireAfterAccess和expireAfterWrite:
當資料達到過期時間,限制只能有1個執行緒去執行資料重新整理,其他請求阻塞等待這個重新整理操作完成,對效能會有的損耗。 - refreshAfterWrite:
當資料達到過期時間,限制只能有1個執行緒去執行新值的載入,其他執行緒取舊值返回(也可設定非同步獲取新值,所有執行緒都返回舊值)。這樣有效地可以減少等待和鎖爭用,所以refreshAfterWrite
會比expireAfterWrite
效能好。但是還是會有一個執行緒需要去執行重新整理任務,而guava cache支援非同步重新整理,如果開啟非同步重新整理,在該執行緒在提交非同步重新整理任務之後,也會返回舊值,效能上更優異。
但是由於guava cache並不會定時清理的功能(主動),而是在查詢資料時,一併做了過期檢查和清理(被動)。那就會出現以下問題:資料如果隔了很長一段時間再去查詢,得到的這個舊值可能來自於很長時間之前,這將會引發問題,對時效性要求高的場景可能會造成非常大的錯誤。
當快取裡沒有要訪問的資料時,不管設定的是哪個模式,所有的執行緒都會阻塞,通過鎖來控制只會有一個執行緒去載入資料
原理
首先要了解guava cache
過期原理。
1. 整體方法
get
方法:
class LocalCache<K, V> extends AbstractMap<K, V> implements ConcurrentMap<K, V> {
V get(K key, int hash, CacheLoader<? super K, V> loader) throws ExecutionException {
checkNotNull(key);
checkNotNull(loader);
try {
if (count != 0) { // read-volatile
// don't call getLiveEntry, which would ignore loading values
ReferenceEntry<K, V> e = getEntry(key, hash);
if (e != null) {
long now = map.ticker.read();
V value = getLiveValue(e, now);
if (value != null) {
recordRead(e, now);
statsCounter.recordHits(1);
return scheduleRefresh(e, key, hash, value, now, loader);
}
ValueReference<K, V> valueReference = e.getValueReference();
if (valueReference.isLoading()) {
return waitForLoadingValue(e, key, valueReference);
}
}
}
// at this point e is either null or expired;
return lockedGetOrLoad(key, hash, loader);
} catch (ExecutionException ee) {
Throwable cause = ee.getCause();
if (cause instanceof Error) {
throw new ExecutionError((Error) cause);
} else if (cause instanceof RuntimeException) {
throw new UncheckedExecutionException(cause);
}
throw ee;
} finally {
postReadCleanup();
}
}
}
可以看到guava cache
繼承了ConcurrentHashMap
,為了滿足併發場景,核心的資料結構就是按照 ConcurrentHashMap 來的。
2. 簡化方法
這裡將方法簡化為跟本次主題有關的幾個關鍵步驟:
if (count != 0) { // 當前快取是否有資料
ReferenceEntry<K, V> e = getEntry(key, hash); // 取資料節點
if (e != null) {
V value = getLiveValue(e, now); // 判斷是否過期,過濾已過期資料,僅對expireAfterAccess或expireAfterWrite模式下設定的時間做判斷
if (value != null) {
return scheduleRefresh(e, key, hash, value, now, loader); // 是否需要重新整理資料,僅在refreshAfterWrite模式下生
}
ValueReference<K, V> valueReference = e.getValueReference();
if (valueReference.isLoading()) { // 如果有其他執行緒正在載入/重新整理資料
return waitForLoadingValue(e, key, valueReference); // 等待其他執行緒完成載入/重新整理資料
}
}
}
return lockedGetOrLoad(key, hash, loader); // 載入/重新整理資料
count
是cache
的一個屬性,被volatile
修飾(volatile int count),儲存的是當前快取的數量。
- 如果
count == 0
(沒有快取)或者根據key
取不到Hash節點,則加鎖並載入快取
(lockedGetOrLoad
)。 - 如果取到Hash節點,則判斷是否過期(
getLiveValue
),過濾掉已過期資料。
3. getLiveValue
V getLiveValue(ReferenceEntry<K, V> entry, long now) {
if (entry.getKey() == null) {
tryDrainReferenceQueues();
return null;
}
V value = entry.getValueReference().get();
if (value == null) {
tryDrainReferenceQueues();
return null;
}
if (map.isExpired(entry, now)) {
tryExpireEntries(now);
return null;
}
return value;
}
通過isExpired
判斷當前節點是否已過期:
boolean isExpired(ReferenceEntry<K, V> entry, long now) {
checkNotNull(entry);
if (expiresAfterAccess() && (now - entry.getAccessTime() >= expireAfterAccessNanos)) {
return true;
}
if (expiresAfterWrite() && (now - entry.getWriteTime() >= expireAfterWriteNanos)) {
return true;
}
return false;
}
isExpired
只判斷了expireAfterAccessNanos
和,expireAfterWriteNanos
兩個時間,結合expireAfterAccess
、expireAfterWrite
和refreshAfterWrite
三個方法的建構函式,可以看到這方法並不去管refreshAfterWrite
設定的時間,也就是如果過了expireAfterAccess
、expireAfterWrite
設定的時間,那資料就是過期了,否則就是沒過期。
如果發現資料已過期,則會連帶檢查是否還有其他過期的資料(惰性刪除
):
void tryExpireEntries(long now) {
if (tryLock()) {
try {
expireEntries(now);
} finally {
unlock();
// don't call postWriteCleanup as we're in a read
}
}
}
void expireEntries(long now) {
drainRecencyQueue();
ReferenceEntry<K, V> e;
while ((e = writeQueue.peek()) != null && map.isExpired(e, now)) {
if (!removeEntry(e, e.getHash(), RemovalCause.EXPIRED)) {
throw new AssertionError();
}
}
while ((e = accessQueue.peek()) != null && map.isExpired(e, now)) {
if (!removeEntry(e, e.getHash(), RemovalCause.EXPIRED)) {
throw new AssertionError();
}
}
}
void drainRecencyQueue() {
ReferenceEntry<K, V> e;
while ((e = recencyQueue.poll()) != null) {
if (accessQueue.contains(e)) {
accessQueue.add(e);
}
}
}
取最近範圍&寫入的資料,一一檢查是否過期。
4. scheduleRefresh
V value = getLiveValue(e, now);
if (value != null) {
return scheduleRefresh(e, key, hash, value, now, loader);
}
getLiveValue
之後,如果結果不為null
,則說明在expireAfterAccess
、expireAfterWrite
兩個模式下沒有過期(或者沒有設定這兩個模式的時間),但是不代表資料不會重新整理,因為getLiveValue
並沒有判斷refreshAfterWrite
的過期時間,而是在scheduleRefresh
方法裡判斷。
V scheduleRefresh(
ReferenceEntry<K, V> entry,
K key,
int hash,
V oldValue,
long now,
CacheLoader<? super K, V> loader) {
if (map.refreshes()
&& (now - entry.getWriteTime() > map.refreshNanos)
&& !entry.getValueReference().isLoading()) {
V newValue = refresh(key, hash, loader, true);
if (newValue != null) {
return newValue;
}
}
return oldValue;
}
滿足以下條件時,會重新整理資料(重新整理執行緒在同步重新整理模式下,返回新值,非同步重新整理模式下可能會返回舊值),否則直接返回舊值:
- 設定了
refreshAfterWrite
時間refreshNanos
。 - 當前資料已過期。
- 沒有其他執行緒正在重新整理資料(
!entry.getValueReference().isLoading()
)。
5. waitForLoadingValue
如果沒有設定refreshAfterWrite
,且資料已過期:
- 如果有其他執行緒正在重新整理,則阻塞等待(通過
future.get()
阻塞)。 如果沒有其他執行緒正在重新整理,則加鎖並重新整理資料。
ValueReference<K, V> valueReference = e.getValueReference(); if (valueReference.isLoading()) { return waitForLoadingValue(e, key, valueReference); } V waitForLoadingValue(ReferenceEntry<K, V> e, K key, ValueReference<K, V> valueReference) throws ExecutionException { if (!valueReference.isLoading()) { throw new AssertionError(); } checkState(!Thread.holdsLock(e), "Recursive load of: %s", key); // don't consider expiration as we're concurrent with loading try { V value = valueReference.waitForValue(); if (value == null) { throw new InvalidCacheLoadException("CacheLoader returned null for key " + key + "."); } // re-read ticker now that loading has completed long now = map.ticker.read(); recordRead(e, now); return value; } finally { statsCounter.recordMisses(1); } } public V waitForValue() throws ExecutionException { return getUninterruptibly(futureValue); } public static <V> V getUninterruptibly(Future<V> future) throws ExecutionException { boolean interrupted = false; try { while (true) { try { return future.get(); } catch (InterruptedException e) { interrupted = true; } } } finally { if (interrupted) { Thread.currentThread().interrupt(); } } }
6. 載入資料
載入資料,最終就是呼叫不管是lockedGetOrLoad
方法,還是scheduleRefresh
中的refresh
方法,最終呼叫的是CacheLoader
的load/reload
方法。
當快取裡沒有要訪問的資料時,不管設定的是哪個模式,都會進入到lockedGetOrLoad
方法中:
- 通過鎖爭搶
擁有載入資料的權利
。 - 搶到鎖的資料,將節點狀態設定為
loading
,並載入資料。 - 沒搶到鎖的資料,進入跟上一步一樣的
waitForLoadingValue
方法,阻塞等到資料載入完成。
lock();
try {
LoadingValueReference<K, V> loadingValueReference =
new LoadingValueReference<K, V>(valueReference);
e.setValueReference(loadingValueReference);
if (createNewEntry) {
loadingValueReference = new LoadingValueReference<K, V>();
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 {
// Synchronizes on the entry to allow failing fast when a recursive load is
// detected. This may be circumvented when an entry is copied, but will fail fast most
// of the time.
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);
}
解決方案
通過以上分析,可以知道在判斷快取是否過期時,guava cache
會分成兩次獨立的判斷:
- 判斷
expireAfterAccess
和expireAfterWrite
。 - 判斷
refreshAfterWrite
。
回到問題“refreshAfterWrite
雖然提升了效能,但是除了同步載入
模式下執行重新整理的那個執行緒外,其他執行緒可能訪問到過期已久的資料
”。我們可以通過expireAfterWrite
和refreshAfterWrite
結合的方案來解決:設定了refreshAfterWrite
的過期時間的同時,可以再設定expireAfterWrite/expireAfterAccess
的過期時間,expireAfterWrite/expireAfterAccess
的時間要大於refreshAfterWrite
的時間。
例如refreshAfterWrite
的時間為5分鐘,而expireAfterWrite
的時間為30分鐘,訪問到過期資料時:
- 如果過期時間小於30分鐘,則會進入
scheduleRefresh
方法,除重新整理執行緒以外的其他執行緒直接返回舊值。 - 如果快取資料長時間未被訪問,過期時間超過30分鐘,則資料會在
getLiveValue
方法中被過濾掉,除重新整理執行緒以外,其他執行緒阻塞等待。