列式数据库是怎么炼成的

列式数据库与行式数据库最大的区别在于数据的存储方式,也就是它们在磁盘上的组织方式不同。传统的行式数据库常用于 OLTP (Online Transaction Processing) 场景,在这个场景下需要频繁的进行数据的插入、更新、删除操作,操作的对象往往是单行数据。而列式数据库常用于 OLAP (Online Analytical Processing) 场景,对于数据的聚合查询更为常见,往往需要扫描某一列的大量数据进行计算。

Storage Difference
Storage Difference

如果同时用过两种类型的数据库,就会发现:

这里列举的优化原则只是冰山一角,仅用于说明两种数据库最显眼的差异。

使用行式数据库过程中,最简单常见的优化原则就是 尽可能命中索引、降低 B+ 树高度、减少扫描行数,如:

  • 优先对区分度高的列建立索引
  • 覆盖索引(索引中包含查询所需的所有列,避免回表)
  • 索引下推(在存储引擎层提前过滤不满足条件的数据)
  • 最左前缀匹配原则
  • 避免使用函数或者隐式类型转换(如:where date(create_time) = '2022-01-01'),会导致索引失效
  • 避免在索引列上使用 !=<> 等操作符,会导致索引失效
  • 避免深度分页
  • 分库分表(提出这一优化方向,也是基于单表数据量过大,索引维护的开销会增加,性能也会退化)
  • 等等…

减少扫描行数这一思路对于列式数据库同样适用(如分区裁剪),但列式数据库还有另一个很重要的优化方向,那就是 减少列,如:

  • 行存特性(如果是点查询,列数据库 I/O 反而会增加,这一点和行式数据库正好相悖)
  • 只读取查询涉及的列(行存也提倡避免 SELECT *,但由于行存以行为单位读取磁盘,主要减少的是网络传输量而非磁盘 I/O;而列存中每列独立存储,少读一列就直接少一份磁盘 I/O)
  • 等等…

当然,任何数据库的优化,都逃不开 减少 I/O 这一核心目的。说得更白话一点,如果有一种完美的存储介质,它没有I/O延迟,也不会丢失数据,那么这些优化也就不再需要了。

1. ClickHouse 的设计#

1.1 整体组件#

从 ClickHouse 的架构图来看,列式数据库包含以下核心组件:

ClickHouse Architecture
ClickHouse Architecture
  • 查询处理层: 查询处理遵循传统范式:解析入站查询、构建并优化逻辑与物理查询计划,然后执行
    • SQL Parser
    • SQL Planner
    • Physical Plan Builder
    • Plan Executor
  • 存储层:由不同的表引擎组成,这些表引擎封装了表数据的格式和位置
    • MergeTree* Family Tables Engines: 代表了 ClickHouse 中的主要持久化格式
    • Special-Purpose Tables Engines:用于加速或分布查询执行的专用表引擎
      • Dictionary
      • Memory
      • Distributed (Data Sharding) 处理分布式
  • 集成层:用于与外部系统进行双向数据交换的虚拟表引擎,例如关系型数据库 (如 PostgreSQL、MySQL) 、发布/订阅系统 (如 Kafka、RabbitMQ) ,或键值存储 (如 Redis) 。还可以与数据湖 (如 Iceberg) 或对象存储中的文件 (如 AWS S3、Google GCP) 交互
    • Virtual Tables Engines
  • 正交组件:提供辅助功能
    • Thread pools
    • Caches
    • RBAC (Role-Based Access Control)
    • Backups
    • Monitoring
  • 访问层:通过不同协议管理用户会话并与应用程序通信
    • User Session
    • Wire protocols

我们更进一步,丢掉分布式特性、集成和监控,只保留最影响 OLAP 查询性能的核心设计,如下所示:

