Go锁,我终于搞懂了

自从前两天写了Go互斥锁实现原理后,我感觉我错了,我不应该看源码,看了也没啥用,会了也不敢用。按照源码中的写法,没人想做代码review,也不好做单元测试。

我就是想知道锁是怎么实现的,结果整一堆优化逻辑,感觉懂了又感觉啥都不懂。所以我痛定思痛,我看早期版本行吧。来让我们看2014年的版本

互斥锁:https://github.com/golang/go/blob/c007ce824d9a4fccb148f9204e04c23ed2984b71/src/sync/mutex.go

读写互斥锁:https://github.com/golang/go/blob/c007ce824d9a4fccb148f9204e04c23ed2984b71/src/sync/rwmutex.go

互斥锁

这个版本的互斥锁没有饥饿模式、没有自旋、没有各种小的性能优化点。但这段代码把最核心的逻辑展示的十分清楚。

说明

type Mutex struct {
	state int32
	sema  uint32
}

  • state表示互斥锁的状态,比如是否被锁定等。

  • sema表示信号量,协程阻塞等待该信号量,解锁的协程释放信号量从而唤醒等待信号量的协程。

state的组成如下图所示:

图片

Locked: 表示该Mutex是否已被锁定,0:没有锁定 1:已被锁定。

Woken: 表示是否有协程已被唤醒,0:没有协程唤醒 1:已有协程唤醒,正在加锁过程中。释放锁时,如果正常模式下,不会再唤醒其它协程。

Waiter: 表示阻塞等待锁的协程个数,协程解锁时根据此值来判断是否需要释放信号量

因为在Go互斥锁实现原理已经写过很多基础知识了,这次只把核心逻辑、核心点写一下,如果对其中部分知识不太理解,可以看一下上一篇文章。

源码

// Copyright 2009 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

// Package sync provides basic synchronization primitives such as mutual
// exclusion locks.  Other than the Once and WaitGroup types, most are intended
// for use by low-level library routines.  Higher-level synchronization is
// better done via channels and communication.
//
// Values containing the types defined in this package should not be copied.
package sync

import (
	"sync/atomic"
	"unsafe"
)

// A Mutex is a mutual exclusion lock.
// Mutexes can be created as part of other structures;
// the zero value for a Mutex is an unlocked mutex.
type Mutex struct {
	state int32
	sema  uint32
}

// A Locker represents an object that can be locked and unlocked.
type Locker interface {
	Lock()
	Unlock()
}

const (
  //status只分为三部分,mutexLocked表示锁是否已经被其它协程占用
  //mutexWoken表示是否唤起协程,让协程开始抢占锁
  //mutexWaiterShift表示阻塞等待锁的协程个数,占int32的高30位
	mutexLocked = 1 << iota // mutex is locked
	mutexWoken
	mutexWaiterShift = iota
)

// Lock locks m.
// If the lock is already in use, the calling goroutine
// blocks until the mutex is available.
//加锁过程仍然以将status的Locked位置为1位加锁成功
func (m *Mutex) Lock() {
	// Fast path: grab unlocked mutex.
  //如果锁没有被占用、没有唤醒的协程、没有等待加锁的协程,直接加锁,成功便返回
	if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
		if raceenabled {//为false,不用管
			raceAcquire(unsafe.Pointer(m))
		}
		return
	}
	//协程主动发起的加锁请求,肯定不是被唤醒的,所以awoke为false
	awoke := false
	for {
		old := m.state
    //无论如何先尝试加锁。因为如果没被占用,肯定是要加锁的。如果被占用,因为使用CAS,所以Locked位也需要为1
		new := old | mutexLocked
    //如果锁是被占用的,则将等待协程值加1
		if old&mutexLocked != 0 {
			new = old + 1<<mutexWaiterShift
		}
    //如果是被唤醒的,CAS操作之后自己就到终止状态(获得锁或者阻塞),所以需要将Woken位置0
		if awoke {
			// The goroutine has been woken from sleep,
			// so we need to reset the flag in either case.
			new &^= mutexWoken
		}
    //CAS操作
    // old                            new
    //(0,1)不是唤醒的,被占用了					(+1,0,1)
    //(1,1)是唤醒的,被占用了           (+1,0,1)
    //(0,0)不是唤醒的,未被占用         (+0,0,1)
    //(1,0)  是唤醒的,未被占用         (+0,0,1)
		if atomic.CompareAndSwapInt32(&m.state, old, new) {
			if old&mutexLocked == 0 { //如果CAS操作时,锁是未被占用,则加锁成功
				break
			}
			runtime_Semacquire(&m.sema) //否则,将当前协程阻塞
			awoke = true //当该协程被唤醒是,用awoke进行标记
		}
	}

	if raceenabled {
		raceAcquire(unsafe.Pointer(m))
	}
}

