IT 论文精读 · PAPER 64
Stonebraker, Abadi, Madden, Cherniack, O'Neil 等 · MIT / Brandeis / UMass Boston / Brown · VLDB 2005
2005 年,Michael Stonebraker 和一群数据库研究者做了个叫 C-Store 的系统原型,把数据库里的数据从「一行一行地存」改成「一列一列地存」。听起来只是换了个摆法,却让「把上亿条记录汇总成一个数字」这类查询快了两个数量级。今天你用的 BigQuery、Snowflake、ClickHouse,以及数据湖里到处都是的 Parquet 文件,走的都是这条路。
把数据库想成超市的账本。传统做法是按收据存:每笔交易的时间、门店、商品、金额写成完整的一张,一张张摞起来。这时老板问一句:「去年全国一共卖了多少钱?」——你只需要「金额」这一个数字,却不得不把每张收据整张抽出来、瞟一眼金额、再塞回去。上亿张收据,力气几乎全花在搬运你根本不看的那些字段上。
C-Store 换了个摆法:把所有收据的「金额」抄成一长条,「门店」抄成另一长条,「商品」再抄一条。老板问金额,你就只搬金额那一条,别的碰都不碰。同样一个问题,要搬的东西少了一大截。
而且这么一摆,还顺手带出三件好事。
一是特别好压缩。一整条里全是同一种东西——门店那条里「北京中关村店」可能一连重复几千行,记一笔「北京中关村店,从第 1 行起,连着 5000 行」就完事了。更妙的是,很多问题压着不解开就能回答:想知道这家店有多少笔交易,看一眼那句「连着 5000 行」即可,根本不用把它摊开还原成 5000 行。
二是同一份数据可以按几种顺序各存一套。常按日期查就排一套按日期的,常按门店查就排一套按门店的——好比同一批账同时按日期、按门店装订了两本,问哪种问题都有一本现成排好的。传统数据库靠「另外建索引」去够到类似效果;这里因为列压得极小,索性多存几套更划算。这几套副本还顺便是备份:坏掉一台机器,从别的副本把它缺的数据重新问出来就行。
三是写入怎么办。排得整整齐齐、又压得死紧的账本,想在中间插一行非常费劲。C-Store 的办法是分成两处:新数据先进一个小小的「随手记」(写得快、不压缩),后台养着一个搬运工,定期把攒够的部分成批合并进那本整齐的大账。读的时候两边一起看,所以不会读丢刚写的数据。
论文里,C-Store 在一组分析型查询上比当时的商用行式数据库快了两个数量级,占的磁盘还不到人家一半。它后来变成一家公司 Vertica(2011 年被惠普收购),而「列式 + 重压缩」从此成了数据仓库的默认长相。
也说句诚实的代价:这套摆法天生是给「写得少、读得多」的分析场景准备的。要一笔笔改订单、改完立刻生效的交易型业务,一行一行地存依然更合适。
按列存,查询就只搬它真正要用的那几列;一整列长得像,于是能压到很小、还能压着不解开直接算;再把同一份数据按几种顺序各存一套,既当索引又当备份;写入慢的问题交给「随手记 + 后台批量合并」。分析型数据库从此换了代。
想看投影、join index、WS/RS 的结构图与实验数字? → 切到精读版
C-Store 把关系数据库的物理存储从「按行」翻成「按列」,并围绕这一点重做了整套设计:重度压缩且尽量在压缩态上直接计算、把同一份数据按不同排序键存成若干重叠的「投影」(用排好序的冗余副本兼任索引与容错副本)、以及用「写优化小库 + 读优化大库 + 后台搬运工」的混合结构把列存最怕的写入吸收掉。它是分析型(OLAP)数据库换代的起点,后来商业化为 Vertica。
作者是 Michael Stonebraker(MIT)、Daniel Abadi、Samuel Madden、Mitch Cherniack(Brandeis)、Elizabeth 与 Patrick O'Neil(UMass Boston)、Stan Zdonik(Brown)等十来人,发表于 VLDB 2005。按列存储本身不是它发明的——1985 年的分解存储模型(DSM)、商用的 Sybase IQ、学术界的 MonetDB 都早有先例;C-Store 的贡献是把列存放进一套完整的、可用的数据库设计里:压缩、排序冗余、混合读写、副本容错、快照读,环环相扣。它下启 Vertica,并与同期的 Dremel(列存 + 多级执行树,见本站那篇)一起成为今日数据仓库的两条源流。
2005 年前后的主流关系数据库(Oracle、DB2、SQL Server 等)血统都来自 1970–80 年代的 OLTP 世界,物理存储是写优化的:一整行连续摆放,插一条记录只要写一处。可市场的重心已经挪向数据仓库——批量灌入、几乎不改、被反复扫描汇总。
在这种负载上,行存有个结构性的浪费:磁盘按块读取,而一个块里塞的是整行。一条「按年汇总销售额」的查询只碰两三个字段,硬盘却被迫把每行的几十个字段全搬进内存;表越宽,浪费越大。当时的补救是狂建索引和物化视图,但那既要额外空间、又拖慢写入,还只照顾得到预先想到的查询。
作者的判断很直接:问题不在于优化得不够,而在于物理布局站错了边。既然读远多于写,就该从零设计一个读优化的存储——哪怕写入变难,也用别的机制补回来。
C-Store 把每一列单独连续存放。查询要哪几列就只读哪几列,不相干的字段一个字节都不碰。宽表上的分析查询,I/O 量常常因此降一个数量级。
代价是重建行(tuple reconstruction):查询往往要把几列拼回同一条记录。C-Store 的办法是靠位置对齐——同一批列里的第 i 个值天然属于第 i 行,于是拼接只是按序号取值,不需要在每个值上存行号。这个「位置即身份」的约定后来成了所有列式引擎的基本功。
一列里全是同类型、同语义、常常还有序的值,重复度远高于一整行,因此压缩率天生比行存高得多。C-Store 按两个维度挑编码:这一列是不是它所在那份数据的排序键(有序 / 无序)× 不同取值多不多:
(值, 起始位置, 连续个数)——几千行压成一行。真正关键的一步是:算子尽量直接吃压缩后的表示,不先解压。统计「某门店的交易笔数」碰上 RLE,只要读出那句「连续 5000 行」,答案就是 5000——省掉的不只是磁盘 I/O,还有解压和逐行处理的 CPU。压缩在这里不再是「省空间、费 CPU」的取舍,而是两头都省。
C-Store 存的不是「表」,而是投影(projection):从某张表出发、挑出一组列(可以顺着外键把别的表的列也拉进来),整体按某个排序键(sort key)排好序。同一列允许出现在多个投影里、各按不同的键排序。只要所有列都至少被某个投影覆盖,任意查询就都能回答。
为什么这么设计?因为一旦按列存 + 重压缩,「多存一份排好序的副本」变得很便宜,而一份排好序的副本同时干三件事:① 当索引用——按该键做范围查询可以直接跳段;② 当压缩加速器用——排序键那一列有序,RLE 压到极致;③ 当容错副本用(见下文 K-safety)。传统数据库里「基表 + 索引 + 物化视图」三样东西,在这里被统一成同一种物件。
每个投影再按排序键的取值范围水平切成段(segment)分布到不同机器;段内每行有一个存储键(storage key),其实就是它在段里的序号,读优化那份根本不落盘、按位置算出来即可。不同投影排序不同,行序自然对不上,于是需要 join index:它为「发送方」投影的每一行记下对应行在「接收方」投影里的 (段号, 存储键),把两种顺序缝回同一条逻辑记录。
列式 + 排序 + 重压缩,等于把数据焊成了一整块——想在中间插一行,几乎要重写一段。C-Store 干脆把写和读拆成两个库:
插入只进 WS;删除是在对应行上打一个标记(记进删除向量),更新 = 删除 + 插入。后台的搬运工(tuple mover)周期性做一次批量归并:把 WS 里够「老」的成块数据合进 RS,顺带重写压缩块、更新 join index。查询则同时读 WS 和 RS 再合并结果,所以刚写的数据不会读丢。「小而快的写区 + 大而齐的读区 + 后台批量合并」这一模式,后来在 LSM 树、ClickHouse 的分区合并、Vertica 的 ROS/WOS 里反复出现。
数据仓库的查询又长又重,和写入抢锁会互相拖死。C-Store 让只读事务跑在稍早的一个时间点上:系统用粗粒度的 epoch(几秒一格)作时间戳单位,由一个时间戳权威推进「高水位」;只读查询挑一个不晚于高水位的 epoch,靠每行的插入与删除时间判断它在那一刻是否可见——于是读事务全程不加锁、也不阻塞写,代价是看到的数据可能旧几秒。读写事务则走常规的两阶段锁。
RS 又大、又是整块重写,给它记预写日志代价高得离谱。C-Store 的取舍是:只给小小的 WS 记日志,整体容错交给副本——保证任意 K 台机器同时故障时剩下的投影仍覆盖所有列(称作 K-safety)。某台机器崩溃重启后,它缺的那段数据不靠重放日志补,而是向其它副本「查」回来,把结果灌回自己的 WS。妙处在于这些副本本来就为查询性能而存在,容错几乎是白捡的。
论文给出的是一组初步实验:TPC-H 派生的仓库式模式上、7 条查询,对手是一个流行的商用行存和一个流行的商用列存(均匿名)。在限定各系统可用磁盘空间的那组配置下,同样的数据 C-Store 占 1.987 GB,商用行存占 4.480 GB(约四成),而查询上 C-Store 平均比商用行存快约 164 倍、比商用列存快约 21 倍。作者把收益归因于三处叠加:只读需要的列、投影代替全表扫描、以及更好的压缩。
作者本人对这些数字相当克制:原型只实现了设计的一部分——WS 与搬运工尚未完整跑通,所以测的基本是读优化那一半;他们明说要等这些补齐才谈得上详尽评估。把它当成「方向性证据」而非「产品对比」更恰当。
这篇之后,列式存储从小众选项变成分析型数据库的默认前提。C-Store 商业化为 Vertica(2011 年被惠普收购),而它那套组合拳——列式布局、按列选编码的重压缩、压缩态上直接计算、排序冗余副本兼任索引、读写分离加后台合并——几乎被后来每一个分析引擎继承:Redshift、ClickHouse、Snowflake、BigQuery,以及 Parquet / ORC 这类列式文件格式。今天「湖仓」里那些扫几十亿行只用几秒的查询,吃的正是这份红利。
它还坐实了 Stonebraker 的那句主张:「一种尺码穿不下所有人」——OLTP 与 OLAP 该用结构不同的引擎,而不是同一个引擎调参数(同一批人随后做的 H-Store/VoltDB 就是这条主张的另一半)。学术上,它把「压缩与执行如何协同」「什么时候把列拼回行(延迟物化)」推成了一整条研究线。
① 一句话:按列存 + 重压缩 + 排序冗余投影 + 写读分离,为「读远多于写」的分析负载重做一套数据库。
② 痛点:行存把整行摆在一起,而分析查询只碰少数列,磁盘被迫搬运大量无人查看的字段。
③ 列存机制:每列连续存放,只读要用的列;拼回一行靠「第 i 个值属于第 i 行」的位置对齐,值上不存行号。
④ 压缩:按「是否排序键 × 取值多不多」选 RLE / 位图 / 差分 / 不编码;关键在算子直接吃压缩表示,省空间同时省 CPU。
⑤ 投影:不存表、只存按不同排序键排好的重叠列组,水平切段;排好序的副本同时充当索引、压缩加速器和容错副本,跨投影拼行靠 join index。
⑥ 写入:新数据进不压缩的 WS,后台搬运工批量归并进压实的 RS,查询两边同读——「小写区 + 大读区 + 后台合并」的祖型之一。
⑦ 并发与容错:只读查询按 epoch 取过去时刻的快照、全程不加锁;RS 不记日志,靠 K-safety 副本互相「查」回丢失的数据。
⑧ 结果:7 条 TPC-H 风格查询上比商用行存快约 164 倍、比商用列存快约 21 倍,占空间约为行存四成;但原型的写入侧未完整实现,作者自称初步。
⑨ 影响与局限:商业化为 Vertica,成为 ClickHouse / Redshift / Snowflake / BigQuery 与 Parquet、ORC 的共同底座;但那组对比不对等、收益来源当时未拆清、join index 在工程上基本没落地、物理设计的难题被转嫁给了设计工具。