levelDB中的LRU

2024-01-20 20:58
文章标签 lru leveldb

本文主要是介绍levelDB中的LRU,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

https://www.jianshu.com/p/9e7773432772

在诸多的Cache策略中,LRUCache(Least Recently Used,最近最少被使用)因为完美地契合了局部性原理,故而成为最常见的Cache策略。而Cache算法的设计与实现,也是面试中经常会遇到的问题。

下面,让我们来看一下LevelDB中的LRUCache实现。

单条缓存记录:LRUHandle

(LRUHandle是Hash表中存储的一个元素,存储了prev、next等信息,维护了一个双向链表,next_hash是当Hash冲突时哈希桶中的多个元素用链表维护)

 23 // An entry is a variable length heap-allocated structure.  Entries24 // are kept in a circular doubly linked list ordered by access time.25 struct LRUHandle {26   void* value;27   void (*deleter)(const Slice&, void* value);28   LRUHandle* next_hash;29   LRUHandle* next;30   LRUHandle* prev;31   size_t charge;      // TODO(opt): Only allow uint32_t?32   size_t key_length;33   uint32_t refs;34   uint32_t hash;      // Hash of key(); used for fast sharding and comparisons35   char key_data[1];   // Beginning of key
...46 };

LevelDB是一个Key/Value存储库,所缓存的数据自然是Key/Value对,26/32/35行用以存储kv对。*这里有一点困惑,key_data 为什么是 char[1],而不直接使用 char*? *

当回收一条缓存记录时,27行中的deleter将被调用,以callback的形式通知缓存用户,一条数据被移出缓存。这里Slice是key类型,姑且认为是string,不影响接下来的理解。

31行的charge用来保存每条缓存记录的容量,当所有缓存记录的容量和超过缓存总容量时,最近最少被使用的缓存记录将被回收。

32行用以维护引用计数。

重点看下28/29/30/34行,28/34行暗示出缓存记录将被置于哈希表中,29/30行暗示出缓存记录将被置于双向链表中。

存疑一:LRUHandle为什么会被同时置于哈希表和双向链表之中呢? (问题驱动,这种学习方法很好)

哈希表:HandleTable

 48 // We provide our own simple hash table since it removes a whole bunch49 // of porting hacks and is also faster than some of the built-in hash50 // table implementations in some of the compiler/runtime combinations51 // we have tested.  E.g., readrandom speeds up by ~5% over the g++52 // 4.4.3's builtin hashtable.53 class HandleTable {54  public:55   HandleTable() : length_(0), elems_(0), list_(NULL) { Resize(); }56   ~HandleTable() { delete[] list_; }57 58   LRUHandle* Lookup(const Slice& key, uint32_t hash) {59     return *FindPointer(key, hash);60   }61 62   LRUHandle* Insert(LRUHandle* h) {63     LRUHandle** ptr = FindPointer(h->key(), h->hash);64     LRUHandle* old = *ptr;65     h->next_hash = (old == NULL ? NULL : old->next_hash);66     *ptr = h;67     if (old == NULL) {68       ++elems_;69       if (elems_ > length_) {70         // Since each cache entry is fairly large, we aim for a small71         // average linked list length (<= 1).72         Resize();73       }74     }75     return old;76   }77  78   LRUHandle* Remove(const Slice& key, uint32_t hash) {79     LRUHandle** ptr = FindPointer(key, hash);80     LRUHandle* result = *ptr;81     if (result != NULL) {82       *ptr = result->next_hash;83       --elems_;84     }85     return result;86   }87 88  private:89   // The table consists of an array of buckets where each bucket is90   // a linked list of cache entries that hash into the bucket.91   uint32_t length_;92   uint32_t elems_;93   LRUHandle** list_;

LookUp用以查找给定记录是否存在 ,Remove用以删除一条记录,Insert用以插入/更新一条记录,如果是更新操作,还会返回旧记录。

**为什么不区分插入和更新操作? **
因为对于缓存写者而言,TA不知道也不关心数据是否在缓存中,TA只是尽可能地将TA认为近期会被访问到的数据写入缓存。这里Insert命名不好,幂等操作用Put更好。(若key不存在,直接插入一个kv对,如果key存在,修改相应的kv对)

存疑二:Insert操作为什么返回旧缓存记录? (75 行 return old)

 95   // Return a pointer to slot that points to a cache entry that96   // matches key/hash.  If there is no such cache entry, return a97   // pointer to the trailing slot in the corresponding linked list.98   LRUHandle** FindPointer(const Slice& key, uint32_t hash) {99     LRUHandle** ptr = &list_[hash & (length_ - 1)];
