虽然传说中的Donald Knuth同学曾经说过“过早优化是万恶之源”(premature optimization is the root of all evil),但在产品代码基本稳定的时候,做一定优化,还是非常有帮助,比如,我曾经通过使用多线程技术将一个原本需要30分钟才能搞定的流程优化到只需30秒,还有,虽然Windows 7和Vista之间的codebase非常相近,但是由于Windows 7在Vista的基础上做了许多的优化,所以Windows 7在保持其绚丽特效的情况下性能非常优异,从而保证其能成功替代Windows XP。谈到BigTable,Google的工程师也同样为了其性能采用了一些优化技术,本文将会根据BigTable的论文来对这些性能优化技术进行详细地分析,在切入正题之前,如果大家对BigTable或者YunTable有什么不熟悉的话,可以通过点击此来阅读本系列之前所有的文章。
可以通过将多个Column Family组合成一个局部性群组(Locality Group),而且系统对Tablet中的每个局部性群组都会生成一个单独的SSTable。通过局部性群组这个机制,能将多个比较类似的Column Family整合到一起,这样做有两个好处:其一是能减少数据的读取,比如,有一个关于人口的有几百个Column的大表,负责地址的应用只需读取专为地址信息设置的局部性群组即可,无需读取其它Column的信息,而且由于这些数据都集合一个Tablet上,所以能降低参与整个查询的机器数目;其二是提升处理速度,比如,App Engine的Datastore通过利用局部性群组这个机制来实现事务,从而避免由于传统的2PC事务机制在性能上不适合BigTable的软肋。还有,可以以局部性群组为单位设定一些有用的调试参数。比如,可以把一个局部性群组设定为全部存储在内存中。Tablet服务器依照惰性加载的策略将设定为放入内存的局部性群组的SSTable装载进内存。加载完成之后,访问属于该局部性群组的列族的时候就不必读取硬盘了。这个特性对于需要频繁访问的小块数据特别有用:在Bigtable内部,我们利用这个特性提高METADATA表中具有位置相关性的列族的访问速度。
在关系型数据库的时代,由于压缩率底,而且在性能上提升幅度也偏低,使得压缩技术对关系型数据库而言,只能算是画龙点睛,但是对基于Column的数据库而言,由于其是将一个Column或者几个近似的Column的数据放在一起存放,所以在压缩率上面非常惊人,甚至到1:9,特别是当今CPU的速度远胜于I/O的传输速度和存储容量的时代,通过增加少许用于解压缩的CPU时间来大幅降低读取和传输数据的时间,这对性能有非常明显的提升。
BigTable也采用了压缩机制,比如,客户程序可以控制一个局部性群组的SSTable是否需要压缩;如果需要压缩,那么以什么格式来压缩。每个SSTable的块都使用用户指定的压缩格式来压缩。假如只需读取一个SSTable中的部分数据,那么就可只需那些部分数据进行压缩,而不必解压整个文件。BigTable采用了两回合可定制的压缩方式。第一遍采用Bentley and McIlroy’s方式,这种方式在一个很大的扫描窗口里对常见的长字符串进行压缩;第二遍是采用快速压缩算法,即在一个16KB的小扫描窗口中寻找重复数据。两个压缩的算法都很快,在2006年左右的X86硬件设备上,这套机制的压缩的速率达到100-200MB/s,解压的速率达到400-1000MB/s。
虽然在Google工程师在选择压缩算法的时候重点考虑的是速度而不是压缩的空间,但是这种两遍的压缩方式在空间压缩率上的表现也是令人惊叹。比如,这种模式的空间压缩比最高可达到了10:1。这比传统的Gzip在压缩时的3:1或者 4:1的空间压缩比好的多;“两遍”的压缩模式如此高效的原因是由于相似的数据聚簇在一起,从而获取较高的压缩率,而且当在Bigtable中存储同一份数据的多个版本的时候,压缩效率会更高。
缓存总是被作为是性能优化的银弹(Silver Bullet),虽然BigTable并没有将缓存作为其核心机制之一,但是为了提高读操作的性能,Tablet服务器使用二级缓存的策略。扫描缓存是第一级缓存,主要缓存Tablet服务器通过SSTable接口获取的 Key-Value对;Block缓存是第二级缓存,缓存的是从GFS读取的SSTable的Block。对于经常要重复读取相同数据的应用程序来说,扫描缓存非常有效;而对于经常要读取刚刚读过的数据附近的数据的应用程序来说,Block缓存更有用,例如,顺序读,或者在一个热点的行的局部性群组中随机读取不同的列。
Bloom过滤器(Filter)是一种能快速判定数据是不是存在于这个集合之内的机制。一个BigTable的读操作必须读取构成Tablet状态的所有SSTable的数据。如果这些SSTable不在内存中,那么就需要多次访问硬盘。这时,可以通过访问SSTable自带的Bloom过滤器来减少硬盘访问的次数,比如,我们可以使用Bloom过滤器查询一个 SSTable是否包含了特定行和列的数据。对于某些特定应用程序,我们只付出了少量的、用于存储Bloom过滤器的内存的代价,就换来了读操作显著减少其所需磁盘访问的次数。使用Bloom过滤器也隐式的达到了当应用程序访问不存在的行或列时,大多数时候能不必访问硬盘的目的。
由于如果每个Tablet所属的Commit日志都存在一个单独的文件的话,那么就会产生大量的文件,并且这些文件会并行的写入GFS。根据GFS服务器底层文件系统实现的方案,要把这些文件写入不同的磁盘日志文件时,会产生大量低效的随机磁盘操作,这将极大地降低整个BigTable系统的性能。为了避免这些问题,我们为每个Tablet服务器一个统一的Commit日志文件,把修改操作的日志以追加方式写入同一个日志文件,因此一个实际的日志文件中混合了对多个Tablet修改的日志记录。
虽然使用单个日志能显著地提高系统的性能,但是却将恢复的工作复杂化了。当一个Tablet服务器宕机时,它加载的Tablet将会被移到很多其它的Tablet服务器上:每个Tablet服务器都装载很少的几个原来的服务器的Tablet。当恢复一个Tablet的状态的时候,新的Tablet服务器要从原来的Tablet服务器写的日志中提取修改操作的信息,并重新执行。然而,这些Tablet修改操作的日志记录都混合在同一个日志文件中的。一种方法是新的Tablet服务器虽然会读取完整的Commit日志文件,但只重复执行它需要恢复的Tablet的相关修改操作。使用这种方法,假如有100台 Tablet服务器,每台都加载了失效的Tablet服务器上的一个Tablet,那么,这个日志文件就要被读取100次(每个服务器读取一次)。
为了避免多次读取日志文件,系统会首先把日志按照关键字(table,row name,log sequence number)排序。排序之后,对同一个Tablet的修改操作的日志记录就连续存放在了一起,因此,只需一次磁盘Seek操作、之后顺序读取就可以了。为了并行排序,可先将日志分割成64MB的段,之后在不同的Tablet服务器对段进行并行排序。这个排序工作由Master服务器来协同处理,并且在一个Tablet服务器表明自己需要从Commit日志文件恢复Tablet时开始执行。
在使用Bigtable时,除了SSTable缓存之外的其它部分产生的SSTable都是不变的,我们可以利用这一点对系统进行简化。例如,当从SSTable读取数据的时候,系统不必对文件系统访问操作进行同步。这样一来,就可以非常高效的实现对行的并行操作。memtable是唯一一个能被读和写操作同时访问的可变数据结构。为了减少在读操作时的竞争,我们可以对内存表采用COW(Copy-on-write)机制,这样就允许读写操作并行执行。
因为SSTable是不变的,因此,我们可以把永久删除被标记为“删除”的数据的问题,转换成对废弃的SSTable进行垃圾收集的问题了,Master服务器采用“标记-删除”的垃圾回收方式删除SSTable集合中废弃的SSTable。