[转]Berkeley DB实现分析

来源:互联网 发布:ubuntu安装中文字体 编辑:程序博客网 时间:2024/05/23 01:12



计划实现一个开源的KV数据库——Simple DB

实现一个开源KV数据库的想法来源于对目前项目中所使用的K-V数据库使用情况的不满意。

先介绍一下我们的目前项目,作为本文的背景:

较为底层的分布式运行平台,使用C/C++实现的Actor模型(异步消息传递系统)

数据schema简单灵活,使用key-value能够很好表示。

数据库有大量的读写请求,有事务需求,数据丢失容忍度很低。

当前,从众多的KV和NOSQL存储产品中,我们使用了Berkeley DB作为底层的存储引擎。

 

为什么选择BDB呢?

1.与传统的RDBMS相比,简单K-V存储的Berkeley DB(再加入“嵌入式”直接库链接的特性)有着优越的性能,容易满足我们大量读写(尤其是大量写)的需求。

2.作为一个嵌入式的K-V数据库,Berkeley DB历史悠久(目前由Oracle所有),稳定且久经考验,文档丰富,辅助工具全面。这是我们之所以在众多的开源K-V(Nosql)数据库中选择BDB的首要原因:靠谱

3.第三个选择BDB的原因是事务支持:作为为数不多的能够提供ACID事务支持的K-V数据库,BDB对事务支持提供了丰富的支持:从不同的隔离级别、不同的持久化保证以及分布式事务2PC的prepare模型等,可配置程度很高。

 

BDB哪些方面不能达到我们的需求?

1.仍然是性能。作为K-V数据库,BDB的性能依然达不到我们的目标:由于有足够大的内存作为缓存,读操作的性能基本没问题,但写操作(尤其是大量应用的事务写)的性能堪忧。

使用Auto-commit(每条写作为一个独立的事务),一条记录的写延时接近于1~10ms。

显示开启事务后,写操作有了极大改善:10~100us(因为不需要即时sync到硬盘),但事务提交操作依然极为耗时。

有人可能会说,你可以调节BDB事务的持久化保证的程度,比如在提交时设置:

DB_TXN_WRITE_NO_SYNC,在提交时只写log到硬盘,不sync(只有OS Crash才会丢数据)

或者更快一些,使用DB_TXN_NOSYNC,连同步调syscall write的开销都省下来(但App crash可能会丢数据)

我们当前的项目是一个底层的运行时平台,对数据丢失的敏感程度是很高的,不像一些常见的互联网应用。我们既需要事务的Durability支持,也需要高的写性能。

2.我们的数据模型相对简单:Key-Value存储,需要事务的Atomicity支持批量修改,需要事务的Durability保证,可能需要Prepare语义支持分布式事务。

不需要对数据库访问的并发支持。当前核心系统是一个基于线程池的异步系统,数据库的写操作和提交操作应该由独立线程来完成,不应阻塞有限的池中线程。因此我们可以不需要并发支持而使用单独线程操作数据库。

至于事务的Consistency和Isolation以及各种复杂的锁协议(尤其是BDB中恼人的page级别的锁粒度),在不需要并发访问的数据库中也不需要。

这样的需求,或许通过详细的配置和tuning也能在BDB上实现,但BDB毕竟太大太复杂(一方面BDB为了通用的需求,实现复杂,难以了解内部机理;一方面是配置复杂),以至于步履蹒跚。

与其继续钻研BDB的各种配置来适配当前特殊的需求,不如轻装上阵,自己实现一个项目特定的底端存储,由于目标和需求少儿明确,实现起来要简单很多,预期能够获得比BDB更高的性能。

 

这个数据库暂时取名为Simple DB,其主要需求和大致实现如下:

1.Key-Value存储,使用Hash实现(可能需要使用linear hash),不采用实现较为复杂的B+树

2.嵌入式,通过链接方式直接使用,不适用其他IPC。只考虑Linux 2.6平台,暂不考虑其他平台。

3.主要支持get/set/traverse操作