// Unlock unlocks m.
// It is a run-time error if m is not locked on entry to Unlock.
//
// A locked Mutex is not associated with a particular goroutine.
// It is allowed for one goroutine to lock a Mutex and then
// arrange for another goroutine to unlock it.
// 解锁逻辑也比go1.13版本简单很多
func (m *Mutex) Unlock() {
	if raceenabled {//默认为false,不用管
		_ = m.state
		raceRelease(unsafe.Pointer(m))
	}

	// Fast path: drop lock bit.
  //将值减一,其实就是将locked位置为0
	new := atomic.AddInt32(&m.state, -mutexLocked)
  //小技巧,检查解锁的锁是否未被加锁,如果是这种情况,就panic
	if (new+mutexLocked)&mutexLocked == 0 {
		panic("sync: unlock of unlocked mutex")
	}
	//因为atomic.AddInt32操作m.state使用的是指针,如果没有其它协程操作m.state的话,new=old=m.state
	old := new
	for {
		// If there are no waiters or a goroutine has already
		// been woken or grabbed the lock, no need to wake anyone.
    //如果没有等待加锁的协程或者当前锁已经唤醒了其它协程,直接返回
		if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken) != 0 {
			return
		}
		// Grab the right to wake someone.
    //要唤醒等待协程了,所以需要将woken位置为1,同时将等待数量减一
		new = (old - 1<<mutexWaiterShift) | mutexWoken
		if atomic.CompareAndSwapInt32(&m.state, old, new) {
			runtime_Semrelease(&m.sema)//如果解锁成功,则唤醒一个等待的协程
			return
		}
		old = m.state//否则继续尝试解锁
	}
}

关注点

  1. Unlock中atomic.AddInt32(&m.state, -mutexLocked)是对m.state的指针真行操作,所以其实对m.state进行了真正的减一,这也是CAS操作时atomic.CompareAndSwapInt32(&m.state, old, new),m.state和old值可能一样的原因

  2. 大家课本上学到的信号量伪代码一般是这样的

wait(S):while S<=0 do no-op;
				  S:=S-1;

signal(S):S:=S+1;

感觉和Mutex中的sema不一样。有这种疑问很正常,因为准确的说,Mutex才是真正意义上的信号量,Mutex中的sema虽然翻译为信号量,但其实只是为了将协程阻塞到以sema为地址的队列上,细节可查看semacquire1、semrelease1。

type Mutex struct {
	state int32
	sema  uint32
}

课本上的伪代码:可以加锁,值减一;不可以加锁,死循环。

Mutex具体实现:可以加锁,Locked位置为1,不可以加锁,协程阻塞。

  1. 存在等待协程饿死的情况

读写锁

上一篇文章没有写读写锁,主要还是因为go1.13的代码阅读起来太过困难。这次看老版本的代码,简单的多,所以顺便写一下读写锁。

说明

读写锁结构体为:

type RWMutex struct {
	w           Mutex  // held if there are pending writers
	writerSem   uint32 // semaphore for writers to wait for completing readers
	readerSem   uint32 // semaphore for readers to wait for completing writers
	readerCount int32  // number of pending readers
	readerWait  int32  // number of departing readers
}

  • Mutex:复用写锁,应对读写锁变为写锁的情况。获得写锁首先要获取该锁,如果有一个写锁在进行,那么再到来的写锁将会阻塞于此

  • writerSem:写阻塞等待的信号量,最后一个读者释放锁时会释放信号量

  • readerSem:读阻塞的协程等待的信号量,持有写锁的协程释放锁后会释放信号量

  • readerCount:记录读者个数

  • readerWait:记录写阻塞时读者个数。表示加写锁的时候有几个读锁,当对应数量的读锁解锁完毕后,写锁开始被唤起。主要目的为防止写锁饿死。

源码

// Copyright 2009 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package sync

import (
	"sync/atomic"
	"unsafe"
)

// An RWMutex is a reader/writer mutual exclusion lock.
// The lock can be held by an arbitrary number of readers
// or a single writer.
// RWMutexes can be created as part of other
// structures; the zero value for a RWMutex is
// an unlocked mutex.
type RWMutex struct {
	w           Mutex  // held if there are pending writers
	writerSem   uint32 // semaphore for writers to wait for completing readers
	readerSem   uint32 // semaphore for readers to wait for completing writers
	readerCount int32  // number of pending readers
	readerWait  int32  // number of departing readers
}
//读协程最大个数。
//这个值主要和readerCount相比较。如果加写锁,则将readerCount减去rwmutexMaxReaders
//这是一个小技巧,既能看出是否加了写锁,也能使用这个值方便的还原出读协程的数量
const rwmutexMaxReaders = 1 << 30

