guava cache過期方案實踐

noname發表於2021-12-10

過期機制

只要是快取,就必定有過期機制,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);    // 載入/重新整理資料

countcache的一個屬性,被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兩個時間,結合expireAfterAccessexpireAfterWriterefreshAfterWrite三個方法的建構函式,可以看到這方法並不去管refreshAfterWrite設定的時間,也就是如果過了expireAfterAccessexpireAfterWrite設定的時間,那資料就是過期了,否則就是沒過期。
如果發現資料已過期,則會連帶檢查是否還有其他過期的資料(惰性刪除):

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,則說明在expireAfterAccessexpireAfterWrite兩個模式下沒有過期(或者沒有設定這兩個模式的時間),但是不代表資料不會重新整理,因為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;
}

滿足以下條件時,會重新整理資料(重新整理執行緒在同步重新整理模式下,返回新值,非同步重新整理模式下可能會返回舊值),否則直接返回舊值:

  1. 設定了refreshAfterWrite時間refreshNanos
  2. 當前資料已過期。
  3. 沒有其他執行緒正在重新整理資料(!entry.getValueReference().isLoading())。

5. waitForLoadingValue

如果沒有設定refreshAfterWrite,且資料已過期:

  1. 如果有其他執行緒正在重新整理,則阻塞等待(通過future.get()阻塞)。
  2. 如果沒有其他執行緒正在重新整理,則加鎖並重新整理資料。

    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方法,最終呼叫的是CacheLoaderload/reload方法。
當快取裡沒有要訪問的資料時,不管設定的是哪個模式,都會進入到lockedGetOrLoad方法中:

  1. 通過鎖爭搶擁有載入資料的權利
  2. 搶到鎖的資料,將節點狀態設定為loading,並載入資料。
  3. 沒搶到鎖的資料,進入跟上一步一樣的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會分成兩次獨立的判斷:

  1. 判斷expireAfterAccessexpireAfterWrite
  2. 判斷refreshAfterWrite

回到問題“refreshAfterWrite雖然提升了效能,但是除了同步載入模式下執行重新整理的那個執行緒外,其他執行緒可能訪問到過期已久的資料”。我們可以通過expireAfterWriterefreshAfterWrite結合的方案來解決:設定了refreshAfterWrite的過期時間的同時,可以再設定expireAfterWrite/expireAfterAccess的過期時間,expireAfterWrite/expireAfterAccess的時間要大於refreshAfterWrite的時間。
例如refreshAfterWrite的時間為5分鐘,而expireAfterWrite的時間為30分鐘,訪問到過期資料時:

  1. 如果過期時間小於30分鐘,則會進入scheduleRefresh方法,除重新整理執行緒以外的其他執行緒直接返回舊值。
  2. 如果快取資料長時間未被訪問,過期時間超過30分鐘,則資料會在getLiveValue方法中被過濾掉,除重新整理執行緒以外,其他執行緒阻塞等待。

相關文章