在上一篇使用Go发现和探索mmap的文章中,我们讨论了数据库如何解决一个主要问题,即:如何处理大于可用内存的磁盘中存储的数据。我们讨论了多少数据库使用内存映射文件解决了此问题,并探讨了mmap功能。
仅仅知道数据库使用内存映射文件来解决问题还不够。它解决了部分谜团,但仍然存在一个问题:数据库究竟如何使用mmap从磁盘读取和写入数据?
我决定深入研究数据库源代码以回答该问题。有很多使用mmap的数据库。他们中的一些人决定不再使用。一些示例:SQLite可以选择使用内存映射的I / O直接访问磁盘内容[1],似乎曾经使用过LevelDB,但它进行了更改[2],Lucene可以使用MMapDirectory [3],LMDB使用mmap [4],来自Counchbase的一个简单的键/值内存数据库moss使用mmap来实现内存数据的持久性[5],而MongoDB为WiredTiger [6]删除了mmap存储引擎。
为此,我选择了bolt,它是由Ben Johnson在LMDB项目的启发下在Go中实现的简单键/值存储。主要是因为源代码简单和我对Go语言的熟悉。我知道,简单的键/值存储可能不是学习有关将数据读/写到磁盘的所有详细信息的最完整的源代码,但是正如我所发现的那样,足够理解它了。
原始的螺栓存储库不再维护。 etcd维护并使用了称为bbolt的螺栓叉。如果您不熟悉Bolt,我建议您阅读BoltDB简介:无痛执行者持久性和Bolt — Go的嵌入式键/值数据库。
我将代码下载到我的机器上,并在编辑器中将其打开。我认为开始挖掘的一个好地方是找出数据库的初始化位置,并在那里查找mmap的任何引用。像大多数嵌入式数据库一样,bolt具有Open方法,用于打开数据库或创建新数据库(如果不存在)。在其中,我找到了对私有mmap函数的引用。这是一个好的开始。
//内存映射数据文件。if err:= db.mmap(options.InitialMmapSize); err!= nil {_ = db.close()return nil,err}
专用mmap负责打开内存映射文件。为了做到这一点,它需要弄清楚要分配多少内存。此任务通过另一种称为mmapSize的方法来完成。给定数据库的大小,此方法可以计算应分配多少字节的内存。
首先将大小从32KB翻倍到1GB。但是,如果数据库大于1GB,则一次增加1GB。
//对于i:= uint(15);,将大小从32KB增加到1GB。我< = 30; i ++ {如果大小< = 1<< i {返回1<< i,nil}} ... //如果大于1GB,则一次增加1GB。sz:= int64(size),如果剩余:= sz%int64(maxMmapStep);余数> 0 {sz + = int64(maxMmapStep)-余数}
现在一切都很好。那就是螺栓计算出多少分配量的方式。但是现在有一个难题,当我们谈论数据库存储布局时,这将非常重要。在确定分配多少之后,需要确保分配的大小是页面大小的倍数。如果您不熟悉数据库存储并且不知道页面是什么,请不用担心,我们将回到此状态。
// //确保mmap大小是页面大小的倍数。//这应该始终为true,因为我们以MBs.pageSize递增:= int64(db.pageSize)if(sz%pageSize)!= 0 {sz =((sz / pageSize)+1)* pageSize}
它不应该检查我们分配的资源是否超过可用的资源?那是mmapSize方法的最后一部分。
//如果我们超过了最大大小,则只会增长到最大大小。如果sz> maxMapSize {sz = maxMapSize}
AMD64体系结构定义了一种64位虚拟地址格式,当前实现中使用其低阶48位。这允许最多256 TiB(2 48字节)的虚拟地址空间。 -x86-64 Wiki
现在,螺栓知道应该分配多少,然后调用系统调用mmap。这是类Unix环境的完整代码:
// mmap内存映射数据库的数据文件。func mmap(db * DB,sz int)error {//将数据文件映射到内存。 b,err:= syscall.Mmap(int(db.file.Fd()),0,sz,syscall.PROT_READ,syscall.MAP_SHARED | db.MmapFlags)if err!= nil {return err} //通知内核mmap是随机访问的。如果err:= madvise(b,syscall.MADV_RANDOM); err!= nil {return fmt.Errorf(" madvise:%s&#34 ;, err)} //保存原始字节片并转换为字节数组指针。 db.dataref = b db.data =(* [maxMapSize] byte)(unsafe.Pointer(& b [0]))db.datasz = sz return nil}
这是一个非常简单的代码。我对syscall.PROT_READ有一些看法,但是我将继续进行下一个会话。很高兴看到有人呼吁在那里疯狂,尽管我不知道这样做有什么好处。我想知道该调用的重要性,它可以提高性能,或者可以设置另一个标志来针对不同的用例从OS获得不同的行为。
我想知道跟踪两个变量的重要性。我无法掌握db.data转换中发生了什么。但是无论如何,我们要记住的是,通过这些变量,bolt将从磁盘读取数据。
浏览源代码时,我寻找了如何在读取和写入中使用mmap的证据。我挖了Get和Put方法。我找不到对db.dataref或db.data的引用进行更新的任何地方。我发现在对事务调用Commit时会发生磁盘写操作。但是在那里我只能找到对WriteAt的调用。因此,我放弃了尝试了解mmap如何用于写入的搜索。
然后,突然,当回头看mmap的调用时,我注意到syscall.PROT_READ标志,这是我第一次查看代码时没有注意到的。因此,mmap仅用于读取螺栓。另一个表明这一点的地方是在DB结构的定义中:
这对我来说很有意义。由于使用mmap时很难控制刷新到磁盘,因此这可能是最安全的方法。螺栓如何写作是另一篇文章的主题。
我们知道bolt如何以及何时分配内存,并且mmap不用于写入。但是,bolt究竟如何才能找到键的值?要了解这一点,我们必须了解数据库通常如何构造其文件。我不会在这里做深。主要是因为我不够深入。只是要尝试一眼正在发生的事情。
文件只是字节数组。我们必须对此字节数组应用某种推理才能有效地使用它。数据库将磁盘中的文件结构化为称为页的块(字节块)。螺栓没有什么不同。数据库文件可以看作是
每个页面都有固定的字节长度,通常与OS页面大小相同(通常为4096字节)。这是设置pageSize的螺栓部分。
// db的默认页面大小设置为OS页面大小。var defaultPageSize = os.Getpagesize()
每个数据库都有其自己的页面布局。螺栓的页面布局在page.go中定义为
const(branchPageFlag = 0x01 leafPageFlag = 0x02 metaPageFlag = 0x04 freelistPageFlag = 0x10)const(bucketLeafFlag = 0x01)type pgid uint64type page struct {id pgid标志uint16计数uint16溢出uint32 ptr uintptr}
id:是用于索引页面的页面标识符。给定一个页面ID,我可以通过mmap在磁盘中找到它,因为磁盘文件只是一个连续固定长度页面的列表。
标志:告诉页面类型。页面有四种类型:meta,freeList,leaf和branch;
ptr:表示页面标题的末尾和页面数据的开始。这是键和值要存储的地方。
考虑到这一点,我们可以看看在给定页面ID的情况下检索页面的代码。
//页面根据当前页面大小从mmap检索页面引用。func(db * DB)page(id pgid)* page {pos:= id * pgid(db.pageSize)return(* page)(不安全。指针(& db.data [pos]))}
我们知道数据库文件在磁盘中的结构,并且我们知道如何从磁盘检索页面。但是bucket.Get([] byte(" key"))搜索如何工作?我们在这里不会做太多的细节。我希望我创建的抽象足以了解正在发生的事情。
如果页面本身在数据部分中包含对其他页面的引用怎么办?如果这些引用形成了B + Tree,该怎么办?
这正是螺栓的作用。将页面视为B + Tree的节点。在B + Tree中,我们有内部节点和叶子。这就是使用“ flags”属性来指示该页面属于哪种节点的原因。
因此,要执行键的搜索,请从根节点开始,在映射的磁盘页面上进行B + Tree遍历。因此,bolt是内存映射的B + Tree文件。您拥有的内存越多,它的行为就越类似于存储键/值存储。
有关此过程的更多详细信息。我大部分都不了解自己。因此,让我们在此抽象级别上保持简单。
当我开始查看源代码时,我搜索了mmap的所有调用。如开头所述,第一个在Open方法中找到。第二个是在分配方法中找到的。
在写入bolt时,需要确保它没有消耗所有分配的内存。如果发现它将超出数据库大小,则会调整内存大小。
//如果最后要调整mmap()的大小。p.id = db.rwtx.meta.pgidvar minsz = int((p.id + pgid(count))+ 1)* db.pageSizeif minsz&gt ; = db.datasz {如果err:= db.mmap(minsz); err!= nil {返回nil,fmt.Errorf(" mmap分配错误:%s&#34 ;, err)}}
阅读螺栓源代码是理解数据库内部结构的一种非常不错的方法。并不是我想的那样吓人。我们忽略了它的大部分源代码,仅着眼于如何使用mmap有效地从磁盘检索数据。我们可以从螺栓中学习更多的概念,例如事务,原子,隔离,并发控制,但我将继续发表其他文章。
重要的是要提醒您,螺栓使用的策略只是多种策略之一。 其他数据库使用不同的页面布局和不同的数据结构。 但是,在更高层次上,我猜想映射数据库的逻辑应该是相同的。 我正在探索关于数据库的更多信息。 如果您有兴趣,可以在Twitter上关注我,在这里我分享更多相关内容。