数据库列存储和行存储的区别
什么是列存储(Column-oriented Storage)?
在传统的数据库中,数据是一行一行写入和读取的。而列存储 (Columnar Storage)顾名思义,是将数据表中的每一列数据单独集中存储在物理磁盘上。
这意味着,表中同一列的所有数值会被连续地存放在一起。比如一个包含"姓名、年龄、职业"的表,在列存储中,"所有的姓名"存在一个数据块,"所有的年龄"存在另一个数据块。
什么是行存储(Row-oriented Storage)?
为了更好地理解列存储,我们需要对比传统的行存储。行存储是将一条记录(Row)的所有字段(Columns)连续存储在一起。当你写入一条新数据时,它的所有信息是作为一个整体被写入磁盘的。
💡 生活化的比喻:
- 行存储: 就像是一本个人档案册。每一页是一个人的所有信息(姓名、年龄、薪水)。如果你想查某一个人的全部信息,翻到那一页就能全看完;但如果你想算出所有人的"平均薪水",你就必须把每一页都翻一遍。
- 列存储: 就像是分类账本。第一本全写名字,第二本全写年龄,第三本全写薪水。如果你想算"平均薪水",只需要拿过第三本账本,里面全都是数字,一眼就能加起来,完全不需要理会名字和年龄。
行存储 vs 列存储:核心区别
这两种存储方式没有绝对的优劣,只有"是否适合特定的业务场景"。以下是它们的详细对比:
| 特性 | 行存储 (Row Storage) | 列存储 (Column Storage) |
|---|---|---|
| 数据物理分布 | 按行连续存储在一块 | 按列单独连续存储 |
| 适用核心场景 | OLTP (联机事务处理),如电商交易、用户注册 | OLAP (联机分析处理),如数据报表、商业智能分析 |
| 查询优势 | 极快地获取一条记录的所有字段 | 极快地对某几列进行聚合计算(如 SUM, COUNT, AVG) |
| I/O 效率 | 如果只查某一列,仍需将整行数据读入内存,产生大量无效 I/O | 只读取需要的列数据,极大减少磁盘 I/O |
| 数据压缩率 | 较低。因为一行中包含字符串、数字、日期等不同类型的数据,难以高效压缩。 | 极高。因为同一列的数据类型完全一致(例如全是数字),可以使用游程编码等高级压缩算法,通常能压缩到原来的 1/10。 |
| 增删改性能 (Insert/Update) | 非常快。直接在末尾追加或定位修改即可。 | 较慢。插入一条记录需要拆分到不同的列文件中,通常采用"批量写入"策略来优化。 |
| 代表性技术/数据库 | MySQL, PostgreSQL, Oracle, SQL Server | ClickHouse, HBase, Snowflake, Parquet (文件格式) |
什么时候该用哪一个?
- 选择行存储: 如果你的系统是用来处理日常业务的(比如淘宝的购物车、银行的转账),操作特点是高频地插入、修改单条记录,并且每次查询都需要用到这条记录的大部分信息,那么行存储是你的不二之选。
- 选择列存储: 如果你的系统是用来做数据分析的(比如分析过去一年淘宝某个品类的总销售额、用户的平均年龄),操作特点是海量数据查询、很少修改数据、通常只关注表中的某几列进行统计,那么列存储能让你的查询速度提升成百上千倍。
行存储 vs 列存储 磁盘存储区别
数据在磁盘上的物理连续性,直接决定了底层操作系统读取数据时的 I/O (输入/输出) 消耗,这也是两者性能差异的根本来源。
为了最直观地说明,我们先假设有一张非常简单的"员工表"(Employee),包含三行数据和四个字段(列):
| ID (编号) | Name (姓名) | Age (年龄) | Salary (薪资) |
|---|---|---|---|
| 1 | Alice | 25 | 5000 |
| 2 | Bob | 30 | 6000 |
| 3 | Carol | 28 | 5500 |
磁盘的基本读取单位通常是"块(Block)"或"页(Page)"(比如 4KB 或 8KB)。我们来看看这三行数据是如何被塞进这些数据块里的。
1. 行存储的磁盘结构:打包放入
在行存储(如 MySQL 的 InnoDB 引擎)中,一条记录的所有字节是紧密挨在一起的。
磁盘上的物理排列大概是这样的:
[块 1]: 1, Alice, 25, 5000, 2, Bob, 30, 6000, 3, Carol, 28, 5500 ...
-
写入逻辑: 当新员工 Dave 入职时,数据库直接在这个文件的末尾(或者找一个有空隙的数据块)追加
4, Dave, 35, 7000。这被称为顺序写,速度非常快。 -
读取灾难(针对分析场景): 假设老板问:"公司的平均薪资是多少?"
数据库必须把包含这三行数据的整个数据块从磁盘读到内存中。为了拿到
5000,6000,5500这三个数字,数据库不得不把无关的Alice,Bob,Carol等数据也一起读出来。这产生了大量无效的磁盘 I/O。
2. 列存储的磁盘结构:分门别类
在列存储(如 ClickHouse、数据湖里的 Parquet 文件)中,表会被垂直切分。每一列会被单独写入一个独立的文件,或者在同一个大文件里分块连续存储。
磁盘上的物理排列大概是这样的:
[文件 A / 块 1 - ID列]: 1, 2, 3 ... [文件 B / 块 2 - Name列]: Alice, Bob, Carol ... [文件 C / 块 3 - Age列]: 25, 30, 28 ... [文件 D / 块 4 - Salary列]: 5000, 6000, 5500 ...
- 读取福音(针对分析场景): 当老板同样问"公司的平均薪资 "时,数据库只需要去磁盘上读取
[文件 D]。由于全是连续的薪资数字,操作系统可以进行高效的顺序读,直接跳过了所有的姓名和年龄。读取的数据量可能只有行存储的 1/4 甚至更少。 - 写入灾难(针对事务场景): 当新员工 Dave 入职时,数据库不能像行存储那样只修改一个地方,它必须分别打开文件 A、B、C、D,在四个不同的地方追加数据。这导致了多次随机写 ,所以列式数据库通常极度反感单条
INSERT,而是要求你攒够一万条数据再进行"批量写入"。
3. 为什么说列存储是"压缩之王"?
磁盘结构的改变不仅降低了读取 I/O,还带来了列存储最强大的杀手锏:极限数据压缩。
在行存储的块里,字母(Name)、短整数(Age)、长整数(Salary)混杂在一起,很难找到规律,一般只能用通用的压缩算法(如 Gzip),压缩比有限。
但在列存储的块里,比如"性别"这一列,在磁盘上看起来就是连续的:
男, 男, 女, 男, 女, 女, 女, 男...
因为数据类型绝对单一,列存储可以使用非常极端的物理压缩算法,比如:
- 字典编码(Dictionary Encoding): 把"男"存为
0,"女"存为1。 - 游程编码(Run-Length Encoding): 如果有连续 100 个男,不用存 100 次"男",直接在磁盘上存
(男, 100)。
这种基于磁盘物理结构的特性,使得列存储的体积通常只有行存储的 十分之一。
行存储存储碎片
数据页(Page/Block)通常绝对不会100%存满,数据库会刻意留出空白,而这确实直接引发了数据库的碎片化(Fragmentation)问题。
让我们把这层窗户纸捅破,看看数据库引擎在底层到底是怎么权衡的。
1. 刻意的留白:填充因子(Fill Factor)
在行存储数据库(如 SQL Server, MySQL, PostgreSQL)中,当你创建一个表或索引时,底层有一个非常重要的参数叫做 填充因子(Fill Factor)。
如果你把填充因子设置为 80% ,这意味着数据库在向一个新的数据页写入数据时,只要数据占到了这个页总容量的 80%(比如 8KB 的页写了 6.4KB),它就会认为这个页"已经满了",然后去开启下一个全新的页。剩下的 20% 空间,就是刻意留出的空白。
2. 为什么要留白?为了应对"变胖"的数据
为什么放着好好的磁盘空间不用,非要留白?主要是为了应付 UPDATE(修改) 操作带来的不可预知性。
假设有一行记录:ID=1, Name='Tom', Bio='Hi'。这行数据非常短,被紧紧地塞在一个数据页里。
明天,Tom 把他的个人简介(Bio)改成了长达 1000 字的小作文。
- 如果页已经 100% 满了: 这个页里根本没有多余的空间放下 Tom 变长的数据。数据库只能把 Tom 的新数据硬生生"拆"开,或者把整行数据搬迁到一个新的页里,并在原来的位置留个"指针"(这叫行迁移/Row Migration)。这会导致以后每次查 Tom 的数据,硬盘磁头都要跳跃两次,性能暴跌。
- 如果页留了 20% 的空白: 数据库就可以从容地把 Tom 变长的数据直接写在这个页的预留空间里。数据依然在物理上是在一起的。
这种机制以及后续的数据增删改,就是导致数据库碎片的罪魁祸首。碎片化主要分为两种,都和这些"空白"息息相关:
-
**内部碎片 (Internal Fragmentation) **
如果你的业务绝大部分是
INSERT(只新增),很少UPDATE,那么你预留的那 20% 空间可能永远用不上。这就形成了"内部碎片"。一个 100GB 的数据库文件,可能里面有 20GB 都是这些空空如也的预留缝隙。此外,当你DELETE(删除)一条记录时,它原本占用的空间并不会马上还给操作系统,而是变成了一个"空洞",这也属于内部碎片。 -
外部碎片 (External Fragmentation) / 页分裂 (Page Split) ------ "空间被打碎了"
哪怕你留了 20% 的空白,有时候数据更新实在太大,或者插入的新数据硬要挤进两个旧数据之间,空白还是不够用了。这时候数据库被迫执行页分裂(Page Split):它会申请一个新的页,把原来页里的一半数据搬过去,腾出位置。这个新申请的页在物理磁盘上,通常和原来的页相隔十万八千里。原本可以"顺序读取"的数据,现在变成了疯狂的"随机读取"。
没有可变长度列也需要预留
如果表里全都是定长列 (Fixed-length columns,比如 INT, BIGINT, CHAR(50), DATE),那么把 Age=20 改成 Age=99,或者把 Status='A' 改成 Status='B',在物理磁盘上都是纯粹的"等长字节替换"。行根本不会变胖。
既然行不会变胖,那还需要预留空白(设置低于 100% 的填充因子)吗?
答案是:通常仍然需要预留! 因为哪怕 UPDATE 不惹麻烦了,INSERT(插入) 依然可能会把完美排列的数据页挤爆。
这背后的根本原因,在于关系型数据库的聚簇索引(Clustered Index,即 B+ 树)结构。
1. 核心原因:为了应对"乱序插入(Out-of-order Insert)"
在 MySQL (InnoDB引擎) 等绝大多数关系型数据库中,表里的数据并不是像往箱子里扔球一样随便乱丢的,而是严格按照主键(Primary Key)的大小顺序排列的。
假设你的数据页 100% 存满了,并且没有可变长度列:
[数据页 1]: ID=1, ID=3, ID=5, ID=7
这时候,业务系统突然要插入一条 ID=4 的新记录。
- 强制插队: 因为 B+ 树的绝对秩序,数据库不能 把
ID=4写到文件末尾的新页里,它必须 把ID=4塞进上面的[数据页 1]中,放在 3 和 5 之间。 - 页分裂爆发: 但是
[数据页 1]已经 100% 满了!没有任何缝隙能塞下哪怕是定长的ID=4。此时,数据库别无选择,只能当场执行页分裂(Page Split):申请一个新页,把 5 和 7 搬过去,再把 4 塞进去。
结论: 只要你的插入操作不是绝对的"从小到大依次递增"(例如你使用了 UUID 作为主键,或者业务允许插入历史时间的数据),你就必须给页留出空白(比如 80% 填充因子),让这些"半路杀出来的程咬金"有地方可以插队。
2. 另一个隐藏的幽灵:MVCC(多版本并发控制)
如果你用的是 PostgreSQL 等数据库,还会遇到一个更特别的机制。
在 PostgreSQL 的底层哲学里,根本没有真正的"原地 UPDATE"。当你执行修改时:
- 它会把你原来的那行数据标记为"已废弃(Dead Tuple)"。
- 然后,它会把修改后的新数据当成一条全新的记录(INSERT),写入到磁盘里。
即使你全是定长列,哪怕只是把 ID=1, Status=0 改成 ID=1, Status=1,数据库也需要额外的空间来存放这条"新记录"。如果你把页 100% 填满了,新记录就只能被迫写到别的页去,这会严重降低查询性能(破坏了 PostgreSQL 引以为傲的 HOT 优化机制)。所以它也必须留白。
那么,什么时候可以大胆地 100% 填满(Fill Factor = 100)?
如果你想把磁盘利用率压榨到极限,一滴空白都不留,你必须同时满足以下三个苛刻的条件:
- 没有变长列: 也就是你提到的,没有
VARCHAR,TEXT,BLOB等,保证 UPDATE 不会撑爆行。 - 主键严格递增: 使用自增 ID(Auto-Increment)或雪花算法 ID。新数据永远比老数据的主键大,永远在文件末尾追加,绝对不会发生"中间插队"。
- 极少或没有 UPDATE/DELETE: 数据写进去就是用来查的(比如日志流水表、归档表)。
如果是这种场景,把填充因子设为 100% 是非常明智的,可以让磁盘 I/O 效率达到最高。
列存储有没有存储碎片
列式存储引擎:数据变更(Mutation)与底层合并机制说明
传统行式关系型数据库(如 InnoDB)通过预留空间(Fill Factor)和页分裂(Page Split)来支持数据的原地修改(In-place Update)。列式存储引擎(如 ClickHouse, HBase, 现代数据湖格式 Parquet/Iceberg)为追求极致的 I/O 顺序读取性能与极限数据压缩率,通常将数据文件设计为不可变(Immutable) 且 100% 紧凑填充。
在此架构下,列式存储彻底摒弃了原地修改,采用 追加写入(Append-Only) 结合 LSM-Tree(Log-Structured Merge-Tree,日志结构合并树) 或类似的数据变异架构来处理增(Insert)、删(Delete)、改(Update)操作。
1. 核心架构原则:不可变性(Immutability)
在列存储的物理层面上,一旦数据块(Block/Segment)被压缩并落盘,该文件即进入只读状态,禁止任何形式的内部字节替换或位移。
此设计的优势在于:
- 消除锁竞争: 读写操作互不干扰,极大地提升了并发读取性能。
- 极致压缩: 数据块完全连续且无碎片,可以使用激进的字典编码和游程编码。
- 顺序 I/O: 避免了磁头随机寻道开销。
2. 写入路径(Write Path):增删改的统一化处理
由于文件不可变,列存储将所有的 INSERT、UPDATE 和 DELETE 操作统一转化为**追加写入(Append)**行为。
-
INSERT(新增):
- 数据首先写入内存缓冲区(MemTable),并记录预写日志(WAL)以保证数据不丢失。
- 当缓冲区达到阈值,数据在内存中按列转换为列式结构并压缩。
- 作为一个全新的、微型的、100% 填满的不可变数据段(Data Part / SSTable)刷写(Flush)到磁盘。
-
UPDATE(更新):
引擎不会定位并修改旧文件中的记录。相反,它会生成一条具有相同主键(Primary Key)但具有更高版本号或最新时间戳的全新记录,并将其追加写入到新的数据段中。此记录称为"增量(Delta)"数据。
-
DELETE(删除):
引擎同样不会去旧文件中擦除数据。它会生成一条特殊的追加记录,该记录包含被删除数据的主键,以及一个特定的删除标记,通常称为墓碑标记(Tombstone)。
3. 读取路径(Read Path):读时合并(Merge-on-Read)
由于数据的历史版本、更新增量和删除标记分散在多个不同的只读文件中,查询时必须进行动态整合,以保证数据的 ACID 隔离性与一致性。
当发起 SELECT 查询时,执行流程如下:
- 多路读取: 引擎同时扫描涉及该查询的所有旧数据段以及包含最新变更的新数据段。
- 内存合并(Merge): 数据在内存中根据主键(Primary Key)进行对齐。
- 版本仲裁:
- 若发现多个相同主键的记录,引擎比较其版本号/时间戳,仅保留版本号最高的一条。
- 若最高版本的记录携带墓碑标记(Tombstone),则在结果集中丢弃该主键的所有记录。
- 返回结果: 将最终正确的快照数据返回给客户端。
(注:此过程会引发读放大 / Read Amplification,因为查询需要扫描并丢弃大量过期或已删除的数据。)
4. 后台回收机制:Compaction(数据合并)
为了解决不断追加产生的小文件过多以及查询时的"读放大"问题,列式存储依赖后台异步的 Compaction(合并) 线程来维护系统的健康运转。
Compaction 核心逻辑:
- 文件选取: 引擎根据特定的合并策略(如 Size-Tiered 或 Leveled)在后台选中若干个具有重叠数据的旧数据段。
- 物理合并: 将这些文件读入内存,执行与"读取路径"相同的版本仲裁和墓碑清理。
- 重写落盘: 将清理掉过期数据(Dead Tuples)后剩下的有效数据,重新按列高比例压缩,生成一个全新的、体积更大的不可变数据文件。
- 原子替换: 更新元数据指针,将读请求指向新文件,并物理删除(Purge)原有的旧文件。
通过 Compaction 机制,系统最终回收了被 UPDATE 和 DELETE 占用的无效存储空间(消除了逻辑碎片),并维持了文件数量的相对稳定。
列式存储物理结构:Chunk (数据块) 与轻量级索引机制
在列式存储的物理实现中,数据并非将一整列无限制地存放在单一文件中。为了满足分布式处理、内存限制以及高效过滤的需求,列存储引入了 Chunk(数据块/分块) 的概念。并且,为了实现极致的 I/O 裁剪(I/O Pruning),Chunk 级别或更细粒度的层面确实广泛且必然地保存了最大值(Max)、最小值(Min)以及布隆过滤器(Bloom Filter) 等元数据。
1. 概念定义:什么是 Chunk?
Chunk(通常在不同技术栈中有不同的称呼,如 Parquet 中的 Row Group,ClickHouse 中的 Granule,Snowflake 中的 Micro-partition) 是列式数据库中数据水平划分的基本物理单元。
虽然列存储的核心思想是"按列存储",但在物理层面上,它实际上采取的是**"先按行分块,再按列存储"**的混合架构(PAX 架构,Partition Attributes Across)。
物理布局示例:
假设一张表有 10 亿行记录。存储引擎不会创建一个包含 10 亿个名字的单个大文件。相反,它会将这 10 亿行切分成多个 Chunk(例如,每 64,000 行作为一个 Chunk)。
- 内部结构: 在这个包含 64,000 行的 Chunk A 内部,数据才会被真正按列(Column 1, Column 2...)连续存储并进行高比例压缩。
- 目的: 当系统只需读取特定范围内的数据时,可以将内存的使用量控制在一个 Chunk 的大小,同时方便分布式集群将不同的 Chunk 分配给不同的计算节点并行处理。
2. Chunk 级别的元数据:Zone Maps (区域映射)
您提到的最大值(Max)和最小值(Min) ,在数据库术语中通常被称为 Zone Maps 、Data Skipping Indexes(数据跳过索引) 或 稀疏索引(Sparse Index)。
在写入一个 Chunk 时,存储引擎会在该 Chunk 的文件头部(Header)或单独的元数据文件中,记录该 Chunk 内每一列的统计信息。
- 包含的统计量:
Min(最小值)、Max(最大值)、Count(非空行数)、Sum(总和)、Null Count(空值数量)。 - 核心作用:谓词下推(Predicate Pushdown)与数据跳过。
- 场景: 执行查询
SELECT * FROM sales WHERE date >= '2023-10-01' AND date <= '2023-10-31'。 - 执行逻辑: 引擎在读取真实数据前,会先扫描所有 Chunk 的元数据。如果 Chunk 1 的
date列记录为Min='2023-01-01', Max='2023-01-31',引擎通过简单的范围比对即可断定该 Chunk 绝对不可能包含符合条件的数据。 - 结果: 整个 Chunk 1 被直接跳过(Skipped),完全不发生磁盘 I/O 读取,也不消耗 CPU 进行解压。
- 场景: 执行查询
3. 布隆过滤器 (Bloom Filter) 的应用
Min/Max 对于范围查询(Range Query) (如时间、自增 ID)极其高效,但对于等值查询(Point Lookup) (如 WHERE user_id = 'A1B2C3D4')则可能失效(如果该 Chunk 的 ID 极其分散,Min 和 Max 的跨度会很大,导致无法跳过)。
此时,布隆过滤器(Bloom Filter) 补足了这一短板。
- 原理: 布隆过滤器是一种概率型数据结构。在写入 Chunk 时,引擎会将该列的所有值通过多个哈希函数映射到一个位图(Bitset)中。此位图同样作为元数据保存在 Chunk 头部。
- 特性:
- 它只能回答两个结论:"绝对不存在" 或 "可能存在"。
- 无假阴性(No False Negatives): 如果布隆过滤器说某个值不存在,那它就绝对不在这个 Chunk 里。
- 执行逻辑: 当查询
WHERE user_id = 'A1B2C3D4'时,引擎将该 ID 传入对应 Chunk 的布隆过滤器:- 如果返回
0(绝对不存在),直接跳过整个 Chunk 的 I/O 读取。 - 如果返回
1(可能存在),引擎才会将该 Chunk 从磁盘读入内存并解压,进行精确的扫描过滤。
- 如果返回
4. 综合执行流水线:查询如何被加速
当一条带有过滤条件的 SQL 下发到列式存储引擎时,底层的处理流水线(Pipeline)是一层层递进的:
- 第一层裁剪(宏观):分区裁剪(Partition Pruning)。 根据目录名或时间分区,直接排除无关的目录。
- 第二层裁剪(Chunk 级 / Min-Max): 读取 Chunk 元数据,利用 Max/Min/Null Count 进行范围校验,跳过不在范围内的 Chunk。
- 第三层裁剪(Chunk 级 / Bloom Filter): 对于等值查询,利用布隆过滤器再次过滤,将剩下的待读取 Chunk 数量降到最低。
- 第四层操作(解压与扫描): 只有经过上述三层筛选后"幸存"的极少数 Chunk 的特定列,才会被真正从磁盘读取到内存,进行解压缩。
通过在 Chunk 级别引入 Min/Max 和布隆过滤器,列式存储将昂贵的"磁盘扫描"转换成了极其廉价的"内存元数据比对"。
列式存储计算层:向量化执行与 CPU SIMD 指令集加速
当底层存储引擎通过 Min/Max 和布隆过滤器完成 I/O 裁剪,将"幸存"的 Chunk 读入内存并解压后,数据库的性能瓶颈便从磁盘 I/O 转移到了 CPU 计算与内存带宽。
为了极速处理这些内存中的庞大数据,现代列式数据库(如 ClickHouse, DuckDB, Apache Arrow 体系)彻底抛弃了传统的逐行处理模式,转而采用 向量化执行(Vectorized Execution) ,并深度压榨现代 CPU 的 SIMD(单指令流多数据流) 硬件指令集,实现了计算性能的指数级飞跃。
1. 痛点:传统"火山模型"为什么在分析场景下失效?
在探讨向量化之前,必须先理解传统行式数据库(如早期的 MySQL、PostgreSQL)的执行引擎------火山模型(Volcano Model / Tuple-at-a-time)。
在火山模型中,查询执行树的每个节点(如 Filter, Aggregate)都会调用一个 next() 函数。
- 执行逻辑: 引擎向上层拉取一行 数据 -> 解析这行的各个字段 -> 执行
WHERE判断 -> 执行累加 -> 再去拉取下一行。 - 致命缺陷:
- 函数调用开销极大: 处理 10 亿行数据,就要调用 10 亿次
next()函数,虚函数调用的开销甚至超过了计算本身。 - CPU 缓存命中率极低(Cache Miss): 一行数据包含多种类型(字符串、数字),CPU 无法有效预取(Prefetch)数据到 L1/L2 缓存中。
- 分支预测失败(Branch Misprediction): 复杂的逐行
if-else判断会让 CPU 的流水线频繁清空重排。
- 函数调用开销极大: 处理 10 亿行数据,就要调用 10 亿次
2. 范式转换:向量化执行 (Vectorized Execution)
为了打破上述瓶颈,列式数据库引入了向量化执行 。它不再一次处理"一行(Tuple)",而是一次处理一整批、同一列的连续数组(Vector/Batch)。
- 内存中的一个 Chunk 被解压后,其中的一列(例如
Age)会以紧凑的、纯粹的数组形式存在:[25, 30, 17, 42, 19, ...]。 - 执行引擎的
next()调用不再返回一行,而是返回一个包含例如 1024 个元素的向量数组。 - 数据库将这个数组直接扔给一个极其紧凑的
for循环进行处理。没有复杂的业务逻辑穿插,只有纯粹的数学运算。这极大地优化了 CPU 的指令缓存和数据缓存。
3. 硬件暴击:SIMD (单指令流多数据流) 深度解析
向量化执行只是软件层面的数组批处理。真正让它起飞的,是现代 CPU 的底层硬件特性:SIMD(Single Instruction, Multiple Data)。
常见的 SIMD 指令集包括 Intel 的 SSE (128位)、AVX2 (256位) 和 AVX-512 (512位),以及 ARM 架构的 NEON。
标量执行 (SISD) vs 向量执行 (SIMD):
- 传统 SISD(单指令单数据): 如果要把数组 A 和数组 B 对应的元素相加,CPU 需要执行:取 A1 -> 取 B1 -> 相加得到 C1;取 A2 -> 取 B2 -> 相加得到 C2。循环往复。
- SIMD(单指令多数据): CPU 内部有超宽的寄存器(如 256-bit)。如果一个 32 位的整数(
INT)占 4 个字节,那么一个 256 位的 AVX2 寄存器可以同时塞入 8 个整数 。- CPU 只需要发射一条 SIMD 加法指令。
- 硬件电路会在一个时钟周期内 ,同时完成 8 对数字的加法运算:
[A1~A8] + [B1~B8] = [C1~C8]。 - 理论算力直接翻了 8 倍(如果是 AVX-512 则是 16 倍)!
4. 天作之合:为什么 SIMD 是列存储的专属武器?
SIMD 拥有恐怖的算力,但它有一个极其苛刻的物理要求:被加载到 SIMD 寄存器中的数据,必须在物理内存中是绝对连续的,且数据类型完全一致。
- 行存储的绝望: 在行存储的内存中,数据是
[Age, Name, Salary, Age, Name, Salary]。为了把 8 个Age塞进 SIMD 寄存器,CPU 必须跨越无用的Name和Salary去"东拼西凑(Gather)",这极其耗时,直接抵消了 SIMD 的性能收益。 - 列存储的狂欢: 列存储解压后,内存里就是纯粹的
[Age, Age, Age, Age...]。CPU 可以使用一条简单的对其加载指令(Aligned Load),瞬间将连续的 8 个(或 16 个)Age填满 SIMD 寄存器。列存储的物理形态,仿佛就是为 SIMD 量身定制的。
5. 执行推演:利用 SIMD 处理 WHERE Age >= 18
让我们看看在 ClickHouse 或 DuckDB 底层,这段看似简单的过滤条件是如何被 SIMD 处理的:
- 加载数据: CPU 使用一条 SIMD 指令,将 8 个连续的年龄
[15, 22, 17, 19, 30, 12, 45, 18]加载到一个 256 位寄存器R1中。 - 加载条件: 将常量
18复制 8 份,即[18, 18, 18, 18, 18, 18, 18, 18],加载到寄存器R2中。 - 向量化比较: 执行一条 SIMD 并行比较指令(如
_mm256_cmpge_epi32)。 - 生成位图掩码 (Bitmask): CPU 在一个周期内完成 8 次比较,并直接输出一个掩码向量:
[0, 1, 0, 1, 1, 0, 1, 1](0 代表不满足,1 代表满足)。 - 高效过滤: 引擎拿着这个掩码,直接去内存中提取对应位置为 1 的其他列数据,完全避免了传统
if (age >= 18)带来的昂贵的 CPU 分支跳转惩罚。