数据分析存储:从“光盘行动”到“智能挑食”——一个数据表的变形记

0 阅读12分钟

你是否曾经纳闷,为什么公司里的报表系统跑个查询要等半天,而你在淘宝下单却秒速完成?这背后其实是一场地盘争夺战——OLTP(联机事务处理,Online Transaction Processing)和 OLAP(联机分析处理,Online Analytical Processing)之间的较量。简单说,OLTP 像是快餐店柜台,讲究快速处理单个订单;而 OLAP 则像后厨大数据分析,得搞清楚"西红柿炒蛋是不是比青椒肉丝更受欢迎"。

今天,我们就通过一个销售记录表的完整"变形记",来揭秘为了让分析查询跑得更快,工程师们是如何对数据"下刀"的。

我们的实验对象:一张简单的销售表

假设我们有一个极小的事实表 sales,包含6条记录:

订单ID日期产品销售额支付方式
12024-01-01iPhone7999信用卡
22024-01-01AirPods1299支付宝
32024-01-02iPhone7999支付宝
42024-01-02MacBook12999微信支付
52024-01-02AirPods1299信用卡
62024-01-03MacBook12999支付宝

第一幕:行存储——传统的"光盘行动"

在 OLTP 数据库(如 MySQL、PostgreSQL 的默认引擎)中,数据是以为单位存储的。想象一下,数据在磁盘上排列如下:

[1, 2024-01-01, iPhone, 7999, 信用卡]
[2, 2024-01-01, AirPods, 1299, 支付宝]
[3, 2024-01-02, iPhone, 7999, 支付宝]
[4, 2024-01-02, MacBook, 12999, 微信支付]
[5, 2024-01-02, AirPods, 1299, 信用卡]
[6, 2024-01-03, MacBook, 12999, 支付宝]

优点:获取单条记录(例如查询订单ID=4的所有信息)极快,因为一次磁盘读取就能拿到该行所有列。 痛点:当分析师问"2024-01-02 的总销售额是多少?"时,系统必须把所有6行数据从磁盘加载到内存,然后解析每一行,只挑出日期2024-01-02销售额进行求和。大量无关的产品支付方式列数据也被迫读取,造成了巨大的 I/O 和 CPU 浪费。这就是"光盘行动"——不管你想不想吃,整盘菜都得端上来。


第二幕:列存储变形记——从此只吃"虾仁"

行存储的"光盘行动"虽然保证了事务处理的完整性,但对分析查询来说却效率低下。于是,工程师们想出了一个绝妙的点子:如果我们不按行存储,而是按列存储会怎样?

这就像去自助餐时,不再把每道菜都装在一个盘子里,而是设立了专门的"虾仁专区"、"牛排专区"、"甜点专区"。当你只想吃虾仁时,直接去虾仁专区拿就好了,完全不用管牛排和甜点!

列式存储的"自助餐专区"

让我们把 sales 表"竖着切"成五个专区:

 `订单ID`:    [1, 2, 3, 4, 5, 6]
 `日期`:      [2024-01-01, 2024-01-01, 2024-01-02, 2024-01-02, 2024-01-02, 2024-01-03]
 `产品`:      [iPhone, AirPods, iPhone, MacBook, AirPods, MacBook]
 `销售额`:    [7999, 1299, 7999, 12999, 1299, 12999]
 `支付方式`:  [信用卡, 支付宝, 支付宝, 微信支付, 信用卡, 支付宝]

现在,回答"2024-01-02 的总销售额是多少?"这个问题变得异常简单:

  1. 走到"日期专区",快速扫描并定位到第3、4、5个位置(对应2024-01-02)。
  2. 然后走到"销售额专区",只取第3、4、5个值(7999, 12999, 1299)进行求和。

至于"产品"和"支付方式"专区?这次查询根本不会去碰它们!I/O 量瞬间减少,查询速度大幅提升。这就是"只吃虾仁"的魅力——只读取计算所必需的列,无关列的数据连看都不看

列压缩:把"虾仁"脱水保存

列存储还有一个意想不到的好处:同列的数据类型一致,更容易压缩。这就像你把所有虾仁收集在一起后,发现可以进行脱水处理,让它们占用更少的空间。

支付方式列为例,它的值只有区区三种。我们可以使用一种叫做位图索引(Bitmap Index) 的压缩方法:

  1. 为每个唯一值创建一个位图(一串0/1),长度等于总行数(6):

    信用卡   [1, 0, 0, 0, 1, 0] (第15行是信用卡)
    支付宝   [0, 1, 1, 0, 0, 1] (第236行是支付宝)
    微信支付 [0, 0, 0, 1, 0, 0] (第4行是微信支付)
    
  2. 压缩这些位图:这些位图里有很多连续的0,可以使用游程编码(Run-Length Encoding, RLE) 进行压缩。

    • 原始支付宝位图 [0, 1, 1, 0, 0, 1] 用 RLE 可以表示为:(0,1), (1,2), (0,2), (1,1)。意思是"1个0,接着2个1,接着2个0,接着1个1"。
    • 这样,原本需要6个字节(或位)存储的列,现在只需要几个小数字就能表示,压缩比极高。

通过列压缩,我们不仅做到了"只吃虾仁",还把虾仁进行了脱水处理,让它们占用更少的空间,进一步提高了存储和读取效率。

这就是列式存储的核心理念:通过改变数据的物理布局,让分析查询能够精准地只读取需要的列,再通过列压缩技术减少存储空间和I/O开销。


第三幕:排序——创造极致的压缩"黄金机会"

在列存储的基础上,排序是让压缩效果达到极致的"秘密武器"。但这里需要明确一个关键点:排序只对排序列本身产生直接的压缩优化效果,对其他列的影响取决于数据的自然相关性

