📊 第6章 大数据的积累
📄 一句话概括

同一批数据,行存和列存不压缩时一样大(都 4000000 字节)——优势不在体积,在「读得少」。 求一列和:行存 0.0573 秒,列存 0.0021 秒,快 26.8 倍。 压缩之后差距更大:行存剩 31%,列存剩 8%。同类放一起,本身就是压缩。

📝 本章和前面几章的关系

上一篇讲两种库的分工。这一篇拆开分析型库最核心的那一招:按列存。 教材 6.3 节:面向列的数据结构——在列方向上压缩数据后实现分析处理的高速化。 这一节的标题已经把答案说了:两件事,读得少和压得动。

一、两种摆法,体积其实一样

演示把二十万条记录用两种方式装:行存是一串定长记录(每条 20 字节),列存是四个独立的数组。 两者都是 4000000 字节,一模一样。 这一点先说清楚,免得误会:列存不是靠「更紧凑」赢的,同样的数据同样的类型,怎么摆都占那么多地方。

真正赢的地方在「读」上——你只需要碰你要的那一列。

二、读得少,所以快

演示第二段:求金额列的总和。 从行存里解:得逐条解包 20 万次,每次拿到四个字段再扔掉三个。0.0573 秒。 从列存里算:直接 sum(col_a),只碰那一个数组。0.0021 秒。

快 26.8 倍。 这个倍数在真实系统里还会更大,因为行存读的是磁盘页,一页里混着好几行的所有列, 读一列要把整页都拉进内存。 所以列存的第一个优势是:IO 量小。 读多少算多少,不带无关的列。

这也是为什么分析型查询在列存上快得离谱——它们通常只关心两三列,却要扫几亿行。 行存那边,99% 的字节都是白读的。

三、压得动才是大头

演示第三段更有意思:同样压缩一遍,行存压到 31%,列存压到 8%。 为什么差这么多?因为同一列里的值太像了。 单号是连续递增的,压缩算法一看就知道「每个加一」;用户号只在一千个值里打转;件数只有九种可能。

行存里这些值被别的字段隔开,二十字节一组反复跳变,压缩算法看不出规律。 把同类的东西放在一起,本身就是一种压缩。 这句话值得抄下来。 压缩带来的收益是双份的:占的地方少了(省钱),读的字节少了(更快)。所以列存的优势是叠加的。

第 6.3 篇会算一笔存储费。按 TB 收费的时候,从 31% 压到 8% 不是小数。

四、列存的三个代价

第一,查一整行要拼。要读好几列再组合起来,比行存慢。 第二,逐行写入慢。一行数据要拆到好几个列文件里,每写一行都得动好几处。 第三,更新一行要改多处。改个金额,得在那一列里找到位置改掉,如果这一列是压缩过的,还要重压。

这三条解释了为什么列式库不适合做业务主库。业务主库天天改一行,这正好是列存最怕的活。 所以真实架构是:业务库(行存)接交易,定期批量灌进分析库(列存)。 批量灌的时候,列存反而更快——因为一次写一大批,拆列的开销被摊薄了。批量和列存是天生一对。

五、这一篇要带走的东西

三句话:列存不省空间,省 IO;同类放一起压得动;它怕频繁改一行。 还有一句判断标准:查询总要「取几列、扫很多行」,就该考虑列存。反过来,总在「按主键取一行、 改一行」,就别折腾。 下一篇讲怎么选具体的产品:SQL on Hadoop、列式 MPP、云数仓,三条路各适合谁。

六、动手 Demo:求一列和:行存 0.0573 秒,列存 0.0021 秒

二十万条记录,分别按行、按列装进内存。 都求金额列的总和,比耗时。 再各自压缩一遍,比压缩率。

import struct, zlib

# 行存:一条记录 20 字节,首尾相接
st = struct.Struct('<iiid')
row_blob = b''.join(st.pack(*r) for r in rows)

# 列存:四个数组各管一列
col_a = [r[3] for r in rows]

# 从行存里挖出第 4 个字段
s = 0.0
for i in range(0, len(row_blob), st.size):
    s += st.unpack_from(row_blob, i)[3]