100     while (*ptr != NULL &&
101            ((*ptr)->hash != hash || key != (*ptr)->key())) {
102       ptr = &(*ptr)->next_hash;
103     }
104     return ptr;
105   }

哈希表的实现中,FindPointer返回一个指向next_hash的指针ptr,而该next_hash指向所找到的缓存记录,有了ptr,就可以直接修改next_hash的指向,达到添加/删除链表节点的目的。而我都是通过保存当前节点与前置节点两个一级指针来完成上述操作的,远没有作者对指针的理解深刻。

107   void Resize() {
108     uint32_t new_length = 4;
109     while (new_length < elems_) {
110       new_length *= 2;
111     }
112     LRUHandle** new_list = new LRUHandle*[new_length];
113     memset(new_list, 0, sizeof(new_list[0]) * new_length);
114     uint32_t count = 0;
115     for (uint32_t i = 0; i < length_; i++) {
116       LRUHandle* h = list_[i];
117       while (h != NULL) {
118         LRUHandle* next = h->next_hash;
119         uint32_t hash = h->hash;
120         LRUHandle** ptr = &new_list[hash & (new_length - 1)];
121         h->next_hash = *ptr;
122         *ptr = h;
123         h = next;
124         count++;
125       }
126     }
127     assert(elems_ == count);
128     delete[] list_;
129     list_ = new_list;
130     length_ = new_length;
131   }

Resize()操作实现内存自动增长,用以保证哈希桶中的单链表的平均长度 <= 1,进而保证添删查操作O(1)的时间复杂度。

LRUCache

134 // A single shard of sharded cache.
135 class LRUCache {
136  public:
137   LRUCache();
138   ~LRUCache();
139 
140   // Separate from constructor so caller can easily make an array of LRUCache
141   void SetCapacity(size_t capacity) { capacity_ = capacity; }
142 
143   // Like Cache methods, but with an extra "hash" parameter.
144   Cache::Handle* Insert(const Slice& key, uint32_t hash,
145                         void* value, size_t charge,
146                         void (*deleter)(const Slice& key, void* value));
147   Cache::Handle* Lookup(const Slice& key, uint32_t hash);
148   void Release(Cache::Handle* handle);
149   void Erase(const Slice& key, uint32_t hash);
150   void Prune();
151   size_t TotalCharge() const {
152     MutexLock l(&mutex_);
153     return usage_;
154   }
155 
156  private:
157   void LRU_Remove(LRUHandle* e);
158   void LRU_Append(LRUHandle* e);
159   void Unref(LRUHandle* e);
160 
161   // Initialized before use.
162   size_t capacity_;
163 
164   // mutex_ protects the following state.
165   mutable port::Mutex mutex_;
166   size_t usage_;
167 
168   // Dummy head of LRU list.
169   // lru.prev is newest entry, lru.next is oldest entry.
170   LRUHandle lru_;
171 
172   HandleTable table_;
173 };

SetCapacity用以设置缓存容量,当容量溢出时,触发回收逻辑。

LRUCache同时维护了一个双向链表(LRUList)和一个哈希表(HandleTable),LRUList中按访问时间排序缓存记录,prev从最近到最久,next反之。

另外,注意一下Insert和LookUp的返回值, Cache::Handle的定义如下:

 40   // Opaque handle to an entry stored in the cache.41   struct Handle { };

我曾多次在API文档中见过opaque handle的字样,但一直不解其意。直到现在才明白,所谓的opaque handle其角色类似于基类指针,隐藏实现细节,每个实现者需要提供自己的实现,如Handle之于LRUHandle。

175 LRUCache::LRUCache()
176     : usage_(0) {
177   // Make empty circular linked list
178   lru_.next = &lru_;
179   lru_.prev = &lru_;
180 }
181 
182 LRUCache::~LRUCache() {
183   for (LRUHandle* e = lru_.next; e != &lru_; ) {
184     LRUHandle* next = e->next;
185     assert(e->refs == 1);  // Error if caller has an unreleased handle
186     Unref(e);
187     e = next;
188   }
189 }
190 
191 void LRUCache::Unref(LRUHandle* e) {
192   assert(e->refs > 0);
193   e->refs--;
194   if (e->refs <= 0) {
195     usage_ -= e->charge;
196     (*e->deleter)(e->key(), e->value);
197     free(e);
198   }
199 }

为什么维护引用计数?
读取数据时,用户首先从缓存中查找欲读的数据是否存在,如果存在,用户将获得命中缓存的Handle。在用户持有该Handle的期间,该缓存可能被删除(多种原因,如:超过缓存容量触发回收、具有相同key的新缓存插入、整个缓存被析构等),导致用户访问到非法内存,程序崩溃。因此,需要使用引用计数来维护Handle的生命周期。

