优雅的Bitcask/BeansDB

900 阅读11分钟
原文链接: zhuanlan.zhihu.com

说明

Bitcask是由Basho提出的海量小文件存储场景下的解决方案,主要解决以下问题:

读写低延迟
随机写请求的磁盘高吞吐量
故障时的快速恢复且不丢数据
使用简单


在bitcask的论文(或者说概要设计更合适)中主要描述了Bitcask对文件存储格式的定义以及一些优化措施。

BeansDB是Douban参考bitcask论文开发的适合于douban使用场景的海量小文件存储系统。作者David通过匠心独具的设计和深厚的技术水平将这一复杂的问题解决的比较优雅,值得仔细品味一番。

作者在概要阅读bitcask论文和beansDB源码的基础上分析讨论了beansDB的设计和实现上的一些关键技术,包括数据文件组织、内存hash tree索引等。最后,作者提出自己的看法以及自己的一些疑惑,希望与各位能一起深入探讨。

数据存储

总体架构

Bitcask主要着眼解决海量小文件存储的存储性能低下问题,beansDB作为其开源版本,实现了bitcask论文中提出的解决方案。作者David将其实现的更优雅。

从整体上来看,一个BeansDB进程将管理的存储空间(可以是一块磁盘或者磁盘上某个目录)组织成一棵树状结构(树的深度可作为启动参数)。数据文件被存储在最底层的目录下,如下是测试环境中一个典型存储配置:

hzdingkai2013@inspur1:~/workspace/beansdb-0.6/bin/bin/bean4/data$ tree 
. 
├── 0 
│   ├── 0 
│   ├── 1 
     ... 
│   ├── e 
│   └── f 
├── 1 
│   ├── 0 
│   ├── 1 
     ... 
│   ├── f 
... 
├── f 
│   ├── 0 
│   ├── 1 
     ... 
│   ├── f

其中有256个子目录(配置的树深度为2)可以存储实际文件数据。每个子目录对应了一个bitcask实例,整个beansDB进程则由多个bitcask实例组织而成。下图能较好地反映hstore与bitcask实例之间的关系:

文件格式

在每个bitcask实例内部,BeansDB采取了bitcask论文定义的文件存储格式,如下:


bitcask实例特点

每个目录下只有一个活跃文件(当前可写入),称为active data file,其他文件称为older data file,只读不写

每个文件作为记录被追加写入active data file

文件的删除也被作为一条record追加至active data file后面

元数据存储

bitcask设计初衷意在解决小文件存储的效率低下问题,而小文件存储效率低下的原因除了文件随机IO较多外,还容易由于文件数量的暴涨而导致元数据(dentry/inode)开销的增大,直接影响读写效率。

为了解决该问题,bitcask将文件以日志方式追加至bitcask实例(目录)的大文件中,自己管理文件索引,即文件key至在磁盘文件位置以及文件内偏移。beansDB文件元数据格式为:


为进一步提高性能,bitcask会将所有元数据缓存在内存中,在读文件时只需要一次IO即可。

为了能将如此众多的文件元数据全部缓存在内存中,必须得尽量压缩每个文件的元数据字节数,beansDB在这方面可谓苦心孤诣:

struct t_item { 
 uint32_t pos;    // 数据记录在日志文件起始偏移 
 int32_t  ver;    // 数据记录版本号 
 uint16_t hash;   // 数据记录hash 
 uint8_t  length; // key的长度 
 char     key[1]; // key的实际值 
};

这里看到一个很奇怪现象就是这里没有记录文件被存储所在的大文件id(在beansDB内有专门称呼为bucket)。这其实是压缩元数据存储空间的极好例子:beansDB将pos的低8位用来存储bucket id

这样每个bitcask实例内便可以最多存储256个bucket。高24位便用来存储bucket内偏移。每个bucket大小一般定义为2GB,这样每个bitcask实例最大支持的存储空间为512GB,妥妥的!

根据上面的定义我们大致可以计算出每个文件元数据占据的总存储空间size = 4B + 4B + 2B + 1B + size(key) = 11B + size(key),可以说已经很小了,按照20B/文件计算,100MB个文件也只占用约2GB内存。

Hash tree

BeansDB使用了hash tree来存储bitcask实例的元数据。使用数组实现了16叉hash tree


