老顾是社区图书馆的档案管理员。最初,每一份读者档案都装在一个透明文件袋里:袋子里依次放着姓名卡、住址卡、年龄卡、电话卡。老王觉得这样很合理——想了解一个人,抽出一个袋子就看全了。可馆长总让他统计“全馆读者的平均年龄”或“按街道统计人数”。每到这时,老王就要把几千个袋子挨个抽出来,翻到年龄卡,抄下来,再放回去,累得腰酸背痛。 后来,一位新来的实习生提议:为什么不在书架的另一侧,按“信息类别”重新归档?把所有“年龄卡”装进一个盒子,所有“住址卡”装进另一个盒子。老王照做了。神奇的事情发生了:统计平均年龄时,他只需要搬一个盒子;统计年龄分布时,他只需要翻一盒卡片。而偶尔查某个人的完整信息时,他只要从五个盒子里各抽一张卡拼起来。 老图书馆的这次“档案室大改造”,正是数据仓库从行式存储走向列式存储的缩影。行式存储像“一人一袋”,简单直观;列式存储像“按类别分盒”,把同一种属性放在一起。大多数数据仓库的分析查询,只关心少数几个属性。把同类数据聚在一起,就能少搬用不到的“袋子”,还能把相同的内容压得更紧。本文就用这个故事,讲清列式存储的原理:它如何省 IO、如何压缩、什么场景下反而吃亏,以及向量化执行如何让它如虎添翼。

岗位关联

对 AI 从业者来说,理解列式存储意味着理解数据仓库“为什么快、何时快”,是数仓建模、查询优化与特征工程性能调优的底层直觉。

目录
  1. 可视化演示
  2. 存储布局差异
  3. 列存IO优势
  4. 列存压缩优势
  5. 场景对比分析
  6. 查改性能特征
  7. 向量化执行
  8. 工程实践落地
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示系统处理流水线、实时性能指标曲线及多方案对比分析,包含数据流转、吞吐延迟监控与方案评估。

本节配有交互动效(进入视口后自动加载,约 3KB)。

系统可视化 处理流程:输入→处理→模型→输出 性能监控:实时指标 P=L×T

存储布局差异

老顾的档案室改造,看起来只是“换了个整理柜”,但本质是把“信息摆放的位置”重新规划了一遍。改造前,一个透明袋子里装着某个人的全部卡片,袋子和袋子一个接一个排在书架上——这就是行式存储:一条记录的所有字段在物理空间上连续存放。改造后,书架另一侧的盒子按属性分类,所有“年龄卡”排成一组,“住址卡”排成另一组——这就是列式存储:一列的所有取值在物理空间上连续存放。

在数据库里,这种差异落到数据块(Block/Page)层面。行存写入时把一行塞进同一数据块,列存写入时把各列分别写进各自的块区域。逻辑上它们都是二维表,物理上却天差地别。数据块是磁盘和内存交换的最小单位,所以“同一类数据挨得近不近”,直接决定了读取效率。

我们用一个抽象模型描述两种布局:假设表有 $N$ 行、$m$ 列,第 $r$ 行第 $j$ 列的值为 $a_{rj}$。

物理布局的差异,就像档案室的两种摆法:一种以“人”为单位聚拢,一种以“属性”为单位聚拢。摆法本身没有绝对好坏,却决定了后面所有性能故事的走向。

D_{\text{row}} = \bigcup_{r=1}^{N}\,[\,a_{r1},a_{r2},\dots,a_{rm}\,]\qquadD_{\text{col}} = \left\{\,[\,a_{11},a_{21},\dots,a_{N1}\,],\; [\,a_{12},a_{22},\dots,a_{N2}\,],\;\dots\right\}
这个式子说明:行存先按“人”分组,组内是全部字段;列存先按“字段”分组,组内是全部记录。
  1. 行存连接第 $r$ 条记录的 $m$ 个字段按顺序写在一起,就像一套档案装进同一个袋子。
    [ar1,ar2,…,arm][a_{r1}, a_{r2}, \dots, a_{rm}]
  2. 列存分组把第 1 列的所有行的值集中到一段空间,第 2 列再集中到另一段空间。
    [a11,a21,…,aN1],  [a12,a22,…,aN2],…[a_{11}, a_{21}, \dots, a_{N1}],\; [a_{12}, a_{22}, \dots, a_{N2}], \dots
  3. 物理含义列存中读取“年龄列”只需访问一段连续区间,而行存需要扫过所有袋子才能挑出年龄卡。
    读“某列”⇒列存命中一个连续块区间\text{读“某列”} \Rightarrow \text{列存命中一个连续块区间}