(如果支持delete操作,也不会释放或重新利用文件空间:重利用释放的空间向来是一个复杂的问题,既要保证效率,又要避免碎片。最多提供一个database compress工具进行文件的压缩处理。)

4.每个数据库包括:数据文件,索引文件和日志文件,不支持并发访问。

5.支持事务,但只提供Atomicity和Durability语义。(使用write-ahead logging)

支持跨同一目录下多个数据库的事务,可能会提供事务的prepare语义。

6.暂不考虑replication支持,不支持shading(由应用层自行完成)

 

希望数据库如其名,简单高效。

Keep it simple, stupid!



Berkeley DB实现分析(1) ——多进程下数据库环境的恢复:DB_REGISTER

一个进程在打开Berkeley DB环境时(DbEnv::open),通常需要恢复环境(DB_RECOVER)以确保数据的完整性。

但DB_RECOVER的语义是强制恢复,即任何情况下都会删除旧环境并创建新环境,以确保环境的正确性。

 

1.每次程序启动时都进行recovery是没有必要的:

1. 环境很有可能是正常的,每个访问进程退出时都正确地使用了DbEnv::close()。

2. 很多时候,Environment的重建是一个比较耗时的行为,增加了恢复服务的等待时间,影响了系统的可用性

恢复环境时,会在BDB环境的描述区域(dbinc/region.h中的REGENV结构,该结构映射到__db.001文件的最开始N个字节)设置panic标记,并删除所有的区域文件(__db.001 ~ __db.006),而BDB库中的几乎所有操作都会检测panic标记(返回错误或抛出DbRunRecoveryException异常)。

3. 因此,当前仍然在正常环境下工作的进程将会由于一个指定了DB_RECOVER进程的加入而被迫退出。

 

2.怎样设置某一个环境为“需要时才进行recovery”?

所有打开同一个环境的进程,如果指定了DB_RECOVER,需要同时指定DB_REGISTER

DB_REGISTER保证只在检测到data corruption时进行数据库环境的恢复。

对于成百上千兆的环境文件,显然不可能对内容进行逐字节验证。那么BDB怎样检测到环境文件的data corruption呢?

 

3.Process Registry实现原理:

既然没法逐字节验证环境文件内容,不妨换个思路:

如果可以确保之前所有的进程都是正常退出(正确调用DbEnv::close),则可以确保环境文件的内容是一致的。

 

于是register文件粉墨登场,每个进程打开/关闭环境时都会在此留下自己的记录:

进程打开环境时:在register文件的某一行记录自己的process id,并使用文件锁(fcntl)锁住该行的第一个byte

正常退出环境时:将其记录“擦除”,并解锁。

register文件所有的行都是等宽的,每一行不是一个process ID slot,就是一个空记录(empty slot),格式如下:

__db.register :

|<--- 25 bytes --->|

                    12345 # prcess ID slot 1

X                            # empty slot

                     12346 # process ID slot 2

                     12347 # process ID slot 3

X                             # empty slot

X

这样每个进程打开环境时,都会遍历register文件所有的行:

1. 该行符合empty slot的格式(X后面跟24个空格),则跳过

2. 该行是一个process ID slot,且该pocess ID不等于该进程本身的pid,则检查是否可以锁住该行的第一个byte:

lock失败:说明该行对应的process依然在环境中,正常并跳过该行

lock成功:说明该行对应的process已经crash(而没有来得及更新register文件),但进程退出时由OS回收了所拥有的文件锁,需要进行recover

3. 该行不够宽度(只可能发生在最后一行):说明进程在更新register文件时被interrupt,需要进行recover。

 

如果不需要进行recovery,则再次遍历register文件所有行:

1. 找到一个empty slot并且可以lock该行的第一个byte,写入自己的process id

2. 直到读到register文件末尾。

等宽行的优势在此体现:结合文件锁,很好地保证了多个进程不会同时写一个文件的同一个位置。

 

4.其他实现方案:

使用flock也可以在某个文件上加上建议锁。这个方法要求每个进程都需要创建并锁住自己的register文件,退出环境时解锁并删除该register文件。这样进程打开环境时只需遍历所有其他进程的register文件,并确保是否已上锁即可。