// RLock locks rw for reading.
// 加读锁
func (rw *RWMutex) RLock() {
	if raceenabled {
		_ = rw.w.state
		raceDisable()
	}
  //加读锁,直接将readerCount值加1
  //什么情况下readerCount会小于0呢,有协程请求写锁了。这种情况下,加读锁的协程需要阻塞
  //因为加读锁的协程到达时间比加写锁的晚,所以得等写锁用完了才能执行,否则加写锁的协程会饿死,不讲武德
	if atomic.AddInt32(&rw.readerCount, 1) < 0 {
		// A writer is pending, wait for it.
		runtime_Semacquire(&rw.readerSem)
	}
	if raceenabled {//为false,不用管
		raceEnable()
		raceAcquire(unsafe.Pointer(&rw.readerSem))
	}
}

// RUnlock undoes a single RLock call;
// it does not affect other simultaneous readers.
// It is a run-time error if rw is not locked for reading
// on entry to RUnlock.
//解读锁
func (rw *RWMutex) RUnlock() {
	if raceenabled {//为false,不用管
		_ = rw.w.state
		raceReleaseMerge(unsafe.Pointer(&rw.writerSem))
		raceDisable()
	}
  //将加读锁的协程数量值减一
  //如果没有尝试加写锁的协程,值r肯定大于0,则解锁结束
  //如果有协程加写锁,就需要看一下自己是否是请求写锁前的最后一个读锁协程了,是的话就要唤醒加写锁协程
	if r := atomic.AddInt32(&rw.readerCount, -1); r < 0 {
    //说明解锁了未加锁mutex,会panic
		if r+1 == 0 || r+1 == -rwmutexMaxReaders {
			raceEnable()
			panic("sync: RUnlock of unlocked RWMutex")
		}
		// A writer is pending.
    //自己是否是请求写锁前的最后一个读锁协程了,是的话就要唤醒加写锁协程
		if atomic.AddInt32(&rw.readerWait, -1) == 0 {
			// The last reader unblocks the writer.
			runtime_Semrelease(&rw.writerSem)
		}
	}
	if raceenabled {
		raceEnable()
	}
}

// Lock locks rw for writing.
// If the lock is already locked for reading or writing,
// Lock blocks until the lock is available.
// To ensure that the lock eventually becomes available,
// a blocked Lock call excludes new readers from acquiring
// the lock.
func (rw *RWMutex) Lock() {
	if raceenabled {
		_ = rw.w.state
		raceDisable()
	}
	// First, resolve competition with other writers.
  //调用mutex加写锁。如果是第一个加写锁的协程,肯定能成功。如果不是第一个,就阻塞了
	rw.w.Lock()
	// Announce to readers there is a pending writer.
  //这个操作实现两个功能,一是将readerCount变为负值,另一个是计算出加写锁时读锁个数
	r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders
	// Wait for active readers.
  //说明加写锁的时候还有读协程在,则加写锁的协程阻塞
	if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 {
		runtime_Semacquire(&rw.writerSem)
	}
  //当加写锁前的所有读锁协程都释放了,加写锁协程会被唤起
  
	if raceenabled {
		raceEnable()
		raceAcquire(unsafe.Pointer(&rw.readerSem))
		raceAcquire(unsafe.Pointer(&rw.writerSem))
	}
}

// Unlock unlocks rw for writing.  It is a run-time error if rw is
// not locked for writing on entry to Unlock.
//
// As with Mutexes, a locked RWMutex is not associated with a particular
// goroutine.  One goroutine may RLock (Lock) an RWMutex and then
// arrange for another goroutine to RUnlock (Unlock) it.
func (rw *RWMutex) Unlock() {
	if raceenabled {
		_ = rw.w.state
		raceRelease(unsafe.Pointer(&rw.readerSem))
		raceRelease(unsafe.Pointer(&rw.writerSem))
		raceDisable()
	}

	// Announce to readers there is no active writer.
  //写锁释放的时候,将readerCount变为正值
	r := atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders)
  //如果释放未加锁的mutex,panic
	if r >= rwmutexMaxReaders {
		raceEnable()
		panic("sync: Unlock of unlocked RWMutex")
	}
	// Unblock blocked readers, if any.
  //顺序唤起所有等待的读协程
	for i := 0; i < int(r); i++ {
		runtime_Semrelease(&rw.readerSem)
	}
	// Allow other writers to proceed.
	rw.w.Unlock()
	if raceenabled {
		raceEnable()
	}
}

