LevelDB架构解析

1. 整体模块架构

 
0
 
leveldb是个嵌入式键值数据库,它的核心原理是采用LSM算法作为存储引擎,写入时采用WAL的方式将随机写转换为顺序写,最终落盘存储为文件时采用有序key组织方式,同时会生成布隆过滤器元数据;读取时采取缓存、二分定位和布隆过滤器等方式快速定位key,因此它在读写方面都拥有很高的性能。缺点也比较明显,由于是多层sst文件的保存方式,数据存储放大是不可避免的,同时还会存在读写放大。
 

2. 核心数据结构

 
0
 
这里主要归纳几个大的模块
Options:用户在打开db时传入的可自定义的参数,可以影响db的一些默认行为,比如可以自定义比较器、布隆过滤器,设置memtable和sst文件的大小;
 
DBImpl模块:暴露给外层操作的对象,该对象会暴露一些接口给客户端调用,比如打开DB接口、增删改查接口、快照接口等;
 
PosixEnv模块:涉及有关操作系统的对象,比如这个对象就是符合POSIX协议的操作系统上运行时会初始化这个对象,对于windows平台的则又是其它的实现对象,总的来说是有关互斥锁、线程创建、文件操作相关的接口;
 
Comparator模块:key比较器,通过它来进行key顺序的比较;
 
Filter模块:过滤器模块,定义过滤器算法,系统有默认的实现,也可以用户传入自定义的,比如每个sst文件里的每个block都会有对应段的数据是经过计算生成了布隆过滤器值,用以快速判断;
 
TableCache模块:sst文件的缓存,在查找一个sst文件的key时,需要先将它缓存到cache里进行管理,cache会进行lru算法淘汰;
 
Memtable模块:写入的kv对象在写到wal后,会写一份到内存中,memtable就是写入数据的内存形式,当memtable写入容量达到阈值时会转为不可写memtable,然后开辟新的memtable进行写入,不可写memtable就会dump成sst文件;
 
Snapshot模块:快照实现比较简单,就保存着一个序号seq,表示这个seq号后的所有操作在这个快照里读都不会读到,做到快照安全读;同时最旧的快照seq号也影响compact的过程;
 
Version模块:一个version代表一个版本,版本对象里保存了该版本需要的所有sst文件元数据;由versionSet对象将version用链表管理起来;
 
Compaction模块:管理sst文件的合并管理,将无用的key在合并的过程中进行删除,释放容量;同时通过合并将sst文件推向更下层且保持有序。
 

3. SST模块

key/value的最终内容都是保存到文件里的,文件都是按照一定的格式组织的。
 
0
 
SST文件格式主要分为几大部分组成:
数据块区域:存放真实的kv值;
元数据块区域:存放比如布隆过滤器的内容;
索引块区域:包含元数据索引和数据索引数据,通过索引可以快速找到对应的数据块或元数据块的偏移量和长度;
尾部区域:主要用以指示索引的位置信息,从它开始找起。
 
从SST文件格式我们可以看到基本都是由一个个的块组成,下图是块的格式:
 
0
 
块的组成结果主要由以下几部分组成:
键值对数据:保存真实数据的,但由于键存储是有序的,所以可能大部分响铃的键前缀很多是一样的,所以它的k/v不单单是键值对,而是做了优化,只记录与前一个键不同部分的数据,这样一个键的数据可以由前一个键推导而来,而v的值则不进行压缩,全量保存;
重启点:一个重启点记录了一组key的开始偏移量和长度,可以认为是一个group,一般是16个key作为一个group,group里的键的压缩遵循刚刚的前缀压缩规则;
块尾部数据:主要包含压缩类型和校验值。
 
SST文件里的数据库、索引块的格式都是如上所述的保存格式。
但对于元数据块则不一样,元数据块当前主要保存的是布隆过滤器值,它只有一个大块,它的格式如下:
 
0
 
过滤器偏移量个数和布隆过滤器个数不一定相等,关系是多比1的关系,比如两个偏移量可能指向同一个布隆过滤器。
leveldb中默认block是4KB,filter的基数是2KB,所以一个block进行flush时会产生2个偏移量,产生一个布隆过滤器,2个偏移量指向同一个布隆过滤器;
假设block是1KB,则2个block会共享一个过滤器偏移量,然后一个偏移量对应一个布隆过滤器。
这样设计的原因应该是为了适应block太小的情况,以免产生太多布隆过滤器。
 
在使用时,先通过key锁定到指定的data block后,得到offset,然后通过offset计算(偏移量/2KB)得到过滤器偏移量,从中得到偏移量和长度,锁定到指定的布隆过滤器数据,然后调用match方法进行布隆过滤器判断。
 