组件 职责
查询处理层 SQL Parser 解析 SQL 语句,生成抽象语法树 (AST)
查询处理层 Query Engine 查询计划生成、优化和执行,即 ClickHouse 中的 SQL planner、Physical Plan Builder、Plan Executor 集成
存储层 MergeTree Engine 列式存储格式、数据分块、压缩编码、稀疏索引、Data Skipping Index、数据读写

查询处理层本身非常复杂,这里不展开;下面会从存储结构、写入路径、查询裁剪和系统级取舍几个角度,拆开看 ClickHouse 为 OLAP 做了哪些核心设计。

1.2 存储结构#

列式数据库通常有如下的特点(相较于行式数据库而言):

  • 压缩率高
  • 大数据集的聚合计算更快
  • 按列过滤高效
  • 并行度高
  • 更新 / 删除 性能较差
  • 点查询 性能较差

而这些特点跟它的存储结构紧密相关。

ClickHouse 中每个 MergeTree* 表引擎的表都被组织为一组不可变的表分片,每当一批行数据插入到表中时,会创建一个分片。后台合并任务会定期将多个小分片合并成一个大分片,直到达到可配置的分片大小。因此为了提升写入性能(减少分片数量),行数据插入支持两种模式:

  • 同步模式:每条 INSERT 创建一个新的分片 (因此建议客户端使用批量 INSERT,降低合并开销)
  • 异步模式:将同一个表的 INSERT 缓存在一起,达到一定数量后,才创建新的分片

而在磁盘上,一个分片对应一个目录,其中包含每一列的一个文件。一个分片的行会在 逻辑上 进一步划分为若干组,每组包含 8192 条记录,成为 granule。一个 granule 表示由 ClickHouse 中扫描和索引查询的最小不可再分数据单元。

但是 granule 只是逻辑上的概念,在物理上,磁盘数据是在 chunk(块)级别进行的,chunk 将某一列中多个相邻的 granule 组合在一起。一个块上的 granule 数量不是固定的,因为一个块又大小限制,默认是 1MB,受到该列的数据类型和分布影响。块还会被压缩用来减小大小和 I/O 成本。

为了在存在压缩的情况下仍然能够快速随机访问单个 granule,ClickHouse 会为每一列存储一个映射,用于将每个 granule 的 ID 关联到其所在压缩块在该列文件中的偏移,以及该 granule 在未压缩块中的偏移。

就是一个索引,用于快速查找每个 granule 在存储中的位置

列还可以进一步采用字典编码,比如:LowCardinality(T) 使用整数 ID 替换原始列值,Nullable(T) 则为列 T 添加一个内部位图,用于表示列值是否为 NULL。

最后,表还可以通过分区表达式进行分区(范围分区、哈希分区或轮询分区)。为了实现分区裁剪,ClickHouse 还会为每个分区存储分区表达式的最小值和最大值,用于查询的时候跳过不需要的分区。

