讨论一下LRU缓存的实现算法

开发 开发工具 算法
本文将讨论一下LRU缓存的实现算法,LRU是Least Recently Used最近最久未使用算法。Oracle系统使用的一种算法,对于在内存中但最近又不用的数据块(内存块)叫做LRU。

业务模型

读、写、删的比例大致是7:3:1,至少要支持500w条缓存,平均每条缓存6k,要求设计一套性能比较好的缓存算法。

算法分析

不考虑MemCached,Velocity等现成的key-value缓存方案,也不考虑脱离.NET gc自己管理内存,不考虑随机读取数据及顺序读取数据的场景,目前想到的有如下几种LRU缓存方案

算法

分析

SortedDictionary

.NET自带的,内部用二叉搜索树(应该不是普通树,至少是做过优化的树)实现的,检索为O(log n),比普通的Dictionay(O(1))慢一点。
插入和删除都是O(log n),而且插入和删除,会实时排序。
但是.NET 2.0的这个类没有First属性

Dictionary + PriorityQueue

Dictionay可以保证检索是O(1);
优先队列可以保证插入和删除都为O(log n);
但是优先队列删除指定的项不支持(至少我找到的优先队列不支持),所以在删除缓存的时候不知道咋实现

Dictionay + Binary heap

二叉堆也是优先队列,分析应该同上,我没有详细评估。

b树

查找,删除,插入效率都很好,数据库都用它,但实现复杂,写一个没有BUG的B树几乎不可能。有人提到stl:map是自顶向下的红黑树,查找,删除,插入都是O(log n),但咱不懂c++,没做详细测试。

Dictionay + List

Dict用来检索;
List用来排序;
检索、添加、删除都没问题,只有在清空的时候需要执行List的排序方法,这时候缓存条目比较多的话,可能比较慢。

Dictionay + LinkedList

Dict用来检索;
LinkedList的添加和删除都是O(1),添加缓存时在链表头加节点,获取缓存时把特定节点移动(先删除特定节点(O(n)),再到头部添加节点(O(1)))到头,缓存满地时候截断掉尾部的一些节点。

目前几种方案在多线程下应该都需要加锁,不太好设计无锁的方案,下面这个链接是一个支持多线程的方案,但原理至今没搞特明白

A High Performance Multi-Threaded LRU Cache
http://www.codeproject.com/KB/recipes/LRUCache.aspx

用普通链表简单实现LRU缓存

以下是最后一种方案的简单实现,大家讨论下这个方案值不值得优化,或者其它的哪个方案比较合适 

  1. public class LRUCacheHelper {  
  2.     readonly Dictionary _dict;  
  3.     readonly LinkedList _queue = new LinkedList();  
  4.     readonly object _syncRoot = new object();  
  5.     private readonly int _max;  
  6.     public LRUCacheHelper(int capacity, int max) {  
  7.         _dict = new Dictionary(capacity);  
  8.         _max = max;  
  9.     }  
  10.    
  11.     public void Add(K key, V value) {  
  12.         lock (_syncRoot) {  
  13.             checkAndTruncate();  
  14.             _queue.AddFirst(key);   //O(1)  
  15.             _dict[key] = value;     //O(1)  
  16.         }  
  17.     }  
  18.    
  19.     private void checkAndTruncate() {  
  20.         lock (_syncRoot) {  
  21.             int count = _dict.Count;                        //O(1)  
  22.             if (count >= _max) {  
  23.                 int needRemoveCount = count / 10;  
  24.                 for (int i = 0; i < needRemoveCount; i++) {  
  25.                     _dict.Remove(_queue.Last.Value);        //O(1)  
  26.                     _queue.RemoveLast();                    //O(1)  
  27.                 }  
  28.             }  
  29.         }  
  30.     }  
  31.    
  32.     public void Delete(K key) {  
  33.         lock (_syncRoot) {  
  34.             _dict.Remove(key); //(1)  
  35.             _queue.Remove(key); // O(n)  
  36.         }  
  37.     }  
  38.     public V Get(K key) {  
  39.         lock (_syncRoot) {  
  40.             V ret;  
  41.             _dict.TryGetValue(key, out ret);    //O(1)  
  42.             _queue.Remove(key);                 //O(n)  
  43.             _queue.AddFirst(key);               //(1)  
  44.             return ret;  
  45.         }  
  46.     }  
  47. }  
