跳转至

03 编码和压缩有什么用?

你好,我是彭旭。

今天我们课程的内容是编码与压缩。在设计一个优秀的数据库时,存储空间、性能以及资源占用无疑是必须深入考虑的关键因素。这些因素直接关联到数据库的效率、成本以及用户的体验。这里面,编码与压缩也是至关重要的一环。

通过这节课的学习,你一定能熟练掌握压缩和编码的常用算法,在使用数据库的时候根据自己的数据表和数据列选择合适的编码格式,优化数据存储结构,节省存储空间,提高数据的读取和查询性能。

如果你是要设计一个存储系统,不管是大量的冷数据存储,分布式场景下大量数据副本占用存储空间,还是有大量IoT设备的心跳数据、时序数据存储,都可以在这节课找到对应的启发。

我们先来看一下数据编码。

什么是编码?

我们工作中经常要将图片转成BASE64格式方便传输与存储,又或者需要处理音视频减少文件大小,从而提升传输效率,有的时候还会加密数据保障安全。其实,这些都是编码。

维基百科对编码的定义是这样的:编码是信息从一种形式或格式转换为另一种形式的过程;解码则是编码的逆过程。

这种信息转化的过程,一般都会伴随着空间的减少。比如,使用数据字典将一个城市字符串“Zhu Hai”编码映射成11,存储空间可能就减少了一半。又因为在数据的扫描处理上,字符串的处理时间可能是数字的2-3倍以上,所以,如果要对比字段或者给字段排序,那使用数字的性能也全面优于使用字符串,查询性能能够得到较大的提升。

那在数据库存储引擎中,都有哪些常用的编码算法呢?

RLE(Run Length Encoding)

第1个是RLE,它会将连续相同的数据序列编码为“重复次数+数据值”组合的形式,以节省存储空间。

比如给定字符串 “AABBBCCCCDDDDD”,经过RLE编码后变成了 “2A3B4C5D”。在压缩后的字符串中,RLE将连续的 “AA” 替换为 “2A”,连续的 “BBB” 替换为 “3B”,连续的 “CCCC” 替换为 “4C”,以此类推。这样,RLE就压缩了原始字符串,减少了重复字符的存储。

看到这里你应该知道了,这种编码算法适合用在重复数据较多的场景,比如对图像数据的编码。

因为图像数据通常用RGB表示,如(255,0,0)。而图像相邻位置的像素值比较接近,RGB 对应的值相同,比如 [255,0,0;255,0,0;255,0,0;255,0,1;255,0,1 …],那么使用RLE就可以表示为 [3:255,0,0; 2:255,0,1],大大减少存储的数据大小。

Delta Encoding

第2个是Delta Encoding,这是一个基于数值增量的编码算法,它会将数据转换为增量值进行存储,节省存储空间,也可以加快数据的查询速度。

举个例子,给定一个整数的数据序列 a = [10, 14, 18, 22, 26],经过Delta Encoding编码后得到的结果是 a’ = [10, 4, 4, 4, 4]。

这里编码的逻辑是,第一个数值保持不变,后续的数值用相对于前一个数值的增量值来表示。因此,原始数据序列中的 [10, 14, 18, 22, 26] 就被转换为 Delta Encoding 后的增量序列 [10, 4, 4, 4, 4]。

等一下,那这种编码算法是不是就有缺陷了?难道只能针对数值数据来进行编码?

其实我们可以稍微变通一下,比如HBase里面使用的Delta Encoding系列算法,PREFIX、DIFF、FAST_DIFF等,也能够支持对字符串数据的编码。

用PREFIX(由PrefixKeyDeltaEncoder实现)举个例子。如下图所示,图的上半部分表示一个存储了4行数据的表,这个表的行键由两部分组成。

图片

图片

第一部分是将用户ID反转后补齐20位获得的,即行键中的红色部分。这个处理是为了避免热点区间,因为用户ID一般是连续的整数,而新用户一般比较活跃,产生的数据较多,所以反转后能够避免连续的新用户全部分到同一个分区或者服务器。

第二部分是一个用Long.MAX_VALUE减去当前时间戳,得到的值再补齐20位,即行键中的紫色部分。保证得到一个定长的行键,就能方便我们从行键中提取数据,而且最新的数据会排在前面。

再看图的下半部分,是开启编码后存储的数据示例。可以看到,表格里多存储了一列前缀长度,但是行键的位数大大减少了。具体怎么做到的呢?

你看,第2行的前缀长40,表示第2行行键的前40位跟第1行的行键全部一样,只有最后1位的2跟第1行行键不一样。第3行行键同样也是只有最后一位3跟前一个行键不一样。第4行的行键跟前面几行相比,前面37位都一样,只有最后4位不一样,那就只需要把最后不一样的4位存到数据块。

也就是说,如果单从键的长度大小来看,差不多压缩了60%-70%的存储空间。

