你是否曾经纳闷,为什么公司里的报表系统跑个查询要等半天,而你在淘宝下单却秒速完成?这背后其实是一场地盘争夺战——OLTP(联机事务处理,Online Transaction Processing)和 OLAP(联机分析处理,Online Analytical Processing)之间的较量。简单说,OLTP 像是快餐店柜台,讲究快速处理单个订单;而 OLAP 则像后厨大数据分析,得搞清楚"西红柿炒蛋是不是比青椒肉丝更受欢迎"。
今天,我们就通过一个销售记录表的完整"变形记",来揭秘为了让分析查询跑得更快,工程师们是如何对数据"下刀"的。
我们的实验对象:一张简单的销售表
假设我们有一个极小的事实表 sales,包含6条记录:
| 订单ID | 日期 | 产品 | 销售额 | 支付方式 |
|---|---|---|---|---|
| 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 | 支付宝 |
第一幕:行存储——传统的"光盘行动"
在 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 的总销售额是多少?"这个问题变得异常简单:
- 走到"日期专区",快速扫描并定位到第3、4、5个位置(对应2024-01-02)。
- 然后走到"销售额专区",只取第3、4、5个值(7999, 12999, 1299)进行求和。
至于"产品"和"支付方式"专区?这次查询根本不会去碰它们!I/O 量瞬间减少,查询速度大幅提升。这就是"只吃虾仁"的魅力——只读取计算所必需的列,无关列的数据连看都不看。
列压缩:把"虾仁"脱水保存
列存储还有一个意想不到的好处:同列的数据类型一致,更容易压缩。这就像你把所有虾仁收集在一起后,发现可以进行脱水处理,让它们占用更少的空间。
以支付方式列为例,它的值只有区区三种。我们可以使用一种叫做位图索引(Bitmap Index) 的压缩方法:
-
为每个唯一值创建一个位图(一串0/1),长度等于总行数(6):
信用卡 [1, 0, 0, 0, 1, 0] (第1、5行是信用卡) 支付宝 [0, 1, 1, 0, 0, 1] (第2、3、6行是支付宝) 微信支付 [0, 0, 0, 1, 0, 0] (第4行是微信支付) -
压缩这些位图:这些位图里有很多连续的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 | 日期 | 产品 | 销售额 | 支付方式 |
|---|---|---|---|---|
| 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 | 支付宝 |
从列存储的视角看,排序后的日期列是这样的:
[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 | 日期 | 产品 | 销售额 | 支付方式 |
|---|---|---|---|---|
| 1 | 2024-01-01 | iPhone | 7999 | 信用卡 |
| 2 | 2024-01-01 | AirPods | 1299 | 支付宝 |
| 3 | 2024-01-02 | AirPods | 1299 | 信用卡 |
| 4 | 2024-01-02 | iPhone | 7999 | 支付宝 |
| 5 | 2024-01-02 | MacBook | 12999 | 微信支付 |
| 6 | 2024-01-03 | MacBook | 12999 | 支付宝 |
现在看列存储视图:
日期列:[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位连续出现
复合排序的智慧:
- 按日期排序:让相同日期的记录聚集
- 在每个日期分组内,再按产品排序:让相同产品在各自日期分组内聚集
这样,两个列都获得了更好的局部性,都能从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) 如果问题变得更复杂,比如"每日、每款产品的总销售额是多少?"我们可以构建一个二维的数据立方体:
| 日期\产品 | iPhone | AirPods | MacBook | 日汇总 |
|---|---|---|---|---|
| 2024-01-01 | 7999 | 1299 | 0 | 9298 |
| 2024-01-02 | 7999 | 1299 | 12999 | 22297 |
| 2024-01-03 | 0 | 0 | 12999 | 12999 |
| 品汇总 | 15998 | 2598 | 25998 | 总计:44594 |
这个立方体预先计算并存储了所有维度组合(日期×产品)的聚合值。现在,无论是问"周二的销售情况"(查一行),还是问"iPhone 的总销售情况"(查一列),甚至是问"整个公司的销售总额"(查右下角的总计),都能在瞬间得到答案。这就是 OLAP 的终极武器之一,支持快速的切片(Slicing)、切块(Dicing)、下钻(Drill-down) 操作。
总结:一场精心策划的数据效率革命
从我们 sales 表的变形记可以看出,分析型存储的优化是一套组合拳:
- 列式存储:改变了数据在磁盘上的根本布局,使查询能够"挑食",只读取需要的列。
- 列压缩:利用列内数据的同质性和重复性(尤其是位图编码处理低基数枚举列),大幅减少存储空间和I/O。
- 智能排序:
- 单列排序:为主排序列创造完美的连续重复值,实现极致压缩
- 复合排序:通过精心设计的排序键顺序,让多个相关列都能获得更好的局部性和压缩效果
- 数据相关性:利用数据的自然相关性(如同一天内相同产品的销售往往集中),增强排序的实际效果
- 物化聚合:将频繁查询的昂贵计算结果(如汇总、立方体)预先算好并保存,用空间换时间,实现亚秒级响应。
所以,下次当你面对一个需要几分钟才能跑出的报表时,可以想象一下:背后的系统可能正在将数百TB的数据,通过列式存储的"刀工"、排序与压缩的"腌制工艺"、以及物化视图的"预制菜"流程,加工成一份能够秒级呈上的数据佳肴。这不是魔术,而是现代数据工程中,为了极致效率而进行的一场静默而精彩革命。