快取機制主要就是用「記憶體空間」來換取「資料查詢時間」,並且潛藏著資料時效性問題,因此開發人員需在天秤兩端讓「效能」及「資料即時性」達到完美的平衡,才可以將快取效果發揮到極致;本文先針對快取共用模組進行設計,後續會在此基礎上實踐不同情境下快取的使用方式。
前言
會想要使用快取的動機往往就是遠端資料過於耗時,並且取回的資料是頻繁地被大眾使用,此時就可以考慮把資料存在快取中,避免不斷重複叫用耗時的服務;快取的原理其實很簡單,主要就是 Key - Value 的概念,建立快取時當然會有需存放的 Value 值,而必需要訂定一個足以代表這個值的唯一條件串 Key,後續當有符合相同條件時,可以憑藉著 Key 在偌大的快取櫃中獲得對應的 Value,而這個快取櫃通常會是記憶體,如此才足以應付快速存取的要求。
效益
在面對大量用戶的情境下,究竟建立快取機制的實際效能為何? 是否能夠紓解人潮呢? 其實在相同數量伺服器下確實是無法「直接」分流人潮,但可以做到的是減少資料庫主機的負擔,將資源負擔分散到 AP 主機上,以此「間接」服務更多人潮,且讓正真需要資料庫資源的用戶不會受到排擠;資料庫資源的消耗除了初次使用資源的用戶外,不斷調用相同資源的已登入用戶也不在少數,因此快取的目的就在於減緩相同資源調用的 DB 效能損耗,讓 DB 資源可以妥善地提供給需要的人,期望在相同資源下服務更多使用者。
實作
墊入快取的位置 (Web API / Server Layer / Data Access) 及快取有效期模式 (AbsoluteExpiration / SlidingExpiration) 的變化都會牽動著此份快取資料的合宜性,針對每種情境都有不同的設置方式,如果設置不合宜有可能會造成反效果,因此在建立快取機制的當下,必須謹慎思考最佳的設置方式來符合情境上的使用;此篇會先針對快取共用模組的基礎建設進行說明,後續筆者會針對不同情境下設置快取的考量點供大家參考。
設置 CacheHelper 是以單台伺服器或設置 Sticky Sessions 負載平衡模式下的多伺服器架構而設計的,簡單講就是要確保用戶幾乎都會使用到相同 AP 伺服器,如此存放在該台 AP 伺服器中的快取資料才會發揮作用;如果無法確保用戶都會訪問到相同 AP 伺服器,那麼此快取機制就會大打折扣,另外若是牽涉到需主動清除快取的情境時,就必需讓快取資料統一導向獨立的快取伺服器(ex. Redis ) 中存取,如此才能確保快取資料的一致性。
筆者在對系統壓測的過程中發現,當一瞬間大量 Request 湧入取得相同資料時(尚未有快取資料存在),會有大量 Request 同時間衝破判斷快取是否存在的閘門來跟 DB 伺服器取得相同資料,類似一波 DDoS 攻擊行為,因此筆者參考黑暗執行續大 改良式GetCachableData可快取查詢函式 文章來建立 Lock 機制,有效避免此問題發生,如果對於這部分有興趣的朋友可以好好拜讀一下那篇文章。
筆者主要是利用 MemoryCache 來實作快取共用模組,可以依照需求設置快取有效期間及模式,另外也提供一個旗標讓開發人員有條件地決定是否使用快取(ex. 相同的資料取得方法,只希望在未登入系統時快取,登入後因資料具即時性而無須快取),以下參考。
public static class CacheHelper
{
// 加入Lock機制限定同一Key同一時間只有一個Callback執行
const string LockPrefix = "$$CacheAsyncLock#";
// 取得每個Key專屬的鎖定對象 (同步)
static object GetLock(string cacheKey)
{
ObjectCache cache = MemoryCache.Default;
string lockKey = LockPrefix + "-sync-" + cacheKey;
lock (cache)
{
if (cache[lockKey] == null)
{
cache.Set(lockKey, new object(),
new CacheItemPolicy() { SlidingExpiration = new TimeSpan(0, 10, 0) });
}
}
return cache[lockKey];
}
// 取得每個Key專屬的鎖定對象 (非同步)
static AsyncLock GetAsyncLock(string cacheKey)
{
ObjectCache cache = MemoryCache.Default;
string asyncLockKey = LockPrefix + "-async-" + cacheKey;
lock (cache)
{
if (cache[asyncLockKey] == null)
{
// 使用 Nito.AsyncEx 套件的 AsyncLock 作為鎖定對象
cache.Set(asyncLockKey, new AsyncLock(),
new CacheItemPolicy() { SlidingExpiration = new TimeSpan(0, 10, 0) });
}
}
return cache[asyncLockKey] as AsyncLock;
}
// 快取資料以節省資源消耗 (同步資源)
// cacheKey: 請嚴格使用唯一代表值 (ex.服務名稱-服務方法名稱-資料依據參數1-資料依據參數2...)
// expireMode: 選擇快取過期時間計算模式(固定時間/未訪問時間)
// cachedSeconds: 時效(s)
// getRemoteResource: 快取資料取得方式(同步)
// isAlwaysGetRemoteResource: 是否套用快取機制(有條件的使用快取)
public static T CacheResource<T>(string cacheKey, ExpireModeEnum expireMode, int cachedSeconds, Func<T> getRemoteResource, bool isAlwaysGetRemoteResource = false) where T : class
{
if (isAlwaysGetRemoteResource)
{
return getRemoteResource();
}
ObjectCache cache = MemoryCache.Default;
lock (GetLock(cacheKey))
{
var cachedData = cache[cacheKey] as T;
if (cachedData == null)
{
// load data from remote resource (db, api ...)
cachedData = getRemoteResource();
// set cache if have data
if (cachedData != null)
{
// set policy (when to remove cache)
var policy = CreateCacheItemPolicy(expireMode, cachedSeconds);
// set cache
cache.Set(cacheKey, cachedData, policy);
}
}
return cachedData;
}
}
// 快取資料以節省資源消耗 (非同步資源)
// cacheKey: 請嚴格使用唯一代表值 (ex.服務名稱-服務方法名稱-資料依據參數1-資料依據參數2...)
// expireMode: 選擇快取過期時間計算模式(固定時間/未訪問時間)
// cachedSeconds: 時效(s)
// getRemoteResource: 快取資料取得方式(非同步)
// isAlwaysGetRemoteResource: 是否套用快取機制(有條件的使用快取)
public static async Task<T> CacheResource<T>(string cacheKey, ExpireModeEnum expireMode, int cachedSeconds, Func<Task<T>> getRemoteResource, bool isAlwaysGetRemoteResource = false) where T : class
{
if (isAlwaysGetRemoteResource)
{
return await getRemoteResource(); // 如果發生錯誤會拋出
}
// 因為 lock 區塊中不能使用 await ,所以利用 Nito.AsyncEx 套件處理 lock 功能
ObjectCache cache = MemoryCache.Default;
using (await GetAsyncLock(cacheKey).LockAsync())
{
var cachedData = cache[cacheKey] as T;
if (cachedData == null)
{
// load data from remote resource (db, api ...)
// 如果發生錯誤會拋出,不會記錄此次的快取資料
cachedData = await getRemoteResource();
// set cache if have data
if (cachedData != null)
{
// set policy (when to remove cache)
var policy = CreateCacheItemPolicy(expireMode, cachedSeconds);
// set cache
cache.Set(cacheKey, cachedData, policy);
}
}
return cachedData;
}
}
// 建立快取政策及存活時間
private static CacheItemPolicy CreateCacheItemPolicy(ExpireModeEnum expireMode, int cachedSeconds)
{
var policy = new CacheItemPolicy();
switch (expireMode)
{
case ExpireModeEnum.ExpireInSecond:
policy.AbsoluteExpiration = DateTimeOffset.Now.AddSeconds(cachedSeconds);
break;
case ExpireModeEnum.ExpireNotUsedInSecond:
policy.SlidingExpiration = TimeSpan.FromSeconds(cachedSeconds);
break;
default:
policy.AbsoluteExpiration = DateTimeOffset.Now.AddSeconds(cachedSeconds);
break;
}
return policy;
}
// 釋放快取
// 輸入的所有關鍵字會以 AND 條件進行篩選
public static void RemoveCache(params string[] keys)
{
ObjectCache cache = MemoryCache.Default;
var expiredCaches = cache.Where(t =>
{
bool isTargetCache = true;
foreach (var key in keys)
{
isTargetCache = t.Key.Contains(key);
if (isTargetCache == false)
{
break;
}
}
return isTargetCache;
});
foreach (var expiredCache in expiredCaches)
{
cache.Remove(expiredCache.Key);
}
}
}
// 快取過期時間計算模式
public enum ExpireModeEnum
{
// 固定時間後就過期
ExpireInSecond = 1,
// 超過固定時間沒有使用就過期
ExpireNotUsedInSecond = 2
}
套用快取
假設目前有一隻查詢用戶常用功能的方法,查詢緩慢已是無法改變的事實,且在相同條件下的資料會不斷被查詢,此時就可以使用快取來減少重複性查詢的資源耗費。
// 取得用戶最常使用的功能清單 (查詢耗時)
public List<Menu> getFreqMenus(string userId, string sysCode)
{
return menuDao.getFreqMenus(userId, sysCode);
}
加上快取機制的方式很簡單,只要把原本的代碼包住就搞定了;此例只要訪問一次就將該用戶的資料快取 10 分鐘,也就表示 10 分鐘內進出操作系統時,已不再向 DB 作耗時的查詢動作,而是從記憶體回應所需資訊。
// 取得用戶最常使用的功能清單 (查詢耗時-加上快取機制)
public List<Menu> getFreqMenus(string userId, string sysCode)
{
var cacheKey = $"menuService-getFreqMenus-{userId}-{sysCode}";
return CacheHelper.CacheResource(cacheKey, ExpireModeEnum.ExpireInSecond, 6000, () =>
{
return menuDao.getFreqMenus(userId, sysCode);
});
}
希望此篇文章可以幫助到需要的人
若內容有誤或有其他建議請不吝留言給筆者喔 !