所以,Delta Encoding特别适合这种按字典序排序的键、值类数据的压缩。比如HBase按行键字典序排序。在时序数据库中,数据以时间戳为key,这些key按顺序递增,同时按顺序排列存储。

这里给你看一个在HBase表定义时使用数据编码的一个例子。HBase默认是没有开启编码的,下面这个建表语句也是在HBase表中使用FAST_DIFF编码的方法。

CREATE 'ls3_encoding_table',
  {NAME => 'cf1', DATA_BLOCK_ENCODING => 'FAST_DIFF'}

字典编码(Dictionary Encoding)

第3个是字典编码,这种编码需要构建一个字典表,将数据值映射为字典索引进行存储,减少数据的存储空间,提高压缩比。

比如我们先构建一个城市的字典表,将城市映射成为一个数字,之后在表数据存储时,针对城市的列,我们只需要存储城市的数字编码即可。

图片

图片

使用字典编码一方面可以节省存储空间,另一方面,如果需要根据城市去过滤查询,使用数字过滤的性能也比字符串要高。

字典编码适合低基数的列,也就是列的distinct取值较少的场景。当然,在分布式数据库中,还要考虑字典的全局性与唯一性,以免出现不同节点字典不一致导致数据不一致的情况。StarRocks就是通过全局字典来解决这个问题的。也就是用一个统一的全局字典来统一所有节点的用来编码的字典表。

总结一下,各种编码方式只要是在合适的场景下使用,就都能节省一定的存储空间,有时候还能提升查询过滤的性能。而且,由于编码后占用空间减小,所以缓存到内存的数据量也变得更多了,同样能够提升性能,但带来的问题就是占用更多的CPU资源。

什么是压缩?

当然,不是所有的编码都能减少存储空间,比如Base64编码会将每3个字节的二进制数据转换为4个Base64字符,反而会增加存储空间。所以在数据库中,通常会结合编码与压缩一起节省存储空间,先将数据编码,然后再压缩。

在计算机技术中,压缩随处可见。它可以分为两种类型。

一种是有损压缩。压缩后的数据会丢失部分信息,但通常丢失的部分不影响数据的完整性。比如针对音频、视频等多媒体数据的压缩,我们常见的JPEG图像、MP3其实都属于有损压缩的技术。

另一种是无损压缩。压缩数据可以完全还原成原始数据,不会损失任何信息。无损压缩通常适用于需要完全保留数据准确性和完整性的情况,数据库领域使用的基本都是无损压缩。

压缩主要是利用消除冗余信息,利用数据的统计特性来减少数据大小、节省存储空间、提升数据传输效率。它会结合不同的编码形式。比如结合字典编码,将重复出现的数据块替换为指向字典中的索引。还有Huffman编码,是根据字符出现频率分配不同长度的编码来实现压缩的。出现次数高的字符用短编码表示,出现次数低的字符用长编码表示,从而减少整体长度。

常用的压缩算法包括Snappy、LZ4、Zstandard、GZip等,不同的压缩算法会在压缩/解压缩速度与压缩比例之间做一个平衡。而且很多压缩算法还能够支持多层压缩,也就是压缩一次后,再进行压缩,这样可以进一步提升压缩比例,但是压缩/解压缩时间会相应增加。

这些压缩算法,通常能够压缩50%-70%以上的空间,极大地提升了数据的读取与写入速度。而且分布式数据库通常还会对数据进行多副本的备份,压缩后的数据再进行副本的同步,传输数据量也大大减少了,传输备份效率也会提升。

这里我整理了一下我们要介绍的几个数据库所支持的压缩算法,以及这些算法的一些压缩指标。

图片

下面是这3个分布式数据库建表时启用编码与压缩的方法。

#HBase 启用压缩
CREATE 'ls3_compress_table',
  {NAME => 'cf1', COMPRESSION => 'SNAPPY'},
  {NAME => 'cf2', COMPRESSION => 'GZ'}

#Starrocks启用压缩
CREATE TABLE `ls3_compress_table`
(
    `id`   INT(11) NOT NULL,
    `key` CHAR(200) NULL ,
    `value` CHAR(200) NULL
) ENGINE = OLAP 
UNIQUE KEY(`id`)
DISTRIBUTED BY HASH(`id`)
PROPERTIES (
"compression" = "ZSTD"
);