列存IO优势

改造后的一个清晨,馆长又要老王统计“全馆读者的平均年龄”。老王不慌不忙,径直走向新书架,抱起那个贴着“年龄卡”的盒子,一本一本地记录。整个过程没有动过任何一个透明袋。他心里清楚:如果是以前,得把几千个袋子全搬出来。

在数据库里,“搬袋子”就是磁盘 IO。行存执行 SELECT AVG(age) FROM users 时,存储引擎只能以行为单位把数据块读入内存,记录中那些与 age 无关的 name、address、phone 字段也跟着一起被搬了进来。列存则只需要读取 age 列所在的连续区域,其余列的数据块根本不会被触达。

假设表有 $N$ 行、$m$ 列,第 $j$ 列平均行宽(每行该列的字节数)为 $L_j$,一次分析查询只用到列集合 $\mathcal{S}$,扫描 IO 量的对比如下:

直观地说,一张 50 列的宽表,分析报表只用到 5 列,列存可以少搬 90% 的数据。数据少搬了,内存带宽、磁盘吞吐、乃至网络传输(在分布式数仓中)全线受益。老顾的腰不酸了,查询也肉眼可见地变快了,这就是“按需取料”的力量。

IOscancol=N×∑i∈SLi  ≪  N×∑j=1mLj=IOscanrowIO_{\text{scan}}^{\text{col}} = N \times \sum_{i \in \mathcal{S}} L_i\;\ll\;N \times \sum_{j=1}^{m} L_j = IO_{\text{scan}}^{\text{row}}
列存扫描 IO = 行数 × 选中列宽之和;行存扫描 IO = 行数 × 全部列宽之和。选中的列越少,差距越大。
  1. 行数 N无论哪种布局,要统计全表平均值,都要“经过”每条记录。
    N=表中的总行数N = \text{表中的总行数}
  2. 列宽求和列存只累计选中列的宽度 $\sum_{i\in\mathcal{S}}L_i$,行存必须累计全部 $m$ 列的宽度。
    ∑i∈SLi  ≤  ∑j=1mLj\sum_{i \in \mathcal{S}} L_i \;\leq\; \sum_{j=1}^{m} L_j
  3. 远小于号当 $\mathcal{S}$ 只是全集的一个小子集时,二者差距成倍放大。
    IOrowIOcol=∑j=1mLj∑i∈SLi≈m∣S∣\frac{IO_{\text{row}}}{IO_{\text{col}}} = \frac{\sum_{j=1}^{m}L_j}{\sum_{i\in\mathcal{S}}L_i}\approx \frac{m}{|\mathcal{S}|}

列存压缩优势

又过了一周,老王在整理“性别卡”时发现一个有趣的现象:一盒上千张卡片,翻来覆去只有“男”“女”两个词。他灵机一动,在盒子外贴了一张说明——“0 代表男,1 代表女”,然后把盒子里所有卡片都换成 0 或 1 的数字。盒子瞬间瘦了一大圈。

这正是列存压缩的核心逻辑:同一列的数据类型一致、值域集中,所以编码手段可以非常激进。最常用的是字典编码(Dictionary Encoding):为列中的不同值建立一个“字典”,每行只存一个紧凑的整数编号。行存中一行的字段五花八门,很难用统一编码压缩;而列存中一段连续区域全是同类数据,压缩率往往能达到 10:1 甚至更高。

设一列有 $N$ 行,每个原始值占 $L_c$ 字节,去重后有 $V$ 种不同取值,字典编码后每行只需 $\lceil \log_2 V \rceil$ 比特来存储编号,压缩率如下:

以“性别”列为例:$V=2$,编号只需 1 比特,原始值若用 UTF-8 中文存储则每行 3 字节即 24 比特,压缩率接近 24 倍。即使考虑字典开销,也远比行存划算。除了字典编码,还有运行长度编码(RLE,对“连续相同的值”只记值和重复次数)、位图编码等。同质数据聚在一起,是这一切压缩魔法的前提——这正是列存的天然优势。

Rcomp=SrawSdict=N×8LcV×8Lc+N×⌈log⁡2V⌉R_{\text{comp}} = \frac{S_{\text{raw}}}{S_{\text{dict}}}= \frac{N \times 8L_c}{V \times 8L_c + N \times \lceil \log_2 V \rceil}
分子是原始字节转换为比特后的总量,分母是“字典占用 + 所有行的编号占用”。$V$ 越小,压缩率越高。
  1. 原始大小$N$ 行每行 $L_c$ 字节,换成比特就是 $N \times 8L_c$。
    Sraw=N×8LcS_{\text{raw}} = N \times 8L_c
  2. 字典大小$V$ 个不同值各存一份原始值,占 $V \times 8L_c$ 比特。
    Sdict表=V×8LcS_{\text{dict表}} = V \times 8L_c
  3. 编号大小$V$ 种取值需要 $\lceil \log_2 V \rceil$ 比特表示,$N$ 行共占 $N\lceil\log_2 V\rceil$ 比特。
    S编号=N×⌈log⁡2V⌉S_{\text{编号}} = N \times \lceil \log_2 V \rceil
  4. 压缩率原始总量除以压缩后总量,大于 1 就说明有效省空间。
    Rcomp=SrawSdict表+S编号R_{\text{comp}} = \frac{S_{\text{raw}}}{S_{\text{dict表}} + S_{\text{编号}}}

场景对比分析

档案室改造完成后,老王也发现了一些不便。某天一位读者来查“张三的完整资料”,老王得从五个盒子里各抽一张卡,再拼成一套档案;而在改造前,他只需要抽一个袋子。两种归档方式各有各的舒服区:查“一个人”行存快,统计“一类属性”列存快。

这个规律在数据库中同样成立。点查(Point Query)如 SELECT * FROM users WHERE id = 42,需要返回一条记录的多个字段。行存只需一次 IO 定位并读出整行;列存却要分别访问多个列文件,再把对应位置的值拼接起来。反之,聚合扫描(Aggregation Scan)如 SELECT SUM(amount) FROM orders,只关心一列,行存却要把整张表的全部字段都搬出来,IO 浪费严重。

两种场景的 IO 特征可以浓缩为:

所以列存并非万能灵药。真实的数据仓库往往负载混合:既有大范围聚合统计,也有少量明细点查。优秀的列存引擎会为点查保留主键索引或一跳(skip)索引,把劣势尽量补平。老顾的结论是:先看清图书馆的主要业务是“查个人”还是“做统计”,再决定书架怎么摆。

IO_{\text{point}}^{\text{row}} \approx L_{\text{row}} \quad;\quadIO_{\text{point}}^{\text{col}} \approx L_{\text{row}} + m \times \delta
IO_{\text{agg}}^{\text{row}} = N \times L_{\text{row}} \quad;\quadIO_{\text{agg}}^{\text{col}} = N \times \sum_{i \in \mathcal{S}} L_i
$\delta$ 是访问单个列文件额外的定位开销。点查时列存要多付 $m\delta$ 的“拼卡费”;聚合时列存只按选中列付费。
  1. 点查行存按主键定位到数据块后,读一次整行即可。
    IOpointrow≈Lrow=∑j=1mLjIO_{\text{point}}^{\text{row}} \approx L_{\text{row}} = \sum_{j=1}^{m}L_j
  2. 点查列存一行数据分散在 $m$ 个列文件中,每个文件都要一次额外定位。
    IOpointcol≈Lrow+m×δIO_{\text{point}}^{\text{col}} \approx L_{\text{row}} + m \times \delta
  3. 聚合行存全表扫描时每行都完整读取,行数与整行宽相乘。
    IOaggrow=N×LrowIO_{\text{agg}}^{\text{row}} = N \times L_{\text{row}}
  4. 聚合列存只读选中列,IO 与选中列宽成正比。
    IOaggcol=N×Lsel,Lsel=∑i∈SLiIO_{\text{agg}}^{\text{col}} = N \times L_{\text{sel}},\quad L_{\text{sel}}=\sum_{i\in\mathcal{S}}L_i