但这种方式可能会产生大量的文件(进程数量*打开的DbEnv数量),按作者的话说:

"but flock would require a separate file for each process of control (and probably each DbEnv handle) in the database environment, which is fairly ugly."

 

5.多进程下恢复环境依然有风险:

当一个进程检查register文件并决定要恢复数据库环境时,会将整个环境设置panic标志,此时所有正在该环境上操作的进程都会检测到错误并退出。

但是仍然会有一个corruption的时间窗口:当一个进程进行了panic检测并将要进行写操作时,另一个进程决定恢复环境。(这个时间窗口是BDB本身就存在的,与是否进行register检测无关。)

按作者的话说,"That's very, very unlikely to happen.",并且有一个可能的解决办法,在一个进程决定要recover时,向环境内的已有进程发送SIGKILL以强制其退出,缺点在于该进程不一定有这样的权限,而且不能准确地判断该进程是否已死。所以该方法默认是不采用的。

/* in file env/env_register.c */#define DB_ENVREG_KILL_ALL 0

 




小议同步IO :fsync与fdatasync

对于提供事务支持的数据库,在事务提交时,都要确保事务日志(包含该事务所有的修改操作以及一个提交记录)完全写到硬盘上,才认定事务提交成功并返回给应用层。

一个简单的问题:在*nix操作系统上,怎样保证对文件的更新内容成功持久化到硬盘?

 

1.  write不够,需要fsync

一般情况下,对硬盘(或者其他持久存储设备)文件的write操作,更新的只是内存中的页缓存(page cache),而脏页面不会立即更新到硬盘中,而是由操作系统统一调度,如由专门的flusher内核线程在满足一定条件时(如一定时间间隔、内存中的脏页达到一定比例)内将脏页面同步到硬盘上(放入设备的IO请求队列)。
因为write调用不会等到硬盘IO完成之后才返回,因此如果OS在write调用之后、硬盘同步之前崩溃,则数据可能丢失。虽然这样的时间窗口很小,但是对于需要保证事务的持久化(durability)和一致性(consistency)的数据库程序来说,write()所提供的“松散的异步语义”是不够的,通常需要OS提供的同步IO(synchronized-IO)原语来保证:
1 #include <unistd.h>2 int fsync(int fd);
fsync的功能是确保文件fd所有已修改的内容已经正确同步到硬盘上,该调用会阻塞等待直到设备报告IO完成。
 
 
PS:如果采用内存映射文件的方式进行文件IO(使用mmap,将文件的page cache直接映射到进程的地址空间,通过写内存的方式修改文件),也有类似的系统调用来确保修改的内容完全同步到硬盘之上:
1 #incude <sys/mman.h>
2 int msync(void *addr, size_t length, int flags)

msync需要指定同步的地址区间,如此细粒度的控制似乎比fsync更加高效(因为应用程序通常知道自己的脏页位置),但实际上(Linux)kernel中有着十分高效的数据结构,能够很快地找出文件的脏页,使得fsync只会同步文件的修改内容。

 

2. fsync的性能问题,与fdatasync

除了同步文件的修改内容(脏页),fsync还会同步文件的描述信息(metadata,包括size、访问时间st_atime & st_mtime等等),因为文件的数据和metadata通常存在硬盘的不同地方,因此fsync至少需要两次IO写操作,fsync的man page这样说:

"Unfortunately fsync() will always initialize two write operations : one for the newly written data and another one in order to update the modification time stored in the inode. If the modification time is not a part of the transaction concept fdatasync() can be used to avoid unnecessary inode disk write operations."

多余的一次IO操作,有多么昂贵呢?根据Wikipedia的数据,当前硬盘驱动的平均寻道时间(Average seek time)大约是3~15ms,7200RPM硬盘的平均旋转延迟(Average rotational latency)大约为4ms,因此一次IO操作的耗时大约为10ms左右。这个数字意味着什么?下文还会提到。

 

Posix同样定义了fdatasync,放宽了同步的语义以提高性能:

1 #include <unistd.h>2 int fdatasync(int fd);
fdatasync的功能与fsync类似,但是仅仅在必要的情况下才会同步metadata,因此可以减少一次IO写操作。那么,什么是“必要的情况”呢?根据man page中的解释:
"fdatasync does not flush modified metadata unless that metadata is needed in order to allow a subsequent data retrieval to be corretly handled."
举例来说,文件的尺寸(st_size)如果变化,是需要立即同步的,否则OS一旦崩溃,即使文件的数据部分已同步,由于metadata没有同步,依然读不到修改的内容。而最后访问时间(atime)/修改时间(mtime)是不需要每次都同步的,只要应用程序对这两个时间戳没有苛刻的要求,基本无伤大雅。
 
 
PS:open时的参数O_SYNC/O_DSYNC有着和fsync/fdatasync类似的语义:使每次write都会阻塞等待硬盘IO完成。(实际上,Linux对O_SYNC/O_DSYNC做了相同处理,没有满足Posix的要求,而是都实现了fdatasync的语义)相对于fsync/fdatasync,这样的设置不够灵活,应该很少使用。
 
 

3. 使用fdatasync优化日志同步

文章开头时已提到,为了满足事务要求,数据库的日志文件是常常需要同步IO的。由于需要同步等待硬盘IO完成,所以事务的提交操作常常十分耗时,成为性能的瓶颈。
在Berkeley DB下,如果开启了AUTO_COMMIT(所有独立的写操作自动具有事务语义)并使用默认的同步级别(日志完全同步到硬盘才返回),写一条记录的耗时大约为5~10ms级别,基本和一次IO操作(10ms)的耗时相同。
 我们已经知道,在同步上fsync是低效的。但是如果需要使用fdatasync减少对metadata的更新,则需要确保文件的尺寸在write前后没有发生变化。日志文件天生是追加型(append-only)的,总是在不断增大,似乎很难利用好fdatasync。
 
且看Berkeley DB是怎样处理日志文件的:
1.每个log文件固定为10MB大小,从1开始编号,名称格式为“log.%010d"
2.每次log文件创建时,先写文件的最后1个page,将log文件扩展为10MB大小
3.向log文件中追加记录时,由于文件的尺寸不发生变化,使用fdatasync可以大大优化写log的效率
4.如果一个log文件写满了,则新建一个log文件,也只有一次同步metadata的开销

 

 

参考资料:

1. linux man pages for fsync/msync/open
2. 《Unix环境高级编程》
3. Berkeley DB Source Code


深入理解Berkeley DB的锁:理论与实践篇

本文仅仅从应用的角度来谈一谈Berkeley DB中锁相关的理论与实践经验,接下来还会有一篇博客来介绍BDB锁的内部实现。

锁粒度

除了Queue Access Method,其他所有的Access Pattern都是页级锁(page-level locking),而Page大小默认为操作系统filesystem的block size(Linux下默认为4K)。

(可以通过减少Page大小,使一个Page上容纳更少的记录来减少页级锁粒度,但是减小Page会影响数据库的IO效率,在缺乏足够性能数据支撑的情况下,很少会这样做。)

  BDB的页级别的锁粒度一向是比较恼人的问题,由于Queue并不常用(key为逻辑记录号,value为定长),而一般使用BDB的都需要较为松散自由的key-value存取,来满足灵活(Schema-Free)的数据,注定了使用BDB大部分情况下都要和页级锁打交道。

  页级锁的存在大大增加了锁冲突的可能,减少了高并发情况下的吞吐量。对于读-写冲突,可以根据业务逻辑的需要选择“脏读"(uncommited read)或者使用快照事务(snapshot)来避免,但是对于写-写冲突,锁争用无法避免,应用程序需要随时做好应付死锁的准备。关于这两点,下文会详细说明。

 