201 void LRUCache::LRU_Remove(LRUHandle* e) {
202   e->next->prev = e->prev;
203   e->prev->next = e->next;
204 }
205 
206 void LRUCache::LRU_Append(LRUHandle* e) {
207   // Make "e" newest entry by inserting just before lru_
208   e->next = &lru_;
209   e->prev = lru_.prev;
210   e->prev->next = e;
211   e->next->prev = e;
212 }
213 
214 Cache::Handle* LRUCache::Lookup(const Slice& key, uint32_t hash) {
215   MutexLock l(&mutex_);
216   LRUHandle* e = table_.Lookup(key, hash);
217   if (e != NULL) {
218     e->refs++;
219     LRU_Remove(e);
220     LRU_Append(e);
221   }
222   return reinterpret_cast<Cache::Handle*>(e);
223 }

注意看LookUp的实现,如果单纯使用链表,则仅能提供O(n)的查询效率,所以在查询时,利用了哈希表实现O(1)的查询。

那么,如果单纯使用哈希表呢?虽然可以实现O(1)的查询,但却无法更新缓存节点的访问时间(218-220行)。这是因为链表可以按照固定的顺序被遍历,而哈希表中的节点无法提供固定的遍历顺序(考虑Resize前后)。

有的读者可能会想,可不可以将访问时间记录在Handle中,然后仅用哈希表,这样既可以实现O(1)的查询,又可以方便地更新缓存记录的访问时间,岂不美哉?但是,如果没有按访问时间排序的链表,当触发缓存回收时,我们如何快速定位到哪些缓存记录要被回收呢?(仅在Handle中存时间的话,每次LRU淘汰需要排序)

回答疑问一:LRUHandle为什么会被同时置于哈希表和双向链表之中呢?

 查询插入删除有序
链表O(n)O(1)O(1)支持
哈希表O(1)O(1)O(1)不支持

注1:哈希表实现O(1)操作的前提是:平均每哈希桶元素数 <= 1
注2:为了保持平均哈希桶元素数,必要时必须Resize,而Resize后,原有顺序将被打破

链表O(n)的查询效率、哈希表不支持排序,两种数据结构单独都无法满足这里的需求。作者巧妙地将二者结合,取长补短,利用哈希表实现O(1)的查询,利用链表维持对缓存记录按访问时间排序。

230 Cache::Handle* LRUCache::Insert(
231     const Slice& key, uint32_t hash, void* value, size_t charge,
232     void (*deleter)(const Slice& key, void* value)) {
233   MutexLock l(&mutex_);
234 
235   LRUHandle* e = reinterpret_cast<LRUHandle*>(
236       malloc(sizeof(LRUHandle)-1 + key.size()));
237   e->value = value;
238   e->deleter = deleter;
239   e->charge = charge;
240   e->key_length = key.size();
241   e->hash = hash;
242   e->refs = 2;  // One from LRUCache, one for the returned handle
243   memcpy(e->key_data, key.data(), key.size());
244   LRU_Append(e);
245   usage_ += charge;
246 
247   LRUHandle* old = table_.Insert(e);
248   if (old != NULL) {
249     LRU_Remove(old);
250     Unref(old);
251   }
252 
253   while (usage_ > capacity_ && lru_.next != &lru_) {
254     LRUHandle* old = lru_.next;
255     LRU_Remove(old);
256     table_.Remove(old->key(), old->hash);
257     Unref(old);
258   }
259 
260   return reinterpret_cast<Cache::Handle*>(e);
261 }

Insert操作是整个LRUCache实现的核心。

  • 235-243行:申请内存,存储用户数据
  • 244行:将该缓存记录插入到双向链表中的最新端
  • 245行:计算已使用容量
  • 247-251行:如果是更新操作,回收旧记录,回答了疑问二:Insert操作为什么返回旧缓存记录?(HandleTable ::Insert是单纯地插入到hash表中,如果是更新,需要从链表中删除掉旧的KV,所以返回old kv用于在链表中删除)
  • 253-258行:已用容量超过总量,回收最近最少被使用的缓存记录。

总结

一个小小的LRUCache实现,不仅使用了引用计数技术来管理内存,还巧妙地结合了哈希表和双向链表两种数据结构的优势,达到性能与功能的完美结合。
 

这篇关于levelDB中的LRU的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/627316

相关文章

LRU 实现原理