Table (users)
├── Partition_202401/                         ← 分区 (按分区表达式划分)
│   │
│   ├── Part_1/                               ← 分片 (不可变, 后台合并)
│   │   │
│   │   │  ── 核心数据文件 ──
│   │   │
│   │   ├── primary.idx                       ← 稀疏索引 (每个 granule 一个条目)
│   │   ├── id.bin                            ← 列数据文件 (压缩存储)
│   │   │   ├── [Chunk 0] ──compressed──┐
│   │   │   │   ├── Header              │ 压缩算法 / 压缩后大小 / 原始大小
│   │   │   │   ├── Granule 0 (8192行)  │
│   │   │   │   ├── Granule 1 (8192行)  │ ~1MB
│   │   │   │   └── Granule 2 (8192行)  ┘
│   │   │   │
│   │   │   └── [Chunk 1] ──compressed──┐
│   │   │       ├── Header              │ 压缩算法 / 压缩后大小 / 原始大小
│   │   │       ├── Granule 3 (8192行)  │
│   │   │       └── Granule 4 (8192行)  ┘
│   │   │
│   │   ├── id.mrk                            ← 标记文件 (granule → 定位信息)
│   │   │   ┌────────────┬──────────────┬──────────────────┐
│   │   │   │ Granule ID │ Chunk Offset │ Granule Offset   │
│   │   │   │            │ (压缩文件中) │ (解压后块内偏移) │
│   │   │   ├────────────┼──────────────┼──────────────────┤
│   │   │   │ 0          │ 0            │ 0                │
│   │   │   │ 1          │ 0            │ 8192             │
│   │   │   │ 2          │ 0            │ 16384            │
│   │   │   │ 3          │ 1048576      │ 0                │
│   │   │   │ 4          │ 1048576      │ 8192             │
│   │   │   └────────────┴──────────────┴──────────────────┘
│   │   │
│   │   ├── name.bin                          ← 其他列的数据文件
│   │   ├── name.mrk
│   │   ├── age.bin
│   │   ├── age.mrk
│   │   │
│   │   │  ── 元数据文件 ──
│   │   │
│   │   ├── checksums.txt                     ← 所有文件的校验和 (数据完整性校验)
│   │   ├── columns.txt                       ← 该 Part 的列名和类型列表
│   │   ├── count.txt                         ← 该 Part 的总行数 (COUNT(*) 直接读取)
│   │   ├── partition.dat                     ← 该 Part 的分区键值
│   │   └── minmax_create_time.idx            ← 分区键列的 min/max (分区裁剪用)
│   │
│   └── Part_2/                               ← 另一个分片
│       └── ...
└── Partition_202402/
    └── ...

注意:插入行是顺序(主键以及排序键)存储在磁盘上的。

也就是说,后面要讲的写入吞吐、查询裁剪,以及并行化执行,本质上都建立在这套 Part、Column、Granule 的组织方式之上。

1.3 数据写入和合并#

列式数据库的写入路径通常围绕两个目标展开:一是尽可能高吞吐地接收新数据,二是通过后台合并逐步把数据整理成更适合查询的形态。

在前面提到的 MergeTree 模型中,行数据插入后会先形成新的 Part。为了兼顾写入吞吐和查询性能,ClickHouse 一方面允许批量写入和异步聚合写入,另一方面又会依赖后台合并任务持续整理这些 Parts。

part 插入和合并

BI 和 Observability 场景要求数据库既能支撑高数据摄取速率,又能通过聚合或数据老化等技术持续减少历史数据量。ClickHouse 提供了不同的合并策略对于已有数据进行持续的增量转换:

  • Replacing Merges:通常用作一种合并时更新机制(通常用于更新频繁的场景),或者作为插入时数据去重机制的替代方案。
  • Aggregating Merges:将具有相同 primary key 列值的多行折叠为一行聚合结果,非 primary key 列必须是汇总值。
  • Time-To-Live (TTL) Merges:为历史数据提供老化机制。可以用于将冷数据移动到成本更低的存储介质中。

在实际的业务场景中,客户端发送数据到服务器时,可能因为各种原因导致无法获取到执行结果(如超时,网络异常等)。这种情况下,客户端无法判断数据是否已经插入到数据库中,在传统的数据库中,往往通过唯一索引来避免重复插入。但是,对于列式数据库来说,维护这样的一个唯一索引带来的开销往往是难以接受的。

因此,ClickHouse 提供了一种更轻量级的替代方案:服务器会维护最近 N 个已经插入 parts 的 hash 值,并忽略哪些 hash 值已经存在的 parts 的重新插入。为了对去重提供更多控制,还允许客户端提供插入数据的 insert token。

这条写入路径也是 ClickHouse 最擅长的“顺风路”:数据尽量以追加和合并的方式被整理。相对地,一旦需求变成直接修改已有数据,代价就会显著上升。

1.4 更新和删除#

MergeTree* 表引擎的设计更适合仅追加(append-only)的工作负载,但某些场景下仍需要偶尔修改已有数据。