锁协议与隔离级别

  默认情况下,BDB的事务采用的是严格的二阶段锁协议(strong strict 2-phase locking, SS2PL),即随着事务的进行不断获取锁(读锁/写锁),直到事务结束(commit/abort)时才会释放所有的锁。

  实际上,SS2PL的约束过于强烈,如果在某些情况下不需要如此之高的隔离程度,可以配置BDB不同的隔离级别(Isolation level)以放宽SS2PL的限制,减少锁争用以提高整个系统的吞吐。

Berkeley DB有4种隔离级别以供选择:

(注意:所有的隔离级别都不允许”脏写“,即一个事务更改另一个事务未经提交的数据,这是事务隔离的最基本保证)

1. Read-Uncommitted :允许”脏读“(一个事务可以读取另一个事务修改中但未提交的数据)。这是能够配置的最低的隔离级别,读写冲突最小。

2. Read-Committed :不允许”脏读“,基本上和默认级别一样,除了事务游标(Cursor)在移动时会释放之前的持有的锁。

3. Serializable:(默认级别)可序列化,遵守SS2PL。相对于Read-Committed级别,游标的锁在其关闭之前不会释放,保证了游标的”可重复读“(repeatable reads)。

4. Snapshot:快照隔离,能够保证和Serializable一样的隔离效果。snapshot事务读DB时不会请求读锁,大大减少读-写冲突。

 

谈谈隔离级别的选择:

Berkeley DB对隔离级别的配置是很灵活的,允许统一数据库环境下的不同的事务采用不同的隔离级别,只要显示在数据层开启了该级别的支持。

1. 对于数据一致性要求不高的场景(如大部分的Web应用),对大部分non-critical数据的读写可以采用Read-Uncommitted级别。该级别下,由于允许”脏读“,读写几乎没有冲突(为什么是”几乎“,下文还会说明),但读到的数据不一定正确。

2. Read-Committed和默认级别几乎没有区别,除了Cursor的锁协议。

  在默认级别下,如果使用事务游标遍历数据库,游标会逐渐获取所有的读锁,极大阻塞其他事务的进行,使用Read-Committed级别会使游标使用锁耦合(lock-coupling)协议,在获取到下一页的锁的同时释放上一页的锁,减少了锁的占用。

3. 默认级别不多说,适合大部分对数据一致性要求高的场景,如处理关键/敏感数据的应用

4. Snapshot在保证默认级别隔离程度的同时减少了读写冲突,适用于多个读事务/单个写事务的应用场景。

  快照隔离的原理是多版本并发控制(Multi-version Concurrency Control),所有事务都会采用Copy-on-write的协议,即需要写数据时,先将原页面的内容拷贝到一个新的页面上,在新的页面上进行修改,提交时再合并到数据库中:由于写事务没有在原页面上修改,保证了快照事务可以安全地读取该页面——实际上,快照事务读数据时不需要加读锁

  快照隔离不是万能的。1.耗内存:由于需要写前复制,数据库需要的Cache数量大约是不开启MVCC支持前的2倍(可以使用db_stat -m查看当前数据库使用cache的情况)2.依然不能解决写-写冲突。

 

锁类型

除了常见的读/写锁,为了减少锁冲突、提高吞吐量,Berkeley DB提供了多种粒度的意向锁(multi-granularity intention lock)。

BDB的锁相容矩阵(conflict matrix)如下图所示:(横栏表示当前持有的锁类型,竖栏表示加锁请求,勾表示锁冲突)

图1:BDB的锁相容矩阵

iRead/iWrite/iRW都是意向锁,意向锁是为了支持层次化锁(hierarchical locking),举例说明:

如果我们需要写某数据某页的某一条记录,我们将会发出一连串原子的锁请求:给DB加iWrite锁,给page加iWrite锁,给record加Write锁。在每个锁请求都被允许的条件下,加锁才算成功,否则放弃之前步骤已经获取的锁。

我们可以看出,对单条记录的读写操作在DB和Page层加的都是意向锁,意向锁比读/写锁弱的多,与之冲突的锁类型大大减少。只有DB级的全局操作(如遍历全记录、修改)才会在DB加上标准的读/写锁。

在这种层次化场景下,意向锁使得锁的粒度被减少了,同时加锁时检查的效率被提高了。

 

