从 ODS 到 Materialized View:一文讲透 OLAP 与数据仓库中的核心概念

如果让一个业务数据库回答“某个用户的订单是否已经支付”,它通常只需要通过主键索引读取几行数据。如果让同一个数据库回答“过去一年每个地区、每个品类、每个月的销售额和去重用户数是多少”,问题就完全不同了:它需要扫描大量订单明细,关联商品和地区信息,再执行聚合、排序甚至窗口计算。

前者是典型的 OLTP(Online Transaction Processing,联机事务处理),后者是典型的 OLAP(Online Analytical Processing,联机分析处理)。

很多数仓术语都诞生于同一个矛盾:业务系统擅长记录事实,但不擅长反复解释事实。 ODS、DWD、DWS、ADS 是为了逐步整理事实;维度建模是为了组织分析视角;Cube、Rollup 和 Materialized View 则是在回答另一个问题:能不能不要每次查询都从最细粒度的数据重新算起?

本文从一张订单表开始,把这些概念串成一个完整体系。

假设我们正在开发一个电商系统。最开始,MySQL 中可能有如下几张表:

CREATE TABLE orders (
    order_id      BIGINT PRIMARY KEY,
    user_id       BIGINT NOT NULL,
    region_id     INT NOT NULL,
    order_status  VARCHAR(32) NOT NULL,
    order_time    DATETIME NOT NULL,
    update_time   DATETIME NOT NULL,
    KEY idx_user_time (user_id, order_time)
);

CREATE TABLE order_items (
    order_id      BIGINT NOT NULL,
    product_id    BIGINT NOT NULL,
    quantity      INT NOT NULL,
    amount        DECIMAL(18, 2) NOT NULL,
    PRIMARY KEY (order_id, product_id)
);

CREATE TABLE products (
    product_id    BIGINT PRIMARY KEY,
    category_id   INT NOT NULL,
    product_name  VARCHAR(255) NOT NULL
);

这套模型首先服务于交易流程:

MiniDFS 06: 容错与自愈

分布式存储系统的核心价值不在于"一切正常时能工作",而在于"局部故障时仍然可靠"。前五篇我们搭建了 MiniDFS 的完整数据通路——从命名空间到元数据持久化,从读路径到写 Pipeline,再到 DataNode 内部机制。这一篇,我们把目光转向系统的免疫系统:Lease 管理如何防止写冲突,ReplicationManager 如何检测和修复副本缺失,以及整个容错闭环如何通过 Heartbeat 通道协调 NameNode 与 DataNode 完成自愈。

在分布式文件系统中,同一个文件不能被两个 Client 同时写入——否则数据会混乱不可恢复。MiniDFS 通过 Lease 机制实现写互斥:Client 在 CreateFile 时获取 Lease,持有期间独占写权限,CompleteFile 时释放。

LeaseManager 的接口设计非常精炼:

class LeaseManager {
public:
    explicit LeaseManager(MetadataStore* store);

    Result<uint64_t> acquire_lease(uint64_t inode_id,
                                   const std::string& client_id);
    Result<Void> renew_lease(uint64_t lease_id,
                             const std::string& client_id);
    Result<Void> release_lease(uint64_t lease_id);
    Result<Void> expire_stale_leases();
    Result<bool> has_active_lease(uint64_t inode_id);

private:
    MetadataStore* store_;
};

几个关键设计决策值得展开讨论。

MiniDFS 05: DataNode 存储与心跳

前四篇从全局视角走完了 MiniDFS 的命名空间、写入 Pipeline 和元数据管理。从这一篇开始,我们把视角切换到单个 DataNode 内部——它如何管理本地磁盘上的 block 文件,如何通过心跳向 NameNode 证明自己还活着,以及如何通过块报告让 NameNode 了解它持有哪些副本。

DataNode 内部架构
DataNode 进程内部组件:LocalBlockStore、HeartbeatSender、BlockReporter 与 NameNode 的交互

每个 DataNode 的数据根目录下有三个子目录,对应 block 文件的三个生命阶段:

<storage_root>/
  tmp/            — 正在通过 Pipeline 写入的 block
    blk_1001_42.blk
  current/        — 已 finalize 的 block,对外可读
    blk_1000_41.blk
  trash/          — 软删除的 block,等待异步清理
    blk_999_40.blk

文件命名格式为 blk_<block_id>_<generation_stamp>.blk,将 block_id 和 generation_stamp 编码在文件名中,使得文件系统层面即可唯一标识一个 block 的特定版本。

MiniDFS 04: 写入 Pipeline

分布式文件系统的写入远比单机复杂——数据要同时落到多个副本上,任何一个环节的失败都需要被检测和处理。HDFS 的经典方案是 Pipeline Replication:Client 只需要把数据发给第一个 DataNode,由 DataNode 链式转发给后续节点,形成一条写入流水线。

