4路组相连cache设计_[LevelDB] 存储6:适者生存 —— Cache

6f08857dbbecc4f8276d7fb199078243.png

存储6:适者生存 —— Cache

大多数磁盘数据库都提供了缓存,因为磁盘和内存的访问速度差了好几个数量级。如果整个数据库的工作集小于内存,那么热数据基本都可以缓存到内存里,这时候数据库表现得就像一个内存数据库,读写效率很高。

最完美的缓存就是将最近将要使用的数据缓存在内存里。然而,未来的访问数据是比较难估算的,一般会采取一些预读的方案将数据预先读取到内存中。而缓存的策略一般都是LRU,也就是根据过去的访问来决定缓存。遵循这样的原则:最近被访问过的数据未来有很大概率再次被访问。

LevelDB提供了一个Cache接口,用户可以实现自己的缓存方式。默认提供了一个LRU Cache,缓存最近使用的数据。

LevelDB的缓存使用在两个地方:

  • 缓存SSTable里的Data Block,也就是缓存数据,数据的缓存不是以Kv为单位的,而是以Data Block为最小单位进行缓存,默认情况下会开启一个8MB的LRU Cache来缓存Data Block。考虑到一次扫描可能将所有的内存缓存都刷出去了,LevelDB支持在扫描时,不缓存数据;
  • 缓存SSTable在内存中的数据结构Table,一个表在使用前需要先被Open,被Open时会将SSTable的元数据,比如Index Block和布隆过滤器,读取到内存中。缓存Table时是以个数计算的,缓存的个数是max_open_files - kNumNonTableCacheFileskNumNonTableCacheFiles表示给非SSTable预留的文件描述符数量,为10。

缓存的实现

缓存接口

缓存有一个接口Cache,每个缓存需要实现这个接口,主要操作包括InsertLookupErase

// include/leveldb/cache.h

class LEVELDB_EXPORT Cache {
    ...

    struct Handle {};

    // 插入一个缓存项
    virtual Handle* Insert(const Slice& key, void* value, size_t charge,
                         void (*deleter)(const Slice& key, void* value)) = 0;

    // 查询一个缓存项
    virtual Handle* Lookup(const Slice& key) = 0;

    // 擦除一个缓存项
    virtual void Erase(const Slice& key) = 0;
    ...
}

分段锁缓存

b6c474f5d05e47678fa035912a434a88.png

LevelDB默认的LRU缓存采用了类似于分段锁的设计方式:

  • 首先实现了一个LRUCache类,这个类实现了一个可以指定容量的LRU缓存,当达到容量后,会将旧的数据从缓存移除;
  • 为了实现线程安全,LRUCache在做一些操作时,会进行加锁,但是加锁操作会降低并发度,针对这个问题,LevelDB对外提供的实际是一个ShardedLRUCache缓存;
  • ShardedLRUCache包含一个LRUCache缓存数组,大小是16,根据缓存键的Hash值的高4位进行哈希,将缓存项分布到不同的LRUCache里,这样当并发操作时,很有可能缓存项不在同一个LRUCache里,不会冲突,大大提高了并发度;
  • ShardedLRUCache的实现只是简单的将对缓存的操作代理到相应的LRUCache里。

以下是Insert操作的实现,根据hash值计算出对应的LRUCache,然后代理到对应的LRUCache

// util/cache.cc

Handle* ShardedLRUCache::Insert(const Slice& key, void* value, size_t charge,
                 void (*deleter)(const Slice& key, void* value)) override {
    const uint32_t hash = HashSlice(key);  // 计算哈希值
    return shard_[Shard(hash)].Insert(key, hash, value, charge, deleter);
}

所以接下来重点讨论LRUCache的实现。

LRUCache实现

// util/cache.cc

class LRUCache {
  size_t capacity_;                        // 缓存容量

  mutable port::Mutex mutex_;              // 包含缓存的锁

  size_t usage_ GUARDED_BY(mutex_);        // 当前使用了多少容量

  LRUHandle lru_ GUARDED_BY(mutex_);       // 缓存项链表

  LRUHandle in_use_ GUARDED_BY(mutex_);    // 当前正在被使用的缓存项链表

  HandleTable table_ GUARDED_BY(mutex_);   // 缓存的哈希表,快速查找缓存项
}

