基于LRU淘汰的高性能缓存
对于一个对性能要求较高的应用程序,使用缓存似乎是必然的选择。分布式系统通常选择分布式的缓存组件,如memCache。而对于小型系统而言,memCache太沉重了,另外序列化的损失也让我放弃它重新实现InProc的数据缓存。
LRU(Least Recent Used)是一种常用的缓存淘汰方式,在这里通过一个双向链表实现。
public class SimpleLRU<T> : ILRU<T>
{
private readonly LinkedList<T> _linklist = new LinkedList<T>();
private readonly int _maxitem;
private readonly int _removeRate;
/// <summary>
/// 在淘汰时和过期时通知缓存字典移除缓存
/// </summary>
private readonly Action<LinkedListNode<T>> onRemoveNode;
public SimpleLRU(int maxitem, int removeRate, Action<LinkedListNode<T>> onRemoveNode)
{
_maxitem = maxitem;
_removeRate = removeRate;
this.onRemoveNode = onRemoveNode;
}
/// <summary>
/// 移除一个缓存项,通常在显示标记缓存过期时使用
/// </summary>
public void Remove(LinkedListNode<T> node)
{
_linklist.Remove(node);
onRemoveNode(node);
}
/// <summary>
/// 标记缓存刚使用过
/// </summary>
public void MarkUse(LinkedListNode<T> node)
{
if(node.List!=null)
_linklist.Remove(node);
_linklist.AddFirst(node);
}
/// <summary>
/// 新增一个缓存项
/// </summary>
public LinkedListNode<T> AddNew(T value)
{
var n = _linklist.AddFirst(value);
AutoKnockOut();
return n;
}
/// <summary>
/// 当容量超出最大容量时,按比例淘汰一部分
/// </summary>
public void AutoKnockOut()
{
if (_linklist.Count > _maxitem)
RemoveFromEnd(_maxitem * _removeRate / 100);
}
private void RemoveFromEnd(int n)
{
if (_linklist.Count < n)
{
return;
}
for (; n > 0; n--)
{
Remove(_linklist.Last);
}
}
/// <summary>
/// 当前缓存大小
/// </summary>
public int Count
{
get { return _linklist.Count; }
}
}
缓存容器的代码实现:
public class Cache<TKey, TValue>
{
private class CacheItem
{
public LinkedListNode<TKey> node;
public TValue value;
}
private readonly Dictionary<TKey, CacheItem> _dic;
private readonly ILRU<TKey> _linklist;
public Cache(int maxItems, int removeRatio)
{
_dic = new Dictionary<TKey, CacheItem>(1000);
_linklist = new SimpleLRU<TKey>(maxItems, removeRatio, OnRemoveNode);
}
/// <summary>
/// 从缓冲中淘汰
/// </summary>
void OnRemoveNode(LinkedListNode<TKey> node)
{
_dic.Remove(node.Value);
}
/// <summary>
/// 标记缓存过期
/// </summary>
public void Remove(TKey key)
{
lock (this)
{
CacheItem item;
if (_dic.TryGetValue(key, out item))
{
_linklist.Remove(item.node);
}
}
}
/// <summary>
/// 设置一个缓存,如果已经存在则更新
/// </summary>
public void Set(TKey key, TValue value)
{
CacheItem item;
lock (this)
{
if (_dic.TryGetValue(key, out item))
{
item.value = value;
_linklist.MarkUse(item.node);
}
else
{
item = new CacheItem { node = _linklist.AddNew(key), value = value };
_dic.Add(key, item);
}
}
}
/// <summary>
/// 获取一个缓存项
/// </summary>
public bool Get(TKey key, out TValue value)
{
CacheItem item;
lock (this)
{
if (_dic.TryGetValue(key, out item))
{
_linklist.MarkUse(item.node);
value = item.value;
return true;
}
}
value = default(TValue);
return false;
}
public int Count
{
get
{
return _linklist.Count;
}
}
}
由于缓存可能存在并发,线程同步是最难以处理的事情,这里用了同步锁,强行将请求串行化来回避并发带来的问题,当然性能不是最佳的。
-------------------------------------------------------------------------------
性能测试:
static void Main(string[] args) { //测试无需淘汰的情况 TestCache(1,1000000,1000000); TestCache(5, 1000000, 5000000); TestCache(10, 100000, 1000000); TestCache(50, 100000, 5000000); //需要淘汰的情况 TestCache(1, 1000000, 80000); TestCache(5, 1000000, 80000); TestCache(10, 100000, 80000); TestCache(50, 100000, 80000); Console.ReadLine(); } public static void TestCache(int threadCount, int count, int maxitem) { Console.WriteLine("线程数:{0}\t循环次数:{1}\t缓存容量:{2}", threadCount, count, maxitem); const int rate = 8; var cache = new Cache<int, int>(maxitem, rate); var timer = new long[threadCount]; var timer2 = new long[threadCount]; Parallel.For(0, threadCount, t => { var time = t + 1; var sp = new Stopwatch(); sp.Start(); for (var i = 0; i < count; i++) { cache.Set(i * time, i * time); } sp.Stop(); timer[t] = sp.ElapsedMilliseconds; sp.Reset(); sp.Start(); for (var i = 0; i < count; i++) { int j = 0; if (cache.Get(i * time, out j)) { Debug.Assert(j == i * time); } } sp.Stop(); timer2[t] = sp.ElapsedMilliseconds; }); Console.WriteLine("当前缓存大小:{0}", cache.Count); Console.WriteLine("{0}个线程分别写入缓存{2}次执行总时间:{1}", timer.Length, timer.Sum() / 1000.0, count); Console.WriteLine("{0}个线程分别读取缓存{2}次执行总时间:{1}", timer2.Length, timer2.Sum() / 1000.0, count); Console.WriteLine("---------------------------"); }
测试机器:
CPU:AMD Athlon*2 4800+ (2.5GHz) Memory:2G
测试结果正如先前估计的一样,单线程下表现优异,在家里的破机器上读取速度接近千万/秒,写速度也达到百万/秒。而在多线程的环境下,性能大幅降低,并发越多性能下降越厉害,不过50个线程并发读写同一个缓存可能已经是极限情况,这时读取速度大约20万/秒,写速度10万/秒,对于多数应用,应该也可以接受。
如果你有更好的实现办法,不妨留言或者邮件告诉我,感激不尽。
补充:测试代码中使用的“线程”并非真正的线程(Thread),而是并行库中的任务,实际的线程可能由并行库根据机器的CPU核心数量决定。虽然不是真正的线程,但是实际上模拟了大并发下的真实场景。