这篇文章从一次 put() 调用开始,逐步拆解 Block 分配、目标节点选择、Pipeline 建立与数据传输、两层 CRC32C 校验,以及 chunk 级别的幂等重试设计。

一次 DfsClient::put(dfs_path, local_path) 调用涵盖五个阶段。首先通过 CreateFile RPC 在 NameNode 创建 inode 并获取 lease(保证写互斥);接着按 kDefaultBlockSize(128 MB)将本地文件切分成若干 block,对每个 block 执行 AllocateBlock / 多次 WriteBlock / CommitBlock 的循环;最后 CompleteFile 释放 lease,使文件对外可见。

void DfsClient::put(const std::string& dfs_path,
                    const std::string& local_path) {
  auto resp = nn_stub_->CreateFile(dfs_path, block_size_, replication_);
  auto inode_id = resp.inode_id();

  std::ifstream ifs(local_path, std::ios::binary);
  std::vector<char> buf(block_size_);
  while (ifs.read(buf.data(), block_size_) || ifs.gcount() > 0) {
    uint64_t bytes_read = ifs.gcount();
    auto alloc = nn_stub_->AllocateBlock(inode_id);
    write_block(alloc, buf.data(), bytes_read);
    nn_stub_->CommitBlock(alloc.block_id(), bytes_read,
                          alloc.generation_stamp());
  }
  nn_stub_->CompleteFile(inode_id);
}

整个过程中 NameNode 只参与元数据协调——分配 block_id、记录副本位置、推进状态机——从不接触实际数据。Client 将数据通过 DataTransferService::WriteBlock 直接发送给 Pipeline 的头节点(DN1),由 DN1 链式转发给后续节点。应答沿反方向回溯:DN3 完成写入后向 DN2 应答,DN2 再向 DN1 应答,最终 DN1 向 Client 返回结果。这种设计使 Client 只需维护一条连接,复制带宽由各 DataNode 分摊。

MiniDFS 03: Namespace 与 Lease

分布式文件系统对用户呈现的是一棵目录树——/data/logs/2024/app.log 这样的路径看起来和本地文件系统没什么区别。但在底层,这棵树的每个节点(inode)是存储在 MySQL 中的一行记录,路径解析是逐级查询,文件创建需要加写锁(Lease)来防止并发冲突。

这篇文章深入 NamespaceManager 和 LeaseManager 的实现,重点讲路径解析的逐级查找、mkdir -p 的事务化实现、递归删除的级联问题,以及 Lease 从分配到过期的完整生命周期。

Namespace 与 Lease 机制架构
Namespace 目录树结构与 Lease 状态机:从路径解析到写互斥的全景视图

MiniDFS 的目录树由 inode 节点构成。每个 inode 代表一个文件或目录,定义在 types.h 中:

enum class InodeType : uint8_t {
    kDirectory = 1,
    kFile = 2,
};

enum class FileState : uint8_t {
    kNormal = 0,
    kUnderConstruction = 1,
    kDeleted = 2,
};

struct Inode {
    uint64_t inode_id = 0;
    InodeType type = InodeType::kDirectory;
    uint64_t parent_id = 0;
    std::string name;

    std::string owner;
    std::string group;
    uint32_t permission = kDefaultPermission;

    uint64_t length = 0;
    uint32_t replication = kDefaultReplication;
    uint64_t block_size = kDefaultBlockSize;

    FileState state = FileState::kNormal;

    uint64_t ctime_ms = 0;
    uint64_t mtime_ms = 0;
    uint64_t version = 0;
};

几个关键设计决策值得展开。首先是 parent_id + name 的组合定位方式。每个 inode 不存储完整路径(如 /data/logs/app.log),而是只存储自己的 nameapp.log)加上父目录的 inode_id。这个设计使得 rename 操作只需修改一行记录的 parent_idname 字段,而存储完整路径的方案需要更新这个节点及其所有后代的路径——在深层目录树中这是 O(n) 的代价。子目录查询也很自然:WHERE parent_id = ? 即可列出某目录下的所有直接子节点。

MiniDFS 02: 元数据持久化

NameNode 的核心职责是管理元数据。HDFS 用 EditLog + FsImage 实现持久化——这套方案在生产中经受了海量验证,但它的复杂度(checkpoint 合并、HA 下的 JournalNode 同步、启动时重放 EditLog)对一个教学项目来说是过度的。MiniDFS 选择了一条不同的路:直接用 MySQL 做元数据后端。

这篇文章深入讲解这个设计选择的 tradeoff,以及在 MySQL 之上构建的三层关键机制:连接池 RAII 封装、事务绑定、ID 原子分配。

