【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 再读回来,观察文件大小
相关推荐
Rain的Java大神之路23 分钟前
PC版网站被狂刷怎么处理
java·数据库·redis·后端·mysql·web安全·运维开发
学长毕业设计3 小时前
基于springboot的云南中草药知识普及管理网站的设计与开发(源码+文档+讲解视频)
java·spring boot·后端
烂蜻蜓3 小时前
Flask入门教程(八):视图函数详解——请求处理与响应的核心
后端·python·flask
卷无止境4 小时前
当FastAPI遇上机器学习,一个脚手架工具能省下多少工夫
后端·python·fastapi
卷无止境4 小时前
Kilo CLI 到底是什么
人工智能·后端·python
coderCN5 小时前
Nodejs 响应头和请求头
后端·node.js
SamDeepThinking5 小时前
里氏替换原则的盲区:为什么符合契约的List替换依然会失败?
后端·面试·程序员
码事漫谈5 小时前
比尔·盖茨这次谈的不是模型,是账单
前端·后端
小林ixn5 小时前
从零设计一个博客系统的数据库:表结构、索引与约束的实战思考
数据库·后端·mysql