1.BoltDB源码解析(六)Get操作
2.BoltDB源码解析(一)使用简介
3.BoltDB 事务流程
4.深入浅出存储引擎
5.BoltDB源码解析(七)Put和Delete操作
6.带你进入 etcd 的源码世界
BoltDB源码解析(六)Get操作
在我们深入了解BoltDB的DB文件结构后,接下来我们将分析其CRUD操作中的解析Get方法。首先来看Bucket的源码Get API,这个操作相对简单,解析无论是源码读事务还是写事务,都可以通过它获取Bucket中指定key的解析挂马和源码value。以下是源码关键代码:
代码的核心是Cursor对象,其seek方法在B-tree上定位key,解析返回存储在B-tree页面中的源码key和value指针。
特别需要注意的解析是,如果查到的源码value是另一个Bucket,函数会返回nil,解析因为Get方法主要针对普通key,源码而非Bucket。解析如果需要操作Bucket,源码应使用Bucket方法,如rootBucket.Bucket("user"),就像在MySQL中操作表一样。
Get方法和Bucket查找过程相似,都通过Cursor.seek定位,但Bucket方法多了openBucket步骤。相似的原因在于BoltDB将Bucket视为value类型存储在B-tree中,这样可以共用一个数据结构来存储Bucket和普通value,Cursor.seek则负责在不分类型的B-tree中查找。
seek方法会在key不存在时返回大于该key的下一个key,这有利于通用性,包括insert、update和delete操作。Cursor的search方法递归查找,根据isLeaf属性决定是在node还是page上进行。
BoltDB的写事务会先copy页面到node进行修改,因此读写操作在node和page的处理有所区别。Cursor的search方法根据当前事务类型,选择在node(写事务)或page(读事务)上搜索。
searchNode和searchPage分别针对node和page执行递归搜索,使用一个stack记录递归路径,确保Cursor能够支持遍历B-tree的操作。BoltDB的高效体现在读操作中,全程基于mmap的page指针操作,实现了真正的零拷贝。
BoltDB源码解析(一)使用简介
BoltDB是一个纯Go语言实现的key value存储,提供库形式而非独立server进程。它是一个简单的存储系统,不支持SQL,但用户可以通过Bolt的API对key value进行增删查改。
使用BoltDB只需一个文件作为DB的持久化文件。与一般数据库不同,Bolt没有单独的日志文件,也不像LevelDB那样需要创建多个文件并执行Compaction。Bolt以mmap内存映射的预约排队 源码方式打开DB文件,增删查改操作直接在内存中进行,操作系统负责磁盘和内存之间的数据传输。
Bolt支持Bucket概念,可以理解为namespace,用于分类组织不同类别的数据。用户可以创建多个Bucket来组织数据,例如在电商网站中,可以将users、orders、items数据分别放入不同的Bucket。
以下是一个示例程序,展示了BoltDB的常规操作:
bolt.Open用于传入要使用的DB文件参数,并返回一个db实例。db.Close用于关闭数据库。
db.Update的入参是一个function,这是Bolt支持transaction的方式。db.View的入参也是一个function,但transaction只能是只读的。
CreateBucketIfNotExists根据名称打开或创建Bucket。
bucket.Put(key, value)将一对key value写入Bucket,若key已存在,则用新value替换旧value。
val := bucket.Get(key)返回key对应的value,若key不存在,则返回nil。
Bolt还支持Cursor概念,用于按照key顺序遍历DB。Cursor支持prefix scan和range scan,具体介绍可参考Bolt的README。
可能有同学疑惑,Bolt似乎只能存储string类型数据,如何存储结构化数据?实际上,Bolt不关心value的结构,将其视为字节序列。我们可以将结构化数据序列化为字节序列存储在Bolt中,使用时再反序列化为结构。Go语言中的序列化反序列化方法(如JSON、Gob、Protobuffers等)均可用于此。
Bolt的基本使用介绍到此,接下来将进行源码解析。
BoltDB 事务流程
BoltDB 的事务流程主要围绕 Tx 结构体展开,它在设计上注重对单写控制和内存管理。事务的开始通常通过 db.Update,涉及到的 Rollback 和 Commit 操作在事务管理中起着关键作用。写事务使用 beginRWTx()方法,它通过互斥锁(rwlock)实现单写,避免了文件锁带来的问题。读事务则通过 beginTx()执行,常用的源码该操作会锁定内存映射,可能影响性能,特别是当写事务频繁需要 remmap时。
初始化阶段,写事务通过拷贝 db.meta 实现版本控制,每次写操作都会更新 metapage 并递增版本号。Commit 时,数据库信息的内存操作完成后,才会进行 B+树的分裂和平衡等ACID事务特性操作。BoltDB的存储基于页(page)和 B+树,每个页面大小通常为操作系统标准的4k,数据以 key-value 形式分布在 bucket 中的 B+树节点上,通过 MVCC机制确保并发控制。
理解 Transaction Commit 的过程,关键在于理解 txid、metapage 和 mmap,这三个元素构成了 BoltDB MVCC机制的核心。对于存储结构和并发特性,可以通过阅读源代码和参考其他资源进行深入学习。BoltDB适合于读多写少的场景,因为其内存映射策略和文件系统操作的方式。要全面掌握 BoltDB,还需要结合详细的代码和理解其内存布局和分页算法。
深入浅出存储引擎
深入浅出存储引擎
本文详细探讨了数据库系统中的存储引擎相关概念,以及存储引擎如何实现高效的数据存储与检索。存储引擎是数据库系统的核心组件,负责处理数据的存储、检索和维护。
首先,文章介绍了数据存储体系,包括OLTP、OLAP与HTAP,以及关系数据库、NoSQL数据库与NewSQL数据库的特性。接着,讨论了基于内存型与磁盘型存储组件的数据存储方式,以及读多写少、写多读少和读多写多组件的处理策略。文章进一步解释了数据存储与检索的过程,强调了存储引擎在其中的核心作用。
文章详细分析了存储引擎的分类,包括基于B+树的存储引擎和基于LSM派系的存储引擎。基于B+树的存储引擎适合于读多写少的场景,而基于LSM派系的存储引擎则适用于写多读少的场景。文章还讨论了内存、持久化内存和磁盘在数据存储中的应用,以及它们的管理机制。
从宏观角度,文章解析了B+树存储引擎的出售资料源码原理,包括其诞生背景、设计目标、数据结构选择、索引维护和存储策略。从微观角度,文章深入探讨了B+树存储引擎的工程细节,如边界条件处理、异常情况处理、事务管理和范围查询。
文章进一步分析了BoltDB核心源码,从整体结构、page解析、node解析、Bucket解析到事务解析,详细解释了BoltDB存储引擎的实现机制。这为理解和实现类似的存储引擎提供了宝贵的参考。
接着,文章深入理解了LSM Tree原理,从其发展背景、从零推导LSM Tree、架构演进和核心问题等角度进行了全面分析。文章详细探讨了LSM Tree的工程应用、KV分离存储技术WiscKey、Bitcask的核心原理以及Moss的核心原理,展示了LSM Tree在不同场景下的应用。
最后,文章分析了LSM派系存储引擎,包括LSM Tree存储引擎、LSM Hash存储引擎、LSM Array存储引擎以及其他LSM存储引擎的特性和实现方法。文章提供了这些存储引擎的详细解析,有助于读者深入了解LSM存储引擎的实现细节。
BoltDB源码解析(七)Put和Delete操作
Put和Delete的实现
上一篇文章我们了解了BoltDB的Get API的实现。现在,我们来探讨Put和Delete API的实现:
Put API的主要功能是将一对键值对插入到Bucket中,如果键已经存在,则更新对应的值。首先,进行一些限制条件的检查,例如Put操作是否由写事务发起的,因为Put只能由写事务调用。此外,还需要检查键和值的大小是否符合限制条件。需要注意的是,Put操作和Get操作一样,这里也使用了Cursor来定位键应该放置的位置。
在实际的Put操作中,会调用Cursor的一个不显眼的方法:
这个方法实际上非常有用,它从当前Bucket的opengl 源码学习B-tree的根节点开始,一直到Cursor定位到的leaf page,为每个page创建一个对应的node结构。当然,如果一个page已经有对应的node,就直接使用它。
为什么要这么做呢?这是因为事务篇中提到的修改操作具有“传染性”,修改B-tree的leaf节点会导致从root到leaf的所有page都需要修改,而BoltDB的修改操作都是在page对应的node里进行的,不是直接在page上修改,因此需要为这些page建立node结构。具体建立node结构的是Bucket的node方法:
Bucket的node方法有两处需要注意,一个是新建的node会被追加到parent node的children中,记录下这些修改的node之间的关系,这个children在node持久化时会有用(node.spill方法)。另一个是node的数据是如何从page中读取的,这是由node的read方法完成的。
node建立好之后,就在要修改的leaf对应的node上调用put方法:
node的put方法相对简单,它是在inodes数组上查找对应的位置,如果exact为true,表示找到了相同的key,直接更新value;如果exact为false,相当于找到了应该插入的位置,然后在对应的inode上记录数据。我们来看一下inodes数组的定义:
inodes数组是node实际存储数据的地方,由多个inode组成,每个不同的key对应一个不同的inode,inode之间是按key排序的。对于leaf节点来说,inode里使用key和value;对于branch节点来说,inode里使用key和pgid,pgid代表一个child page的id。value和pgid不会同时使用。
put方法结束后,当前的Put操作也就结束了。也就是说,Put操作所做的仅仅是把新增或修改的数据放入到它所在的page对应的node内存中。
顺便提一下Delete操作,它和Put操作非常类似,在建立起node结构之后,在对应的node的inodes数组中删除找到的key相等的inode就完成了,这里不再展开。
那么,什么时候会把这些node里的数据持久化到DB文件里呢?是在整个写事务commit的时候。
事务的Commit实现
下面是事务commit的代码简化,保留了重要部分:
Commit的整体流程比较长,下面一点一点进行说明。
tx.root.rebalance(),这个root是root Bucket,rebalance是对root Bucket下所有子Bucket的所有node进行rebalance。这是什么意思?注意node的初始数据虽然来自一个page,但在经历了一些Delete操作后,有些node里面的数据可能过少,这时会先把这个node和它的左兄弟或右兄弟node合并(node的rebalance方法),合并后node数会减少,但不存在node里数据过少的情况。这个操作对应于B-tree的merge操作,只不过这些node都是Go的内存结构,合并起来非常简单。当然,合并后把这些node spill到page的操作,需要的page总数也会减少。
tx.root.spill(),这个方法是把root Bucket下所有子Bucket的所有node的内容都写入这个事务分配的dirty page里。注意这些dirty page是这个事务临时分配在内存里的,结构和DB文件的page完全一样,但还不是mmap映射的DB的page。
刚开始看到spill这个方法时,感觉它代价有些高,感觉像是把整个B-tree都走了一遍。后来仔细看才发现不是这么回事。这个spill只对有node结构的节点进行处理,那些没修改过的page没有对应的node,根本不会处理。
注意在经过多次Put操作后,node里存放的数据可能出现一个page写不下的情况,比如insert了几千个key value。spill会先把这样的node split成多个大小合适的node(node的split方法),然后把这些node分别写入不同的page中。这个操作对应于B-tree的split操作。和rebalance方法类似的道理,因为这些node都是Go的内存结构,split起来非常容易。
if tx.meta.pgid > opgid,这个判断是看当前事务需要的page数是否大于事务执行前DB文件有的page数,如果大于,说明DB文件放不下了,就调用db.grow增大文件,以容纳新增的page。
紧接着是freelist的持久化操作,因为写事务可能使用了freelist里的一些page,同时也可能释放了一些page到freelist里,所以freelist很可能发生了变化,需要持久化。
tx.write(),这个方法就是把所有的临时分配的dirty page都写入DB文件对应的page里。
tx.writeMeta(),这个方法是把这个tx里的meta写到meta0或者meta1里面(写事务会交替写这两个meta page,这也是个常用技术,叫ping-pong buffer)。它的代码值得看一下:
首先把meta写到临时分配的buf里,然后用文件IO写到DB文件里,最后调用fdatasync,把OS文件的buffer cache持久化到磁盘上。至此,写事务的所有数据都已经落盘完毕。后面新开启的事务会因为这个meta的txid是最大的,而选择使用这个最新的meta page。而这个meta page包括最新的root bucket,最新的freelist,最新的pgid,这些总体构成了一个DB的最新版本,保证新开启的事务读到最新版本的数据。
看tx.write()和tx.writeMeta()的实现可以发现,写入数据用的是db.ops.writeAt,而这个方法默认值就是File.WriteAt方法,所以实际写入文件用的是文件IO,而不是直接写mmap内存。而BoltDB使用mmap一开始就把mmap映射的内存标记为只读的,压根不允许直接写mmap内存。为什么要这么做呢?
猜测可能是为了安全。前面讲到Get操作为了性能是zero copy的,发现Get返回来的value是mmap上数据的指针,如果mmap设置为可读写的,应用程序代码五花八门,可能会通过指针一不小心修改了mmap上的数据,这样的修改因为走的不是API是无法保证事务的。把mmap设置为只读的消除了这种可能性。反过来说,如果mmap设置为可读写的,Get就不能返回mmap上的指针了,为了安全一定要copy一份数据出来才行,降低了Get的性能。
这里还有个很自然而且很重要的问题是,如果事务commit失败了呢,BoltDB如何保证事务的原子性(ACID的A),确保这个写事务的所有操作,不论是落盘的,还是没落盘的,都不会生效?
原子性要求,不管是commit走到哪一步,哪怕是已经把修改的数据,甚至包括修改的freelist已经落盘,只要最终事务commit失败,都不能对正确性产生任何影响。这里的正确性是指,数据库的状态(有实际的key value数据,freelist, pgid等共同构成)必须是在这个写事务运行之前的状态,数据不能被破坏,这个写事务也不能留下可被后续事务读到的任何更新。
要做到原子性貌似挺难的,因为事务的commit里包括很多步骤,这些步骤都不是原子性的。不过重要的一点是,不论commit运行到哪一步,因为tx.writeMeta是最后一步,只有这一步运行成功commit才算成功,如果说commit失败了,那么tx.writeMeta一定是没运行,或者运行了半截,这个meta page没写完整,机器断电了。总之,这些情况下我们不会得到一个合法的新的meta page(这种情况下meta的validate方法会失败,因为meta的checksum不对)。这时候ping-pong buffer的meta page就起重要的作用了,因为交替写meta page的原因,即使这个写事务新的meta page没写成功,这个写事务运行前版本的meta page还在,而这个meta page包括这个写事务运行前的DB版本所有的状态(kv数据,freelist,pgid等)。这个meta page会被后续事务使用,就像那个失败的写事务从来没有运行过一样。而那个写事务留下的kv数据的page,freelist的page,即使是持久化了,也因为没有写成新的meta,没有机会被用到。
还有个自然的疑问,即使这个失败的写事务写的page因为没有合法的meta无法被引用,不会影响正确性,但无法被引用是不是也意味着这些page无法被回收,浪费了磁盘空间?
答案是也不会。在原来版本的meta里的free list和pgid的共同作用下,这些page会被视为free的,还可以使用,不会出现无法回收这些page的情况。
还有个疑问,既然BoltDB交替写meta0和meta1,是不是连续两个事务commit正好在写meta时失败,数据库就废了?
仔细研究发现,还是没事!因为写事务的txid也是meta的一部分,一个写事务失败,导致txid不会增长,下一次写事务的txid还是一样,meta的交替写是因为txid的变化引起的,既然没变化,就不交替了。所以下一个写事务即使写meta还失败了,也还是写的上一个写事务写的那个meta,不会把两个meta都写坏。
总结一下,ping-pong buffer的meta page真是设计得精巧,是BoltDB达到原子性的关键!
带你进入 etcd 的世界
带你进入 etcd 的世界
etcd 是一个可靠的分布式键值存储,基于 Raft 协议保证一致性,主要用在配置共享和服务发现。作为 CoreOS 公司开源的项目,其源代码地址为 /#...github.com/polarismesh/...
在服务治理中,Polaris-server作为控制面,负责服务数据、配置数据、治理规则的管理以及下发至北极星SDK以及实现了xDS的客户端。
那么,Polaris-server是如何处理客户端的服务注册请求的呢?服务数据是如何存储的呢?本文将带您深入了解Polaris-server的启动流程及北极星实现服务注册的过程。
在使用PolarisMesh服务治理平台时,可通过官方SDK使用手册进行服务注册操作,具体可以参考:
polarismesh.cn/docs/%E4...
polarismesh.cn/docs/%E4...
以下通过Polaris-java SDK为例,展示如何通过ProviderAPI的registerInstance方法将服务实例注册请求发送至北极星服务端。
当调用providerAPI.registerInstance方法后,SDK内部将自动设置实例的TTL周期,交由RegisterFlow执行注册动作。RegisterFlow主要负责流程编排,它执行的主要流程如下:
设置实例的TTL周期 将请求发送至服务端进行注册在服务端处理注册请求的过程中,数据流主要经历以下几个流程:
apiserver层接收并处理请求 权限检查通过resource auth filter层 服务层将服务实例数据写入北极星集群中存储层采用插件化设计,单机模式使用boltdb,集群模式依赖MySQL。在MySQL存储层实现中,针对实例信息,北极星将其拆分为三个表。因此在操作存储层时,需要进行以下操作以持久化服务实例信息:
创建实例表 创建配置表 创建规则表BoltDB源码解析(二)事务
最近几天一直在研究BoltDB的代码,现在对它有了更深入的了解。这篇主要介绍BoltDB的事务处理。
BoltDB的事务主要分为两类:一类是只读事务,另一类是读写事务。只读事务仅允许读取操作,而读写事务则可以同时进行读取和写入操作。在并发控制方面,BoltDB允许任意多个只读事务同时进行,但读写事务只能有一个。
BoltDB支持一定程度的多版本并发控制(MVCC),这意味着读事务不会阻塞写事务,反之亦然。在程序运行过程中,你可能会发现多个读事务和一个写事务在同时进行。
只读事务是通过db.View方法执行的,具体代码如下:
Bolt的注释非常清晰,每一步都标明了具体操作。db.begin是新建一个transaction,而fn参数是用户传递的事务主体函数。
注意,只读事务不会调用transaction的commit函数,除非发生error,此时需要调用t.Rollback()进行清理工作。
读写事务是通过db.update执行的,整体上和View的代码类似,但是会创建一个读写事务。
读写事务如果没有发生错误,最后会调用Commit方法,将事务进行的修改持久化到DB文件里,实现事务ACID特性里的“D"。
BoltDB使用B-tree作为磁盘数据结构,在事务commit时,所有在内存中的修改都要持久化到磁盘上。在事务commit时,所有修改都需要持久化到多个新page里。
读事务实现得比较简单,就是在基于mmap的B-tree上搜索到具体的key,返回对应的value。为了提升性能,BoltDB全程尽量避免copy。
写事务比读事务要复杂,BoltDB如果需要修改一个page上的数据,首先会通过B-tree搜索定位到具体的key所在的leaf page,但它不会直接在这个page上修改,而是把这个page的数据copy到一个叫node的内存结构体里,修改是在node结构体里做的。
在写事务中,所有的修改都暂存在内存里,在事务commit之前不会持久化。在事务commit的时候,所有的修改都要持久化。
因此,BoltDB的使用建议是,一个事务做的事情不要太多,这样不必耗费太多内存保存中间状态,commit也不至于耗时太多。