LRUCache的实现有以下特点:

  • 每一个缓存项都保存在一个LRUHandler里;
  • 每一个LRUHandler首先被保存在一个哈希表table_里面,支持根据键快速的查找;
  • LRUCache里面有两个双向链表lru_in_use_,每一个LRUHandler可以在两个链表中的一个里,但是不会同时在两个里,也有可能有些LRUHandler被淘汰出缓存了,不在任何链表上;
  • in_use_保存当前正在被引用的LRUHandler,这个链表主要是为了检查;
  • lru_保存没有被使用的LRUHandler,按照访问顺序来保存,lru_.next保存最旧的,lru_.prev保存最新的,需要淘汰缓存时,会从lru_里的next开始淘汰;
  • 当一个LRUHandler被使用时,会从lru_移动到in_use_,使用完成后,会从in_use_重新移动到lru_里;
  • 每个LRUCache都有一个容量capacity_,表示这个缓存的大小,每次插入一个项时都会指定这个缓存项的大小,更新usage_字段,当usage_超过capacity_时,就淘汰最旧的缓存项,直到低于capacity_

3c65f24a9a38cc5fd4f3b5cd6a2e8412.png

以下是LRUHandler的定义:

// util/cache.cc

struct LRUHandle {
    void* value;                                 // 值
    void (*deleter)(const Slice&, void* value);  // 数据项被移出缓存时的回调函数
    LRUHandle* next_hash;                        // 哈希表的链接
    LRUHandle* next;                             // 两个双向链表的链接
    LRUHandle* prev;
    size_t charge;                               // 缓存项的大小
    size_t key_length;                           // 键的长度
    bool in_cache;                               // 当前项是否在缓存中
    uint32_t refs;                               // 当前项的引用计数
    uint32_t hash;                               // 哈希值
    char key_data[1];                            // 键值

    Slice key() const {
        return Slice(key_data, key_length);
    }
};

LRUCache通过引用计数来管理LRUHandler

// util/cache.cc

void LRUCache::Ref(LRUHandle* e) {
    if (e->refs == 1 && e->in_cache) {  // 如果当前在lru_里,移动到in_use_里
        LRU_Remove(e);                  // 先从链表中移除
        LRU_Append(&in_use_, e);        // 插入到in_use_
    }
    e->refs++;
}

void LRUCache::Unref(LRUHandle* e) {
    e->refs--;
    if (e->refs == 0) {  // 销毁缓存项
        (*e->deleter)(e->key(), e->value);
        free(e);
    } else if (e->in_cache && e->refs == 1) {
        // 重新移动到lru_里
        LRU_Remove(e);
        LRU_Append(&lru_, e);
    }
}

通过引用计数,LRUCache有以下特点:

  • 当一个LRUHandler被加入到缓存里面,并且没有被使用时,计数为1;
  • 如果客户端需要访问一个缓存,就会找到这个LRUHandler,调用Ref,将计数加1,并且当此时缓存在lru_里,就移动到in_use里;
  • 当客户端使用完一个缓存时,调用Unref里,将计数减1,当计数为0时,调用回调函数销毁缓存,当计数为1时,移动到in_use里面;
  • 这样可以自动控制缓存的销毁,当一个LRUHandler被移出缓存时,如果还有其他的引用,也不会被销毁。

所以查找一个缓存就非常简单了:

// util/cache.cc

Cache::Handle* LRUCache::Lookup(const Slice& key, uint32_t hash) {
    MutexLock l(&mutex_);                    // 加锁操作,使用分段缓存减少锁等待
    LRUHandle* e = table_.Lookup(key, hash);
    if (e != nullptr) {
        Ref(e);
    }
    return reinterpret_cast<Cache::Handle*>(e);
}

void LRUCache::Release(Cache::Handle* handle) {
    MutexLock l(&mutex_);
    Unref(reinterpret_cast<LRUHandle*>(handle));
}
  • 通过哈希表查找对应的LRUHandler
  • 如果找到了,调用Ref,返回缓存项;
  • 使用完缓存项后,调用Release释放缓存。

插入缓存需要将缓存项插入到哈希表以及链表中,并且更新容量,如果缓存容量过多,需要淘汰旧缓存。插入一个缓存项的步骤如下:

  • 生成一个LRUHandler保存缓存的内容,计数为1;
  • 再将计数加1,表示当前缓存项被当前客户端引用,插入到in_use_链表中;
  • 插入时会指定插入项的大小更新usage_字段;
  • 插入到哈希表中;
  • 如果有相同值旧的缓存项,释放旧项;
  • 判断容量是否超标,如果超标,释放最旧的缓存项,直到容量不超标为止。

缓存使用

LevelDB里SSTable在内存中是以Table结构存在的,要使用一个SSTable,必须先进行Open操作,会将Index Block和Filter Data都读取到内存里,保存在Table里,但是Data Block依然保存在磁盘上。需要读取数据时,可以将数据放到缓存中,下次再次访问数据时,就可以从缓存里读取。所以缓存有两方面:

  • 每个Table结构都要占据一定的内存,被打开的Table放在一个缓存中,缓存一定数量的Table,当数量太多时,有一些Table需要被驱逐出内存,这样当需要再次访问这些Table时需要再次被打开;
  • 每个Table的Data Block可以被缓存,这样再次访问相同的数据时,不需要读磁盘。