查改性能特征

老王很快又遇到新麻烦。一位读者搬家了,需要同时修改住址、电话和紧急联系人。在改造后的小盒子模式下,老王得在三个盒子里各翻出对应的卡片改一遍;而在旧档案袋模式下,抽出一个袋子,改完放回,一次搞定。

列式存储的更新代价,正是“一行数据被拆散到了多个列文件”带来的。执行 UPDATE 时,扫描阶段还能享受列存优势,但写入阶段必须定位并重写涉及的所有列:一是要找到该行在每列中的物理位置,二是每个列文件的对应片段都要重写。列越多、单行字段越宽,写放大(Write Amplification)越严重。

设字段数为 $m$,单列平均更新代价近似为“读取定位 + 写入重写”:

两者的“读改写总字节”看起来相同,但列存每列都是一次独立的随机 IO,定位成本 $m \times \delta$ 会额外叠加,并发更新时锁的分散也加剧冲突。

正因如此,以高并发小事务更新为主的 OLTP 系统(如订单履约、账户余额)普遍采用行存加索引,而 OLAP 数据仓库以批量导入和不可变分区为主,天然规避了列存的更新短板。老顾最后在档案室立了条规矩:需要频繁改动的读者信息,保留透明袋归档;只读的分析卡片,全部放进属性盒。

Cupdatecol≈∑j=1m(Lj⏟读旧值+Lj⏟写新值)=2LrowC_{\text{update}}^{\text{col}} \approx\sum_{j=1}^{m} \left(\underbrace{L_j}_{\text{读旧值}} + \underbrace{L_j}_{\text{写新值}}\right)= 2L_{\text{row}}
C_{\text{update}}^{\text{row}} \approxL_{\text{row}} + L_{\text{row}} = 2L_{\text{row}}
更新总代价 = 读旧值 + 写新值,列存还要叠加“每列一次定位”的额外开销。
  1. 读旧值更新前必须把旧行/旧列值读出来,列存要分别读 $m$ 个列片段。
    读代价=∑j=1mLj=Lrow\text{读代价} = \sum_{j=1}^{m} L_j = L_{\text{row}}
  2. 写新值写入新值同样要落盘到 $m$ 个列文件中。
    写代价=∑j=1mLj=Lrow\text{写代价} = \sum_{j=1}^{m} L_j = L_{\text{row}}
  3. 额外定位每个列文件独立寻址,新增 $m \times \delta$ 的随机 IO 开销。
    Cupdatecol−Cupdaterow≈m×δC_{\text{update}}^{\text{col}} - C_{\text{update}}^{\text{row}} \approx m \times \delta

向量化执行

周末,老王又要统计“各年龄段人数”。他面对一整盒年龄卡,发现卡片上的数字排得整整齐齐。他突然想到:与其一张一张地看,不如把这一摞卡片放进扫描仪,一次扫过一整页,电子表格直接算出每一档的数量。这种“一次处理一整组”的思路,在 CPU 里叫做向量化执行。

列存为向量化提供了天然土壤:同一列的数据在内存里连续且类型一致,CPU 可以用 SIMD(单指令多数据)指令一次对多个数执行相同的运算。比如一次加法指令同时处理 4 个或 8 个整数。此外,连续内存访问对 CPU 缓存高度友好:读第一列时,后续值大概率已在缓存行里等你了。行存中字段类型混杂、值分布零散,很难让 SIMD 高效工作。

设查询要对 $n$ 个值做同样的运算,标量模式需要执行 $n$ 条指令,向量模式每条指令同时处理 $V_L$ 个数,理想加速为:

结合压缩后的数据,向量化还能直接在编码/压缩数据上做部分运算,进一步省去解压。列存、压缩、向量化三者环环相扣:列存提供连续同质数据,压缩减小数据量,向量化让每个 CPU 周期干更多活。老顾的扫描仪,让档案室真正步入了“自动化时代”。