数据索引块的k/v值:
k值是前一个数据块的最后一个键和后一个数据块的第一个键的最短分隔符,这里举个例子比较好理解。
比如前一个block的key的最后一个key是abc,下一个block的第一个key是abe,则产生的最短分隔符就是abd。
这样设计的好处是可以节省索引块键的长度。
查找的时候通过key查找锁定到对应的索引键,解析它的值即v的值可以得到对应数据块的偏移量和长度,然后去这个block里找这个key。
 
元数据索引块里的k/v值:
目前最常见情况是元数据索引块里只有一个key,key值为布隆过滤器的名称,值为元数据的偏移量和长度,指示了整个元数据块的位置。如果是此种情况,则元数据块只有一个块,这个块的结果如上面元数据块所示结构。
SST文件里这样设计是保证了拓展性,如果后续还有其它元数据块,那么元数据索引里可以增加key,然后根据key进行查找对应元数据块的偏移量和长度。
 
SSTable的层级:
SST文件分为7个层级,为0到6层,除了第0层,其它层的SST文件的key范围是不交叉的,每个SST文件本身包含的key也是有序的;第0层的SST文件是由Memtable通过compact得到的,大于0层的SST文件是由上一层的SST文件和当前层有交叉key的SSt文件通过compact合并得到的。
 
level0的sst文件大于等于4个文件时会触发compaction,最多允许8个sst文件存在于L0层(超过会暂停写入,直到L0的sst文件往下合并到更下层),默认sst文件大小是2MB,所以L0层一般保持在8MB左右;
L1层允许的最大大小是10MB,L2层是100MB,L3层是1GB,L4层10GB,L5层100Gb,L6层1TB。从L1层开始容量大概是10倍的关系。
 
0
 
 

4. Log模块

写入kv时,第一步是顺序写入到log中,然后写入到内存memtable对象中。这样即使发生宕机,也能从log中恢复出memtable数据结构。
这个log叫write ahead log,即预写日志,简称WAL。
 
不单单wal是采用这种log结构方式存储,manifest的内容保存也是用这种log方式进行保存。
 
Log的保存格式:
0
 
type定义了以下几种:
(1)kFullType:表示这是所有的数据了,可以一次从一个block(32KB为一个block)里读取到完整的数据;
(2)kFirstType:表示这个只是这块数据的开头部分,后续还有数据;
(3)kMiddleType:表示这个是数据的中间部分,后续还有数据;
(4)kLastType:表示这个是数据的最后部分,后续没有数据了。
 
填充字节:当一个block里的剩余部分不足7个字节时(不足以写一个header了),用0进行填充。
 
上图举了两种数据1和数据2的情况,数据1是当前block可以写下,所以type为full类型,从当前block即可读取完整进行返回;数据2则举例了覆盖3种type的情况,分别是头、中间部分和尾部,只有横跨读取完这几个block才能将数据2读取完整进行返回。
 
上面说明了header的详细格式,从header中就能知道怎么读取到一个完整保存的数据。
log这种组织格式在leveldb中不单单用于存储wal的内容,manifest的内容也使用这种格式进行存储的。
下图举例假设是kv数据存入wal log里,content的组织格式:
0
 
假设一个批次batch写入了3个key,那么这三个kv就会组织为一个content,一起写入到wal中,此时kv个数就是3,seq就是第一个kv的seq号,那么第2个和第3个kv的seq号则是seq+1和seq+2,因为leveldb的删除其实也是写入,0表示新增,1表示删除,如果是新增类型的,则会记录key和value,如果是删除类型的,则只要记录key即可。
 

5. Memtable模块

当数据进行写入时,是先写到wal和memtable中的(1对1),当memtable写满了(默认4MB)后会变成只读memtable(开辟一个新的memtable进行写入),接着会触发L0层的compaction将该只读memtable dump为sst文件。
 

5.1. 跳表结构

memtable是写入的kv的内存形态,它的实现在leveldb中是跳表数据结构,它是一个多层的单向链表,链表组织单元是Node对象,每个Node对象里记录着kv值和它每层的next Node,总高度是12,0到11,第0高度的链表拥有所有Node,按照排序单向链表串联起来,第1到11则是稀疏的链表,越高层Node的数量越少。
 
0
 
上图是简单的跳表结构示意图,表示该跳表中一共只有3个key,每个key都会被初始化为一个Node对象,Node对象在生成的时候就会通过伪随机的方式决定它的高度,比如Node1它的高度是3,那么它就有0,1和2层,所以通过跳表头结点的0,1,2都能通过链表的方式找到该Node节点。
 
Node里的key值的保存结构:
0
 