更新时,ClickHouse 会就地重写表中的所有 parts。如果要避免删除整表或者更新列时,表或者列的大小在短时间内翻倍,这个操作就无法提供原子性(如果要提供原子性,需要一次性生成新副本,修改完成后再替换旧的 part)。不提供原子性,意味着并行执行的 SELECT 可能同时读取到已修改和未修改的 parts。这样也可以减少修改带来的开销。

删除时,依然需要较大的开销,因为如果要删除特定行,则需要对表的所有列进行删除。作为替代方案,ClickHouse 提供了轻量级删除(Lightweight Delete),即额外维护一个位图列,用于标记某一行是否被删除。在 SELECT 查询时,再针对该位图列进行过滤。

轻量级删除可以降低删除带来的开销,但是会降低查询性能。

1.5 查询与裁剪#

前面介绍存储结构、append-only Part 和排序键,并不是为了把磁盘格式讲得更完整,而是因为这些设计最终都会体现在查询阶段:它们共同服务的目标,是让分析查询尽可能减少无效扫描。

就算是列式存储,可以减少 I/O,但是面对单次查询就要扫描 PB 级别的数据时,速度和成本也难以接受,因此数据库往往还需要进一步减少扫描的数据量,这就是数据裁剪的目的。

在 ClickHouse 中数据裁剪通过以下方式实现:

  • 主键索引:(稀疏索引) 通过为表定义主键索引,来决定每个 Partition 中行的排序顺序,该索引是局部聚簇。另外分区内还会存储每个 Granule 中的首个主键列值到 Granule ID 的映射。这样查询时,如果是根据主键列查询,那么可以二分查找主键索引来快速定位。

  • 表投影: 即包含相同行,但是按照不同的主键排序的表。但是很显然,这样会增加 insert、merge 和存储开销。

  • 跳过索引: 这是一种比投影更轻量的方案。它旨在索引数据块(多个连续 Granule 的层级)存储少量元数据,从而避免扫描无关行。ClickHouse 中支持的跳过索引有:

    • MinMax:为每个索引数据块存储索引表达式的最小值和最大值。
    • Set:存储每个索引数据块中可配置数量的唯一值。
    • BloomFilter:针对行、token 或者 n-gram 值进行构建。

一条分析查询在 ClickHouse 中通常不是像 OLTP 数据库那样,先命中某个索引,再迅速精确定位到某一行并返回结果;更常见的方式是逐层减少扫描范围。

  1. 查询会先根据分区条件做分区裁剪,再根据主键稀疏索引缩小到候选 granule;
  2. 如果还定义了跳过索引,那么可以继续排除不可能命中的数据块。
  3. 等这些裁剪都完成之后,系统才真正去读取涉及的列,并执行过滤、聚合等算子。

因此,ClickHouse 的优化重点并不是“精确命中单行”,而是尽可能减少无效扫描的分区、块和列。不同查询模式在这种设计下的收益也不一样。

  • 对于点查来说,ClickHouse 并不占优势,因为最小扫描单位仍然是 granule,很难像 B+ 树那样精准命中单行;
  • 对于范围查询来说,如果过滤条件和排序键方向一致,稀疏索引通常能较有效地缩小候选范围;
  • 而对于聚合查询来说,列式存储的优势会最明显,因为查询往往只需要读取少量列,并且天然适合向量化执行和批量计算。

当存储结构、写入路径和查询裁剪都确定之后,剩下的问题就变成:这套系统还能如何扩展、并为了吞吐接受哪些系统级代价。

1.6 数据复制#

ClickHouse 基于 Raft 协议提供分布式且具备容错的协调层。当各节点执行本地的 insert、merge、mutation 和 DDL 语句时,复制日志会在所有其他节点上异步回放。这里不关注列式数据库的分布式特性,因此也不展开。

1.7 ACID 特性#

