对于普通的以数据块/文件为单位的压缩,传统的(流式)数据压缩算法工作得不错,时间长了,大家也都习惯了这种数据压缩的模式。基于这种模式的数据压缩算法层出不穷,不断有新的算法实现。包括使用最广泛的gzip、bzip2、Google的Snappy、新秀Zstd等。
gzip几乎在在所有平台上都有支持,并且也已经成为一个行业标准,压缩率、压缩速度、解压速度都比较均衡;
bzip2是基于BWT变换的一种压缩,本质是上对输入分块,每个块单独压缩,优点是压缩率很高,但压缩和解压速度都比较慢;
Snappy是Google出品,优点是压缩和解压都很快,缺点是压缩率比较低,适用于对压缩率要求不高的实时压缩场景;
Zstd是一个压缩新秀,压缩率比LZ4和Snappy都高不少,压缩和解压速度略低;相比gzip,压缩率不相上下,但压缩/解压速度要高很多。
对于数据库,在计算机世界的太古代,为I/O优化的Btree一直是不可撼动的,为磁盘优化的Btree block/page size比较大,正好让传统数据压缩算法能得到较大的上下文,于是,基于block/page的压缩也就自然而然地应用到了各种数据库中。在这个蛮荒时代,内存的性能、容量与磁盘的性能、容量泾渭分明,各种应用对性能的需求也比较小,大家都相安无事。
现在,我们有了SSD、PCIe SSD、3D XPoint等,内存也越来越大,块压缩的缺点也日益突出:
使用通用压缩技术(Snappy、LZ4、zip、bzip2、Zstd等),按块/页(block/page)进行压缩(块尺寸通常是4KB~32KB,以压缩率著称的TokuDB块尺寸是2MB~4MB),这个块是逻辑块,而不是内存分页、块设备概念中的那种物理块。
写入时,很多条记录被打包在一起压缩成一个个的块,增大块尺寸,压缩算法可以获得更大的上下文,从而提高压缩率;相反地,减小块尺寸,会降低压缩率。
读取时,即便是读取很短的数据,也需要先把整个块解压,再去读取解压后的数据。这样,块尺寸越大,同一个块内包含的记录数目越多。为读取一条数据,所做的不必要解压就也就越多,性能也就越差。相反地,块尺寸越小,性能也就越好。
一旦启用压缩,为了缓解以上问题,传统数据库一般都需要比较大的专用缓存,用来缓存解压后的数据,这样可以大幅提高热数据的访问性能,但又引起了双缓存的空间占用问题:一是操作系统缓存中的压缩数据;二是专用缓存(例如RocksDB中的DBCache)中解压后的数据。还有一个同样很严重的问题:专用缓存终归是缓存,当缓存未命中时,仍需要解压整个块,这就是慢Query问题的一个主要来源(慢Query的另一个主要来源是在操作系统缓存未命中时)。
这些都导致现有传统数据库在访问速度和空间占用上是一个此消彼长、无法彻底解决的问题,只能采取一些折衷。
以RocksDB为例,RocksDB中的BlockBasedTable就是一个块压缩的SSTable,使用块压缩,索引只定位到块,块的尺寸在dboption里设定,一个块中包含多条(key,value)数据,例如M条,这样索引的尺寸就减小到了1/M:
M越大,Block的尺寸越大,压缩算法(gzip、Snappy等)可以获得的上下文也越大,压缩率也就越高。
创建BlockBasedTable时,Key Value被逐条填入buffer,当buffer尺寸达到预定大小(块尺寸,当然,一般buffer尺寸不会精确地刚好等于预设的块尺寸),就将buffer压缩并写入BlockBasedTable文件,并记录文件偏移和buffer中的第一个Key(创建index要用),如果单条数据太大,比预设的块尺寸还大,这条数据就单独占一个块(单条数据不管多大也不会分割成多个块)。所有Key Value写完以后,根据之前记录的每个块的起始Key和文件偏移,创建一个索引。所以在BlockBasedTable文件中,数据在前,索引在后,文件末尾包含元信息(作用相当于常用的FileHeader,只是在文件末尾,所以叫footer)。
一般情况下,操作系统会有文件缓存,所以同一份数据可能既在DB Cache中(解压后的数据),又在操作系统Cache中(压缩的数据)。这样会造成内存浪费,所以RocksDB提供了一个折衷:在dboption中设置DIRECT_IO选项,绕过操作系统Cache,这样就只有DB Cache,可以节省一部分内存,但在一定程度上会降低性能。
FM-Index的功能非常丰富,历史也已经相当悠久,不算是一种新技术,在一些特殊场景下也已经得到了广泛应用,但是因为各种原因,一直不温不火。最近几年,FM-Index开始有些活跃,首先是GitHub上有个大牛实现了全套Succinct算法,其中包括FM-Index,其次Berkeley的Succinct项目也使用了FM-Index。
FM-Index属于Offline算法(一次性压缩所有数据,压缩好之后不可修改),一般基于BWT变换(BWT变换基于后缀数组),压缩好的FM-Index支持以下两个最主要的操作:
压缩过程又慢又耗内存(Berkeley的Succinct压缩过程内存消耗是源数据的50倍以上);
可以用一种简单的方式把Flat Model成KeyValue Model:挑选一个在Key和Value中都不会出现的字符“#”(如果无法找出这样的字符,需要进行转义编码),每个Key前后都插入该字符,Key之后紧邻的就是Value。如此,search(#key#)返回了#key#出现的,我们就能很容易地拿到Value了。
Berkeley的Succinc项目在FM-Index的Flat Text模型上实现了更丰富的行列(Row-Column)模型,付出了巨大的努力,达到了一定的效果,但离实用还相差太远。
Terark公司提出了“可检索压缩(Searchable Compression)”的概念,其核心也是直接在压缩的数据上执行搜索(search)和访问(extract),但数据模型本身就是KeyValue模型,根据其测试报告,速度要比FM-Index快得多(两个数量级),具体阐述:
普通的索引技术,索引的尺寸相对于索引中原始Key的尺寸要大很多,有些索引使用前缀压缩,能在一定程度上缓解索引的膨胀,但仍然无决索引占用内存过大的问题。
较之传统实现索引的数据结构,Nested Succinct Trie的空间占用小十几倍甚至几十倍。而在保持该压缩率的同时,还支持丰富的搜索功能:
实际上我们实现了很多种CO-Index,其中Nested Succinct Trie是适用性最广的一种,在这里对其原理做一个简单介绍:
Succinct Data Structure是一种能够在接近于信息论下限的空间内来表达对象的技术,通常使用位图来表示,用位图上的rank和select来定位。
虽然能够极大降低内存占用量,但实现起来较为复杂,并且性能低很多(时间复杂度的项很大)。目前开源的有SDSL-Lite,我们则使用自己实现的Rank-Select,性能也高于开源实现。
每个结点占用 2ptr,如果我们对传统方法进行优化,结点指针用最小的bits数来表达,N个结点就需要2*[log2(N)]个bits。
对比传统基本版和传统优化版,假设共有216个结点(包括null结点),传统优化版需要2 bytes,传统基本版需要4/8 bytes。
仅使用Succinct技术,压缩率远远不够,所以又应用了径压缩和嵌套。这样一来,压缩率就上了一个新台阶。
PA-Zip对整个数据库中的所有Value(KeyValue数据库中所有Value的集合)进行全局压缩,而不是按block/page进行压缩。这是针对数据库的需求(KeyValue 模型),专门设计的一个压缩算法,用来解决传统数据库压缩的问题:
压缩率更高,没有双缓存的问题,只要把压缩后的数据装进内存,不需要专用缓存,可以按ID直接读取单条数据,如果把这种读取单条数据看作是一种解压,那么:
按ID顺序解压时,解压速度(Throughput)一般在500MB每秒(单线GB/s,适合离线分析性需求,传统数据库压缩也能做到这一点;
按ID随机解压时,解压速度一般在300MB每秒(单线GB/s,适合在线服务需求,这一点完胜传统数据库压缩:按随机解压300MB/s算,如果每条记录平均长度1K,相当于QPS = 30万;如果每条记录平均长度300个字节,相当于QPS = 100万;
预热(warmup),在某些特殊场景下,数据库可能需要预热。因为去掉了专用缓存,TerarkDB的预热相对简单高效,只要把mmap的内存预热一下(避免Page Fault即可),数据库加载成功后就是预热好的,这个预热的Throughput就是SSD连续读的IO性能(较新的SSD读性能超过3GB/s)。
Key以全局压缩的形式保存在CO-Index中,Value以全局压缩的形式保存在 PA-Zip中。搜索一个Key,会得到一个内部ID,根据这个ID,去PA-Zip中定点访问该ID对应的Value,整个过程中只触碰需要的数据,不需要触碰其他数据。
如此无需专用缓存(例如RocksDB中的DBCache),仅使用mmap,完美配合文件系统缓存,整个DB只有mmap的文件系统缓存这一层缓存,再加上超高的压缩率,大幅降低了内存用量,并且极大简化了系统的复杂性,最终完成数据库性能的大幅提升,从而同时实现了超高的压缩率和超高的随机读性能。
从更高的哲学层面看,我们的存储引擎很像是用构造法推导出来的,因为CO-Index和PA-Zip紧密配合,完美匹配KeyValue模型,功能上“刚好够用”,性能上压榨硬件极限,压缩率逼近信息论的下限。相比其他方案:
传统块压缩是从通用的流式压缩衍生而来,流式压缩的功能非常有限,只有压缩和解压两个操作,对太小的数据块没有压缩效果,也无法压缩数据块之间的冗余。把它用到数据库上,需要大量的工程努力,就像给汽车装上飞机机翼,然后要让它飞起来。
相比FM-Index,情况则相反,FM-Index的功能非常丰富,它就必然要为此付出一些代价——压缩率和性能。而在KeyValue模型中,我们只需要它那些丰富功能的一个非常小的子集(还要经过适配和),其他更多的功能毫无用武之地,却仍然要付出那些代价,就像我们花了很高的代价造了一架飞机,却把它按在地上,只用轮子跑,当汽车用。
虽然RocksDB提供了一个相对完整的KeyValueDB框架,但要完全适配我们特有的技术,仍有一些欠缺,所以需要对RocksDB本身也做一些修改。将来可能有一天我们会将自己的修改提交到RocksDB版。
为了更好的兼容性,TerarkDB对RocksDB的API没有做任何修改,为了进一步方便用户使用TerarkDB,我们甚至提供了一种方式:程序无需重新编译,只需要替换 librocksdb.so并设置几个变量,就能体验TerarkDB。
目前大家可以免费试用,可以做性能评测,但是不能用于production,因为试用版会随机删掉0.1%的数据。
因此,我们采取了一些必要的紧急措施,让我们的网络暂时处于脱机状态。同时,我们也一直在全力恢复数据,但我们不得不承认将所有丢失数据全部找回的可能性机器渺茫。
UTC时间下午6点,所有坐标荷兰的专用服务器恢复联机在线。云服务器方面,我们仍在持续探索解决方案。
全部专用服务器恢复在线。如果您的专用服务器仍存在问题,请致邮。目前,所有虚拟机数据正在上传至一个新的服务器。——梦见吃饺子