GaussDB技术解读丨高级压缩
本文作者|华为云数据库GaussDB首席架构师 冯柯
【背景介绍】
数据压缩与关系数据库的结合,早已不是一个新鲜的话题,当前我们已经看到了各种各样数据库压缩的产品和解决方案。对于GaussDB来说,在今天引入数据压缩,究竟能够给客户带来什么不一样的价值,是过去一段时间我们一直在思考的问题。
为了回答这个问题,我们首先对各种通用压缩算法进行了广泛的测试,从性能最好的LZ4/Snappy,到性能与压缩率均衡的Zstd/Zlib,再到强调压缩率的LZMA/BZip。我们发现:即使是性能最好的压缩算法,仍然无法做到对一个在线数据库的性能不产生显著影响。我们也调研了数据库领域的各种编码方法,包括近几年学术界发布的一些基于预测和线性拟合的编码方法,从研究发布的测试结果及实测来看,数据库编码用于解决特定数值分布的可压缩性问题,与压缩算法的成熟度相比,当前并没有一种通用的数据库编码方法,能够在大多数真实数据集中的场景下提供稳定的压缩率。
这是我们对于数据库压缩这个领域的一个基本技术判断。过去的产品实践也验证了这一点,我们看到很多商业数据库和开源数据库都提供了对于压缩的支持,绝大多数时候,留给客户的选择就是决定要不要在特定的表上开启压缩。开启压缩意味着空间节省,但同时意味着性能下降,这个看似简单的选择恰恰是客户最难做的。这也是为什么有了这么多数据库压缩的产品,我们却很少能看到数据压缩真正广泛应用在数据库在线业务中的根本原因。
这给了我们更多的启示。我们相信,真正可被应用的数据库压缩技术,能够去兼顾压缩率与业务影响的平衡,应该是选择性的。即我们能够基于技术去判定数据的温度,并基于这样的判定,去选择性地压缩业务中相对较冷的数据,而不去碰那些相对较热的数据。
这样的技术选择意味着我们无法去满足所有业务场景,我们要求业务的数据温度分布,必须满足80-20分布规则。即我们去压缩那些占用80%存储需求、但只占用20%计算需求的冷数据,而不去碰那些只占用20%存储需求、但却占用80%计算需求的热数据。幸运的是,我们发现绝大多数对于容量控制有需求的业务,都具备这样的特征。
【场景及目标选择】
通过对大量业务场景的分析,我们发现业务对于数据库压缩技术的需求是多元化的,有在线交易业务(OLTP)存储压缩的场景,有分析业务(OLAP)存储压缩的场景,有历史业务存储压缩的场景,也有容灾业务传输压缩的场景。不同的场景,对于压缩技术的诉求,如果从压缩性能、压缩率、解压性能的三维指标去看,从对业务侵入的容忍度去看,是完全不同的。
这意味着如果我们想要打造一个全场景的GaussDB高级压缩特性,它应该是多个技术的组合,包括不同的压缩算法、不同的冷热判定模型及方法、不同的数据存储组织等,通过不同的技术组合及应用去满足不同的场景需求。
这同时意味着我们在不同压缩适用场景的支持上需要有个优先级的取舍。我们的答案是选择去优先支持OLTP存储压缩场景,这是我们认为数据库压缩技术最有价值的业务领域,当然也是技术挑战最大的领域。
确定场景之后,接下来是确定技术目标,我们面向这个场景,究竟要打造什么样的核心竞争力,这取决于我们对于典型客户场景的分析。我们识别了两类典型客户场景:
场景A:客户业务来自于IBM小机,单库容量50TB,迁移到开放平台后,面临容量过大和运维窗口过长问题。选择拆库意味着分布式改造,对于一个已经稳定运行许多年的存量关键业务来说,这种技术选择风险过高。选择压缩可以显著降低容量风险,但业务最初的设计并没有考虑冷热分离(比如基于时间维度建立分区),需要一种零侵入的压缩技术支持,同时对业务性能影响足够低。
场景B:客户业务基于分布式集群部署,单集群容量已经超过1PB,并且仍在快速增长,需要定期扩容。选择压缩可以降低扩容频率,显著降低业务的软硬件成本,并减少变更风险。但业务的数据分布设计是面向扩展性的(比如基于用户维度建立分区),没有考虑冷热分离,因此同样的,业务需要一种零侵入的压缩技术支持,同时对性能影响足够低。
通过对客户典型场景的需求梳理,我们确定了GaussDB OLTP存储压缩的基本设计目标:1)冷热判定对业务应该是零侵入的,不应对业务的已有数据分布、逻辑模型有任何依赖;2)对业务影响必须足够低,我们定义目标低于10%,并挑战5%;3)提供合理的压缩率,我们定义目标不低于2:1。基本设计目标的定义,使得我们能够将后续每个具体场景中的技术选择都变成一个确定性问题。
【冷热判定】
确定设计目标后,我们开始进行工程落地。有三个问题需要解决:1)如何实现对数据的冷热判定;2)如何实现压缩后数据的存储组织;3)如何实现有竞争力的压缩算法。
对于冷热判定,首先要确定判定的粒度。数据的冷热判定可以基于不同粒度实现,行级、块级或表/分区级,粒度越粗,实现的复杂度越低,但对业务的侵入也越大。基于设计目标,很自然的,我们选择行级的冷热判定,这是对业务数据分布依赖最小的方案,我们需要解决的,是如何控制引入冷热判定的代价。
我们利用GaussDB存储引擎已有的机制巧妙地解决了这一问题。具体来说,GaussDB存储引擎在每行数据的元数据Meta中记录了最近一次修改该行的事务ID(XID),该信息被用来支持事务的可见性判定,从而实现多版本并发控制(MVCC)。对于特定行来说,如果其XID足够“老”,老到它对所有当前已经活跃的事务都可见,那么这时候我们实际上已经不关注XID的具体值,我们可以通过引入一个特定的标志位(FLG)来记录这一点,而原来XID中填充的值可以被一个物理时间来代替,这个物理时间就表征了其所属行最后一次修改时间的上限(LMT,Last Modified Time)。很显然,LMT可以用来支持冷热判定(具体见图1):
图1:行级冷热判定
上述方案的好处是引入LMT并没有增加额外开销,对业务的逻辑模型也没有任何依赖,在大多数时候,如果不是特别严格要求,业务可以定义一个简单的规则来实现冷热判定,比如:
AFTER 3 MONTHS OF NO MODIFICATION
此时系统会扫描目标表,对于所有满足当前时间减去LMT超过3个月的行进行压缩。
注意在上述方案中,我们实际上只识别了行的写热点,但并没有识别行的读热点,我们只知道满足条件的行3个月内未发生任何更新,但我们无法确认这些行在3个月内是否被频繁读取。维护行的读热点,目前从技术上没有低成本的解决方案。对于像订单明细这样的流水类业务,这个方案可以很好地工作,因为数据的读和写呈现出相同的温度特征,其访问频率随着未修改时间的增加不断衰减。但对于像手机相册这样的收藏类业务,仅识别写可能是不够的,因为一个很早建立的收藏关系仍然可能被频繁访问。
这意味着,即使系统进行了冷热判定,我们仍然需要去优化业务可能访问压缩数据的场景,我们把这个问题留给了存储组织和压缩算法,对于压缩算法来说,我们更关注其解压性能。
另一个问题是在某些场景下,使用默认的冷热判定可能是不够的,比如对于某些类型的交易而言,其产生的订单明细可能在3个月内确实不会被修改,但会在达到一个特定的触发条件后被更新(比如解冻担保交易)。这种场景在实际业务中并不常见,但如果业务确实关注性能,那么我们支持在默认的冷热判定规则以外,允许业务自定义规则,比如:
AFTER 3 MONTHS OF NO MODIFICATION ON (order_status = "finished")
此时系统会仅压缩3个月未修改、且订单状态已经完结的数据。
当前我们支持的自定义规则,是任意合法的行表达式,业务可以写任意复杂的表达式来表征数据的冷热判定规则,但表达式中所引用的任何字段,只能是目标表上的合法字段。通过这种默认和自定义规则的组合使用,我们提供了业务足够低的使用门槛和更好的灵活性。
【存储组织】
当满足冷热判定条件的行被压缩时,我们需要决定如何存储这些压缩后的数据,基于设计目标,我们选择了对业务侵入最小的存储组织实现——块内压缩。
我们知道关系数据库的存储组织都是基于固定长度的分块的,在GaussDB数据库中,典型的数据块大小为8KB,选择更大的数据块显然有利于压缩,但对业务性能会造成更大的影响。所谓块内压缩,是指:1)单个块内所有满足冷热判定条件的行,会作为一个整体进行压缩;2)压缩后形成的数据就存放在当前的数据块中,存放区域称为BCA(Block Compressed Area),它通常位于块的尾部。
块内压缩的设计意味着解压任何数据只依赖于当前块,而不需要访问其它的数据块,从压缩率的视角看,这样的设计并不是最友好的,但它非常有利于控制业务影响。注意在我们前面的讨论中,即使业务定义了冷热判定条件,仍然存在一定的概率会访问压缩数据,我们希望这个访问代价能够有一个确定性的上限。
图2给出了块内压缩的详细流程:首先,当压缩被触发时,系统扫描数据块中的所有行,根据指定的冷热判定条件,识别出R1和R3是冷数据(图2(a));接着,系统将R1和R3作为一个整体进行压缩,将压缩后的数据就存放在该数据块的BCA中(图2(b));如果业务后续需要更新R1,那么系统会为更新后的数据生成一个新的拷贝R4,并标识BCA中的R1已经被删除(如图2(c));最后,当系统在该数据块上需要更多空间时,可以回收BCA中属于R1的空间(图2(d))。
图2:块内压缩
在整个设计中有两点需要注意:1)我们实际上只压缩了用户数据Data,并没有压缩相应的元数据Meta,后者通常用来支持事务的可见性;2)我们支持将冷数据重新变为热数据,以消除因为冷热误判而带来的影响。同样地,从压缩率的视角,这样的设计并不是最友好的,但它极大地减少了对业务的侵入。简单来说,业务对于压缩数据的访问,与正常数据完全相同,在功能上没有任何限制,在事务语义上也没有任何差别。这是非常重要的原则:我们的OLTP存储压缩对于业务是完全透明的,这是当前这个特性,以及后续GaussDB高级压缩系列所有特性都将遵循的基本原则。
【压缩算法】
基于设计目标,如果从压缩率、压缩性能、解压性能的三维指标来看,我们实际上需要的是一个能够提供合理的压缩率、合理的压缩性能、但是极致的解压性能的压缩算法,这是我们压缩算法设计的基础。
我们首先测试了直接使用LZ4进行压缩,LZ4是目前已知的压缩性能和解压性能最好的开源三方库,从实测结果看,LZ4的压缩率是偏低的。我们仔细分析了其算法原理,LZ4是基于LZ77算法的一种实现,LZ77算法的思想非常简单,就是把要压缩的数据看成一个字节流,算法从字节流的当前位置开始,前向寻找和当前位置相同的匹配字符串,然后用匹配到的字符串的长度以及与当前位置的偏移,用来表示被匹配的字符串,从而达到压缩的效果。从算法原理上看,LZ77算法对于长文本会有比较好的压缩效果,但是对于结构化数据中大量的短文本以及数值类型,效果就有限,我们实际的测试也验证了这一点。
接下来,我们将压缩算法分为了两层:第一层,我们按列对一些数值类型进行了编码,我们选择了简单的差值编码,这种编码足够轻量级,解压特定字段不需要依赖其它字段的值;第二层,我们将编码后的数据再调用LZ4进行压缩。注意在第一层中,我们实际上是按列编码、按行存储,这和业界的一般实现(按列编码并存储)有很大不同,按列存储对压缩率会更加友好,但是按列存储意味着同一行的数据会被分散到BCA的不同区域,这种传统的设计无法支持我们后续希望实现的部分解压,我们将在结束语中更详细地说明这一问题。
通过实测,我们发现这种列编码+通用压缩的实现方式有效地提升了压缩率,同时控制了业务影响的明显增加,但两层实现之间是松耦合的,这引入了许多额外的开销。因此我们在仔细权衡之后,决定放弃LZ4,而是完全基于LZ77算法,重新实现一个紧耦合的压缩算法。
这在当时看来是一个非常冒险的尝试,事实上,在我们之前,还没有任何数据库内核团队,会选择自己去实现一个通用压缩算法。但从最后取得的收益来看,我们实际上是打开了一扇全新的大门。当列编码与LZ77算法之间的边界被打破时,我们引入了一系列的优化创新,考虑篇幅原因,我们无法展现全部技术细节,在这里,我们只介绍两个小的优化:
第一个优化是内置行边界。我们发现,当系统采用两层压缩算法后,我们需要额外地保存每一行数据在编码后的长度,因为我们需要在LZ77算法解压后找到每一行的边界,这是一个不小的开销。为了消除这个开销,我们选择在LZ77的编码格式中嵌入一个行边界的标记,这个标记只占用了1个位,其开销较现有方案大幅降低。当然,这个标记位被占用后,LZ77前向搜索的最大窗口长度减少了一半,但在我们这个场景中,这并不是什么问题,因为我们的典型页面长度只有8KB。
第二个优化是2字节短编码。原有LZ4的实现中,为了提高压缩性能,系统使用3字节编码来描述一个匹配,这意味着系统能够识别的最短匹配为4字节。但是在结构化数据中,3字节的匹配是非常普遍的,参考下面一个例子:
A = 1 … B = 2
其中,A和B是同一行数据中的两个整数型字段,它们的值分别为1和2,基于当前的字节序,该行数据实际在内存中存放的形式如下所示:
01 00 00 00 … 02 00 00 00
注意上面标红的部分,很明显,这里面有一个3字节的匹配,但是它无法被LZ4识别。
我们通过在LZ77算法中额外引入2字节短编码来解决这一问题,2字节短编码可以识别最小3字节的匹配,从而相对LZ4能够提升压缩率。当然,引入短编码会有额外的开销:1)压缩性能会有一定程度的下降,因为我们需要建立两个独立的HASH表,幸运的是,在我们这个场景中,极致压缩性能并不是我们追求的目标;2)2字节编码减少了表达匹配串与被匹配串之间距离的位宽,这意味着3字节的匹配必须离得更近才能被识别,在我们这个场景中,这并不是什么问题,因为相对于这个限制,一个典型数据行的长度已经是足够小的。
【效果评估】
我们使用标准的TPCC测试来评估启用OLTP存储压缩特性对业务的影响。TPCC模型共包含9张表,其中空间会动态增长的流水表共有3张,在这3张表中,订单明细表(Orderline表)的空间增长比其它表多一个数量级,因此我们选择在这张表上开启压缩。基于TPCC的业务语义,每笔订单一旦完成配送,其订单状态就进入完结状态,完结的订单不会再被修改,但仍有一定的概率被查询。基于这个语义,我们选择冷热判定原则为只压缩已经完结的订单。
我们分别测试了在不开启压缩和开启压缩状态下系统的性能值,结果如图3所示:
图3:业务影响评估
测试结果表明:在TPCC测试场景下,开启压缩与不开启压缩相比,系统性能大概降低了1.5%。这是一个非常不错的结果,这意味着即使在超过百万tpmC的业务峰值场景中,系统也可以开启压缩。我们不知道在此之前,业内是否有其它数据库产品也能够达到这一水平。
我们测试了Orderline表的压缩率,作为更丰富的数据集,我们同时选择了TPCH模型中的4张表(Lineitem、Orders、Customer、Part表)进行测试。为了便于比较,对于每个数据集,我们同时测试了LZ4、ZLIB和我们的压缩算法的压缩率表现,其中ZLIB是强调压缩解压性能和压缩率均衡的算法,其压缩解压性能较LZ4低了5-10倍。最终结果如图4所示:
图4:压缩率评估
测试结果与我们预期的相符,在数值型字段较多时,我们的压缩算法的压缩率要高于所有通用压缩算法,但在文本型字段较多时,我们的压缩算法的压缩率会介于LZ类和LZ + Huffman组合类的压缩算法之间。
【运维TIPS】
注意我们的压缩方案实际上是离线的,也就是数据刚生成时必然是热数据,它们不会触发压缩,业务访问这些数据的性能也不会受任何影响;随着时间的推移,这些数据的温度会逐渐降低,最终被独立的压缩任务识别为冷数据并进行压缩。
选择在业务低峰期运行这些压缩任务、并控制其资源消耗是运维端需要关注的问题。在这块我们提供了丰富的运维手段,包括指定运维窗口、压缩任务的并行度、每个压缩任务的压缩数据量等。对于绝大多数业务来说,单位时间内新增的数据量实际是比较有限的,因此业务也可以选择一个特定的时间段集中完成压缩任务,比如每个月第一天的凌晨两点到四点,完成3个月前新增冷数据的压缩。
业务在决定开启压缩之前,可能希望先了解开启压缩后的收益,并根据收益大小做出决策。为此我们提供了一个压缩率评估工具,能够对目标表的数据进行采样,并使用和实际压缩过程完全相同的算法对采样数据进行压缩,计算压缩率,但不会实际生成BCA,不会修改任何数据。
如果业务将压缩数据迁移到另一个表,可能会导致所有数据从压缩状态变为非压缩状态,从而导致空间膨胀,这并非我们的方案引入的,而是所有压缩方案都需要解决的问题。如果冷热判定规则非常确定,那么业务可以手动执行压缩任务使压缩立即生效;对于耗时较长的大容量压缩表的迁移,业务仍然可以选择定期地开启自动压缩任务来完成。
最后,对于压缩的开启和关闭,我们提供最细粒度的控制,无论是普通表、普通分区表中的单个分区,还是二级分区中的任意单个分区、子分区,业务都可以单独开启或关闭压缩。这使得对于业务本身已经对数据区分了冷热(比如基于时间分区)的场景,仍然可以和我们的压缩特性很好地配合。
【结束语】
在OLTP表压缩这个特性中,我们引入了一系列的技术创新,包括全新的压缩算法、细粒度的自动冷热判定和块内压缩支持等,可以在提供合理压缩率的同时,大幅度降低对业务的影响,我们希望这个特性能够在支持关键在线业务的容量控制中发挥重要价值。
接下来我们还将在降低引入压缩对业务的影响、部分解压特性、OLTP索引压缩等方面持续创新迭代,我们希望能够有开创性的技术突破来解决相关的问题,为业务创造更大价值。