44.16 OLAP 与列式存储

一句话

行存按 “整行” 存,适合 “取一行所有字段”(点查/事务);列存按 “整列” 存,适合 “扫一列做聚合”(分析)——因为分析查询只碰少数几列,列存读的字节数天然少一个数量级,再加上同列同类型好压缩。

行存 vs 列存

维度 行存(OLTP) 列存(OLAP)
存储布局 一行连续 一列连续
点查(取整行) 快(一次 IO) 慢(要拼多列)
聚合(扫一列) 慢(读整行) 快(只读该列)
压缩 差(行内类型混杂) 好(同列同类型)
更新 就地更新 批量追加/合并
典型 MySQL、PostgreSQL ClickHouse、Doris、Parquet

实测:只查 2 列时,行存与列存读多少字节

100 万行 × 6 列(id/uid/amt/city/ts/flag),查询 “按 city 求和 amt”:

布局 需要读取 说明
行存 82.2MB 必须读整行(6 列全读)
列存 9.5MB 只读 city + amt 两列
减少 88% 这就是列存 “扫描快” 的根源

再加上列内压缩:amt 列压缩后 33KB、city 列 25KB(同列同类型,压缩率远高于行存)。所以同样的查询,列存可能比行存少读 1–2 个数量级的字节。

-- 列存引擎会自动做 “列裁剪” + “谓词下推”
SELECT city, sum(amt) FROM orders WHERE ts >= '2026-01-01' GROUP BY city;
-- 只读 city/amt/ts 三列;ts 的 min/max 索引先跳过大半数据块

三个关键机制

机制 作用 收益
列裁剪 只读查询涉及的列 读字节 ∝ 涉及列数/总列数
谓词下推 + min/max 索引 跳过不满足条件的块 常能跳过 90%+ 数据块
向量化执行 按批(1024/4096 行)处理 减少函数调用、利于 SIMD

反模式

反模式 后果
用列存做高并发点查 每次要拼多列,延迟高
用行存跑大范围聚合 读整行,IO 放大数倍
列存频繁单行更新 每次都是 “标记 + 后台合并”,写放大高
SELECT * 查宽表 列裁剪失效,等于行存
不做分区/排序键设计 min/max 索引跳过率低
小批量频繁导入 产生大量小文件,合并压力大

三条铁律

🔑 列存赢在 “只读需要的列 + 同列好压缩”,不在 “存储格式新”。

🔑 点查用行存,聚合用列存;混用两边都慢。

🔑 列存要设计排序键/分区,否则 min/max 索引形同虚设。

自检

Q1 为什么列存 “只读 2 列” 就能比行存少读 88%?

▸ 答案:因为行存把一行 6 列连续放在一起,读任何一列都要把整行读进来(82.2MB);列存把每列单独连续存放,只读 city 与 amt 两列(9.5MB)。读字节数 ≈ 涉及列数 / 总列数,列越多、查询涉及的列越少,差距越大。

Q2 列存为什么压缩率更高?

▸ 答案:因为同一列的数据类型相同、取值相近、有序性好:整数列可以用 Delta/位打包,低基数列可以用字典/RLE。行存里一行混着字符串、整数、时间戳,压缩算法很难找到跨行重复模式。

Q3 列存怎么做 “更新一行”?

▸ 答案:通常不就地改:要么写 “删除标记 + 新版本”(MergeTree 的 ALTER UPDATE 是异步 mutation),要么进 delta 存储后台合并。所以列存适合 “批量追加 + 少量更新”,高频单行更新会很贵。

Q4 什么是 “向量化执行”,为什么列存需要它?

▸ 答案:向量化是按批处理(一次 1024/4096 个值)而不是逐行处理,减少函数调用与分支,利于 CPU 流水线与 SIMD。列存的数据本来就是 “连续同类型”,天然适合按批处理;行存逐行处理则难以向量化。

Q5 为什么 “SELECT *” 在列存里是灾难?

▸ 答案:因为列裁剪失效:本来只读 2 列,SELECT * 要读全部列,读字节数回到行存水平甚至更差(列存每列单独 IO)。所以列存场景要显式列出字段,禁止 SELECT *。

Q6 列存的 “小文件问题” 从哪来?

▸ 答案:来自频繁小批量写入:每次写入生成一个新文件/分区,后台要不断合并。文件太多会导致元数据膨胀、查询要打开大量文件。解法是批量导入(攒够再写)+ 后台 compaction + 合理分区粒度。

Q7 什么时候该上列存而不是 “行存 + 索引”?

▸ 答案:当查询是 “大范围扫描 + 少数列聚合”(报表、BI、日志分析),且数据量大到 “行存读整行” 成为瓶颈时。反之,点查、事务、频繁更新仍是行存的强项;很多系统是 “行存做交易 + 列存做分析” 的组合。

关联