多年前發展過一種可快取查詢:呼叫GetCachableData函式時傳入Cache Key、查詢或產生資料Callback函式、Cache保留期限(或指定閒置未用多久自動清除)三個參數,GetCachableData會依「若Cache有資料就直接沿用;若Cache無資料則當場產生並存入Cache」原則聰明處理,從此不需操心何時該查資料何時用Cache,應用起來挺方便的。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Caching;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
 
namespace GetCachable
{
    public static class CacheManager
    {
        /// <summary>
        /// 取得可以被Cache的資料(注意:非Thread-Safe)
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="key">Cache保存號碼牌</param>
        /// <param name="callback">傳回查詢資料的函數</param>
        /// <param name="cacheMins"></param>
        /// <param name="forceRefresh">是否清除Cache,重新查詢</param>
        /// <returns></returns>
        public static T GetCachableData<T>(string key, Func<T> callback, 
            int cacheMins, bool forceRefresh = false) where T : class
        {
            ObjectCache cache = MemoryCache.Default;
            string cacheKey = key;
 
            T res = cache[cacheKey] as T;
            //是否清除Cache,強制重查
            if (res != null && forceRefresh)
            {
                cache.Remove(cacheKey);
                res = null;
            }
            if (res == null)
            {
                res = callback();
                cache.Add(cacheKey, res, 
                    new CacheItemPolicy() {
                        SlidingExpiration = new TimeSpan(0, cacheMins, 0)
                    });
            }
            return res;
        }
 
 
        /// <summary>
        /// 取得可以被Cache的資料(注意:非Thread-Safe)
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="key">Cache保存號碼牌</param>
        /// <param name="callback">傳回查詢資料的函數</param>
        /// <param name="absExpire">有效期限</param>
        /// <param name="forceRefresh">是否清除Cache,重新查詢</param>
        /// <returns></returns>
        public static T GetCachableData<T>(string key, Func<T> callback, 
            DateTimeOffset absExpire, bool forceRefresh = false) where T : class
        {
            ObjectCache cache = MemoryCache.Default;
            string cacheKey = key;
            //取得每個Key專屬的鎖定對象
            T res = cache[cacheKey] as T;
            //是否清除Cache,強制重查
            if (res != null && forceRefresh)
            {
                cache.Remove(cacheKey);
                res = null;
            }
            if (res == null)
            {
                res = callback();
                cache.Add(cacheKey, res, new CacheItemPolicy()
                {
                    AbsoluteExpiration = absExpire
                });
            }
            return res;
        }
    }
}

不過原本的設計有個小問題,例如:有個網站透過GetCachableData由資料庫讀取五千筆員工資料並Cache住一小時,以便後續能快速地用員編查姓名。想像一個場景,尖峰時刻Cache逾時被清除(或是網站因故重啟),線上一百名使用者同時瀏覽某一網頁使用到員工姓名查詢,於是GetCachableData同時被100條Thread呼叫,MemoryCache本身為Thread-Safe多執行緒讀寫不致出錯,但Cache不存在觸發100個資料庫查詢,對形成一波完美的DDoS攻擊!接著資料庫忙碌、網頁卡住、使用者無助、老闆暴怒、開發者想哭…

以下範例可展示此問題,同時開啟三條Thread呼叫GetCachableData,則Callback動作也會同時三份(Callback執行時會印出Thread n Start/Stop Job訊息以利觀察)。這三次查詢動作只有一次是必要的,其餘兩次將取得相同結果覆寫同一Cache,平白消耗資源,在極端案例中甚至可能讓系統崩潰。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
 
namespace GetCachable
{
    class Program
    {
        static void Main(string[] args)
        {
            var tasks = new List<Task>();
            for (var i = 0; i < 3; i++)
            {
                tasks.Add(Task.Factory.StartNew(() =>
                {
                    var data = CacheManager.GetCachableData<string>("KEY",
                        () =>
                        {
                            Console.WriteLine("Thread {0} Start Job",
                                Thread.CurrentThread.ManagedThreadId);
                            Thread.Sleep(3000);
                            Console.WriteLine("Thread {0} Stop Job",
                                Thread.CurrentThread.ManagedThreadId);
                            return "OK";
                        }, 10);
                    Console.WriteLine("Data:" + data);
                }));
            }
            tasks.ForEach(t => t.Wait());
 
            Console.WriteLine("Done");
            Console.ReadLine();
        }
    }
}

執行結果如下,可觀察到三條Thread同時執行Callback:

Thread 13 Start Job
Thread 11 Start Job
Thread 12 Start Job
Thread 13 Stop Job
Data:OK
Thread 11 Stop Job
Thread 12 Stop Job
Data:OK
Data:OK
Done