用双头链表代替普通链表
 
突然想起来了,可以把链表换成双头链表,然后在字典里保存链表节点,在Get方法的时候直接从字典里获取到要移动的节点,然后把这个节点的上一个节点的Next指针指向给下一个节点,下一个节点的Previous指针指向上一个节点,这样就把移动节点的操作简化成O(1)了,提高了缓存读取的效率。

_dict.TryGetValue(key, out ret);    //O(1)
ret.Next.Previous = ret.Previous     //O(1)
ret. Previous.Next. = ret.Next         //O(1)
  _queue.AddFirst(key);                      //O(1)

我改进后的链表就差不多满足需求了,

操作

基本操作

复杂度

读取

Dict.Get

Queue.Move

O 1

O 1

删除

Dict.Remove

Queue.Remove

O 1

O 1

增加

Dict.Add

Queue.AddFirst

O 1

O 1

截断

Dict.Remove

Queue.RemoveLast

O k

O k

K表示截断缓存元素的个数

其中截断的时候可以指定当缓存满的时候截断百分之多少的最少使用的缓存项。

其它的就是多线程的时候锁再看看怎么优化,字典有线程安全的版本,就把.NET 3.0的读写锁扣出来再把普通的泛型字典保证成ThreadSafelyDictionay就行了,性能应该挺好的。

链表的话不太好用读写锁来做线程同步,大不了用互斥锁,但得考虑下锁的粒度,Move,AddFirst,RemoveLast的时候只操作一两个节点,是不是想办法只lock这几个节点就行了,Truncate的时候因为要批量操作很多节点,所以要上个大多链表锁,但这时候怎么让其它操作停止得考虑考虑,类似数据库的表锁和行锁。