为最大化并发读写操作的性能,ClickHouse 尽可能避免使用锁。查询在开始时会基于所有相关表中全部 parts 的一个快照来执行。这样可以保证在执行期间,由并行 INSERT 或合并操作新插入的 parts 不会参与本次查询执行。为防止 parts 被同时修改或删除,在查询执行期间,会增加这些被处理 parts 的 引用计数

因此,ClickHouse 从严格意义上来说并不满足 ACID,只有 SELECT 和 INSERT 并行时,能做到互不影响(INSERT 会新建一个 part,而 SELECT 会对 parts 产生一个快照)。

这样的取舍在 OLAP 场景下是完全可以接受的,放弃原子性可以换来更高的读写性能。

1.8 并行化查询#

前面介绍过 ClickHouse 中采用了多层级存储结构,得益于这样的设计,查询时可以分三个层级对查询进行并行化,这三个层级分别是:表分片(Table Shard)、数据块(Data Chunks)和数据元素(Data Elements)。

表分片,这里是指分布式架构中,将分布式表根据分片键将数据分配到不同的 Shard (位于不同的节点或者集群)上,每个 Shard 本质上都是一个独立的表。

这一点和传统数据库的分库分表相同。

Parallel Query
Parallel Query Overview

这里提到的 SIMD(Single Instruction Multiple Data)是一种并行计算技术,可以提升算术运算、过滤和聚合的性能。比如前面提到的删除位图,就可以利用 SIMD 与命中行的位图进行位运算,用一条 CPU 指令即可完成多行数据的过滤。

至于多核和多节点并行,涉及比较多的细节(Physical Plan、Repartition、Aggregation、Merge 等),这里也不展开了。

小结#

到这里,前面这些看起来分散的主题,其实都可以重新压回同一张设计图:存储结构决定了写入路径、修改代价、查询裁剪方式,以及一致性和并行化的边界。

ClickHouse 的存储是一个多级结构,每一级都有明确的职责:

Table → Partition → Part → Column File (.bin) → Compressed Chunk → Granule
  • Partition(分区):按业务维度(通常是时间)划分数据,配合 minmax_*.idx 实现分区裁剪,查询时整块跳过无关分区。
  • Part(分片):数据写入的基本单位,不可变,后台异步合并。不可变性让并发读写无需加锁(SELECT 基于 parts 快照执行),代价是更新和删除开销大。
  • Column File(列文件):每列独立存储,查询时只读取涉及的列;同类型数据连续排列,天然适合压缩编码。
  • Compressed Chunk(压缩块):物理 I/O 的最小单位(~1MB),包含 Header 和多个 Granule 的压缩数据。通过 .mrk 标记文件实现 Granule 级别的随机访问而无需解压整个列文件。
  • Granule(粒度):逻辑上的最小扫描单元(8192行),稀疏索引和跳过索引都以 Granule 为粒度工作。粒度越小定位越精确,但索引开销越大。

换句话说,1.2 解释了这张结构图本身,1.31.4 解释了它为何擅长追加写入却不擅长原地修改,1.5 解释了它为何能减少查询扫描量,而 1.61.8 则补上了这套设计在复制、一致性和并行执行上的系统级边界。

设计上的核心取舍:

选择 获得 牺牲
列式存储 高压缩率、列裁剪、向量化计算 点查不友好
不可变 Part(append-only) 无锁并发、简化一致性 更新/删除代价高
稀疏索引(非 B+ 树) 索引体积小可以常驻内存,维护成本低 点查不友好,最小扫描单位是 Granule
放弃严格 ACID 更高的读写吞吐 并发修改时无事务隔离
Part hash 去重(非唯一索引) 写入路径轻量 只能去重整个 Part,非行级去重
按分区组织数据 分区裁剪、冷热数据管理更自然 分区过细会导致 Part 过多,过粗又会削弱裁剪效果
较大的 Granule / Chunk 索引更稀疏、元数据更少、顺序 I/O 更友好 过滤不够精确,可能为了少量命中数据多扫一个 granule
后台异步合并(merge) 写入路径轻、吞吐高、读写更容易并发 会带来写放大,且查询性能会受 merge 进度影响

