形式化验证与 TLA+:行为的数学建模

2015 年 4 月,AWS 的工程师在《Communications of the ACM》上发表了一篇论文1,记录了 TLA+ 在 DynamoDB 复制协议中发现的一个极端 bug——它经过了设计审查、代码审查和大量测试,没有人发现它。TLC 模型检验器给出的最短反例轨迹包含 35 个高层步骤。论文指出:在足够大的规模下,这种复合事件的低概率并不能构成防御——历史上 AWS 在生产环境中确实观察过同等复杂的组合。

本文讨论的问题只有一个:怎样在代码落地之前,用数学方法证明一个系统设计没有逻辑错误。


先看一个简单的问题:一个全局计数器 counter,两个线程各自执行 100 次递增。最终 counter 的值是多少?

学过并发编程的人知道答案:不一定是 200。counter += 1 不是原子操作——它包含读、加、写三步。两个线程同时读到同一个值,各自加 1 后写回,结果相当于丢失了一次递增。

这是并发编程中最基础的竞态条件。常规对策是加锁、用原子指令、或者按并发模型重新设计。

但这里有一个更根本的问题:你怎么确认你的对策是正确的?

测试可以让你更自信,但无法穷举。两个线程各执行 100 步——不考虑更复杂的交错,仅指令级的交错组合就已经是天文数字。线程数更多、步骤稍复杂之后,状态空间急剧膨胀,任何基于采样的验证手段都碰不到边界。

这不是测试方法论的问题,也不是工程师不够认真。这是一个组合数学问题:

系统的状态空间随并发组件的数量呈指数增长——这叫状态空间爆炸(state space explosion)。

2015 年,Kyle Kingsbury 在 Jepsen 分布式系统正确性分析中对 MongoDB 的复制协议进行了系统性测试2。当时 MongoDB 使用的复制协议(protocol v0)以 wall-clock 时间戳作为操作排序的依据。在时钟偏斜的网络分区场景下,一个被隔离的节点如果时钟较快,可能在选举中胜出,导致已经被多数派确认的写入被静默丢弃。Jepsen 的测试显示:在 4525 次以 majority write concern 提交的写入中,有 93 次被确认后丢失。这不是一个可以通过增加测试用例数量来解决的问题——它是协议设计的逻辑缺陷。

另一个更著名的案例是 Therac-25 放射治疗机事故(1985-1986)3:软件中的竞态条件导致 6 名患者接受了超过百倍剂量的辐射,其中 3 人死亡。事后调查发现,触发条件依赖于操作员在特定时间窗口内按下特定按键组合——在测试期间从未被复现。

打造高效终端 IDE:我的 Neovim 完全配置指南

一套好的编辑器配置,不是插件堆砌,而是对工作流的深度理解。

我最初接触 Vim 的动机非常简单——那会儿市面上没有一款让我满意的 C 语言开发工具。IDE 太重、启动慢,轻量编辑器又缺少代码导航和补全能力。Vim 的模态编辑理念和高度可定制性吸引了我,从一个简单的 .vimrc 开始,这门手艺一直打磨到了今天。

这套配置的 Git 仓库始于 2017 年 12 月 13 日,至今已有 2000+ 次提交,跨越了八年多时间。它的演进过程本身就是终端编辑器生态变迁的一个缩影:

时间里程碑说明
2017.12VimScript + vim-plug初始提交,纯 VimScript 配置,使用 vim-plug 管理插件
2018 ~ 2019功能扩展期逐步加入 ftplugin、代码补全(YouCompleteMe)、文件树、状态栏等,年度提交量 ~280
2020.06Lua 试水第一批 .lua 文件出现,开始在文件类型检测和少量插件配置中使用 Lua
2020.12init.lua 上线正式用 init.lua 替代 init.vim,标志着全面转向 Neovim Lua 生态
2021.01packer.nvim从 vim-plug 迁移到 packer.nvim,享受 Lua-native 插件管理器的性能提升
2022.12lazy.nvim迁移到 lazy.nvim,全面采用懒加载策略,启动速度从 200ms+ 降至 100ms 以内
2023深度优化期大规模重构 LSP 配置、精细化 snippet 体系、引入 mini.nvim 生态替代大量独立插件
2025.03blink.cmp将 nvim-cmp 替换为 blink.cmp,补全性能获得质的飞跃(Rust 后端 + Frecency 算法)
2026.03venux 命名配置框架正式命名为 venux,提取独立的 UI 组件库和工具函数层
2026.05treesitter 升级移除 nvim-treesitter,迁移到内置 treesitter + tree-sitter-manager.nvim 的轻量方案
2026.06Snacks.nvim核心工作流全面迁移到 Snacks.nvim:picker 替换 Telescope、dashboard 替换 alpha-nvim;格式化迁移到 conform.nvim;默认主题切换为 everforest;DAP 精简为 java 依赖
2026.07KeyFinder + folio自研命令面板 KeyFinder(基于 Snacks.picker);Markdown 预览迁移到自研 folio.nvim(Go 后端);集成 venn.nvim 和 vim-diagon 绘图工具;新增 context_menu UI 组件

每一次迁移都不是追逐新潮,而是对旧方案的局限有了切肤之痛。

一文讲透数据仓库与 OLAP 核心概念

从 ODS、维度建模到 Cube、Materialized View 与 Query Rewrite

数据库领域有一类概念特别容易被讲乱:ODS、DWD、事实表、维度表、Cube、Rollup、Projection、Materialized View、Query Rewrite。它们经常一起出现在数据平台架构图里,却不属于同一个层次。

ODS 和 DWD 是数仓工程中的数据分层;事实表和维度表来自维度建模;Cube、Slice 和 Dice 描述多维分析语义;数据库语境下的 Rollup、Projection 和 Materialized View(物化视图,以下简称 MV)是用于加速查询的物理机制;Query Rewrite(查询改写)和 Cost Based Optimization(基于代价的优化,以下简称 CBO)则属于优化器。

它们之所以经常同时出现,是因为都与一个问题有关:

当业务数据越来越多,分析需求越来越复杂时,如何避免每一次查询都从最原始的数据重新计算?

本文从一张订单表开始,按照问题出现的顺序梳理这套体系。

在进入细节之前,先把概念放回它们所属的层次。很多误解并不是术语本身难,而是把不同层次的概念放在一起比较。

层次核心概念
数仓分层ODS、DWD、DWS、ADS
维度建模Grain、Fact、Dimension、SCD
模型设计Star Schema、Snowflake Schema、Galaxy Schema
OLAP 语义Cube、Slice、Dice、Roll Up、Drill Down、Pivot
预聚合Aggregate Table、Summary Table、Rollup
数据库实现Aggregate Key、Projection、Materialized View
优化器Query Rewrite、Statistics、Cardinality、CBO

这些概念属于不同抽象层次。数仓分层描述数据加工流程,维度建模描述业务语义,OLAP 操作描述分析方式,数据库实现和优化器则负责控制查询成本。

理解这张图,后面的术语就不容易混淆。例如:

  • DWS 中的日销售汇总表和数据库中的 MV 都可能保存聚合结果,但前者是数仓工程资产,后者是数据库对象。
  • OLAP 语义中的 Roll Up 是从日上卷到月的分析动作,Doris 或 StarRocks 中的 Rollup 则是一种物化索引。
  • Cube 是观察事实的逻辑空间,不等于数据库必须完整物化一个立方体。

下面从最初的问题开始。

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,工具链归工具链。