seq号占7字节,type占1字节,type为0表示新增,1表示删除。
key的比较默认使用内部key进行比较,内部key是key+seq号+type,排序规则:按key值字节序升序排序,key值一样的情况按seq号降序排序。
 
在查找的时候,是从最高高度开始找。
举例:查找key2
(1)设置查找出发点为跳表头结点,查找高度为3(因为最大高度为3);
(2)从头结点(因为当前查找出发点为头结点)的next_[2]找(因为当前查找高度为3),找到Node1,它的key是key1,比key2小,说明Node1之前的都是不需要去找的,设置查找出发点为Node1,查找高度还是3;
(3)Node1在3层找下一个,即Node1的next_[2]值,它的值为null,表示链表没有下一个Node了,所以高度需要减1,查找高度变为2
(3)从Node1的next_[1](当前查找高度为2)找,找到Node2,它的key是key2,找到了。
 
举例:查找key0:
(1)设置查找出发点为跳表头结点,查找高度为3(因为最大高度为3);
(2)从头结点(因为当前查找出发点为头结点)的next_[2]找(因为当前查找高度为3),找到Node1,它的key是key1,比key0大,说明该层的所有Node都是不符合要求的(因为下一个节点只会更大),所以高度需要减1,查找高度变为2
(3)头结点在2层找下一个,即头结点的next_[1]值,找到Node1,它的key是key1,比key0大,说明该层的所有Node都是不符合要求的(因为下一个节点只会更大),所以高度需要减1,查找高度变为1
(4)头结点在1层找下一个,即头结点的next_[0]值,找到Node0,它的key是key0,找到了。
 

5.2. 跳表插入

当进行put key或者delete key时,对于跳表来说其实都是插入key的过程(用type字段标识,0是洗澡能,1是删除),所以对于该跳表来说没有删除key的场景。同时由于每次插入key都会带上一个全局递增的seq,所以插入的内部key都不会是重复的,key值一样,但seq不一样,则内部key不一样,所以即使key值一样,但新插入的seq比较大,所以它在同样的key里是排在前面的,会优先被找到。
 
举例插入的过程:
假设我们现在的跳表状态是如下的:
 
0
 
插入key3,高度随机选择为4:
0
 
插入过程:
(1)通过key3去跳表里从高层到低层查找到L0层刚好小于该key的Node,会找到Node0;
(2)插入新增key3即Node3到Node0和Node1中间。
 
插入过程,在查找的过程中有个小技巧,即源码中的prev[kMaxHeight]变量,假设要查找的key为x,它在从高层往低层(直到L0层)查找时记录了每层刚好小于x的Node对象,后续在插入x的Node的时候,就可以使用prev每层指向的Node直接指向该x的Node。
 
删除key5,高度随机选择为2:
0
 
插入删除key过程:
(1)通过key5去跳表里从高层到低层查找到L0层刚好小于该key的Node,会找到Node3;
(2)插入删除key5即Node4到Node3和Node1中间。
 
后续找key5时,会优先找到Node4(Node1在Node4后面),查看到它的type是删除,从而返回该key不存在的结果(因为它被删除了)。

 

6. 缓存模块

leveldb中的缓存是LRU策略的实现,缓存的数据是sst文件,默认容量大小是8MB个,分成16个槽,每个槽容量0.5MB个,每个sst文件的加入缓存算一个容量(所以它这不是按照sst文件大小来算的,按个数来算的)。
当读取sst文件的时候,都会首先去cache中查找是否存在,如果不存在则需要从目录中找到sst文件,然后打开并解析,接着将它插入到缓存对象中。
cache里的缓存单元item的key是sstable的fileNumber,value是个TableAndFile对象,它包含了文件描述符对象和Table对象,Table对象里其实是包含了这个sst文件解析出来的footer对象和sst文件解析出来的indexBlock对象。
在插入时key和value也会被包装为一个LRUHandle作为item元素。
 
0
 
对于ref大于等于2的item放入到in_uae_链表中,ref等于1的item放入到lru_链表中(表示缓存满了要淘汰时可以随时淘汰lru_链表中的元素),item的ref为0时会从链表中移除,同时销毁它自己。
 
插入缓存的过程:
(1)对key(sst的number)进行哈希得到哈希值,对16进行取模得到对应的bucket槽,槽会对应一个LRUCache对象,使用哈希值再对数组长度进行取模,得到对应的数组index,该单元是个单向链表,将它追加到链表的最后面;
(2)如果当前cache容量满了,将lru_链表表头的next指向的item进行淘汰;
(3)将该item的ref置为2(因为要被使用),加入到in_use_双向链表中;
 