// RLocker returns a Locker interface that implements
// the Lock and Unlock methods by calling rw.RLock and rw.RUnlock.
func (rw *RWMutex) RLocker() Locker {
	return (*rlocker)(rw)
}

type rlocker RWMutex

func (r *rlocker) Lock()   { (*RWMutex)(r).RLock() }
func (r *rlocker) Unlock() { (*RWMutex)(r).RUnlock() }

整个流程如下图

图片

关注点

  1. 对于写锁,有两个阻塞。一是Mutex自身阻塞,另一个是RWMutex的writerSem阻塞。两者的作用不一样。Mutex用于处理写锁与写锁之间的关系,writerSem用于处理写锁与读锁之间的关系

  2. 源码使用readerWait记录请求写锁时读锁协程个数,当这些协程都释放锁,写锁加锁成功,防止写锁饿死

总结

通过阅读Go锁源码,明白真正的信号量实现逻辑。虽然整体思路上,和操作系统课本讲述是一致的,但是仍然有很多细节不一样。像Go没有选择空等,进行阻塞优化性能;像读写锁的实现要比整型信号量复杂的多。

对于源码的复杂性,我觉得分两个方面看。对于Go这种源码,一点点的性能优化,对于全球那么多使用者来说,收益是巨大的,所以可读性的重要性没那么高。如果是想学习的话,还是看一下早期的、可读性高一些的源码,在理解的基础上再看最新版本的源码,不但入门难度降低,而且能够思考别人是怎么进行思考、进行优化的。

对于具体业务而言,代码一定要有较高的可读性,不然code review的同学或者今后维护的同学,太痛苦!!!

资料

  1. go中semaphore(信号量)源码解读

  2. https://github.com/golang/go/blob/c007ce824d9a4fccb148f9204e04c23ed2984b71/src/sync/mutex.go

最后

大家如果喜欢我的文章,可以关注我的公众号(程序员麻辣烫)

我的个人博客为:https://shidawuhen.github.io/

图片

往期文章回顾:

  1. 设计模式

  2. 招聘

  3. 思考

  4. 存储

  5. 算法系列

  6. 读书笔记

  7. 小工具

  8. 架构

  9. 网络

  10. Go语言