#Clickhouse启用压缩
CREATE TABLE ls3_compress_table
(
    dt Date CODEC(ZSTD),
    ts DateTime CODEC(LZ4HC),
    float_value Float32 CODEC(NONE),
    double_value Float64 CODEC(LZ4HC(9)),
    value1 Float32 CODEC(Delta, ZSTD),
    value2 Float32 CODEC(Gorilla)
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(dt)
ORDER BY (dt, ts);

这3个分布式数据库都支持LZ4算法,而且其中两个都把它当做默认的压缩算法,Clickhouse则最为灵活,可以设置列级别的压缩算法。而HBase默认是不启用编码与压缩的,你可以基于自己的数据选用一个合适的编码压缩算法,一般都会提升整体的性能。

压缩算法指标

不同的压缩算法,在不同场景、不同类型数据集、不同机器配置下表现差异较大,你可以从这个压缩性能基准测试网站看到各种压缩算法的压缩比率、压缩速度、解压速度的一个对比。

为了方便你自行做一些测试,了解这些压缩算法在自己的数据集里的表现,这里我用我们之前写入Parquet文件的例子来演示一下LZ4与Snappy的压缩。

import pyarrow as pa
import pyarrow.parquet as pq
import random
import time
# 创建一个员工表,包含员工ID、部门ID、薪资 3列
data = {
    'employ_id': [],
    'dep_no': [],
    'salary': [],
}
# 随机生成 10000000 个员工数据
for i in range(10000000):
    data['employ_id'].append('NO' + str(i + 1))  # 员工ID从1开始递增
    data['dep_no'].append(random.randint(1, 10))  # 部门ID随机取值范围为1-10
    data['salary'].append(random.randint(1000, 10000))  # 薪资随机取值范围为1000-10000
table = pa.table(data)
# 不压缩写入
start_time = time.time()
pq.write_table(table, 'ls3_output.parquet', compression=None)
end_time = time.time()
execution_time = (end_time - start_time)*1000
print(f"不压缩执行时间: {execution_time} 毫秒")
# 使用SNAPPY压缩写入
start_time = time.time()
pq.write_table(table, 'ls3_output_snappy.parquet', compression='SNAPPY')
end_time = time.time()
execution_time = (end_time - start_time)*1000
print(f"SNAPPY压缩执行时间: {execution_time} 毫秒")
# 使用LZ4压缩写入
start_time = time.time()
pq.write_table(table, 'ls3_output_lz4.parquet', compression='LZ4')
end_time = time.time()
execution_time = (end_time - start_time)*1000
print(f"LZ4压缩执行时间: {execution_time} 毫秒")

可以看到例子中我们随机生成了1000万行员工数据,然后分别不压缩、使用Snappy压缩、使用LZ4压缩后写入Parquet文件。输出结果如下所示。

不压缩执行时间: 432.391881942749 毫秒
SNAPPY压缩执行时间: 489.5129203796387 毫秒
LZ4压缩执行时间: 523.9160060882568 毫秒

同时输出文件大小分别为:不压缩= 153.9M、Snappy压缩= 73.3M、LZ4压缩= 66.7M。

可以看到,压缩率LZ4相对更高,Snappy则性能稍优。

但压缩也不是“放之四海而皆准”的。在一些高性能系统中,如果大量使用压缩,将会占用较多的CPU资源,进而有可能导致系统响应性能下降。而且有时候数据需要解压后才能查询过滤,这对读性能又是一个不利的因素。

小结

本节课我们介绍了编码与压缩的原理和用途,包括节省存储空间,通过优化数据对比和过滤让相同的内存下能够缓存更多的数据,从而提升数据传输、读写的性能。当然,带来的副作用是对CPU资源的占用。

比如HBase2.0之前有一个叫做PREFIX_TREE的编码算法,某些情况下甚至直接导致CPU占用100%,不过在HBase2.0之后的版本,这个编码算法被弃用了。

不同的编码与压缩的方式基本是在平衡效率与性能,你需要根据你的使用场景进行取舍。比如在时序数据集下可能就适合用Delta编码压缩的方式。低基数的全球地址表就适合使用字典编码等。这节课的结尾,我也提供了一个使用自己数据集来测试不同压缩方式的例子,你可以用来判断哪个压缩算法更合适你的数据集。

编码与压缩是数据库高性能的一个重要因素,LZ4、Snappy、ZStandard是在数据库引擎中支持较广的压缩方式,一般情况下使用默认的编码压缩会取得较好的性能平衡,知道调整这些编码与压缩算法的方法,可以让你根据自己的数据集的进行一些性能调优。

下节课,我们将要介绍另外一个提升数据库并发与性能的重要机制,事务与MVCC。

思考题

你认为编码与压缩带来的都是性能的提升吗?你见过哪些编码与压缩带来的负面影响?

欢迎你在留言区和我交流。如果觉得有所收获,也可以把课程分享给更多的朋友一起学习。欢迎你加入我们的读者交流群,我们下节课见!

精选留言(2)
  • zheng 👍(4) 💬(1)

    编码压缩其实是时间换空间。实际使用中就要看什么时候空间相对重要。一般是IO(网络和磁盘)和内存不足,后者可能还需要流式解压。

    2024-06-21

  • 密码123456 👍(2) 💬(2)

    编码的话,但是见过。比如base64。服务器设置post大小是10m。正常图片是小于10m的,但是经过base64编码他就超过10m了。压缩的话,还是没遇到过。应该就是太吃cpu性能吧,影响服务器的吞吐量。

    2024-06-26