[效能調教] 善用快取提高效能 - 建立 MemoryCache 共用模組

快取機制主要就是用「記憶體空間」來換取「資料查詢時間」,並且潛藏著資料時效性問題,因此開發人員需在天秤兩端讓「效能」及「資料即時性」達到完美的平衡,才可以將快取效果發揮到極致;本文先針對快取共用模組進行設計,後續會在此基礎上實踐不同情境下快取的使用方式。

前言


會想要使用快取的動機往往就是遠端資料過於耗時,並且取回的資料是頻繁地被大眾使用,此時就可以考慮把資料存在快取中,避免不斷重複叫用耗時的服務;快取的原理其實很簡單,主要就是 Key - Value 的概念,建立快取時當然會有需存放的 Value 值,而必需要訂定一個足以代表這個值的唯一條件串 Key,後續當有符合相同條件時,可以憑藉著 Key 在偌大的快取櫃中獲得對應的 Value,而這個快取櫃通常會是記憶體,如此才足以應付快速存取的要求。

image from huffingtonpost 

 

效益


在面對大量用戶的情境下,究竟建立快取機制的實際效能為何? 是否能夠紓解人潮呢? 其實在相同數量伺服器下確實是無法「直接」分流人潮,但可以做到的是減少資料庫主機的負擔,將資源負擔分散到 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 機制,有效避免此問題發生,如果對於這部分有興趣的朋友可以好好拜讀一下那篇文章。

由於非同步作業無法在 lock 區塊中使用,因此本文使用 Nito.AsyncEx 套件來實現非同步的 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);
    });
}

 


希望此篇文章可以幫助到需要的人

若內容有誤或有其他建議請不吝留言給筆者喔 !