【Rust 2026教程:从零构建 Mini-OLAP 引擎】第 1 章 列式存储:OLAP 的物理基石

1.1 背景知识

OLAP vs OLTP

数据库世界有两类典型负载:

  • OLTP( 联机事务处理 :MySQL、PostgreSQL 的主战场。特点是高并发、点查、单行更新,例如"扣减一个库存"。
  • OLAP(联机分析处理) :DuckDB、ClickHouse、Snowflake 的主战场。特点是宽表扫描、聚合、低并发,例如"统计过去 30 天每个品类的 GMV"。

OLTP 关心 ,OLAP 关心。两者在存储、执行、索引上的设计哲学完全不同。

行存 vs 列存

假设一张订单表 orders(id, user_id, amount, ts),有 1 亿行。

行存(按行排列)

(id1,user1,amt1,ts1), (id2,user2,amt2,ts2), ...

  • 优点:读单条记录只需一次 IO
  • 缺点:SELECT SUM(amount) 要把所有列都读进内存,IO 浪费严重 列存(按列排列)

[id1,id2,...], [user1,user2,...], [amt1,amt2,...], [ts1,ts2,...]

  • 优点:只读需要的列;同列类型相同,压缩率极高;CPU cache 友好,便于 SIMD
  • 缺点:单行写入/更新成本高

DuckDB、ClickHouse、Parquet 都选择列存------OLAP 场景下这是几乎唯一的正确答案。

Vector / Batch

完整列存还需要一个关键概念:Vector(DuckDB 术语,也叫 RecordBatch、Chunk)。

一次不处理一行,也不处理整列,而是处理"一段列"------例如固定 2048 行。这样既能享受列存的紧凑性,又不会一次性把 1 亿行装进内存。

1.2 设计思路

我们直接使用 arrow2 作为列存基础------这是 Rust 生态最成熟的 Apache Arrow 实现,DuckDB 的 Rust 绑定也兼容 Arrow 内存格式。

定义两个核心类型:

  • Column:一列数据,本质是 Arc<dyn Array>(用 Arc 让列可低成本共享)
  • Batch:一批列,即 Chunk<Column>

1.3 代码实现

Cargo.toml 关键依赖:

toml 复制代码
[package]
name = "miniduck"
edition = "2021"

[dependencies]
arrow2 = { version = "0.18", features = ["compute"] }
hashbrown = "0.14"
ahash = "0.8"
sqlparser = "0.45"

src/storage.rs

rs 复制代码
use std::sync::Arc;
use arrow2::array::{Array, Float64Array, Int64Array, Utf8Array};
use arrow2::chunk::Chunk;

/// 用 Arc<dyn Array> 让列零拷贝共享
pub type Column = Arc<dyn Array>;
pub type Batch  = Chunk<Column>;

#[derive(Clone)]
pub struct Table {
    pub name: String,
    pub schema: Vec<String>,   // 列名列表
    pub batch: Batch,          // 教学版:单 batch
}

impl Table {
    pub fn from_columns(name: &str, cols: Vec<(&str, Column)>) -> Self {
        let schema = cols.iter().map(|(n, _)| n.to_string()).collect();
        let arrays: Vec<Column> = cols.into_iter().map(|(_, c)| c).collect();
        Table { name: name.to_string(), schema, batch: Chunk::new(arrays) }
    }
    pub fn col_index(&self, name: &str) -> Option<usize> {
        self.schema.iter().position(|n| n == name)
    }
    pub fn num_rows(&self) -> usize { self.batch.len() }
}

// 便捷构造器
pub fn col_i64(v: &[i64])  -> Column { Arc::new(Int64Array::from_slice(v)) }
pub fn col_f64(v: &[f64])  -> Column { Arc::new(Float64Array::from_slice(v)) }
pub fn col_str(v: &[&str]) -> Column { Arc::new(Utf8Array::<i32>::from_slice(v)) }

写一个最小 demo(examples/ch1_table.rs):

rs 复制代码
use miniduck::storage::*;

fn main() {
    let t = Table::from_columns("orders", vec![
        ("id",     col_i64(&[1, 2, 3, 4])),
        ("city",   col_str(&["BJ", "SH", "BJ", "SZ"])),
        ("amount", col_f64(&[10.0, 20.0, 15.0, 30.0])),
    ]);
    println!("schema: {:?}", t.schema);
    println!("rows:   {}",   t.num_rows());
}

运行:cargo run --example ch1_table

1.4 自测题

  1. 一张 100 列的宽表,查询只用到 3 列。行存 vs 列存的 IO 量大致差几倍?为什么?
  2. 为什么列存压缩率天然高于行存?举两种典型的列式压缩算法。
  3. 为什么 DuckDB 选择 2048 作为默认 vector size,而不是直接 1 行或者整列?提示:从 L1/L2 cache 大小思考。
  4. Arrow 的 ArrayBox<dyn Array> 装箱,会不会有虚函数开销?这开销在 OLAP 场景里是否重要?

1.5 拓展学习

  • 论文:MonetDB/X100: Hyper-Pipelining Query Execution(2005,向量化执行的开山之作)
  • Apache Arrow 内存格式规范
  • 动手:用 arrow2::io::parquetTable 写成 parquet 再读回来,观察文件大小
相关推荐
rocky_rocky2 小时前
ComboBox的异步延迟加载机制
后端
接着奏乐接着舞2 小时前
spring cloud知识点
后端·spring·spring cloud
ltl3 小时前
位置编码:为什么需要它,为什么用正弦
后端
明月_清风3 小时前
Go 函数设计的工程智慧:多返回值、闭包与那些"反直觉"的选择
后端·go
却尘3 小时前
一个 `&` 引发的血案:改完配置 pipeline 装聋作哑,顺便重学了 Python/Go/Java
后端·go
倚栏听风雨3 小时前
Spring AI 实战:用 JdbcChatMemory + MySQL 给 AI 接上「长期记忆」
后端
我叫黑大帅3 小时前
最简单的生产-消费者,你都会遇到哪些问题?
后端·面试·go
swipe5 小时前
Agentic RAG:用 LangGraph 构建会路由、会纠错、会收敛的闭环 RAG
后端·langchain·llm
折哥的程序人生 · 物流技术专研5 小时前
《Java 100 天进阶之路》第23篇:缓冲区数据结构 ByteBuffer
java·开发语言·数据结构·后端·面试·求职招聘