深度部析 boltdb 实现: 2 事务与一致性
boltdb
是号称支持事务的,并且支持 mvcc,那就看一下细节实现吧。总体来讲,事务只是一种标准,具体实现和传统的 oltp db 还真不一样
如何开启事务
// Start a writable transaction.
tx, err := db.Begin(true)
if err != nil {
return err
}
defer tx.Rollback()
// Use the transaction...
_, err := tx.CreateBucket([]byte("MyBucket"))
if err != nil {
return err
}
// Commit the transaction and check for error.
if err := tx.Commit(); err != nil {
return err
}
可以参考官网,第一步就是 Begin
, 参数 true 或 false 来决定是否是写事务,拿到 tx
后开始操作,最后一定是 commit
,如果出错就要回滚。这块和 mysql
比较像。
db.Update(func(tx *bolt.Tx) error {
b, err := tx.CreateBucket([]byte("MyBucket"))
if err != nil {
return fmt.Errorf("create bucket: %s", err)
}
return nil
})
还有一种写法,就是用闭包的形式,db.Update
用来操作写事务,db.View
用来操作只读事务。
ACID 与 MVCC
数据库,谈起事务首先想到的就是 ACID
,其中 AD
是必须满足的,CI
看业务场景来妥协,弱一致强一致等等。除此之外,大部份主流数据库都支持 MVCC
, 我们分别看下如何保证的。
Atomicity
写写是串行的,每个 tx 要么 commit 要么 rollback,不可能存在中间状态。这里面用到了 cow
技术,开启事务时 meta 信息复制一份,写操作是写到新的 page, 然后提交时更改 metadata, 如果 rollback 释放该页。
// init initializes the transaction.
func (tx *Tx) init(db *DB) {
tx.db = db
tx.pages = nil
// Copy the meta page since it can be changed by the writer.
tx.meta = &meta{}
db.meta().copy(tx.meta) // 复制一份 meta
// Copy over the root bucket.
tx.root = newBucket(tx)
tx.root.bucket = &bucket{}
*tx.root.bucket = tx.meta.root
// Increment the transaction id and add a page cache for writable transactions.
if tx.writable {
tx.pages = make(map[pgid]*page)
tx.meta.txid += txid(1)
}
}
meta
在 tx 初始化时会拷贝一份,由于 meta 页里存储 b+tree
根节点,所以相当于做了一份快照。
// writeMeta writes the meta to the disk.
func (tx *Tx) writeMeta() error {
// Create a temporary buffer for the meta page.
buf := make([]byte, tx.db.pageSize)
p := tx.db.pageInBuffer(buf, 0)
tx.meta.write(p)
// Write the meta page to file.
if _, err := tx.db.ops.writeAt(buf, int64(p.id)*int64(tx.db.pageSize)); err != nil {
return err
}
if !tx.db.NoSync || IgnoreNoSync {
if err := fdatasync(tx.db); err != nil {
return err
}
}
// Update statistics.
tx.stats.Write++
return nil
}
提交时会调用 writeMeta
写脏页,写数据时 b+tree
会分裂,root 节点可能会变,所以需要 writeMeta
写回。
Consistency
传统数据库,比如 MySQL
通过 redo
, undo
, binlog
来保证事务一致性,当系统崩溃重启后,未提交的事务如果己经写 binlog 了,那么直接提交,如果未写 binlog,那么通过 undo 来回滚。boltdb
这块理解也比较简单,写写是串行的,每次都会写新 page, 提交后都刷盘。
Isolation
func (db *DB) Begin(writable bool) (*Tx, error) {
if writable {
return db.beginRWTx()
}
return db.beginTx()
}
首先看,开启事务,如果是写事务调用 db.beginRWTx
, 读事务调用 db.beginTx
, 深入实现细节,会发现,写事务多了一把锁 db.rwlock.Lock()
,也就是说写写是一把全局锁,写读,读读可以并发。可以认为 隔离性(I),满足 serialization
,所以写性能肯定很差。但是读属于快照读,下面 mvcc
再说。
Durability
boltdb
持久化比较简单粗暴,commit
时直接调用操作系统 write
写脏页,然后调用 fdatasync
刷盘,这些很可能都是随机写。对比下其它数据库 leveldb
, 顺序写日志,然后更新内存 skiplist
后就完成。写到这其实都不想再看了,实现的这么挫哪个服务敢用啊~
MVCC
传统数据库大多用 mvcc
, 但是实现差别蛮大的。
-
oracle
和mysql
使用 undo, redo 来做多版本,表里只存一份记录,相反postgresql
表里存各个版本的数据,并没有 redo,所以表比较容易膨胀,需要定期vaccum
.boltdb
比较像postgresql
,数据写到新的页中,回滚会回收脏页,提交会释放无用的页。这里的回收与释放都是添加到freelist
里,而不是还给操作系统。 - 传统数据库有
当前读
和快照读
两种,但boltdb
可以认为,永远是快照读,如果有写事务 commit,先前开启的读事务永远读不到最新的数据。
小结
有点偏理论了,先写这些,具体在各个流程再贴代码