LRU缓存实现代码

  1. public class DoubleLinkedListNode {  
  2.     public T Value { get; set; }  
  3.    
  4.     public DoubleLinkedListNode Next { get; set; }  
  5.    
  6.     public DoubleLinkedListNode Prior { get; set; }  
  7.    
  8.     public DoubleLinkedListNode(T t) { Value = t; }  
  9.    
  10.     public DoubleLinkedListNode() { }  
  11.    
  12.     public void RemoveSelf() {  
  13.         Prior.Next = Next;  
  14.         Next.Prior = Prior;  
  15.     }  
  16.    
  17. }  
  18. public class DoubleLinkedList {  
  19.     protected DoubleLinkedListNode m_Head;  
  20.     private DoubleLinkedListNode m_Tail;  
  21.    
  22.     public DoubleLinkedList() {  
  23.         m_Head = new DoubleLinkedListNode();  
  24.         m_Tail = m_Head;  
  25.     }  
  26.    
  27.     public DoubleLinkedList(T t)  
  28.         : this() {  
  29.         m_Head.Next = new DoubleLinkedListNode(t);  
  30.         m_Tail = m_Head.Next;  
  31.         m_Tail.Prior = m_Head;  
  32.     }  
  33.    
  34.     public DoubleLinkedListNode Tail {  
  35.         get { return m_Tail; }  
  36.     }  
  37.    
  38.     public DoubleLinkedListNode AddHead(T t) {  
  39.         DoubleLinkedListNode insertNode = new DoubleLinkedListNode(t);  
  40.         DoubleLinkedListNode currentNode = m_Head;  
  41.         insertNode.Prior = null;  
  42.         insertNode.Next = currentNode;  
  43.         currentNode.Prior = insertNode;  
  44.         m_Head = insertNode;  
  45.         return insertNode;  
  46.     }  
  47.     public void RemoveTail() {  
  48.         m_Tail = m_Tail.Prior;  
  49.         m_Tail.Next = null;  
  50.         return;  
  51.     }  
  52. }  
  53. public class LRUCacheHelper {  
  54.     class DictItem {  
  55.         public DoubleLinkedListNode Node { get; set; }  
  56.         public V Value { get; set; }  
  57.     }  
  58.     readonly Dictionary _dict;  
  59.     readonly DoubleLinkedList _queue = new DoubleLinkedList();  
  60.     readonly object _syncRoot = new object();  
  61.     private readonly int _max;  
  62.     public LRUCacheHelper(int capacity, int max) {  
  63.         _dict = new Dictionary(capacity);  
  64.         _max = max;  
  65.     }  
  66.    
  67.     public void Add(K key, V value) {  
  68.         lock (this)  
  69.         {  
  70.    
  71.             checkAndTruncate();  
  72.             DoubleLinkedListNode v = _queue.AddHead(key);   //O(1)  
  73.             _dict[key] = new DictItem() { Node = v, Value = value }; //O(1)  
  74.         }  
  75.     }  
  76.    
  77.     private void checkAndTruncate() {  
  78.         int count = _dict.Count;                        //O(1)  
  79.         if (count >= _max) {  
  80.             int needRemoveCount = count / 10;  
  81.             for (int i = 0; i < needRemoveCount; i++) {  
  82.                 _dict.Remove(_queue.Tail.Value);        //O(1)  
  83.                 _queue.RemoveTail();                    //O(1)  
  84.             }  
  85.         }  
  86.     }  
  87.    
  88.     public void Delete(K key) {  
  89.         lock (this) {  
  90.             _dict[key].Node.RemoveSelf();  
  91.             _dict.Remove(key); //(1)   
  92.         }  
  93.     }  
  94.     public V Get(K key) {  
  95.         lock (this) {  
  96.             DictItem ret;  
  97.             if (_dict.TryGetValue(key, out ret)) {  
  98.                 ret.Node.RemoveSelf();  
  99.                 _queue.AddHead(key);  
  100.                 return ret.Value;  
  101.             }  
  102.             return default(V);   
  103.         }  
  104.     }  
 
LRU缓存性能测试

用双头链表测试了一下,感觉性能还可以接受,每秒钟读取可达80w,每秒钟写操作越20w

程序初始化200w条缓存,然后不断的加,每加到500w,截断掉10分之一,然后继续加。

测试模型中每秒钟的读和写的比例是7:3,以下是依次在3个时间点截取的性能计数器图。
图1

性能计数器图1

图2

性能计数器图2


图3

性能计数器图3


内存最高会达到1g,cpu也平均百分之90以上,但测试到后期会发现每隔一段时间,就会有一两秒,吞吐量为0,如最后一张截图,后来观察发现,停顿的那一两秒是二代内存在回收,等不停顿的时候# gen 2 collections就会加1,这个原因应该是链表引起的,对链表中节点的添加和删除是很耗费GC的,因为会频繁的创建和销毁对象。 

LRU缓存后续改进

1、 用游标链表来代替普通的双头链表,程序起来就收工分配固定大小的数组,然后用数组的索引来做链表,省得每次添加和删除节点都要GC的参与,这相当于手工管理内存了,但目前我还没找到c#合适的实现。

 

2、 有人说链表不适合用在多线程环境中,因为对链表的每个操作都要加互斥锁,连读写锁都用不上,我目前的实现是直接用互斥锁做的线程同步,每秒的吞吐量七八十万,感觉lock也不是瓶颈,如果要改进的话可以把Dictionary用ThreadSafelyDictionary来代替,然后链表还用互斥锁(刚开始设想的链表操作只锁要操作的几个节点以降低并发冲突的想法应该不可取,不严谨)。

3、 还有一个地方就是把锁细分以下,链表还用链表,但每个链表的节点是个HashSet,对HashSet的操作如果只有读,写,删,没有遍历的话应该不需要做线程同步(我感觉不用,因为Set就是一个集合,一个线程往里插入,一个线程往里删除,一个线程读取应该没问题,顶多读进来的数据可能马上就删除了,而整个Set的结构不会破坏)。然后新增数据的时候往链表头顶Set里插入,读取某个数据的时候把它所在的节点的Set里删除该数据,然后再链表头的Set里插入一个数据,这样反复操作后,链表的最后一个节点的Set里的数据都是旧数据了,可以安全的删除了,当然这个删除的时候应该是要锁整个链表的。每个Set应该有个大小上限,比如20w,但set不能安全的遍历,就不能得到当前大小,所以添加、删除Set的数据的时候应该用Interlocked.Decrement()和 Interlocked.Increment()维护一个Count,一遍一个Set满的时候,再到链表的头新增一个Set节点。

LRU缓存性能测试脚本

  1. class Program {  
  2.     private static PerformanceCounter _addCounter;  
  3.     private static PerformanceCounter _getCounter;  
  4.    
  5.     static void Main(string[] args) {  
  6.         SetupCategory();  
  7.         _addCounter = new PerformanceCounter("wawasoft.lrucache""add/sec"false);  
  8.         _getCounter = new PerformanceCounter("wawasoft.lrucache""get/sec"false);  
  9.         _addCounter.RawValue = 0;  
  10.         _getCounter.RawValue = 0;  
  11.    
  12.         Random rnd = new Random();  
  13.         const int max = 500 * 10000;  
  14.    
  15.    
  16.         LRUCacheHelper<intint> cache = new LRUCacheHelper<intint>(200 * 10000, max);  
  17.    
  18.         for (int i = 10000*100000 - 1; i >= 0; i--)  
  19.         {  
  20.             if(i % 10 > 7)  
  21.             {  
  22.                 ThreadPool.QueueUserWorkItem(  
  23.                     delegate  
  24.                         {  
  25.                             cache.Add(rnd.Next(010000), 0);  
  26.                             _addCounter.Increment();   
  27.                         });  
  28.             }  
  29.             else 
  30.             {  
  31.                 ThreadPool.QueueUserWorkItem(  
  32.                    delegate  
  33.                    {  
  34.                        int pop = cache.Get(i);  
  35.                        _getCounter.Increment();  
  36.                    });  
  37.             }  
  38.         }  
  39.         Console.ReadKey();  
  40.     }  
  41.    
  42.     private static void SetupCategory() {  
  43.         if (!PerformanceCounterCategory.Exists("wawasoft.lrucache")) {  
  44.    
  45.             CounterCreationDataCollection CCDC = new CounterCreationDataCollection();  
  46.    
  47.             CounterCreationData addcounter = new CounterCreationData();  
  48.             addcounter.CounterType = PerformanceCounterType.RateOfCountsPerSecond32;  
  49.             addcounter.CounterName = "add/sec";  
  50.             CCDC.Add(addcounter);  
  51.    
  52.    
  53.             CounterCreationData getcounter = new CounterCreationData();  
  54.             getcounter.CounterType = PerformanceCounterType.RateOfCountsPerSecond32;  
  55.             getcounter.CounterName = "get/sec";  
  56.             CCDC.Add(getcounter);  
  57.    
  58.             PerformanceCounterCategory.Create("wawasoft.lrucache","lrucache",CCDC);  
  59.    
  60.         }  
  61.     }  
  62.    

【编辑推荐】

  1. .net缓存应用与分析
  2. Hibernate缓存机制探讨
  3. 充分利用ASP.NET的三种缓存提高站点性能
  4. ASP.NET缓存使用中的几点建议
  5. 缓存设计详解:低成本的高性能Web应用解决方案
责任编辑:彭凡 来源: cnblogs
相关推荐

2009-07-06 13:23:12

C#面向集合

2020-07-29 10:20:28

Redis数据库字符串

2022-06-17 07:49:14

缓存LRU

2022-03-08 08:02:44

Java系统错误码

2020-10-30 11:30:15

Least Recen

2020-02-19 19:18:02

缓存查询速度淘汰算法

2015-07-29 10:31:16

Java缓存算法

2023-04-14 07:34:19

2022-03-31 09:13:49

Cache缓存高并发

2021-07-26 21:15:10

LRU缓存MongoDB

2021-05-18 08:31:46

缓存HTTP服务器

2023-09-12 14:56:13

MyBatis缓存机制

2023-07-06 12:39:14

RedisLRULFU

2024-09-19 09:30:39

缓存框架抽象

2015-07-15 10:19:16

Java代码使用缓存

2020-09-18 10:31:47

LRU算法数组

2023-09-06 07:58:45

数据缓存Redis

2024-03-15 07:17:51

MySQLLRU算法缓存池

2021-03-01 18:42:02

缓存LRU算法

2024-10-16 11:28:42

点赞
收藏

51CTO技术栈公众号