Svec=TscalarTvector≈nn/VL=VLS_{\text{vec}} = \frac{T_{\text{scalar}}}{T_{\text{vector}}}\approx \frac{n}{n \big/ V_L} = V_L
向量化加速比约等于“单条指令能同时处理的数据个数”。现代 CPU 的 SIMD 寄存器往往能一次处理 4~16 个数据,配合列存可带来数倍到数十倍的提升。
  1. 标量执行$n$ 个数据逐个处理,共执行 $n$ 条运算指令。
    Tscalar=n×t指令T_{\text{scalar}} = n \times t_{\text{指令}}
  2. 向量执行每条指令处理 $V_L$ 个数据,总指令数降为 $n / V_L$。
    Tvector=nVL×t指令T_{\text{vector}} = \frac{n}{V_L} \times t_{\text{指令}}
  3. 加速比两者相消后,加速比就是 $V_L$。
    TscalarTvector=VL\frac{T_{\text{scalar}}}{T_{\text{vector}}} = V_L

工程实践落地

几年后,老顾的图书馆声名远扬,隔壁几家图书馆都来取经。老王总结出了一套“书架哲学”:需要频繁增删改的读者登记表,用透明袋逐人归档;承载年度统计、读者画像分析的大宽表,按属性分盒存放。两种归档方式共存,各司其职。

这套哲学映射到数据库选型上,就是 OLTP 与 OLAP 的分工。现代数据仓库引擎(ClickHouse、Doris、以及 Hive/Spark 中的 Parquet、ORC 文件格式)普遍采用列式存储,搭配压缩、索引和向量化执行,把分析查询的 IO 和 CPU 开销压到极致。而 MySQL、PostgreSQL 等事务型数据库坚持行存加 B+ 树索引,保证点查与更新的事务性能。数仓建模时的“宽表”设计,正是为了让报表查询命中尽量少的列文件。

工程上可以定义一个简单的收益评估,帮助判断列存是否适配某个负载:假设聚合类查询占比 $w_{\text{agg}}$,点查类占比 $w_{\text{point}}$,

老顾的故事到此圆满:先分类,再选择合适的位置存放,最后用对的工具批量读取。列式存储不是取代行式存储,而是让数据仓库在分析世界中找到自己的主场。数据从业者掌握这套原理后,再看数仓建模和查询优化,就能做到“心中有一张书架图”。

Qcol=wagg×IOscanrowIOscancol−wpoint×IOpointcolIOpointrowQ_{\text{col}} = w_{\text{agg}} \times \frac{IO_{\text{scan}}^{\text{row}}}{IO_{\text{scan}}^{\text{col}}}- w_{\text{point}} \times \frac{IO_{\text{point}}^{\text{col}}}{IO_{\text{point}}^{\text{row}}}
当 $Q_{\text{col}} > 0$ 时,列存带来的聚合收益大于点查损失,适合上列存;反之应留在行存。
  1. 聚合收益项聚合类查询从行存切换为列存后,IO 节省的倍率作为收益。
    收益=IOscanrowIOscancol\text{收益} = \frac{IO_{\text{scan}}^{\text{row}}}{IO_{\text{scan}}^{\text{col}}}
  2. 点查损失项点查类查询在列存下的 IO 劣势倍率作为损失。
    损失=IOpointcolIOpointrow\text{损失} = \frac{IO_{\text{point}}^{\text{col}}}{IO_{\text{point}}^{\text{row}}}
  3. 加权求和按负载占比加权,净值为正说明列存整体划算。
    Qcol=wagg×收益−wpoint×损失Q_{\text{col}} = w_{\text{agg}} \times \text{收益} - w_{\text{point}} \times \text{损失}

AI 应用场景

电商数仓的经营分析报表

阶段:数据分析与决策支持(OLAP)

解决的问题:传统行存执行“按省份、按品类统计今日 GMV”这类聚合查询时,需要全表扫描订单明细的所有字段,几十亿行数据带来巨大的 IO 开销,报表分钟级都出不来。列存只读取“金额、省份、品类”三列,IO 减少一个数量级,配合向量化聚合,秒级出数。

