缓存的作用
leveldb为了提高写的性能,牺牲了部分的读性能。最差的情况可能需要遍历各个level中的每个文件。为了缓解读性能,leveldb引入了缓存机制,当然,版本信息中包含各个level的文件元信息在一定程度上也可以提高读性能。
基本组件
整个缓存模块的基本组件由上至下分为:ShardedLRUCache
、LRUCache
、HandleTable
、LRUHandle
。
- ShardedLRUCache:这个是为了减少频繁加锁解锁开销的所设计的,采用分区的思想,把不同的元素划分到不同的LRUCache中。
- LRUCache:这个分为两部分:哈希表和双向循环链表,用于管理缓存数据的结构
- HandleTable:哈希表,采用开链法的思想避免hash冲突,里面存放一个个具体的元素
- LRUHandle:表示元素的实体,<key,value>结构
其四个的大致关系如下图:
LRUCache
这个结构用于存储<key,value>
结构的数据。其在设计上用途特别多,next_hash
用于hash表;prev
,next
则是用于双向循环链表。而且其还采用了和shared_ptr
相同的设计,只有当这个LRUCache
的引用计数为0时才会真正的去调用用户传入的deleter
去释放资源。
void* value; //<key,value>
//中的value值,通过指针访问,可以是任何类型,这个可以替换成 tem
void (*deleter)(
const Slice&,
void*
value); //自定义的删除器,因为元素可能是在堆内存的申请的,需要特殊的释放
LRUHandle* next_hash; //解决hash碰撞,指向下一个hash值相同的元素
LRUHandle* next; //用于list 构成双向循环链表
LRUHandle* prev; //用于list 构成双向循环链表
size_t charge; // 所占内存大小
size_t key_length; // key的长度
bool in_cache; // 元素是否在LRU Cache中
uint32_t refs; // 元素的引用计数,主要用于Remove函数
uint32_t
hash; // 通过离线计算的思想,计算元素的Hash值,在比较的时候直接比较这个Hash值,不用进行字符串比较那些费时的操作
char key_data
[1]; // Key的开始,多个Key在相同的内存空间,采用key_begin+key_length的方式来定位Key
HashHandle
这个类会构建一个hash表来存储实际的LRUHandle
,其属性只有三个:
private:
uint32_t length_; // hash表长
uint32_t elems_; // hash表元素的个数
LRUHandle** list_; // hash表,每个表项都是一个双向循环数组
我们可以看到,LevelDB使用的一个二级指针来表示一个一个Hash表,这个是我认为比较有新意的点。我们先看一下它表现出来的图示:
注:
LevelDB中hash桶默认的个数就是4个
插入元素
在hash表中插入元素首先要做的就是知道这个值应该被我们放到哪个hash桶里面,这个过程对应的就是函数FindPointer
:
/**
* @brief 找到key所应该插入的双向循环链表
*
* @param key key值
* @param hash key的hash值
* @return LRUHandle** 适合插入key的位置
*/
LRUHandle** FindPointer(const Slice& key, uint32_t hash) {
// length-1 相当于对hash进行取模的hash运算
LRUHandle** ptr = &list_[hash & (length_ - 1)];
//因为有hash冲突问题,所以这里我们拿到指针不一定是想要的,需要不断对比
while (*ptr != nullptr && ((*ptr)->hash != hash || key != (*ptr)->key())) {
ptr = &(*ptr)->next_hash;
}
return ptr;
}
而找到元素应该插入的位置之后便直接利用头插法插入元素就可以。假设我们现在要插入key
为10,此时我们的length
为4,那么011 & 1010 = 10
,我们就用头插法把它插入第二个桶之中就可以:
而插入之中,我们难免会遇到空间不够的情况,这个时候我们就要进行扩容。LevelDB的扩容规则就是整个hash表的元素个数>桶的个数,所以说上面的表咱画的有点不正规了,哈哈哈哈。
LevelDB会执行二倍扩容的策略,比如说咱当前的6个元素是大于4个桶的个数的,那么下面我们就要拿到8个桶的hash表了。之后对里面的元素进行rehash
过程,重新构建hash表并删除原有hash表。
整个过程的代码如下:
/**
* @brief 哈希表中插入元素
*
* @param h 待插入元素的指针
* @retval LRUHandle* 原有元素存在于hash表中就返回指向原有元素的指针
* @retval nullptr 不存在返回
*/
LRUHandle* Insert(LRUHandle* h) {
LRUHandle** ptr = FindPointer(h->key(), h->hash);
LRUHandle* old = *ptr;
h->next_hash = (old == nullptr ? nullptr : old->next_hash);
*ptr = h;
if (old == nullptr) {
++elems_;
if (elems_ > length_) {
// Since each cache entry is fairly large, we aim for a small
// average linked list length (<= 1).
// 扩容
Resize();
}
}
return old;
}
LRUCache
LRUCache采用LRU的缓存结构,其主要结构有两个LRU List
和Hash Table
。其中特别要注意的是其设计的两个链表lru
和in_use
,同一个元素是不同同时存在于这两个链表。
private:
size_t capacity_; // LRU 缓存的总共容量,字节数
mutable port::Mutex mutex_; //锁
size_t usage_ GUARDED_BY(mutex_); // LRU 缓存使用的容量
LRUHandle lru_ GUARDED_BY(
mutex_); // LRU List, prev指向的是比较新的数据,next指向比较旧的数据
//这里面的数据满足条件refs==1 && in_cache==true
LRUHandle in_use_
GUARDED_BY(mutex_); // in_use list,里面存放着被client使用的数据
//数据满足条件refs >= 2 and in_cache==true
HandleTable table_ GUARDED_BY(mutex_); // HashHandle,哈希表
插入元素
而对于这缓存的插入元素操作来说,其首先会根据上层传递过来的数据,申请LRUHandle
所需的内存空间并构建这个元素。同时,这里会初始的把引用计数设置为1,因为这个数据传过来就是要被外部使用的。之后,把它插入到in_use链表和hash表中,这里要注意的就是如果hash表之前有这个元素,会把老元素通过FinishErase
删除,同时将新元素添加至hash表。
而LRUCache则没有像HashTable那样的扩容机制,所以一旦超了界限其就会删除最旧的数据。
/**
* @brief 元素插入到缓存中
*
* @param key key
* @param hash 元素key的hash值
* @param value 元素value
* @param charge 元素所占的内存大小
* @param deleter 自定义的删除器
* @return Cache::Handle* 返回指向元素的指针
*/
Cache::Handle* LRUCache::Insert(const Slice& key, uint32_t hash, void* value,
size_t charge,
void (*deleter)(const Slice& key,
void* value)) {
MutexLock l(&mutex_);
// 内存构建缓存元素
//这里-1时因为 char key[1];
LRUHandle* e =
reinterpret_cast<LRUHandle*>(malloc(sizeof(LRUHandle) - 1 + key.size()));
e->value = value;
e->deleter = deleter;
e->charge = charge;
e->key_length = key.size();
e->hash = hash;
e->in_cache = false;
e->refs = 1; // for the returned handle.
//这个被返回值引用的
std::memcpy(e->key_data, key.data(), key.size());
if (capacity_ > 0) {
e->refs++; // for the cache's reference. 这个被cache引用
e->in_cache = true;
LRU_Append(&in_use_, e); //加入in_use list
usage_ += charge; //缓存使用的字节数++
FinishErase(table_.Insert(e)); //元素添加至hash表
} else {
//缓存没有容量了,这个时候就单独返回e
e->next = nullptr;
}
//容量不够,且lru 链表不为空
//这里就是在清理lru list 直到满足容量
while (usage_ > capacity_ && lru_.next != &lru_) {
LRUHandle* old = lru_.next;
assert(old->refs == 1);
bool erased = FinishErase(table_.Remove(old->key(), old->hash));
if (!erased) { // to avoid unused variable when compiled NDEBUG
assert(erased);
}
}
return reinterpret_cast<Cache::Handle*>(e);
}
ShardedLRUCache
前面也说过,ShardedLRUCache就是为了避免锁开销而采用的一种基于分区的机制。这一点我们也可以从其的Insert
函数看出来。其根据key
计算出其应该在的LRUCache
,之后调用这个LRUCache
的Insert
操作就可以了。
Handle* Insert(const Slice& key, void* value, size_t charge,
void (*deleter)(const Slice& key, void* value)) override {
//计算key的hash值
const uint32_t hash = HashSlice(key);
//Shared 是一个hash函数
return shard_[Shard(hash)].Insert(key, hash, value, charge, deleter);
}
参考文献
[1] leveldb之cache
[2] levelDB 源码