要改良此一缺點,可在多執行緒查詢時加入Lock機制,相同Key值的查詢單一時間只允許一組Callback執行,執行完成後其餘等待的Thread可直接取用Cache結果,省下無效益的Callback動作。程式範例如下,依Key值建立Object作為鎖定對象,即能實現一Key值不會有兩份以上Callback同時執行:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Caching;
using System.Text;
using System.Threading.Tasks;
 
namespace GetCachable
{
    public static class BetterCacheManager
    {
        //加入Lock機制限定同一Key同一時間只有一個Callback執行
        const string AsyncLockPrefix = "$$CacheAsyncLock#";
        /// <summary>
        /// 取得每個Key專屬的鎖定對象
        /// </summary>
        /// <param name="key">Cache保存號碼牌</param>
        /// <returns></returns>
        static object GetAsyncLock(string key)
        {
            ObjectCache cache = MemoryCache.Default;
            //取得每個Key專屬的鎖定對象(object)
            string asyncLockKey = AsyncLockPrefix + key;
            lock (cache)
            {
                if (cache[asyncLockKey] == null) cache.Add(asyncLockKey,
                    new object(),
                    new CacheItemPolicy() {
                        SlidingExpiration = new TimeSpan(0, 10, 0)
                    });
            }
            return cache[asyncLockKey];
        }
 
        /// <summary>
        /// 取得可以被Cache的資料(注意:非Thread-Safe)
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="key">Cache保存號碼牌</param>
        /// <param name="callback">傳回查詢資料的函數</param>
        /// <param name="cacheMins"></param>
        /// <param name="forceRefresh">是否清除Cache,重新查詢</param>
        /// <returns></returns>
        public static T GetCachableData<T>(string key, Func<T> callback, 
            int cacheMins, bool forceRefresh = false) where T : class
        {
            ObjectCache cache = MemoryCache.Default;
            string cacheKey = key;
 
            //取得每個Key專屬的鎖定對象
            lock (GetAsyncLock(key))
            {
                T res = cache[cacheKey] as T;
                //是否清除Cache,強制重查
                if (res != null && forceRefresh)
                {
                    cache.Remove(cacheKey);
                    res = null;
                }
                if (res == null)
                {
                    res = callback();
                    cache.Add(cacheKey, res, 
                        new CacheItemPolicy() {
                            SlidingExpiration = new TimeSpan(0, cacheMins, 0)
                        });
                }
                return res;
            }
        }
 
 
        /// <summary>
        /// 取得可以被Cache的資料(注意:非Thread-Safe)
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="key">Cache保存號碼牌</param>
        /// <param name="callback">傳回查詢資料的函數</param>
        /// <param name="absExpire">有效期限</param>
        /// <param name="forceRefresh">是否清除Cache,重新查詢</param>
        /// <returns></returns>
        public static T GetCachableData<T>(string key, Func<T> callback, 
            DateTimeOffset absExpire, bool forceRefresh = false) where T : class
        {
            ObjectCache cache = MemoryCache.Default;
            string cacheKey = key;
            //取得每個Key專屬的鎖定對象
            lock (GetAsyncLock(key))
            {
                T res = cache[cacheKey] as T;
                //是否清除Cache,強制重查
                if (res != null && forceRefresh)
                {
                    cache.Remove(cacheKey);
                    res = null;
                }
                if (res == null)
                {
                    res = callback();
                    cache.Add(cacheKey, res, new CacheItemPolicy()
                    {
                        AbsoluteExpiration = absExpire
                    });
                }
                return res;
            }
        }
    }
}

改用BetterCacheManager後,同時三條Thread呼叫GetCachableData()只會觸發一次Callback,可減少高承載系統產生重複查詢的壓力:

Thread 9 Start Job
Thread 9 Stop Job
Data:OK
Data:OK
Data:OK
Done

以上私房做法,提供大家參考。


Comments

# by 阿撒不魯の居場所

不知可否讓我轉發呢 謝謝

# by Jeffrey

to 阿撒不魯の居場所,歡迎,註明出處即可。

# by 阿撒不魯の居場所

Jeffrey大 感謝您

# by Alan

大大您好,想請問一個可能跟文章沒有關係的問題 您在每個 Method 定義 "ObjectCache cache = MemoryCache.Default;" 不知道如果定義在外面 "static ObjectCache cache = MemoryCache.Default;" 這兩種有甚麼不同?

# by Jeffrey

to Alan, 沒什麼不同,都是指向同一個物件,除非有某個 Method 惡搞修改 cache = null 之類的。要防範的話可加上 readonly,寫成 static readonly ObjectCache cache。

Post a comment