具体案例:某电商平台大促期间,运营看板需要实时查看“各品类在各渠道的销售趋势”。订单明细表按天分区存储在 ClickHouse 列式表中,查询只扫描必需的列,配合主键索引跳过无关分区,看板刷新时间从 3 分钟降到 2 秒。

用户行为日志的特征工程

阶段:特征提取与特征工程

解决的问题:推荐系统每天要处理 TB 级埋点日志,日志包含上百个字段,但训练特征只用 user_id、item_id、行为类型、时间戳等少数几列。若按行存读取,每次特征流水线都要搬运全部字段,存储和 IO 成本极高。列式文件格式(Parquet/ORC)按列读取 + 高压缩,让特征抽取又快又省。

具体案例:某短视频推荐团队用 Spark 读取近 7 天用户点击日志构建训练样本,日志以 Parquet 列存存储。特征工程只取 4 列进行过滤和 join,扫描数据量下降了约 70%,特征任务从 40 分钟压缩到 12 分钟。

监控指标与异常检测

阶段:在线监控与时序异常检测

解决的问题:云平台监控系统每秒写入数百万条指标,查询又经常是“过去 5 分钟所有机器的平均 CPU 使用率”。指标数据按“时间 + 标签”组织,同标签的指标值天然连续存放,列式时序数据库能高效压缩并加速聚合,让异常检测模型快速拿到聚合后的指标序列。

具体案例:某云计算厂商使用列式时序数据库存储百万级机器的指标,同一指标序列连续存储并做增量压缩,支撑“集群平均负载”的秒级聚合查询,为 AIOps 异常检测模型提供了实时特征输入。

Python 演示

python78 行
# -*- coding: utf-8 -*-"""列式存储原理模拟: IO对比与字典编码压缩"""import random random.seed(42)N = 10000 def gen_rows(n):    cities = ["北京", "上海", "广州", "深圳", "杭州"]    genders = ["男", "女"]    rows = []    for i in range(n):        rows.append({            "id": i,            "city": random.choice(cities),            "age": random.randint(18, 60),            "gender": random.choice(genders),            "score": random.randint(0, 100),        })    return rows def to_columnar(rows):    cols = {}    for col in rows[0]:        cols[col] = [r[col] for r in rows]    return cols def io_row_scan(rows):    """行存扫描: 每行读出所有字段"""    bytes_read = 0    for r in rows:        for v in r.values():            if isinstance(v, int):                bytes_read += 8            else:                bytes_read += len(v.encode("utf-8"))    return bytes_read def io_col_scan(cols, need):    """列存扫描: 只读需要的列"""    bytes_read = 0    for c in need:        for v in cols[c]:            if isinstance(v, int):                bytes_read += 8            else:                bytes_read += len(v.encode("utf-8"))    return bytes_read def dict_encode(values):    code_map = {}    code_list = []    for v in values:        if v not in code_map:            code_map[v] = len(code_map)        code_list.append(code_map[v])    return code_map, code_list def main():    rows = gen_rows(N)    cols = to_columnar(rows)    need = ["age", "gender"]    row_io = io_row_scan(rows)    col_io = io_col_scan(cols, need)    print("行存扫描全部字段: {:,} 字节".format(row_io))    print("列存只读 age+gender: {:,} 字节".format(col_io))    print("IO 节省比例: {:.1%}".format(1 - col_io / row_io))     code_map, code_list = dict_encode(cols["gender"])    raw = sum(len(v.encode("utf-8")) for v in cols["gender"])    encoded_bits = len(code_list)    encoded_bytes = (encoded_bits + 7) // 8 + 20    print("gender 原始大小: {:,} 字节".format(raw))    print("字典编码后大小: {:,} 字节".format(encoded_bytes))    print("压缩率: {:.1f}x".format(raw / encoded_bytes)) if __name__ == "__main__":    main()
目的:用 1 万行模拟数据,对比行存全表扫描与列存只读两列时的 IO 量,并演示字典编码对低基数列(性别)的压缩效果。
  1. rows = gen_rows(N)生成 1 万条用户记录,每条包含 id、city、age、gender、score 五个字段。
  2. to_columnar(rows)把行式数据转成列式结构:每个字段对应一个列表,模拟列存的物理布局。
  3. io_row_scan(rows)模拟行存扫描:遍历每条记录的全部字段并累计字节数。
  4. io_col_scan(cols, need)模拟列存扫描:只遍历“age“和”gender”两个列表。
  5. dict_encode(cols["gender"])对性别列做字典编码:为不同取值分配整数编号。
  6. encoded_bytes = (encoded_bits + 7) // 8 + 20每行只需 1 比特编号,按字节对齐后再加上字典本身的存储。