在前面的课程里,我和你多次提到过etcd数据存储在boltdb。那么boltdb是如何组织你的key-value数据的呢?当你读写一个key时,boltdb是如何工作的?今天我将通过一个写请求在boltdb中执行的简要流程,分析其背后的boltdb的磁盘文件布局,帮助你了解page、node、bucket等核心数据结构的原理与作用,搞懂boltdb基于B+ tree、各类page实现查找、更新、事务提交的原理,让你明白etcd为什么适合读多写少的场景。boltdb磁盘布局在介绍一个put写请求在boltdb中执行原理前,我先给你从整体上介绍下平时你所看到的etcd db文件的磁盘布局,让你了解下db文件的物理存储结构。boltdb文件指的是你etcd数据目录下的member/snap/db的文件, etcd的key-value、lease、meta、member、cluster、auth等所有数据存储在其中。etcd启动的时候,会通过mmap机制将db文件映射到内存,后续可从内存中快速读取文件中的数据。写请求通过fwrite和fdatasync来写入、持久化数据到磁盘。上图是我给你画的db文件磁盘布局,从图中的左边部分你可以看到,文件的内容由若干个page组成,一般情况下page size为4KB。page按照功能可分为元数据页(meta page)、B+ tree索引节点页(branch page)、B+ tree 叶子节点页(leaf page)、空闲页管理页(freelist page)、空闲页(free page)。文件最开头的两个page是固定的db元数据meta page,空闲页管理页记录了db中哪些页是空闲、可使用的。索引节点页保存了B+ tree的内部节点,如图中的右边部分所示,它们记录了key值,叶子节点页记录了B+ tree中的key-value和bucket数据。boltdb逻辑上通过B+ tree来管理branch/leaf page, 实现快速查找、写入key-value数据。boltdb API了解完boltdb的磁盘布局后,那么如果要在etcd中执行一个put请求,boltdb中是如何执行的呢? boltdb作为一个库,提供了什么API给client访问写入数据?boltdb提供了非常简单的API给上层业务使用,当我们执行一个put hello为world命令时,boltdb实际写入的key是版本号,value为mvccpb.KeyValue结构体。这里我们简化下,假设往key bucket写入一个key为r94,value为world的字符串,其核心代码如下:// 打开boltdb文件,获取db对象db,err := bolt.Open("db", 0600, nil)if err != nil { log.Fatal(err)}defer db.Close()// 参数true表示创建一个写事务,false读事务tx,err := db.Begin(true)if err != nil { return err}defer tx.Rollback()// 使用事务对象创建key bucketb,err := tx.CreatebucketIfNotExists([]byte("key"))if err != nil { return err}// 使用bucket对象更新一个keyif err := b.Put([]byte("r94"),[]byte("world")); err != nil { return err}// 提交事务if err := tx.Commit(); err != nil { return err}12345678910111213141516171819202122232425如上所示,通过boltdb的Open API,我们获取到boltdb的核心对象db实例后,然后通过db的Begin API开启写事务,获得写事务对象tx。通过写事务对象tx, 你可以创建bucket。这里我们创建了一个名为key的bucket(如果不存在),并使用bucket API往其中更新了一个key为r94,value为world的数据。最后我们使用写事务的Commit接口提交整个事务,完成bucket创建和key-value数据写入。看起来是不是非常简单,神秘的boltdb,并未有我们想象的那么难。然而其API简单的背后却是boltdb的一系列巧妙的设计和实现。一个key-value数据如何知道该存储在db在哪个page?如何快速找到你的key-value数据?事务提交的原理又是怎样的呢?接下来我就和你浅析boltdb背后的奥秘。核心数据结构介绍上面我们介绍boltdb的磁盘布局时提到,boltdb整个文件由一个个page组成。最开头的两个page描述db元数据信息,而它正是在client调用boltdb Open API时被填充的。那么描述磁盘页面的page数据结构是怎样的呢?元数据页又含有哪些核心数据结构?boltdb本身自带了一个工具bbolt,它可以按页打印出db文件的十六进制的内容,下面我们就使用此工具来揭开db文件的神秘面纱。下图左边的十六进制是执行如下bbolt dump命令,所打印的boltdb第0页的数据,图的右边是对应的page磁盘页结构和meta page的数据结构。$ ./bbolt dump ./infra1.etcd/member/snap/db 01一看上图中的十六进制数据,你可能很懵,没关系,在你了解page磁盘页结构、meta page数据结构后,你就能读懂其含义了。page磁盘页结构我们先了解下page磁盘页结构,如上图所示,它由页ID(id)、页类型(flags)、数量(count)、溢出页数量(overflow)、页面数据起始位置(ptr)字段组成。页类型目前有如下四种:0x01表示branch page,0x02表示leaf page,0x04表示meta page,0x10表示freelist page。数量字段仅在页类型为leaf和branch时生效,溢出页数量是指当前页面数据存放不下,需要向后再申请overflow个连续页面使用,页面数据起始位置指向page的载体数据,比如meta page、branch/leaf等page的内容。meta page数据结构第0、1页我们知道它是固定存储db元数据的页(meta page),那么meta page它为了管理整个boltdb含有哪些信息呢?如上图中的meta page数据结构所示,你可以看到它由boltdb的文件标识(magic)、版本号(version)、页大小(pagesize)、boltdb的根bucket信息(root bucket)、freelist页面ID(freelist)、总的页面数量(pgid)、上一次写事务ID(txid)、校验码(checksum)组成。meta page十六进制分析了解完page磁盘页结构和meta page数据结构后,我再结合图左边的十六进数据和你简要分析下其含义。上图中十六进制输出的是db文件的page 0页结构,左边第一列表示此行十六进制内容对应的文件起始地址,每行16个字节。结合page磁盘页和meta page数据结构我们可知,第一行前8个字节描述pgid(忽略第一列)是0。接下来2个字节描述的页类型, 其值为0x04表示meta page, 说明此页的数据存储的是meta page内容,因此ptr开始的数据存储的是meta page内容。正如你下图中所看到的,第二行首先含有一个4字节的magic number(0xED0CDAED),通过它来识别当前文件是否boltdb,接下来是两个字节描述boltdb的版本号0x2, 然后是四个字节的page size大小,0x1000表示4096个字节,四个字节的flags为0。第三行对应的就是meta page的root bucket结构(16个字节),它描述了boltdb的root bucket信息,比如一个db中有哪些bucket, bucket里面的数据存储在哪里。第四行中前面的8个字节,0x3表示freelist页面ID,此页面记录了db当前哪些页面是空闲的。后面8个字节,0x6表示当前db总的页面数。第五行前面的8个字节,0x1a表示上一次的写事务ID,后面的8个字节表示校验码,用于检测文件是否损坏。了解完db元数据页面原理后,那么boltdb是如何根据元数据页面信息快速找到你的bucket和key-value数据呢?这就涉及到了元数据页面中的root bucket,它是个至关重要的数据结构。下面我们看看它是如何管理一系列bucket、帮助我们查找、写入key-value数据到boltdb中。bucket数据结构如下命令所示,你可以使用bbolt buckets命令,输出一个db文件的bucket列表。执行完此命令后,我们可以看到之前介绍过的auth/lease/meta等熟悉的bucket,它们都是etcd默认创建的。那么boltdb是如何存储、管理bucket的呢?$ ./bbolt buckets ./infra1.etcd/member/snap/dbalarmauthauthRolesauthUsersclusterkeyleasemembersmembers_removedmeta123456789101112在上面我们提到过meta page中的,有一个名为root、类型bucket的重要数据结构,如下所示,bucket由root和sequence两个字段组成,root表示该bucket根节点的page id。注意meta page中的bucket.root字段,存储的是db的root bucket页面信息,你所看到的key/lease/auth等bucket都是root bucket的子bucket。type bucket struct { root pgid // page id of the bucket's root-level page sequence uint64 // monotonically incrementing, used by NextSequence()}1234上面meta page十六进制图中,第三行的16个字节就是描述的root bucket信息。root bucket指向的page id为4,page id为4的页面是什么类型呢? 我们可以通过如下bbolt pages命令看看各个page类型和元素数量,从下图结果可知,4号页面为leaf page。$ ./bbolt pages ./infra1.etcd/member/snap/dbID TYPE ITEMS OVRFLW======== ========== ====== ======0 meta 01 meta 02 free3 freelist 24 leaf 105 free123456789通过上面的分析可知,当bucket比较少时,我们子bucket数据可直接从meta page里指向的leaf page中找到。leaf pagemeta page的root bucket直接指向的是page id为4的leaf page, page flag为0x02, leaf page它的磁盘布局如下图所示,前半部分是leafPageElement数组,后半部分是key-value数组。leafPageElement包含leaf page的类型flags, 通过它可以区分存储的是bucket名称还是key-value数据。当flag为bucketLeafFlag(0x01)时,表示存储的是bucket数据,否则存储的是key-value数据,leafPageElement它还含有key-value的读取偏移量,key-value大小,根据偏移量和key-value大小,我们就可以方便地从leaf page中解析出所有key-value对。当存储的是bucket数据的时候,key是bucket名称,value则是bucket结构信息。bucket结构信息含有root page信息,通过root page(基于B+ tree查找算法),你可以快速找到你存储在这个bucket下面的key-value数据所在页面。从上面分析你可以看到,每个子bucket至少需要一个page来存储其下面的key-value数据,如果子bucket数据量很少,就会造成磁盘空间的浪费。实际上boltdb实现了inline bucket,在满足一些条件限制的情况下,可以将小的子bucket内嵌在它的父亲叶子节点上,友好的支持了大量小bucket。为了方便大家快速理解核心原理,本节我们讨论的bucket是假设都是非inline bucket。那么boltdb是如何管理大量bucket、key-value的呢?branch pageboltdb使用了B+ tree来高效管理所有子bucket和key-value数据,因此它可以支持大量的bucket和key-value,只不过B+ tree的根节点不再直接指向leaf page,而是branch page索引节点页。branch page flags为0x01。它的磁盘布局如下图所示,前半部分是branchPageElement数组,后半部分是key数组。branchPageElement包含key的读取偏移量、key大小、子节点的page id。根据偏移量和key大小,我们就可以方便地从branch page中解析出所有key,然后二分搜索匹配key,获取其子节点page id,递归搜索,直至从bucketLeafFlag类型的leaf page中找到目的bucket name。注意,boltdb在内存中使用了一个名为node的数据结构,来保存page反序列化的结果。下面我给出了一个boltdb读取page到node的代码片段,你可以直观感受下。func (n *node) read(p *page) { n.pgid = p.id n.isLeaf = ((p.flags & leafPageFlag) != 0) n.inodes = make(inodes, int(p.count)) for i := 0; i < int(p.count); i++ { inode := &n.inodes[i] if n.isLeaf { elem := p.leafPageElement(uint16(i)) inode.flags = elem.flags inode.key = elem.key() inode.value = elem.value() } else { elem := p.branchPageElement(uint16(i)) inode.pgid = elem.pgid inode.key = elem.key() } }12345678910111213141516171819从上面分析过程中你会发现,boltdb存储bucket和key-value原理是类似的,将page划分成branch page、leaf page,通过B+ tree来管理实现。boltdb为了区分leaf page存储的数据类型是bucket还是key-value,增加了标识字段(leafPageElement.flags),因此key-value的数据存储过程我就不再重复分析了。freelist介绍完bucket、key-value存储原理后,我们再看meta page中的另外一个核心字段freelist,它的作用是什么呢?我们知道boltdb将db划分成若干个page,那么它是如何知道哪些page在使用中,哪些page未使用呢?答案是boltdb通过meta page中的freelist来管理页面的分配,freelist page中记录了哪些页是空闲的。当你在boltdb中删除大量数据的时候,其对应的page就会被释放,页ID存储到freelist所指向的空闲页中。当你写入数据的时候,就可直接从空闲页中申请页面使用。下面meta page十六进制图中,第四行的前8个字节就是描述的freelist信息,page id为3。我们可以通过bbolt page命令查看3号page内容,如下所示,它记录了2和5为空闲页,与我们上面通过bbolt pages命令所看到的信息一致。$ ./bbolt page ./infra1.etcd/member/snap/db 3page ID: 3page Type: freelistTotal Size: 4096 bytesItem Count: 2Overflow: 025123456789下图是freelist page存储结构,pageflags为0x10,表示freelist类型的页,ptr指向空闲页id数组。注意在boltdb中支持通过多种数据结构(数组和hashmap)来管理free page,这里我介绍的是数组。Open原理了解完核心数据结构后,我们就很容易搞懂boltdb Open API的原理了。首先它会打开db文件并对其增加文件,目的是防止其他进程也以读写模式打开它后,操作meta和free page,导致db文件损坏。其次boltdb通过mmap机制将db文件映射到内存中,并读取两个meta page到db对象实例中,然后校验meta page的magic、version、checksum是否有效,若两个meta page都无效,那么db文件就出现了严重损坏,导致异常退出。Put原理那么成功获取db对象实例后,通过bucket API创建一个bucket、发起一个Put请求更新数据时,boltdb是如何工作的呢?根据我们上面介绍的bucket的核心原理,它首先是根据meta page中记录root bucket的root page,按照B+ tree的查找算法,从root page递归搜索到对应的叶子节点page面,返回key名称、leaf类型。如果leaf类型为bucketLeafFlag,且key相等,那么说明已经创建过,不允许bucket重复创建,结束请求。否则往B+ tree中添加一个flag为bucketLeafFlag的key,key名称为bucket name,value为bucket的结构。创建完bucket后,你就可以通过bucket的Put API发起一个Put请求更新数据。它的核心原理跟bucket类似,根据子bucket的root page,从root page递归搜索此key到leaf page,如果没有找到,则在返回的位置处插入新key和value。为了方便你理解B+ tree查找、插入一个key原理,我给你构造了的一个max degree为5的B+ tree,下图是key r94的查找流程图。那么如何确定这个key的插入位置呢?首先从boltdb的key bucket的root page里,二分查找大于等于r94的key所在page,最终找到key r9指向的page(流程1)。r9指向的page是个leaf page,B+ tree需要确保叶子节点key的有序性,因此同样二分查找其插入位置,将key r94插入到相关位置(流程二)。在核心数据结构介绍中,我和你提到boltdb在内存中通过node数据结构来存储page磁盘页内容,它记录了key-value数据、page id、parent及children的node、B+ tree是否需要进行重平衡和分裂操作等信息。因此,当我们执行完一个put请求时,它只是将值更新到boltdb的内存node数据结构里,并未持久化到磁盘中。事务提交原理那么boltdb何时将数据持久化到db文件中呢?当你的代码执行tx.Commit API时,它才会将我们上面保存到node内存数据结构中的数据,持久化到boltdb中。下图我给出了一个事务提交的流程图,接下来我就分别和你简要分析下各个核心步骤。首先从上面put案例中我们可以看到,插入了一个新的元素在B+ tree的叶子节点,它可能已不满足B+ tree的特性,因此事务提交时,第一步首先要调整B+ tree,进行重平衡、分裂操作,使其满足B+ tree树的特性。上面案例里插入一个key r94后,经过重平衡、分裂操作后的B+ tree如下图所示。在重平衡、分裂过程中可能会申请、释放free page,freelist所管理的free page也发生了变化。因此事务提交的第二步,就是持久化freelist。注意,在etcd v3.4.9中,为了优化写性能等,freelist持久化功能是关闭的。etcd启动获取boltdb db对象的时候,boltdb会遍历所有page,构建空闲页列表。事务提交的第三步就是将client更新操作产生的dirty page通过fdatasync系统调用,持久化存储到磁盘中。最后,在执行写事务过程中,meta page的txid、freelist等字段会发生变化,因此事务的最后一步就是持久化meta page。通过以上四大步骤,我们就完成了事务提交的工作,成功将数据持久化到了磁盘文件中,安全地完成了一个put操作。小结最后我们来小结下今天的内容。首先我通过一幅boltdb磁盘布局图和bbolt工具,为你解密了db文件的本质。db文件由meta page、freelist page、branch page、leaf page、free page组成。随后我结合bbolt工具,和你深入介绍了meta page、branch page、leaf page、freelist page的数据结构,帮助你了解key、value数据是如何存储到文件中的。然后我通过分析一个put请求在boltdb中如何执行的。我从Open API获取db对象说起,介绍了其通过mmap将db文件映射到内存,构建meta page,校验meta page的有效性,再到创建bucket,通过bucket API往boltdb添加key-value数据。添加bucket和key-value操作本质,是从B+ tree管理的page中找到插入的页和位置,并将数据更新到page的内存node数据结构中。真正持久化数据到磁盘是通过事务提交执行的。它首先需要通过一系列重平衡、分裂操作,确保boltdb维护的B+ tree满足相关特性,其次需要持久化freelist page,并将用户更新操作产生的dirty page数据持久化到磁盘中,最后则是持久化meta page。
最新发布
04-06
### 数据存储结构 BoltDB 是一种基于 Go 编写的键值数据库,其底层采用 B+ 树作为核心数据结构[^2]。整个数据库的内容被持久化到单一文件中,并通过内存映射(memory-mapping)技术加载到内存中以加速读取操作。 #### 文件布局 BoltDB 的文件分为多个页(Page),每一页大小固定,默认为 4KB 或者自定义配置的其他值。这些页构成了 BoltDB 的基本单元,用于存储元数据、索引和实际的数据内容。页面之间通过指针相互连接形成复杂的逻辑结构。 - **元数据页**:保存关于数据库的整体信息,例如根 Bucket 所在位置等。 - **叶子节点页**:存放具体的 Key 和 Value 对应关系。 - **分支节点页**:构建起多层次的导航体系以便快速检索目标 key。 ### B+树的应用 正如所提到,在 Boltdb 中 cursor 封装了 bucket 上的操作接口,而这个过程实际上依赖于内部维护的一个高效查询机制——即 B+ 树[^3]。每当执行 CRUD (Create, Read, Update, Delete) 操作时,都会涉及到对该棵树相应部分修改或访问: - 当新增一条记录时,如果当前叶节点已满,则触发分裂动作创建新的子节点并调整父级索引项; - 删除某个特定条目可能导致某些节点变得稀疏甚至空洞,此时需考虑合并临近兄弟节点或者重新分配空间; - 更新现有 entry 则相对简单些,只需找到对应的位置替换旧值即可。 由于采用了 B+ 树这种高度优化过的算法模型,使得即使面对海量规模的数据集也能保持较为稳定的性能表现。 ### 事务提交原理 尽管不像 MySQL 那样具备完整的 ACID 特性支持,但 BoltDB 同样实现了轻量版的事物管理功能来保障一致性与可靠性[^1]。以下是有关如何处理事物的关键要点: - **写入前准备**: 开始一个新的 write transaction 前先定全局互斥(lock),防止并发冲突发生. - **日志记录**: 跟踪所有的变更活动直至最终确认完成之前都暂存起来;一旦出现问题可随时回滚至初始状态. - **两阶段提交协议(Two-phase Commit Protocol)**: 类似传统分布式系统中的做法分成 prepare 和 commit 两个步骤来进行安全验证后再正式生效更改成果. - Prepare Phase: 确认所有必要的资源已经就绪并且能够满足即将发生的变动需求; - Commit Phase: 如果前面一切顺利则真正实施更新并将结果同步反映到磁盘上去. 最后值得注意的是,BoltDb 并未引入 Undo Log/Redo Log 这样的复杂概念而是依靠简单的 Copy-On-Write 技巧达成类似效果的同时简化整体架构设计思路. ```go // 示例代码展示开启事务的方式 func main() { db, err := bolt.Open("my.db", 0600, nil) if err != nil { log.Fatal(err) } defer db.Close() err = db.Update(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("MyBucket")) if b == nil { _, err := tx.CreateBucketIfNotExists([]byte("MyBucket")) if err != nil { return fmt.Errorf("create bucket: %s", err) } } err := b.Put([]byte("answer"), []byte("42")) if err != nil { return err } return nil }) } ```
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

程序员麻辣烫

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值