前言 我们常用缓存提升数据查询速度,由于缓存容量有限,当缓存容量到达上限,就需要删除部分数据挪出空间,这样新数据才可以添加进来。缓存数据不能随机删除,一般情况下我们需要根据某种算法删除缓存数据。常用淘汰算法有 LRU,LFU,FIFO,这篇文章我们聊聊 LRU 算法。 LRU 简介 LRU 是 Least Recently Used 的缩写,这种算法认为最近使用的数据是热门数据,下一次很大概

剖析LRU算法及LinkedHashMap源码实现机制

一、简述 LRU(Least Recently Used),注意L代表的不是Latest,翻译成中文通常叫:近期最少使用算法、最近最少使用算法。LRU与LFU(Least Frequently Used)、FIFO(First In First Out)是3种常用的缓存算法(页面置换算法)。缓存算法的应用场景有很多,例如操作系统在物理内存不足时触发的磁盘交换、CPU中L1、L2、L3缓存淘汰

LRU算法 - LRU Cache

这个是比较经典的LRU(Least recently used,最近最少使用)算法,算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。 一般应用在缓存替换策略中。其中的”使用”包括访问get和更新set。 LRU算法 LRU是Least Recently Used 近期最少使用算法。内存管理的一种页面置换算法,对于在内存中但又不用的

页面置换算 - FIFO、LFU、LRU

缓存算法(页面置换算法)-FIFO、LFU、LRU   在前一篇文章中通过leetcode的一道题目了解了LRU算法的具体设计思路,下面继续来探讨一下另外两种常见的Cache算法:FIFO、LFU 1.FIFO算法   FIFO(First in First out),先进先出。其实在操作系统的设计理念中很多地方都利用到了先进先出的思想,比如作业调度(先来先服务),为什么这个原则在很多地方都会

深度揭秘Redis缓存策略:LRU vs LFU,如何选择最佳方案?

在追求极致性能的高并发系统中,缓存技术如同润滑油,让数据访问更加流畅。Redis,作为业界公认的键值存储明星,其灵活的淘汰策略尤为引人注目。今天,我们将带您走进LRU与LFU的世界,探讨这两种策略的差异、适用场景。 LRU:时间的考验者 想象一下,您的书架是缓存空间,每本书代表一个数据项。当空间不足时,您会如何选择书籍移出书架?LRU(最近最少使用)策略便是这样一位“图书管理员”,它优先移除那

LRU和LFU的实现及优缺点

计算机内部有很多使用缓存的地方,缓存能够保证系统的快速运转。但是一个缓存组件是否好用,取决于它的缓存命中率,而命中率又和缓存组件自己的缓存数据淘汰算法息息相关。常用的缓存算法有:FIFO、LRU、LFU。 FIFO 先进先出算法FIFO(First In First Out)的基本思想是:选择最早调入内存的页面淘汰。 类似于队列的思想,所以实现起来也不困难。 我们通过一个操作系统内的页面置换

Redis的内存淘汰策略- volatile-lru

`volatile-lru` 策略简介 在 `volatile-lru` 策略下,当 Redis 的内存使用达到配置的上限(`maxmemory`)时,它会优先删除那些设置了过期时间的键,并且选择最近最少使用的键进行删除。LRU 算法的核心思想是,优先删除那些最近没有被访问的数据,以腾出内存空间给新数据或更常用的数据。 这种策略适用于以下场景: - 需要优先删除临时数据的场景。 - 应用中

LRU、LFU、FIFO算法总结

一、概述 (1)FIFO:First In First Out,先进先出 (2)LRU:Least Recently Used,最近最少使用 (3)LFU:Least Frequently Used,最不经常使用    FIFO表示先进先出,类似于对列,在数据的结构上使用对列来实现。 结构图: 1. 新访问的数据插入FIFO队列尾部,数据在FIFO

使用LinkedHashMap实现固定大小的LRU缓存

使用LinkedHashMap实现固定大小的LRU缓存 1. 什么是LRU? LRU是"Least Recently Used"的缩写,意为"最近最少使用"。LRU缓存是一种常用的缓存淘汰算法,它的核心思想是:当缓存满时,优先淘汰最近最少使用的项目。 LRU缓存的工作原理: 新数据插入到缓存头部每当缓存命中(即缓存数据被访问),则将数据移到缓存头部当缓存满时,将链表尾部的数据丢弃 LRU

LRU的java实现

什么是LRU LRU(Least Recently Used)直译来看,就是最近最少使用,因为LRU是一个淘汰算法,所以指的是,每次都淘汰离当前时间最远的被使用到的那一个。也就是最久没有被使用到的那个。 为什么需要LRU LRU其实大部分操作系统为最大化页面命中率而广泛采用的一种页面置换算法,是为了提高页面的命中率而使用的算法,就应用开发来说,Redis的内存淘汰策略使用的就是LRU算法,当