之所以使用hash tree的主要原因有二:

类似于普通的哈希字典, 保存 key 以及它在磁盘中的位置, 方面快速访问.

可快速比较两个存储节点中的内容是否一致, 将所有 key 按照其哈希组织成树, 同时节点的哈希是子节点的哈希的哈希, 这样可以快速比较并找出差异.

关键流程

在这里并不打算一一枚举API实现,只以BeansDB为例大致描述其核心API的内部流程。

Put

  1. 根据key定位该发往哪个存储目录(bitcask instance);
  2. 在bitcask的hash tree中根据key查找对应的Item是否存在,如果存在,说明是update,记录下原始版本号(oldv);
  3. 根据原始版本号以及更新类型决定新版本号;
  4. 为要写入的数据计算hash:uint16_t hash = gen_hash(value, vlen)
  5. 如果步骤2得到的item不为null并且当前写入数据和已经存在数据(根据步骤4计算的hash值对比)是一样的,那其实什么也不用做,直接返回即可;
  6. 分配新的DataRecord并初始化,以容纳要写入的新数据;
  7. 将6分配的DataRecord写入bitcask的缓存buffer,等待后续定期sync到日志文件(bucket);
  8. 在bitcask的hash tree中插入或更新该记录的元数据信息并返回成功

说明:

在beansdb的实现中,每个bitcask实例有两个hash tree,全局的hash tree和当前正在写入bucket的hash tree。这么做的原因是为了加快生成hint file的速度。可参考bc_rotate的代码。因此,在8的插入hash tree时候需要分别插入这两棵树

上面的写过程都是在bitcask的写锁保护之下完成的pthread_mutex_lock(&bc->write_lock) 。这会不会成为性能瓶颈?应该不至于,因为全是内存操作!
步骤7中可能写入Bitcask实例的内存缓冲区可能就返回了,潜在风险!

Get

根据key定位存储目录(bitcask instance);

  1. 在bitcask的hash tree中查该key对应的Item是否存在,不存在则返回null
  2. 判断item中记录的bucket id(pos & 0xff)是否是当前正在写入的bucket,如果是先判断要读取的数据是否在内存缓冲区;
  3. 否则根据pos & 0xffffff00从相应的bucket中读出数据并返回。

Delete

delete的流程与put一致,只是其version设置为-1。标记为删除,与正常的put区别在于:

value值为空;

新的DataRecord的版本为-oldversion - 1,为负值;

说明

  • delete的时候并没有将记录的元数据(Item)从hash tree中删除;而是有个后台线程会做定期压缩(将version<0的的元数据记录删掉)。这样设计的主要理由我想可能是避免在删除的时候对hash tree作调整吧。后台压缩的实现可参考bc_optimize

几个关键问题

内存使用

这个我们在前面大致估算过,2GB的内存可大约存储100M个文件,与beansDB作者的设计目标差不多,比Bitcask内存利用效率高。

异常恢复

为了解决BeansDB设计在进程异常退出时每个bitcak实例都需要从磁盘恢复内存文件映射表(key->元数据)而不得不读取大量数据的问题。bitcask会将每个大文件(bucket)生成一个索引文件hintfile,其实是该bucket内所有文件元数据的一份磁盘dump。

前面我们看到在写入bitcask实例时除了写入bitcask全局的hash tree外还写入了一棵current hash tree,这便是当前活跃bucket的元数据树,在bucket写满关闭时在后台由内存dump至磁盘中。想法非常自然,实现非常优雅。

即便如此,由于每个bitcask实例中索引文件可能也比较多(假如按照每个bitcask管理256GB磁盘空间,而每个文件平均大小为128KB计算,每个bitcask总的文件数量约为2M,而每个文件元数据量约为20B,则每个bitcask实例的元数据总量约为40MB,即每个bitcask的元数据恢复时间也比较快,取决于元数据IO速度),即使有多个bitcask实例,我们也可以采取并行恢复(每个bitcask实例并行地读出元数据,构建hash tree)的策略来提升恢复效率。

数据可靠性以及由此引出的一致性讨论

本篇主要讨论beasdb如何优雅实现了bitcask定义的存储格式。我们并没有讨论文件数据的可靠性以及采用多副本策略保证可靠性时的一致性问题。作者代码中也没相关内容。