当该缓存被使用完后,进行ref-1,ref变为1,将其从in_use_链表中移除,加入到lru_链表中。
 

7. Compaction模块

compaction的触发有两种策略:
(1)size compaction
L0层的sst文件数量达到阈值触发,或者L1-L5层的文件总容量(比如L1层的sst文件总大小达到10MB)达到阈值触发;
 
情形1:当进行key写入时,memtable满了后,memtable dump为L0层的sst文件中,当L0层的文件数量达到阈值时,触发compaction,将L0层的文件与L1层的文件进行归并,合并到L1层;
 
情形2:由于上层compaction操作导致下层的sst文件总容量可能达到阈值,触发compaction,所以它有种递归的感觉。
 
(2)seek compaction
某个文件无效读取key次数过多时标记它需要进行compaction。
 
这里举例说明什么情况算一次无效读取
比如Ln层有个f1文件,它的key范围是[L1, H1],Ln+1层有个f2文件,它的key范围是[L2, H2];
要查找一个key abc,abc既在[L1, H1]范围,也在[L2, H2]范围,因此这两文件都会被选为候选查找文件;
首先在f1里找,发现找不到,但继续往下找,从f2里找到了,所以f1相当于有一次无效读取,当无效读取次数多了,则说明该文件需要进行一次compaction了。
 
compaction的工作原理流程图:
 
0
 
上述流程图只是把compaction的主流程画出来了,其实细节还有很多优化,比如:
(1)memtable生成的sst文件不一定就放到L0层,如果该sst文件与L0层和L1层都没有重叠的key,则可以推放到L1层去,这样就可以减少L0层到L1层的compaction次数和压力;
(2)选出level+1层的文件后,会尝试从中找到最小key和最大key,然后回到level层去找是否可以多包含一些level层的文件,如果多包含了,同时并不会改变level+1层参与compaction的文件,则执行这个优化,否则还是按照已选出的进行compaction;
(2)compaction过程中会将无效的key进行清理;
 
compaction流程删除无效key的原则:
(1)序号小于等于快照最旧序号,key值有重复,前面已经有同样的key,序号更大的了,已经放到结果sst文件中了,那么这个key就可以drop掉了,不放入结果sst中;
(2)序号小于等于快照最旧序号,是删除类型,该key没有出现在更下层里了(因为如果有,但你在这里将它删除掉,则导致下层的该key变得可见,用户会读到旧数据,但其实他应该是被删除的),可以drop掉;
 
清除无效文件方法:
因为进行了compaction,所以肯定有些sst或者wal文件时无效的,可以清理的,那么它怎么来进行识别呢?
(1)首先通过db目录可以拿取到所有的sst文件,这里面有有效的也有失效的;
(2)遍历版本链表,将各个版本的文件都加入到有效文件列表中,同时正在进行compaction过程中的sst文件也会加入到有效文件列表中;
(3)遍历所有sst文件,如果它不存在于有效文件列表,则将它进行删除。
 

8. 版本模块

当进行一次compaction后,就会生成一个版本,加入到版本链表中,版本链表是个环形双向链表,当旧版本的ref为0时就会从链表中移除掉,那么后续清理无效文件时也就自然而然的会把它的文件删除掉。
 
0
 
版本链表是内存形态的,假设发生宕机,那么自然这些信息都会丢失,因此需要有manifest文件来将版本信息进行保存,manifest文件里第一项会保存一份全量的版本元信息,后续的版本只保存增量版本信息,如下图:
 
0
 
 
只有重启后进行revover时才会进行一次压缩,写入一个最新版本,然后写入一个增量版本,所以一般运行过程中就是一直append增量版本到manifes文件中。

 

9. 快照模块

leveldb中的快照模块比较简单,快照对象里就保存了一个seq号,表示这个seq号后面的,我如果使用这个快照来进行读取,都不会看见,会一直读取到该快照前的数据。
 
在做compaction的时候,如果某个key可以drop则需要保证它的seq号要小于等于最旧快照保存的seq号(必要条件,还有其它条件),因为如果大于最旧快照,则有可能把其它快照需要读取的key给删除掉了。
 
快照也有个环形双向链表,表头节点的next就是最旧快照,prev则是最新快照;
0
 
一般用法就是先调用api生成一个快照,,然后使用该快照来进行读取,等不需要了时,进行快照的删除。
 

10. 执行流程

10.1. recover流程

当进程进行退出后,重新打开时就是进入了recover流程。
 
0
 

10.2. 写入kv流程

 
0
 

10.3. 读取kv流程

 
0
 
 
 
posted @   luohaixian  阅读(73)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
历史上的今天:
2019-12-19 LeetCode算法题-链表类
点击右上角即可分享
微信分享提示