Table缓存

SSTable的文件名类似于000005.ldb,前缀部分就是一个file_numberTable就是用这个file_number作为键来缓存的。Table的缓存存储在TableCache类里面。

// db/table_cache.cc

Status TableCache::FindTable(uint64_t file_number, uint64_t file_size,
                             Cache::Handle** handle) {
    Status s;
    char buf[sizeof(file_number)];
    EncodeFixed64(buf, file_number);
    Slice key(buf, sizeof(buf));     // key为file_number
    *handle = cache_->Lookup(key);   // cache_是LRUCache的实例
    if (*handle == nullptr) {        // 如果缓存没命中,则打开新的Table
        ...
        s = Table::Open(options_, file, file_size, &table);
        TableAndFile* tf = new TableAndFile;
        tf->file = file;
        tf->table = table;
        // 插入一个缓存项,大小为1
        *handle = cache_->Insert(key, tf, 1, &DeleteEntry);
    }
    return s;
}

查询一个Table时步骤如下:

  • 先从缓存里面找,键是file_number,如果找到了,就可以直接返回Table
  • 如果没有找到,需要Open这个SSTable,然后插入到缓存里面;
  • 缓存的capacity_大小为支持打开的Table的个数,而每一个缓存项大小为1,这样当缓存的Table个数大于容量时,就会将最旧的Table淘汰。

Data Block缓存

每个Table打开的时候,都会指定一个cache_id,这是一个单调递增的整数,每个Table都有一个唯一的cache_id。在每一个SSTable里面,每一个Data Block都有一个固定的文件偏移offset。所以每一个Data Block都可以由cache_idoffset来唯一标识,也就是根据这两个值生成一个键,来插入和查找缓存。

// table/table.cc

// 根据一个Index读取一个Data Block

Iterator* Table::BlockReader(void* arg, const ReadOptions& options,
                             const Slice& index_value) {
    Table* table = reinterpret_cast<Table*>(arg);
    Cache* block_cache = table->rep_->options.block_cache;
    Block* block = nullptr;
    Cache::Handle* cache_handle = nullptr;

    BlockHandle handle;                   // 保存索引项
    Slice input = index_value;
    Status s = handle.DecodeFrom(&input);

    if (s.ok()) {
        BlockContents contents;
        // 使用缓存,则先读缓存
        if (block_cache != nullptr) {
            // 构造缓存键,使用cache_id和offset
            char cache_key_buffer[16];
            EncodeFixed64(cache_key_buffer, table->rep_->cache_id);
            EncodeFixed64(cache_key_buffer + 8, handle.offset());
            Slice key(cache_key_buffer, sizeof(cache_key_buffer));
            // 查找缓存是否存在
            cache_handle = block_cache->Lookup(key);
            // 存在则直接获取到block
            if (cache_handle != nullptr) {
                block = reinterpret_cast<Block*>(block_cache->Value(cache_handle));
            } else {
                // 否则从文件里读取Data Block
                s = ReadBlock(table->rep_->file, options, handle, &contents);
                if (s.ok()) {
                    block = new Block(contents);
                    if (contents.cachable && options.fill_cache) {
                        // 插入缓存
                        cache_handle = block_cache->Insert(key, block, block->size(),
                                               &DeleteCachedBlock);
                    }
                }
            }
        } else {
            // 不使用缓存,直接读取数据
            s = ReadBlock(table->rep_->file, options, handle, &contents);
            if (s.ok()) {
                block = new Block(contents);
            }
        }
    }
  ...
}

当要获取一个Data Block时:

  • 从这个Data Block的索引项出发,根据索引得到offset,然后根据Table得到cache_id,这样就得到了缓存键;
  • 在缓存里读取Data Block,如果存在就可以返回;
  • 否则从文件里读取Data Block,这里根据选项fill_cache,可以决定是否插入到缓存。

参考源码

include/leveldb/cache.h: 定义Cache接口
util/cache.cc: 实现LRU缓存
table/table.cc: 读取Data Block时使用缓存
db/table_cache.cc:实现一个Table结构的缓存

小结

以上便是LevelDB里面缓存的实现,对于磁盘型的数据库,缓存是非常重要的,如果内存足够大,大到足以容纳所有数据,那么数据库的读效率就像内存数据库一样。除了数据部分,索引和元数据LevelDB一般是缓存在内存里面的,基于SSTable的结构和存储,这些数据都不会改变,只读不写。只有Compaction时,才会变化,但是是生成新文件,而不是写旧数据,所以也不会有缓存更新过期的问题。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值