2. 手搓一个最小列式数据库#

前面已经拆完了 ClickHouse 的核心设计,但真实的生产级系统太复杂,不可能在一篇文章里完整复刻。好在如果只保留几个最关键的机制:分区、Part、列文件、稀疏索引、列裁剪,其实已经足够拼出一个最小可用的列式数据库骨架。下面我们尝试站在实现者的角度,把这些设计重新串起来。

2.1 先定义 MVP 的能力边界#

在动手之前,先主动砍掉一大批能力,把目标收敛到一个最小闭环。

我们只支持:

  • 单机
  • 单表
  • append-only 写入
  • 按时间字段分区
  • 分区内按一个排序键排序
  • 每个 granule 维护一条稀疏索引
  • 支持 filter + sum / count 这类简单聚合查询

我们明确不支持:

  • update
  • delete
  • join
  • 事务
  • 压缩
  • 分布式执行

这样做的目的是让核心结构尽量清晰:只关注一批行是如何写入成列式结构的,以及一条查询是如何一步步减少扫描量的。

2.2 核心数据结构#

如果把前面介绍过的概念压缩成最小模型,那么核心结构大概如下:

Table
└── Partitions[partition_key]
    └── Parts[]
        ├── ColumnFiles[column_name]
        └── PrimaryIndex[]

其中每一层的职责分别是:

  • Table:保存表的 schema、分区键、排序键,以及所有分区的入口。
  • Partition:按照分区键组织数据,例如按天或者按月保存同一时间范围内的多个 Part。
  • Part:一次批量写入形成的不可变数据块,内部已经按排序键排好序。
  • ColumnFile:Part 中每一列独立存储的数据文件。
  • Chunk:ColumnFile 的内部结构,包含 Header 和多个 Granule 的数据,压缩单元。
  • Granule:逻辑上的最小扫描单元(8192行),primary_index 和跳过索引都以 Granule 为粒度工作。
最小列式数据库的层级结构:Partition、Part、列文件与稀疏索引

这里最关键的一点是:Part 内部按排序键有序、列独立存储、granule 级别有 primary_index。只要这三点成立,后面的查询裁剪就有了基础。

2.3 写入路径伪代码#

接下来考虑一批行写进来之后,会发生什么。

写入路径:分区、排序、切分 granule 与按列落盘

使用伪代码描述如下:

def insert(rows):
    grouped = group_by(rows, partition_key)

    for partition_value, partition_rows in grouped:
        sorted_rows = sort_by(partition_rows, sort_key)
        granules = split_into_granules(sorted_rows, granule_size=8192)

        part = Part(schema=schema, sort_key=sort_key, granules=granules)
        partitions[partition_value].add_part(part)


class Part:
    def __init__(self, schema, sort_key, granules):
        self.schema = schema
        self.sort_key = sort_key
        self.granules = granules
        self.primary_index = self._build_primary_index()
        self.column_files = self._build_column_files()

    def _build_primary_index(self):
        index = []
        for granule_id, granule in enumerate(self.granules):
            index.append({
                "first_sort_key": granule.rows[0][self.sort_key],
                "granule_id": granule_id,
            })
        return index

    def _build_column_files(self):
        column_files = {}
        for column in self.schema:
            column_files[column] = []
            for granule in self.granules:
                column_files[column].append(extract_column(granule.rows, column))
        return column_files

这段流程看起来很朴素,但已经包含了列式数据库最关键的几个动作:

  1. 按分区键分桶:让后续查询可以先裁剪整个分区。
  2. 按排序键排序:让相邻数据在逻辑上更有局部性,便于索引裁剪。
  3. 切成 granule:把最小扫描单位控制在一个比行更粗,但又足够细的粒度上。
  4. 按列写入:支持列裁剪,同时对聚合算子更友好。
  5. 构造 primary_index:记录每个 granule 的首个排序键值,用于查询时快速排除不可能命中的 granule。

