【存储】etcd的存储是如何实现的(3)-blotdb
在etcd系列中,我们对作为etcd底层kv存储的boltdb进行了比较全面的介绍。但是还有两个点没有涉及。
第一点是boltdb如何和磁盘文件交互。
持久化存储和我们一般业务应用程序的最大区别就是其强依赖磁盘文件。一方面文件数据结构和内存数据结构的差异很大,需要设计合适的文件数据结构(文件布局)来保证足够的读写效率;另一个方面,在与磁盘文件的读写交互上也需要做各种优化以提升db的整体性能。boltdb的文件布局在上一篇中已经介绍过了,本篇中会介绍mmap,boltdb用来做文件交互的技术。
第二点是boltdb如何管理free page。
在第一篇中我们讲过,boltdb采用了shadow paging(影子分页)的实现,这种类似copy
mmap是linux提供的一个系统调用。
mmap的作用是将进程的一块虚拟内存和文件相映射,通过对该虚拟内存的读写就可以直接对文件进行读写。相对于一般的使用read、write系统调用来读写文件的情况,使用mmap可以减少用户空间和内核空间之间的数据拷贝,提高效率。
mmap的原理是将一块虚拟内存和一块内核物理内存相映射,以此来减少用户空间和内核空间的数据拷贝。更具体的细节这里就不展开,网上有不少文章都介绍的非常详细,感兴趣可自行了解。
mmap是一种高效的操作文件的方式,但是应用在数据库也会有一些问题。这些问题产生的根本原因就是在mmap中,内存回写文件完全由内核管理而无法由存储应用层控制。可能导致的后果有:
在mmap中,内存刷盘的过程完全由操作系统控制,其无法感知上层应用的情况,会存在事务尚未提交,就将脏页刷盘的情况。这种情况可能会对事务造成影响。
博主认为在存在mvcc的db中其实是没有影响的,如果没有mvcc,则确实会产生中间状态。因为mvcc是存储应用层针对事务设计,如果没有mvcc,则事务完全依赖持久化能力。当然实际情况会更复杂,应该根据不同的实现具体情况具体分析。
解决mmap事务安全可以采用wal+copy-on-write,或者采用shadow paging(影子分页)。boltdb采用的是shadow paging。其能解决问题的根本原因是shadow paging中,每次写入都会将更新写入新的页而不是在原有页上update。在页表刷盘前,即使新的页被回写磁盘,该页也只会被当作空白页对待。
操作系统无法感知存储应用层对数据的使用情况,其页面的置换无法保证db的热点数据在内存中,会导致i/o的性能波动。
除以上问题外,mmap还存在一些其他的问题,数据库大神andy专门写了一篇论文来论证为什么不要在数据库中使用mmap,详情见论文。但实际上,在工业界的很多知名项目中都使用了mmap,其中的考量博主目前也没有深入研究,留待下回分解。
freelist在内存中维护了所有的空闲页,以便快速的进行页的分配。boltdb支持两种形式的freelist,分别以数组和map的形式维护空闲页面,可以在创建DB对象时通过option中的FreelistType参数控制。
const (
// FreelistArrayType indicates backend freelist type is array
FreelistArrayType = FreelistType("array")
// FreelistMapType indicates backend freelist type is hashmap
FreelistMapType = FreelistType("hashmap")
)
官方解释中,数组形式的freelist实现简单,但是性能较差,尤其当db比较大时,并且容易产生文件碎片。map形式的freelist性能很快,在文件碎片问题上表现更好,但不能保证分配的页是offset最小的页。默认使用数组形式的freelist。
数组形式的freelist使用数组按照升序来维护所有的page id。当需要分配一块n页的连续空间时,会从前向后找到第一块大于n页的连续空间,从中截取n页分配。这种形式保证了我们一定能拿到offset最小的符合要求的page。但是遍历的方式是O(n)的复杂度,当db很大时,可能会产生严重的性能问题。同时这种方式也很容易产生文件碎片。
// 省略其他字段
type freelist struct {
freelistType FreelistType // freelist type
ids []pgid // all free and available free page ids.
}
map形式的freelist采用三个map维护空闲页,或者说连续的页面组成的不同大小的连续空间。
// 省略其他字段
type freelist struct {
freelistType FreelistType // freelist type
freemaps map[uint64]pidSet // key is the size of continuous pages(span), value is a set which contains the starting pgids of same size
forwardMap map[pgid]uint64 // key is start pgid, value is its span size
backwardMap map[pgid]uint64 // key is end pgid, value is its span size
}
type pidSet map[pgid]struct{}
当需要分配一块n页的连续空间时,会在freemaps中查找是否存在n页的连续空间,如有,则选择一块分配,否则随机选择大于n页的连续空间分配(这样看在碎片问题上也没好到哪去)。forwardMap和backwardMap的作用则是在释放内存时进行连续空间的合并。
上面提到了freelist有数组和map两种实现,其差别主要在空闲页的分配上。个人觉得freelist的最有意思的点在页的释放上,这也是freelist跨事务管理page的体现。
在介绍具体的实现前,我们先回顾下boltdb事务的特点。
回顾过以上内容,我们再来看具体的实现。
boltdb会将读写事务和只读事务分别记录。其中读写事务同时最多存在一个,只读事务同时存在多个,采用数组记录,并且boltdb会按照txnid升序维护只读事务。
// 省略其他字段
type DB struct {
rwtx *Tx // 读写事务
txs []*Tx // 只读事务
}
当事务结束(提交或者回滚)时,会调用事务的close方法消除事务。
// 省略多余代码
func (tx *Tx) close() {
if tx.db == nil {
return
}
if tx.writable {
// Remove transaction ref & writer lock.
tx.db.rwtx = nil
} else {
tx.db.removeTx(tx)
}
}
页的释放分为两部分:
在读写事务中,当发生update或者delete操作时,对应的页需要被释放。但是这些页不能在读写事务提交时立刻被释放,因为可能会被只读事务的快照持有。
所以freelist对外提供free方法,记录当前事务需要释放的页。读写事务中在对应的位置调用free方法。
待释放的page以txPending的方式组织,这种组织方式是为了在事务回滚时快速进行回滚操作。
type txPending struct {
ids []pgid
alloctx []txid // txids allocating the ids
lastReleaseBegin txid // beginning txid of last matching releaseRange
}
free方式的实现如下。其会记录释放的页以及对应分配该页的txnid。在boltdb中只有开启读写事务才会对txnid进行递增,所以txnid可以认为是版本的概念。当一个版本不被只读事务持有,那么该版本分配的待释放页就可以释放。在boltdb中,只读事务永远只能拿到最新版本的快照而无法获取旧版本的快照,所以页总是可以被释放。但同时长的只读事务也会导致页迟迟不能释放,从而可能会导致db空间快速增长。
// free releases a page and its overflow for a given transaction id.
// If the page is already free then a panic will occur.
func (f *freelist) free(txid txid, p *page) {
if p.id <= 1 {
panic(fmt.Sprintf("cannot free page 0 or 1: %d", p.id))
}
// Free page and all its overflow pages.
txp := f.pending[txid]
if txp == nil {
txp = &txPending{}
f.pending[txid] = txp
}
allocTxid, ok := f.allocs[p.id]
if ok {
delete(f.allocs, p.id)
} else if (p.flags & freelistPageFlag) != 0 {
// Freelist is always allocated by prior tx.
allocTxid = txid - 1
}
for id := p.id; id <= p.id+pgid(p.overflow); id++ {
// Verify that page is not already free.
if _, ok := f.cache[id]; ok {
panic(fmt.Sprintf("page %d already freed", id))
}
// Add to the freelist and cache.
txp.ids = append(txp.ids, id)
txp.alloctx = append(txp.alloctx, allocTxid)
f.cache[id] = struct{}{}
}
}
在新读写事务开启时,会根据txPending和当前只读事务的状态来释放待释放的page,可以认为是一种lazy的策略,但是在这个场景下效果很好。
boltdb会遍历进行中的所有只读事务(db.Txs),调用release和releaseRange方法来释放已结束版本的待释放页。
func (db *DB) freePages() {
// Free all pending pages prior to earliest open transaction.
sort.Sort(txsById(db.txs))
minid := txid(0xFFFFFFFFFFFFFFFF)
if len(db.txs) > 0 {
minid = db.txs[0].meta.txid
}
if minid > 0 {
db.freelist.release(minid - 1)
}
// Release unused txid extents.
for _, t := range db.txs {
db.freelist.releaseRange(minid, t.meta.txid-1)
minid = t.meta.txid + 1
}
db.freelist.releaseRange(minid, txid(0xFFFFFFFFFFFFFFFF))
// Any page both allocated and freed in an extent is safe to release.
}
至此,我们对boltdb的各个方面进行了比较完善的讲解。