MiniDFS 元数据层架构
元数据层整体架构:从 NameNode Manager 到 MySQL 的分层设计

HDFS 的元数据持久化遵循经典的 WAL(Write-Ahead Log)思路。每次元数据变更——创建文件、追加 block、修改权限——都以一条 EditLog record 追加写入磁盘。FsImage 则是某一时刻的全量 namespace 快照。NameNode 启动时加载最近的 FsImage,然后顺序重放此后的所有 EditLog 条目,恢复到最新状态。

这个方案的工程复杂度主要体现在三处。第一是 Checkpoint 过程:SecondaryNameNode(或 HA 架构下的 StandbyNameNode)需要定期将 EditLog 合并进 FsImage 以避免重放时间无限增长,大集群的 FsImage 动辄数十 GB,合并本身就是一个不可忽视的 I/O 密集操作。第二是 HA 方案引入的 JournalNode 集群:Active NameNode 将 EditLog 写入多数派 JournalNode,Standby 从 JournalNode 拉取并重放,保持 namespace 同步——这套机制引入了 Paxos 式的多数派确认、fencing、epoch 管理等分布式一致性的全套复杂度。第三是 EditLog 自身的格式管理:segment 滚动、序列化版本升级、损坏恢复工具。

MiniDFS 01: 架构与协议设计

MiniDFS 是一个用 C++20 从零实现的简化版分布式文件系统。它不追求功能完整覆盖,而是聚焦分布式文件系统最核心的几个问题——元数据管理、数据分块与 Pipeline 复制、副本放置与容错——给出一个可以实际运行的实现,并在过程中深入理解每个设计决策背后的 tradeoff。

这篇文章是系列的入口。我会先讲为什么要造这个项目、它和 HDFS 的关系,然后给出整体架构,最后完整走一遍"写入一个文件"的端到端链路,让读者对后续每篇文章的位置有一个全局认知。

学习分布式系统最有效的方式是亲手实现一遍。阅读论文能理解设计意图,但只有真正写出能跑的代码,才会遇到论文中一笔带过的工程问题——事务边界怎么划、并发控制在哪一层做、心跳超时设多长才合理。

HDFS 的源码是 Java 实现,经过十余年演进,代码量庞大(核心模块超过 30 万行),HA、Federation、Erasure Coding 等高级特性与核心逻辑交织在一起,阅读门槛极高。MiniDFS 的目标是一个最小可运行闭环:保留 HDFS 的核心架构决策,砍掉所有非本质复杂度,把精力集中在真正重要的设计问题上。

MiniDFS 的设计哲学是「保留骨架,简化实现」。下面从两个维度做对比。

保留的核心设计:

单 NameNode + 多 DataNode 的 Master/Worker 架构;Block 分块存储 + Pipeline 链式复制;Rack-aware 副本放置策略;Lease 机制实现写互斥;Heartbeat + BlockReport 的注册与上报机制;Block 与 Replica 的双层状态机管理(kAllocating → kCommitted → kDeletedkWriting → kFinalized → kCorrupt → kDeleted)。

砍掉的特性:

HA(Secondary NameNode / JournalNode / ZKFC)、Federation(多 Namespace)、Snapshot / Quota / ACL、Append / Truncate、Erasure Coding、Short-circuit Local Read、HDFS Balancer / Mover。这些特性各自重要,但它们本质上是在核心架构之上的增量演进,不影响对基本原理的理解。

Flux 13: 项目路线图

到目前为止,这个系列已经从用户语法、parser、runtime、UDF、标准库、table pipeline、connector、physical plan、LSP、测试和性能几个角度拆开了 cpp/pl/flux。它已经越过了“玩具 parser”的阶段,更像一个小型 Flux 查询引擎实验场:能解析、能执行、能导入标准库、能跑表流查询、能接 SQLite/MySQL、能做部分 pushdown、能输出 explain/profile,也有 LSP、conformance 和 benchmark。

最后一篇不继续加新模块,而是做一次收束:当前能力到底覆盖到哪里?哪些边界是刻意选择?接下来从“可用”到“好用”,优先级应该怎么排?

我的判断是:下一阶段最重要的不是继续堆更多 builtin 或更多数据源,而是把共享基础设施做厚。比如 analyzer/binder、类型诊断、metadata/statistics、Page execution profile、workspace index、conformance 和 benchmark 门禁。这些能力一旦稳定,会同时改善 runtime、LSP、optimizer 和文档。

先给一张总表,帮助读者快速建立项目现状。