常见误区

误区列式存储不就是把一张表竖着摆吗?行式横着摆、列式竖着摆,反正逻辑上都是同一个二维表,查询结果也没区别。

正解逻辑表确实都是二维表,但列式存储的关键在物理布局:同一列的值在磁盘上连续存放。这决定了扫描时只需要搬动对应列的数据块,也决定了同质数据可以被高效压缩。画成横着还是竖着只是表象,数据在磁盘上的“邻居是谁”才是性能差异的根源。

为何常见:我们平时用 Excel 或关系型数据库,看到的就是行列表格,容易忽略存储引擎在物理层如何排列数据。

误区列式存储读取效率这么高,那订单系统、账户系统这种高频读写的场景也应该换成列存,肯定更快。

正解列存的优势集中在“大范围扫描少数列”;点查一条完整记录时,列存要跨多个列文件拼行,更新一条记录要写多个列文件,随机 IO 和写放大反而更严重。OLTP 以高频点查和小事务更新为主,行存 + 索引才是主场。

为何常见:看到列存在 OLAP 报表上的惊艳表现后,容易把“快”归因于存储本身,而忽略负载类型与 IO 模式的匹配。

误区既然列存压缩率这么高,那就把所有列都压到极致,存储省了,查询也会更快。

正解压缩节省磁盘和 IO,但解压需要 CPU 开销。压缩率过高可能让 CPU 成为新瓶颈,尤其在与向量化执行结合时,需要在“少读数据”和“解压开销”之间找平衡。实际引擎还会选择不压缩或轻量压缩热点列。

为何常见:存储成本的压力让初学者本能地追求高压缩率,忽略了计算与 IO 之间的权衡关系。

误区行存有 B+ 树索引,列存数据排得整整齐齐,应该不需要索引了吧。

正解列存同样需要索引加速,只是形态不同。常见的有 Zone Map(每个数据块记录列的最大/最小值,跳过不符合条件的块)、稀疏索引、布隆过滤器等。它们帮助列存在聚合查询时快速跳过无关数据块。“无需索引”是误解,列存只是把索引换成了更适合列式扫描的形态。

为何常见:列存的顺序存储在视觉上给人一种“天然有序”的错觉,让人忽略了海量数据下仍需辅助结构来剪枝。

误区把一张宽表按字段拆成几张子表,每张子表存一部分列,逻辑上不就是列式存储了吗?

正解逻辑上的垂直分表只保证了“按列读取的表结构”,并不保证物理上各列连续存放,也不具备列存配套的压缩、向量化、区段索引等能力。真正的列存储是从数据页布局、编码格式到执行引擎的端到端重设计。

为何常见:从 SQL 逻辑视角看,垂直分表和列存很相似,容易忽略存储引擎物理层与执行层的协同设计。

面试题

Q1. 列式存储最核心的物理特征是?

单选题 · easy

  • A.一行记录连续存放
  • B.一列数据连续存放← 正确答案
  • C.数据按主键有序存放
  • D.数据按访问频次存放

答案:B。列式存储把同一列的所有值连续放在一起,就像把全部“年龄卡”装进同一个盒子。A 是行式存储的特征,C 是聚簇索引的特征,D 是冷热分层的思路,都与列存核心定义无关。

Q2. 以下哪个查询场景,列式存储的优势最明显?

单选题 · easy

  • A.按主键查一条订单
  • B.汇总全国订单总金额← 正确答案
  • C.插入一条新订单记录
  • D.修改一条订单的备注

