多年前發展過一種可快取查詢:呼叫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
{
publicstaticclass 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>
publicstatic 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>
publicstatic 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
{
staticvoid 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
{
publicstaticclass BetterCacheManager
{
//加入Lock機制限定同一Key同一時間只有一個Callback執行
conststring AsyncLockPrefix = "$$CacheAsyncLock#";
/// <summary>
/// 取得每個Key專屬的鎖定對象
/// </summary>
/// <param name="key">Cache保存號碼牌</param>
/// <returns></returns>
staticobject GetAsyncLock(string key)
{
ObjectCache cache = MemoryCache.Default;
//取得每個Key專屬的鎖定對象(object)
string asyncLockKey = AsyncLockPrefix + key;
lock (cache)
{
if (cache[asyncLockKey] == null) cache.Add(asyncLockKey,
newobject(),
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>
publicstatic 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>
publicstatic 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
以上私房做法,提供大家參考。