领域当前状态说明
语法前端可用子集稳定scanner/parser/AST 覆盖常见 Flux 文件、表达式、函数、pipe、类型语法和部分错误恢复
用户语法有完整导览当前支持变量、option、import、函数、默认参数、pipe 参数、对象/数组、运算符、table pipe
Runtime主干可用ValueEnvironment、expression evaluator、statement executor、closure、pipe 参数可运行
UDF/高阶函数主路径可用支持 expression/block body、闭包、默认参数、array 高阶函数、有限状态表达
标准库常用 package 覆盖array/csv/date/dict/join/json/math/regexp/runtime/sqlite/strings/system/timezone/types/mysql
表流模型已形成主干TableValue、logical tables、group key、empty table、aggregate/selector、join/window 已覆盖
ConnectorSQLite/MySQL 可用metadata/split/page source,保守 pushdown,复杂语义 fallback
OptimizerRBO 主力,CBO 框架支持安全前缀下推、projection pruning、barrier insertion,CBO 暂不伪造精度
Physical executionPage pipeline 主干ExecutionTask -> Pipeline -> Driver -> Operator -> Page,支持 exchange、accumulator、profile
LSP完整雏形diagnostics、completion、hover、definition、references、rename、semantic tokens、code action 等
测试分层可回归parser/runtime/connector/optimizer/CLI/LSP/conformance/benchmark 均有覆盖
性能有 benchmark 方法内存执行、SQLite/MySQL connector scan、profile、baseline compare 已建立
文档系列文章成形已有架构、语法、实现、测试、性能和 roadmap 说明

这张表背后的重点不是“都完成了”,而是“主干已经有了”。项目现在的价值在于边界清楚:语法归语法,运行时归运行时,标准库归标准库,查询计划归查询计划,connector 归 connector,工具链归工具链。

Flux 12: 性能优化

性能优化不能脱离架构。cpp/pl/flux 早期是 eager interpreter,所有数据尽量变成 TableValue,再由 builtin 一步步处理。这条路径简单、可测、适合小数据,也非常适合把语言语义先跑通。

但查询引擎一旦开始面对 SQLite/MySQL、大表 scan、group aggregate、top-n、join 和 profile,性能问题就不再是“某个函数慢一点”。真正的瓶颈往往来自执行模型:整表 materialization、row object 中间态、重复 key 构造、connector 读了太多列、blocking operator 没有内存边界。

这一篇讲 Flux 性能优化的主线:先用 profile 定位瓶颈,再把热路径从 TableValue 推向 Page streaming,通过 connector pushdown 减少数据移动,用 partial/final accumulator 降低 root 阶段压力,最后用 benchmark 固定同机同口径的比较方法。

先看一条典型查询:

import "sqlite"

sqlite.from(path: "metrics.db", table: "cpu")
    |> range(start: 2024-01-01T00:00:00Z, stop: 2024-01-01T01:00:00Z)
    |> filter(fn: (r) => r.region == "west" and r._value > 80.0)
    |> keep(columns: ["_time", "host", "_value"])
    |> group(columns: ["host"])
    |> mean(column: "_value")
    |> sort(columns: ["_value"], desc: true)
    |> limit(n: 10)

它看起来只是几行 pipe,但执行时至少有几类成本:

Flux 11: 测试体系

语言和查询引擎项目最容易出现一种慢性问题:功能越加越多,旧语义悄悄坏掉。今天修 parser,明天改 runtime,后天补 connector pushdown,再过几天优化 LSP;如果没有清晰的测试分层,每次改动都会变成“看起来没问题”的冒险。

cpp/pl/flux 现在已经有 scanner/parser、runtime evaluator、标准库、table pipeline、connector、optimizer、physical executor、CLI 和 LSP。它不是一个单点库,而是一条从源码到编辑器、从 AST 到查询执行、从语义到性能的链路。测试体系要守住的不是某个函数,而是这条链路的边界。

这一篇讲的不是“多写测试”这种泛泛建议,而是 Flux 项目当前如何分层:哪些 bug 应该落在 parser test,哪些应该落在 runtime eval,哪些必须进入 conformance,哪些只能靠 benchmark 观察趋势。

当前测试大致分成这些层:

  • scanner / strconv unit test。
  • parser unit test 和 AST dump。
  • runtime value/env/eval/page/exec unit test。
  • connector runtime/source unit test。
  • optimizer RBO/CBO unit test。
  • CLI unit/smoke test。
  • stdlib conformance test。
  • cross-source 和 feature examples。
  • LSP unit test。
  • benchmark runner。
  • 静态检查。

每一层守不同边界。parser test 不应该承担 runtime 正确性;runtime eval test 不应该依赖真实 MySQL;LSP test 不应该通过人工打开编辑器来证明 JSON-RPC 正确;benchmark 更不应该伪装成 correctness test。