列存是常见的数据存储技术,说到列存常常就意味着高性能,现代分析型数据库基本都会把列存作为标配, 列存的基本原理是减少硬盘的读取量。一个数据表有多个列,但运算可能只会用到其中少数几列,采用列存时,用不着的列就不必读出来了,而采用行式存储时,则要把所有列都扫描一遍。当取用列只占总列数的小部分时,列存的 IO 时间优势会非常大,就会显得计算速度快了很多。 不过,列存也有另一面,并不是在任何场景下都有优势。
列存的实现远比行存复杂,因为数据表的列数可以事先确定,但行数却在不断增长。行存时按记录顺序写出,有追加时继续写即可,使用单个文件也很容易实现。列存则不行,考虑到数据有追加的情况,事先就无法确定行数,不可能把一列全部写出去再写下一列。一般会分块处理,一个数据块写入固定的 N 行数据,写满后启用下一个数据块;读取时以块为单位,块内列存,块间可以理解成是行存。这需要有个专门的管理模块用目录表记着这些不断增加的数据块及其中每个列的信息,麻烦很多。所以列存很难在单个数据文件中实现,一般只会出现在专业的数据仓库产品中。
这种分块机制在数据量不太大的时候对并行计算不友好。并行时要能把数据分段。这有两个要求:每段数据量基本相同(每线程处理能力相当),可以较灵活的分段(事先不能预测并行数)。行存可以按数据行分段,很小数据量就可以并行了。列存就只能按块分段,块内数据不能再分。但是每块的记录数(就是那个 N)不能太小,否则还是会由于硬盘的最小读取单位而造成较大的浪费,极端情况 N=1 就相当于行存,而且 N 太小也会导致总数据量很大时的目录表很大,目录管理的负担过重。所以通常这个 N 会取到 100 万或更大的数,而总块数要在数百以上才能保证灵活分段,也就是说,总数据量达到几亿条以上时,列存的并行计算才会比较顺畅。 SPL 有个倍增分段算法,可以让这个 N 随着数据量增加而变大,而总块数则维持固定。这样目录表规模也是固定的,在单个文件中也能方便地实现列存,较小数据量也能灵活分段并行。
从列存的原理上看,只有当运算涉及的列较少时,列存的优势才会明显。很多用于性能测试的案例(比如作为国际标准的 TPCH)也确实是这样,容易测出列存数据库的优势。但实际场景却不全是这样,像金融业务中,上百列的表且其中大部分都要用到的情况并不罕见,这时列存的效果就会大打折扣。虽然因为列存压缩比更高,读取量仍然会比行存更小,但涉及列很多时,优势并没那么明显,毕竟列存的读取过程也要比行存复杂很多。 所以,在实际场景中发现跑不出测试案例的性能时,也不要觉得很奇怪,也不表示测试是有假。
列存还会造成硬盘的随机读取。每列是连续存储的,但不同的列就不连续了。读的列越多造成的随机程度就越重,即使单线程单任务也会这样。对于固态硬盘,这个问题不算很严重,每一列都连续且上面那个 N 够大时,读取浪费占比很小,而固态硬盘没有寻道时间。 但对于机械硬盘,因为有寻道时间的存在,这将是灾难性的。在访问列数较多时,很可能发生还不如行存的性能好的现象。并发或并行还会进一步恶化这个问题。而通过加大缓冲区来缓解又会占用大量内存。 机械硬盘要慎用列存。
列存还有个较大的问题是它的索引性能要远比行存低。我们之前讲过索引表会存储有序的键值和对应记录在原表中的位置。对于行存来说,记录位置就是一个数;但列存就不一样了,一条记录的每个列各有各的位置,原则上需要都记录下来,这样一来,索引表几乎和原表一样大,空间占用大,读取成本高,这和复制原表后排序区别并不大了。 实际上当然不会这么做了,通常用的手段还是上述的分块机制,索引中只保存记录的序号。查找时从索引中读出序号,再定位到相应的块,然后从块起始点“数”到相应序号后读出列值。这个“数”的动作会针对每个列都执行,最好情况也要读出列数个硬盘单位,运气不好整个块都要扫描一遍,而行存索引一般只要读一两个硬盘单位就够了(视记录占的空间)。列存比行存的读取量会大出几十上百倍,碰到机械硬盘更是还有要命的寻道时间。所以列存基本上无法应对高并发查找的需求。
遍历用列存,查找用行存。遍历和查找都有高性能需求的数据,甚至有必要冗余两份存储。数据平台应该允许程序员根据实际情况决定采用的存储方式,而不是一刀切地替用户选择。 嗯, SPL 就可以自由选择行存还是列存,还有带值索引方案可以为遍历用列存数据提供一套查找用的行存副本。