答案:B。汇总总金额只需读取金额一列,列存只搬一个“盒子”即可完成。点查、插入、单行更新都是行存的擅长领域,列存反而要付出跨列拼装或写多列文件的代价。

Q3. 列存执行“查一条记录的多个字段”通常比行存慢,主要原因是?

单选题 · mid

  • A.列存无法保存主键信息
  • B.列存需要读取多个列文件再拼接← 正确答案
  • C.列存支持的记录行数更少
  • D.列存不支持数据压缩

答案:B。一行数据在列存中被拆散到各个列文件中,点查要分别访问多个列文件并拼接,随机 IO 次数更多。A、C、D 均不符合列存的事实:列存有主键与各类索引,行数由容量决定,压缩也是列存的强项。

Q4. 字典编码特别适合压缩哪一类列?

单选题 · mid

  • A.高基数的用户 ID 列
  • B.低基数的性别标签列← 正确答案
  • C.唯一的时间戳标记列
  • D.浮点型的评分值列

答案:B。字典编码用整数编号映射取值,去重后取值越少(基数越低),编号越短,压缩率越高。性别列只有两种取值,编号仅需 1 比特。高基数列的编号会变长,字典体积大,收益有限。

Q5. 列存能获得高压缩率的根本原因是?

单选题 · easy

  • A.同列数据同质且值域接近← 正确答案
  • B.列存使用了加密算法
  • C.列存省略了字段类型
  • D.列存仅保存不重复的行

答案:A。同一列的取值类型一致、取值范围集中,因此可以用字典编码、运行长度编码等方法高效压缩。B、C、D 都不符合事实:列存不加密、不省略类型、也不只保存不重复的行。

Q6. 向量化执行相比标量执行更快的本质是?

单选题 · hard

  • A.提高了 CPU 时钟频率
  • B.一条指令同时处理多个数据← 正确答案
  • C.用 GPU 完全取代了 CPU
  • D.减少了磁盘 IO 的次数

答案:B。SIMD 向量指令让 CPU 在单个周期内对一组数据执行相同运算。A 是硬件频率问题,C 不是列存向量化的核心(很多场景仍用 CPU SIMD),D 是列存本身的 IO 优势而非向量化本质。

Q7. 列存中 UPDATE 写放大的根源是?

单选题 · mid

  • A.一行数据分布在多个列文件中← 正确答案
  • B.列存不支持随机写入
  • C.列存没有预写日志
  • D.列存的行锁粒度太粗

答案:A。更新一行需要定位并重写该行涉及的所有列文件,列越多写放大越明显。B、C、D 都不是本质原因:列存可以随机写、也有日志与并发控制,只是代价结构不同。

Q8. 下列哪些场景更适合列式存储?(多选)

多选题 · mid

  • A.大范围的聚合统计← 正确答案
  • B.高并发的单行点查
  • C.只取少数列的宽表扫描← 正确答案
  • D.频繁的小事务提交

答案:AC。聚合统计和只取少数列的宽表扫描正是列存的“主场”:少搬数据、同质压缩、向量化加速。高并发点查和频繁小事务提交则是行存的优势场景,列存会面临跨列拼装和写放大。

Q9. 下列哪些技术常与列存协同来提升查询性能?(多选)

多选题 · hard

  • A.向量化执行← 正确答案
  • B.字典编码← 正确答案
  • C.运行长度编码← 正确答案
  • D.行级锁优化

答案:ABC。列存为向量化提供了连续同质数据,字典编码与运行长度编码利用列内重复值大幅压缩。行级锁是事务并发控制手段,与列存性能提升没有直接协同关系,更多属于 OLTP 范畴。

Q10. 分析型数据仓库普遍采用列存的原因包括?(多选)

多选题 · extreme

  • A.聚合只读所需列减少 IO← 正确答案
  • B.同质数据的压缩率更高← 正确答案
  • C.向量化利于批量计算← 正确答案
  • D.单行插入的延迟更低

答案:ABC。列存让聚合查询只搬必要列,同质数据带来高压缩率,连续内存让 SIMD 向量化高效执行,这三者环环相扣。D 恰恰相反:列存单行插入需要分散写入多个列文件,延迟通常高于行存。