大概猜测:

  • 首先上层设置proxy,将写请求转发至多副本,多副本全部写入才算成功;
  • 如果发生写异常,直接通过同步hash tree来做数据同步即可,可参考前面说的hash tree特性来理解。

beansDB存储应用

根据作者的博客透露,beansDB目前在douban主要有两套存储集群:一套存储诸如图片、日记等极小文件,而另外一套存储诸如音乐、视频等较大文件,我倒是很好奇对于一些超大文件(如超过最大bucket的限制),beansDB如何处理?猜测大概是应用层切片。

可能丢数据

由于beansDB在写入时只是写缓冲区即返回,因此,客户端即使得到写成功的结果也不一定保险。据社区有人反映,确实丢数据了。

数据merge

由于beansdb的删除仅是逻辑删除,其实际物理数据依然存在,因此,需要后台任务定期对垃圾数据较多的bucket作空间回收:merge。

beansDB中的merge也比较简单:顺序扫描每个bucket。扫描其hash tree的每个记录,查找其在全局hash tree中是否依然有效,如果无效,该记录空间即可被回收。

merge 的时候, 对当前merge的bucket会生产一个小的hash tree, 对应新生产的数据文件。一个数据文件 merge 完成后, 会锁住写, 并这个小的hash tree的内容更新到主体 hash tree 中, 这个过程是MlogN 复杂度的内存操作, N是一个bucket 的记录数, M 是当前数据文件的记录数, M一般在1M以内, 更新应该能在 1s 左右的时间内完成。

数据结构

Item

typedef struct t_item Item; 
struct t_item { 
 uint32_t pos;    // 数据记录在日志文件起始偏移 
 int32_t  ver;    // 数据记录版本号 
 uint16_t hash;   // 数据记录hash 
 uint8_t  length; // key的长度 
 char     key[1]; // key的实际值 
};

Item代表了每个数据记录(文件)的元数据信息,被存储在beansdb的hash tree的每个叶子节点(node)上。

Node

typedef struct t_node Node; 
struct t_node { 
 uint16_t is_node:1; // 是否是中间节点 
 uint16_t valid:1;    
 uint16_t depth:4;   // 节点深度 
 uint16_t flag:9; 
 uint16_t hash;      // node hash value ? 
 uint32_t count;     // 节点中有效数据项个数 
 Data *data;         // 节点中存储的实际数据项 
};

Node是hash tree中的每个节点的数据结构。

Data

typedef struct t_data Data; 
struct t_data { 
 int size; 
 int used; 
 int count;  // Item数组的实际大小 
 Item head[0]; 
};

Data是节点Node中实际存储的数据。该数据是由Item组成的数组。

DataRecord

typedef struct data_record { 
 char *value;    // 数据在内存中的指针 
 union { 
     bool free_value;    // free value or not 
     uint32_t crc; 
 }; 
 int32_t tstamp; 
 int32_t flag; 
 int32_t version; 
 uint32_t ksz; 
 uint32_t vsz; 
 char key[0];   // 实际key值 
} DataRecord;

DataRecord是实际存储在日志文件的数据记录的格式。每个数据记录按照该格式被一条条追加至日志文件中。

bitcask_t

struct bitcask_t { 
 uint32_t depth, pos; 
 time_t before; 
 Mgr    *mgr; 
 // 当前正在写入和全局的hash tree 
 HTree  *tree, *curr_tree; 
 int    last_snapshot; 
 int    curr;             // 当前正写入的bucket 
 uint64_t bytes, curr_bytes; 
 // 写缓冲区,写入记录先写入缓冲区 
 // 缓冲区满时再flush至日志文件 
 char   *write_buffer; 
 time_t last_flush_time; 
 uint32_t    wbuf_size, wbuf_start_pos, wbuf_curr_pos; 
 pthread_mutex_t flush_lock, buffer_lock, write_lock; 
 int    optimize_flag; 
};

参考资料

https://github.com/douban/beansdbgithub.com图标http://www.wzxue.com/%E6%B7%B1%E5%85%A5%E8%A7%A3%E6%9E%90beansdb/#Beansdb8211www.wzxue.com
http://www.douban.com/note/122507891/www.douban.com