也就是说,一批行数据在写入完成后,不再是“很多行记录”,而是变成了:按分区组织的多个 Part,每个 Part 内部按列存储,并额外带有 granule 级别的 primary_index

2.4 查询路径伪代码#

再来看一条带过滤和聚合的查询是怎么执行的。

def query(partition_predicate, sort_key_predicate, other_predicates, aggregate_column):
    result = 0

    candidate_partitions = prune_partitions(partitions, partition_predicate)

    for partition in candidate_partitions:
        for part in partition.parts:
            candidate_granules = prune_granules(part.primary_index, sort_key_predicate)

            predicate_columns = read_predicate_columns(part, candidate_granules, [
                sort_key_predicate.column,
                *columns_of(other_predicates),
            ])

            for granule_id in candidate_granules:
                matched_row_ids = filter_granule(
                    predicate_columns,
                    granule_id,
                    sort_key_predicate,
                    other_predicates,
                )

                values = read_column_values(part, aggregate_column, granule_id, matched_row_ids)
                result += aggregate(values)

    return result
查询裁剪链路:先缩小分区 / Part 扫描范围,再减少 Granule,最后减少列 I/O

这条伪代码更接近 sum 这一类需要读取聚合列的执行路径;如果是 count,只需要统计 matched_row_ids,不必读取聚合列。

这条路径里,真正重要的不是代码细节,而是扫描量如何被一层层削减:

  1. 先裁剪分区:不在时间范围内的分区整块跳过。
  2. 再裁剪 granule:通过 primary_index 只保留可能命中的 granule。
  3. 读取谓词列并筛出命中位置:只读取过滤条件涉及的列,在 granule 内找出真正命中的行位置。
  4. 最后读取聚合列并计算:只对命中的位置读取目标列值,再执行聚合。

换句话说,primary_index 的作用是先缩小候选 granule 的范围,而真正的过滤发生在读取谓词列之后,真正的值读取发生在聚合列阶段。 这里的“值读取”特指最终聚合目标列的值读取,不包括前面为了过滤而读取的谓词列。

这其实就是前面反复提到的三个关键词:先缩小分区 / Part 扫描范围、再减少 Granule、最后减少列 I/O

2.5 用一个查询例子串起来#

假设现在有这样一条查询:

SELECT sum(amount)
FROM orders
WHERE dt BETWEEN '2026-03-01' AND '2026-03-31'
  AND user_id = 1001

它的执行过程可以理解为:

第一步,数据库先根据 dt 做分区裁剪。假设表是按月分区,那么 3 月之外的分区可以直接跳过,连对应的 Part 都不需要打开。

第二步,在命中的 3 月分区里,再查看每个 Part 的 primary_index。只有当 Part 内部的数据按 user_id 排序,或者 user_id 是排序键的前导列时,数据库才能先圈定哪些 granule 可能包含 user_id = 1001;但这一步只能缩小候选 granule,不能像 B+ 树那样直接定位到具体行。

第三步,真正读取数据时,也不需要把整行都读出来,只需要先读取过滤条件涉及的列。这里通常先读取 user_id,在候选 granule 内筛出真正命中的行位置;如果 dt 已经在分区裁剪阶段处理掉,甚至连 dt 列本身都不需要读取。

第四步,在命中的位置确定之后,再去读取 amount 列对应的值,并把这些值逐步累加起来。

走完这个过程就会发现,列式数据库真正高效的地方,并不只是“按列存”这四个字,而是它围绕“减少扫描量”形成了一整套层层递进的组织方式:

  • 分区负责减少需要扫描的 Part
  • primary_index 负责减少需要扫描的 Granule
  • 谓词列负责筛出真正命中的位置
  • 聚合列负责在命中的位置上读取值并计算结果

这也是为什么说,列式数据库的核心不只是换了一种存储格式,而是围绕分析查询重新组织了整条数据路径。

参考资料#

访问量 访客数