3.1 单列排序的直击效果

让我们先按日期列进行排序。我们的数据已经基本按日期有序了,所以排序后的变化不大:

订单ID日期产品销售额支付方式
12024-01-01iPhone7999信用卡
22024-01-01AirPods1299支付宝
32024-01-02iPhone7999支付宝
42024-01-02MacBook12999微信支付
52024-01-02AirPods1299信用卡
62024-01-03MacBook12999支付宝

从列存储的视角看,排序后的日期列是这样的: [2024-01-01, 2024-01-01, 2024-01-02, 2024-01-02, 2024-01-02, 2024-01-03]

这带来了什么?

  • 连续重复值:相同的日期值连续出现!2024-01-01连续2次,2024-01-02连续3次。
  • 极致的游程编码:用RLE压缩后变为:(2024-01-01, 2), (2024-01-02, 3), (2024-01-03, 1)
  • 压缩效果:原本需要6个日期值的存储空间,现在只需要存储3个"块"(每个块包含值和重复次数),压缩率极高!

3.2 其他列呢?真相与误区

重要澄清:只按日期排序,并不会"自动"让产品列的值也聚集在一起。

排序后的产品列:[iPhone, AirPods, iPhone, MacBook, AirPods, MacBook]

  • iPhone出现在第1和第3位(不连续)
  • AirPods出现在第2和第5位(不连续)
  • MacBook出现在第4和第6位(不连续)

真相排序操作是按指定列重新排列整行数据,其他列只是"被动地"跟着移动。如果这些列的值与排序列有自然相关性,它们可能会表现出更好的局部性,但这不是排序算法的直接目的。

3.3 复合排序:追求更全面的优化

如果想要让多列都获得压缩好处,可以使用复合排序键。比如,我们按日期, 产品排序:

订单ID日期产品销售额支付方式
12024-01-01iPhone7999信用卡
22024-01-01AirPods1299支付宝
32024-01-02AirPods1299信用卡
42024-01-02iPhone7999支付宝
52024-01-02MacBook12999微信支付
62024-01-03MacBook12999支付宝

现在看列存储视图:

  • 日期列:[2024-01-01, 2024-01-01, 2024-01-02, 2024-01-02, 2024-01-02, 2024-01-03](仍有连续重复值)
  • 产品列:[iPhone, AirPods, AirPods, iPhone, MacBook, MacBook]

惊喜!产品列现在有了更多连续重复值:

  • AirPods在第2、3位连续出现
  • MacBook在第5、6位连续出现

复合排序的智慧

  1. 按日期排序:让相同日期的记录聚集
  2. 在每个日期分组内,再按产品排序:让相同产品在各自日期分组内聚集

这样,两个列都获得了更好的局部性,都能从RLE等压缩技术中受益。

3.4 实际应用中的选择

在真实的数据仓库中:

  • 通常会选择最常被查询的列作为主排序列(如日期
  • 如果需要优化多列查询,会使用复合排序键
  • 排序策略基于对查询模式的分析和数据分布的理解

第四幕:物化视图与数据立方体——把答案提前写好

即使有了列存储和排序压缩,每次计算"每日总销售额"依然要扫描和聚合数据。对于高管每天早会都要看的报表,这太浪费了。于是,物化视图(Materialized View) 出场了。

我们可以创建一个物化视图 daily_sales_summary,它预先计算并物理存储好以下结果:

日期       | 总销售额
-----------|---------
2024-01-01 | 9298 (7999+1299)
2024-01-02 | 22297 (7999+12999+1299)
2024-01-03 | 12999

以后查询"2024-01-02 的总销售额是多少?",引擎直接从这个只有3行的小表中查找答案,速度是光速级的。

更上一层楼:数据立方体(Data Cube) 如果问题变得更复杂,比如"每日、每款产品的总销售额是多少?"我们可以构建一个二维的数据立方体:

日期\产品iPhoneAirPodsMacBook日汇总
2024-01-017999129909298
2024-01-02799912991299922297
2024-01-03001299912999
品汇总15998259825998总计:44594

这个立方体预先计算并存储了所有维度组合(日期×产品)的聚合值。现在,无论是问"周二的销售情况"(查一行),还是问"iPhone 的总销售情况"(查一列),甚至是问"整个公司的销售总额"(查右下角的总计),都能在瞬间得到答案。这就是 OLAP 的终极武器之一,支持快速的切片(Slicing)、切块(Dicing)、下钻(Drill-down) 操作。

总结:一场精心策划的数据效率革命

从我们 sales 表的变形记可以看出,分析型存储的优化是一套组合拳:

  1. 列式存储:改变了数据在磁盘上的根本布局,使查询能够"挑食",只读取需要的列。
  2. 列压缩:利用列内数据的同质性和重复性(尤其是位图编码处理低基数枚举列),大幅减少存储空间和I/O。
  3. 智能排序
    • 单列排序:为主排序列创造完美的连续重复值,实现极致压缩
    • 复合排序:通过精心设计的排序键顺序,让多个相关列都能获得更好的局部性和压缩效果
    • 数据相关性:利用数据的自然相关性(如同一天内相同产品的销售往往集中),增强排序的实际效果
  4. 物化聚合:将频繁查询的昂贵计算结果(如汇总、立方体)预先算好并保存,用空间换时间,实现亚秒级响应。

所以,下次当你面对一个需要几分钟才能跑出的报表时,可以想象一下:背后的系统可能正在将数百TB的数据,通过列式存储的"刀工"、排序与压缩的"腌制工艺"、以及物化视图的"预制菜"流程,加工成一份能够秒级呈上的数据佳肴。这不是魔术,而是现代数据工程中,为了极致效率而进行的一场静默而精彩革命。