美文网首页
LevelDB 的 Cache 实现

LevelDB 的 Cache 实现

作者: rickif | 来源:发表于2020-10-09 22:20 被阅读0次

    本文基于leveldb 1.22 版本展开,主要讨论 LevelDB 的缓存 cache 实现。cache 可以根据数据内容是否进行了解压缩分为 compressed cache 和 uncompressed cache。前者通常直接缓存磁盘数据块的镜像,后者则缓存解压后的数据块。对于 LevelDB 而言,在用户态仅仅实现了对 uncompressed cache 的支持,由操作系统的 page cache 发挥 compressed cache 的作用。而 RocksDB 则在这基础上更进一步,在用户态实现了 compressed cache 的支持。

    SSTable 文件布局

    LevelDB 的数据存储在 SSTable 文件中,每个文件对应一个 sorted table,即一系列按照 key 进行排序的 entry。entry 可能有两种类型:一种保存对应 key 的 value;另一种则保存了对应 key 的删除标记。LSM 的删除操作是惰性的,在执行 compaction 时数据的实际删除才可能发生。

        <beginning_of_file>
        [data block 1]
        [data block 2]
        ...
        [data block N]
        [meta block 1]
        ...
        [meta block K]
        [metaindex block]
        [index block]
        [Footer]        (fixed size; starts at file_size - sizeof(Footer))
        <end_of_file>
    

    SSTable 文件的数据布局如上所示:

    • data block 保存数据的 key/value 对;
    • meta block 目前有 filter meta block 和 stats meta block 两种类型;
    • metaindex block 包含对应每个 meta block 的多个 entry,key 是 meta block 的名字,value 则是 meta block 的 offset 和 size;
    • index block 包括对应每个 data block 的多个 entry,key 是介于当前 data block 的最后一个 key 和下一个 data block 的第一个 key 之间的值,value 则是 data block 的 offset 和 size;
    • footer 存储在 SSTable 文件的末尾,保存 metaindex block 和 index block 的 offset 和 size,还包括一些补零和一个魔数 0xdb4775248b80fb57。

    LevelDB 的 cache 根据缓存的数据类型分为两种,缓存 meta block 数据的 table cache 和 缓存 data block 数据的 block cache。

    数据结构

    ShardedLRUCache

    LevelDB 的两种 cache 都基于共同的数据结构 ShardedLRUCache。数据的淘汰按照 LRU(the least recent used)规则;根据 key 的前4个 byte 计算 hash 值,将其分布在16个 shard 上,每个 shard 的数据结构为 LRUCache。如此,可以将锁的粒度降低为 shard 级别,提升 cache 的并发读写能力。

    class LRUCache {
     public:
      LRUCache();
      ~LRUCache();
    
      // Like Cache methods, but with an extra "hash" parameter.
      Cache::Handle* Insert(const Slice& key, uint32_t hash, void* value,
                            size_t charge,
                            void (*deleter)(const Slice& key, void* value));
      Cache::Handle* Lookup(const Slice& key, uint32_t hash);
      void Release(Cache::Handle* handle);
      void Erase(const Slice& key, uint32_t hash);
      void Prune();
    
     private:
      // mutex_ protects the following state.
      mutable port::Mutex mutex_;
      LRUHandle lru_ GUARDED_BY(mutex_);
    
      // Dummy head of in-use list.
      // Entries are in use by clients, and have refs >= 2 and in_cache==true.
      LRUHandle in_use_ GUARDED_BY(mutex_);
    
      HandleTable table_ GUARDED_BY(mutex_);
    };
    

    LRUCache 的实现与通常一样,基于链表和 hash table 实现。其中链表存储实际的数据,支持数据的快速尾部插入和数据的移动;而 hash table 则存储指向链表元素的指针,支持数据的快速查询。
    LRUCache 维护了 in_use_ 和 lru_ 两个 entry 链表,其中 in_use_ 链表包括了当前正在被客户端引用的 entry;lru_ 包括当前没有被客户端引用的 entry,并按照 LRU 规则进行了排序。在进行数据查询时,正在被客户端访问的 entry 引用计数自增1,entry 指针移动到 in_use_ 头部;当没有客户端引用时,entry 引用计数此时为1,entry 指针移动到 lru_ 头部;当有新的 entry 插入时,entry 引用计数自增1。对于覆盖更新的场景,淘汰与新插入 key 相同的已有 entry,并将 entry 加入到 in_use_ 头部。对于全新插入的场景,将 entry 直接加入到 in_use_ 头部,并淘汰 lru_ 中最旧的 entry。如此,entry 的所有权不归属于 cache 来管理,可以减少一次数据拷贝,提升性能。

    cache

    class ShardedLRUCache : public Cache {
     public:
      explicit ShardedLRUCache(size_t capacity);
      ~ShardedLRUCache() override;
      Handle* Insert(const Slice& key, void* value, size_t charge,
                     void (*deleter)(const Slice& key, void* value)) override;
      Handle* Lookup(const Slice& key) override;
      void Release(Handle* handle) override;
      void Erase(const Slice& key) override;
      void* Value(Handle* handle) override;
      uint64_t NewId() override;
      void Prune() override;
      size_t TotalCharge() const override;
    };
    

    ShardedLRUCache 是 cache 的实现,提供的主要接口如下:

    1. Insert 实现数据的插入。由于传入的 key 和 value 所有权并不一定属于 cache,所以需要提供对应数据的删除函数 deleter;
    2. Lookup 查找特定 key 对应的 entry;
    3. Release 对应 entry 引用计数减1,若引用计数为0,则调用 deleter 删除该 entry。通常用于释放 Lookup 返回的 entry;
    4. Erase 从 hash table 中删除该 key 对应的entry,并且从 in_use_/lru_ 中删除该 entry,若引用计数为0,调用 deleter 删除该 entry;
    5. Value 读取对应 entry 的值,通常用于从 Lookup 读取的 entry 读取值;
    6. NewId 每打开一个 table cache,就会生成一个新的 cache id;
    7. Prune 移除所有未使用(在 lru_ 中)的 entry;
    8. TotalCharge 返回内存消耗数值。

    cache 中的数据是文件的副本,读取流程如下:

    1. 在 cache 中查找特定 key,若找到则直接返回相应的 entry;若未找到则继续执行;
    2. 从文件中读取 key 对应 entry 到 cache 中,再返回相应的 entry

    由于 LSM Tree 的 SSTable 文件的不变性,SSTable 文件只会被删除而不会被更改,因此无需考虑数据更新带来的 cache 和文件数据不一致的问题, cache 也无需支持数据的更新 ,这也是 LSM Tree 的 cache 实现一大特点。
    由于 table cache 缓存的是 block cache 中 block 的元信息,所以两者需要结合起来发挥作用。一个完整的数据查询流程是,先从 table cache 中获取到元信息,再根据元信息去 block cache 或 SSTable 文件中查找相应的 block 数据。

    table cache

    table cache 中缓存的是 meta block 的数据,key 为 SSTable 文件对应的 file_number,这是一个在进行 L0 compaction 时就确定的序号,在数据库级别单调递增且保证唯一。这个 file_number 同时也是 SSTable 文件的文件名。
    在读取过程中,当 table cache 中找不到 key 对应的 entry 时,需要从文件中加载数据构建。读取通过 mmap 将整个文件映射到内存中实现。先从文件中读取固定长度(48 字节)的 footer,从 footer 中解析出 index block/metaindex block 的 offset 和 size 信息,最后读取 index block 和 metaindex block 的信息。
    在得到 index block 数据后,通常需要根据 index 查找 data block。index 可以认为是一个有序数组,查找需要利用到二分查找。不同的是,为了减少 key 所占用的存储空间,LevelDB 采取了 key 压缩技术,简单来说就是每个若干个 key 保存一个完整的 key 的 entry 作为重启点,重启点之间的 key 则只需要在重启点的公共前缀基础上存储后缀即可。因此,在进行二分查找时需要首先找到重启点,根据最近的重启点信息构结合 entry 的非公共后缀找到最终需要查找的 entry。LevelDB 的 key 压缩又是一个很有意思的话题,我们后续的 compaction 篇章再见。

    block cache

    block cache 中缓存的是 data block 的数据,key 为 table cache 的 cache_id 和从 index 中获取的 data block offset。当 block cache 中找不到 key 对应的 entry 时,将从 table cache 之前使用过的 mmap 内存中加载数据构建 block cache,不会出现再次 mmap。

    总而言之,cache 是一种常用的用于加速磁盘文件访问的手段,在系统具备查询热点时可以显著提升查询性能。需要指出的是,在范围查询的场景下,cache 往往会因为大范围的数据扫描导致热数据被淘汰,而冷数据被加载至内存中。系统的缓存命中率会急剧下降,造成系统性能降低。针对这个问题,InnoDB 存储引擎将 buffer pool 调整分为 old、young 两个区域。因为范围查询引入的数据通常只会被访问一次,这样的数据将被放在 old 区域,而 young 区域则会保存多次访问的热点数据。

    参考文献

    LevelDB Table Format
    RocksDB Block Cache
    MySQL Buffer Pool

    相关文章

      网友评论

          本文标题:LevelDB 的 Cache 实现

          本文链接:https://www.haomeiwen.com/subject/myvrpktx.html