# 从列存里直接算
s2 = sum(col_a)

实跑输出:

--------------------------------------------------------------------
列式存储:只读要用的列,还压得动
--------------------------------------------------------------------

  ① 同一批数据,两种摆法
  --------------------------------------------------------------------
  行存:一条记录 20 字节,共 4000000 字节
  列存:四列分开装,合计 4000000 字节
  不压缩的时候,两种摆法占的空间差不多——列存的优势不在体积,在「读得少」。

  ② 求一列和,读的字节数差多少
  --------------------------------------------------------------------
  从行存里解出 a 列求和:59700000.00,耗时 0.0573 秒
  从列存里直接 sum:     59700000.00,耗时 0.0021 秒
  快 26.8 倍
  求一列和的时候,行存必须把整条记录解出来,再扔掉另外三个字段。列存只碰它要的那一列。

  ③ 更有意思的是压缩
  --------------------------------------------------------------------

| 存法 | 原始 | 压后 | 剩多少 |
|---|---:|---:|---:|
| 行存 | 3.81 MB | 1.17 MB | 31% |
| 列存 | 3.81 MB | 0.29 MB | 8% |

  列存压得狠得多。
  因为同一列里的值类型相同、范围相近——单号是连续递增的,用户号只在一千个值里打转,
  件数只有九个可能。
  行存里这些值被别的字段隔开,压缩算法看不出规律。把同类的东西放在一起,本身就是一种压缩。

  ④ 列存的三个代价
  --------------------------------------------------------------------

| 代价 | 表现 | 怎么缓解 |
|---|---|---|
| 查一整行要拼 | 要读好几列再组合 | 宽表、预计算 |
| 逐行写入慢 | 一行要拆到好几处 | 攒批再写 |
| 更新一行要改多处 | 每列都要动 | 只追加不改,定期合并 |

  这三条解释了为什么列式库不适合做业务主库:业务主库天天改一行,列存最怕这个。
  所以真实架构是:业务库(行存)接交易,定期批量灌进分析库(列存)。
  前者保证改得快,后者保证算得快。

第一段那两个 4000000 字节是同一个数,不是巧合——同样的数据同样的类型,怎么摆都占那么多。 列存的优势从第二段才开始。 第二段那个 26.8 倍,比上一篇 SQL 对比内存列的 12.6 倍还大。 因为这里连 SQL 引擎的开销都没有,纯粹是「解包 20 万次」对「一次 sum」。 第三段的压缩率才是本篇最该记的数:31% 对 8%。差的不是算法,是数据摆放方式让规律显形了。 最后那张代价表解释了为什么列存不能当主库用。真实架构里两者是配合关系,不是替代关系。

30 秒实验

  1. 把 col_a 换成 array.array('d', col_a),看内存占用和 sum 的耗时各变多少。
  2. 给行存也加上 zlib.compress(..., 9)(最高压缩级别),看能不能压到 8%。
  3. 尝试求「所有件数大于 5 的行的金额和」,看行存和列存各要碰几列。

常见误区

  1. 「列存省空间。」不压缩时一样大。它省的是读的字节数,压缩后才顺带省空间。
  2. 「列存全面优于行存。」改一行、查一行它都更慢。两种结构对应两种负载,没有优劣。
  3. 「列存可以随便更新。」更新一列里的一行,可能要重压整块。列式库通常只追加、定期合并。
自测三问(点开对答案) 1. 不压缩的时候,列存和行存占的空间一样吗? —— 一样。同样的数据和类型,怎么摆都占那么多字节。列存赢在读得少,不在体积小。 2. 为什么列存压得比行存狠? —— 因为同一列里的值类型相同、范围相近,规律很明显。行存里这些值被别的字段隔开,规律被破坏了。 3. 列存为什么不适合做业务主库? —— 业务主库天天改一行、查一行。 列存改一行要动好几列,查一行要拼好几列,正好是它最不擅长的两件事。

下一篇讲选型:SQL on Hadoop、列式 MPP、云数仓,三条路怎么选。

相关笔记