uRead/iwasWrite不是意向锁,而是BDB为了支持”脏读“(Read-Uncommitted)而使用的特殊的锁类型。

iwasWrite:在Read-Uncommitted级别下,所有事务的写操作先获取写锁,在Page上完成具体的修改后,写锁降级(downgrade)为iwasWrite——”已写锁“:iwasWrite的锁相容列表和普通的写锁基本相同:除了允许uRead。

uRead:在Read-Uncommitted级别下,允许”脏读“的事务在读数据时,会尝试获取该数据的”脏读锁“(uRead),在Page上完成一次完整的读取后,释放该uRead锁(注意是完成读取后即释放,"脏读锁“是一种临时锁,不会被长期持有,想一想为什么)

 

死锁与死锁检测

决定使用事务的一刻起,我们注定要与锁冲突进行无休止的战争。正如前文所述:

1. 尽管我们可以设置各种隔离级别来减少读-写冲突,写-写冲突总是不可避免的。

2. 即使应用层能够保证不会同时写同一个逻辑记录,页级锁的存在常常使这样的努力成为徒劳:)

 

除了影响并发性能,锁冲突带来的另一个严重问题是死锁。有两种情形可能造成BDB的死锁:

1. 资源的循环依赖:如线程1中的事务A持有Page1的锁,想要获取Page2的锁;线程2的事务B持有Page2的锁,想要获取Page1的锁:谁也不撒手。

2. ”自死锁“:同一个线程中开启了两个事务,一个事务等待另一个事务的锁,其实上是自己等待自己。

 

对于第一种死锁,Berkeley DB提供了两种死锁检测接口:

1. 自动检测:env->set_lk_detect(reject policy),每当一个加锁请求即将被阻塞时,BDB都会遍历内部的锁表(lock table)以检测是否有死锁发生。

如果有死锁发生,一部分拥有锁的事务将会被强制abort以解除死锁(abort时会释放所有已获得的锁)。可以指定BDB选择abort事务的策略,默认情况下是随机,为了系统的吞吐量考虑,一般选择abort掉拥有写锁数量最少的事务(DB_LOCK_MINWRITE),因为持有写锁多的事务一般是已经执行了更多工作的事务。

2. 手工检测,如直接使用db_deadlock工具来检测并解决当前的死锁,在调试时极为有用。

然而对于第二种的“自死锁”,BDB的死锁检测无能为力。根据我们项目中使用BDB的经验来看,由于程序不慎而导致的“自死锁”还是比较常见的。

 

可以使用如下的方法判断是死锁还是“自死锁”:

1. 使用db_stat -Co工具来打印当前数据库的锁表,查看是否有锁的循环依赖:

图2:一个典型的死锁:

 

图3:一个典型的“自死锁”:

 

2. 使用db_deadlock工具,如果是正常的死锁,则一定可以被检测并解除。

 

应用层策略

Design for failure:

在支持事务的数据库中,死锁是常态。一定要在系统设计中考虑到死锁的可能,尽可能防止死锁,并提供相应的容错、重试的策略。

尽可能地防止死锁:

1. 所有事务使用一致地顺序来获取锁

如按照固定的顺序访问多个数据库、按Key的顺序重排(reorder)记录写入数据库的次序

2. 在事务的最后访问热点资源(hot spot),使其锁持有时间尽可能短

 

容错与重试:

在事务进行的任意一点,都有可能因为出现死锁而被BDB终止。如果需要重试,必须回到原事务起点,开启一个新事物并重新执行。

(这也是不鼓励长事务的原因:除了长时间持有锁影响了并发的其他事务,在发现死锁时,长事务也相对较难找到一个起点,将之前的操作重演一遍)

 

参考资料

《Oracle Berkeley DB Programmer's Reference Guide》

《Oracle Berkeley DB API Reference for C++》

《Oracle Berkeley DB Getting Started with Transaction Processing for C++》

《Berkeley DB 死锁的调试》 http://www.chenyajun.com/2008/06/29/21



文章来源:http://www.cnblogs.com/promise6522/tag/Berkeley%20DB/



0 0