Files
ChuBlog/README.md
T

234 lines
15 KiB
Markdown
Raw Normal View History

2026-09-19 19:22:50 +08:00
# ChuSQL
本项目是一个简单的数据库项目,服务端采用Haskell和Rust联合编写,采取B/S模式,管理客户端运行在浏览器网页上。
## 仓库布局
| 目录 | 语言 | 组件 |
| ----------------- | ------- | ----------------------------------------------------------------- |
| `chusql-engine/` | Haskell | 执行引擎:词法分析与AST、表达式求值、关系代数、内存表、服务端接口 |
| `chusql-storage/` | Rust | 存储引擎:磁盘IO、B+树、缓冲池、WAL、崩溃恢复、并发控制 |
| `chusql-web/` | 前端 | 浏览器端管理客户端 |
# 架构分层
## Haskell部分
作为函数式编程语言,从语言层面契合SQL语句解析和代数数学运算,同时有强大的类型安全系统。
- SQL词法分析、AST
2026-09-22 21:26:54 +08:00
- 语义分析:表/列存在性与类型检查(执行前的关卡)
2026-09-19 19:22:50 +08:00
- 事务处理
- 查询优化、内存表管理
- 关系代数运算
- 服务端接口与请求相应
## Rust部分
高性能、内存安全且具有强大并行异步编程能力的现代编程语言。
- 磁盘IO
- B+树、页面管理
- 缓冲池
- WAL
- 事务日志和崩溃恢复
- 并发控制
## 前端部分
- 前端部分目前还没想好选型架构,暂且使用简单前端三件套做一个简单的仿CLI WebUI即可。后期可能会加上Rust编写的CLI前端
> Rust和Haskell部分的通信通过管道实现,后期跨平台到Linux后使用Unix Domain Socket
> 如果找到了较好的Rust+Haskell集成方法也会及时替换
# 性能基准
2026-09-25 14:21:55 +08:00
三个基准,各管一件事,**主基准是全链路**(Haskell 引擎和 Rust 存储进程一起启动,跑真实情景):
2026-09-25 14:21:55 +08:00
| 基准 | 位置 | 测什么 |
| ---- | ---- | ------ |
| **全链路** | `benchmark/fullchain/` | SQL 文本 → 引擎 → 命名管道 → Rust → WAL/堆表/索引 → 落盘,整条路 |
| 查询层 | `benchmark/chusql-engine/` | 只用内存数据,隔离出优化器与连接算法的效果 |
| 存储层 | `benchmark/chusql-storage/` | Rust 侧页 IO / B+ 树 / 堆表 / WAL 的微基准 |
## 全链路:Haskell + Rust 一起跑
```bash
cd chusql-storage && cargo build --release # 先编译存储进程(找不到 release 会用 debug 并提醒你)
cd benchmark/fullchain
stack run chusql-fullchain # 默认 users = orders = 200
stack run chusql-fullchain -- 1000 1000 # 换规模
# 改过 chusql-engine 源码之后,先 stack build --force-dirty 再跑(原因见"计时怎么做"的坑四)
```
它做的事:造一个临时数据目录和独立管道名 → 起 Rust 存储进程 → 用引擎的 IPC 存储连上去 →
按真实情景跑一遍(建表、逐条 INSERT、全表 SELECT、过滤、JOIN、按主键点查)→ 校验行数 → 收摊。
**每条语句都是整条链路**:SQL 文本真的要过词法/语法分析、语义检查、关系代数,再走管道到 Rust,
经 WAL(fsync)、堆表、B+ 树、页缓冲池落到磁盘,结果再原路返回。
计时用**墙上时钟(单调时钟)**,不是 CPU 时间——这条链路大部分工夫花在等 IO 和跨进程往返上。
### 实测(每条语句平均耗时)
| 指标 | 200 × 200 | 500 × 500 | 1000 × 1000 |
| ---- | --------: | --------: | ----------: |
| 单条 `INSERT`(整条语句) | 2.66 ms | 2.86 ms | 2.88 ms |
| 同一条 `INSERT`(原始 IPC,不经引擎) | 2.50 ms | 2.85 ms | 2.77 ms |
| **多行 `INSERT`(100 行/条)** | — | — | **0.059 ms/行** |
| `SELECT * FROM users` | 0.4 ms | 1.0 ms | 1.9 ms |
| `SELECT name FROM users WHERE age > 90` | 0.3 ms | 0.8 ms | 2.7 ms |
| `SELECT ... JOIN ... WHERE` | 0.9 ms | 2.4 ms | 5.3 ms |
| `SELECT name FROM users WHERE id = k` | 0.2 ms | 0.2 ms | 0.2 ms |
| `DELETE FROM users WHERE id = k`(删 1 行) | — | — | 4.8 ms |
| `DELETE FROM users`(删全部 1000 行) | — | — | 20.2 ms |
| `SELECT ... WHERE code = k`(无索引 → 建索引后) | — | — | 1.8 ms → **0.2 ms** |
四条结论:
1. **写入是 fsync 说了算,和数据量无关。** 单条 INSERT 约 2.7 ms。能单独量出来的落盘成本约 1.0 ms
(写日志 0.43 + 清日志 0.42 + 表数据 flush 0.15),其余是这几次同步落盘之间的等待和跨进程往返。
表从 200 行长到 1000 行,单条耗时基本不动——**插一行和插一千行的落盘代价几乎一样**。
所以想快就得多行一起提交:`VALUES (...), (...), ...` 一条语句 100 行,
每行 2.88 ms → **0.059 ms(约 49 倍)**。
2. **点查是 O(1)。** 按主键查一行 0.2 ms(其中原始 IPC 索引查找 0.07 ms),1000 行时仍是 0.2 ms。
建在别的列上的索引也一样:`WHERE code = k` 从 1.8 ms(没有索引,退回扫描)降到 0.2 ms。
3. **删除的代价和"删几行"无关了。** 删 1 行 4.8 ms、删全部 1000 行 20.2 ms:
前者就贵在一次全表扫描 + 一次落盘,后者是同一笔扫描加上 1000 次 O(1) 的行级删除。
改之前,删 1 行也要把整张表重写一遍并重建整棵索引。
4. **单表查询与那张表的行数成正比**(1000 行 1.9 ms,约 2 µs/行)。查询层现在的设计是把表拉进内存算,
这个数字就是"拉 1000 行过管道 + 在内存里过一遍"的价钱。
## P2 这一版改了什么
四件事,都对应"把整表操作换成行级/批量操作":
- **① 行级 DELETE。** B+ 树加 `delete`(惰性删除:只摘叶子里的键,不合并节点);
堆表把槽位长度置 0(读侧本来就把"长度 0"当作没有这一行);查询层的 `DELETE` 改成
"扫一遍算出要删哪些 id → `delete_keys` 一次删掉",不再 `replaceAll` 重写整表。
- **② 多索引 + `CREATE INDEX` + 规划器选路。** 优化器把"单列等值条件"标成点查节点
`Lookup 别名 表 列 值`;**到底有没有索引由存储层回答**——没有就回 `no_index`,查询层退回全表扫描。
这样索引元数据只存在数据字典一处,加索引不用改优化器。
边界写在下面:**只支持单列整数索引,而且索引列必须唯一**。
- **③ 批量插入。** 语法支持 `INSERT INTO t (...) VALUES (...), (...), ...`:一条语句走一次 WAL、一次 fsync。
- **④ 列统计。** 数据字典里每列记"不同值个数"(内存里按值哈希精确维护,上限 4096 之后转下界),
随 `catalog.json` 落盘,**启动时扫一遍表重建**,所以重启后数字仍然准;插入/删除实时更新。
### 一个必须讲清楚的取舍:索引列必须唯一
原来的索引只服务 `id`,"唯一"是默认成立的前提。要做通用索引就必须回答一个问题:
**一个列上有重复值时,"走索引取一行"和"全表扫一遍再筛"还能得到同一个答案吗?**
现在不能——索引的每个键只放得下一个行位置。所以这一版的规则是:
- 建索引时查重,有重复值就**报错、不建**(不留半成品);
- 索引列上插入重复值会被**拒绝**(老代码是"覆盖索引条目",那会让老行再也查不到)。
真正的解法是让 B+ 树支持重复键(键后面带上行号,再做叶子链上的范围扫描),
那要把 B+ 树的 `i64` 定长键换成内存可比较的变长字节键——和"复合键"是同一块地基,
留到单独一轮做。这条限制记在根 README 和 `projectplan.txt` 里,不是忘了。
> 顺带修掉一个旧 bug:点查节点原来会把**别名前缀丢掉**,
> 于是 `SELECT u.name FROM users u WHERE u.id = 1` 取不到列(投影按 `u.name` 找,
> 回来的行却是 `name`)。现在 `Lookup` 也带着别名,回来的一行和 `Scan 别名 表` 长得一样,
> 并且补了对照测试。
## 全链路这一版改了什么:别再为语义检查搬整个库
原来的 `runStatementM` 每条语句都先取一次**整库快照**(把所有表的所有行都拉过管道),
只为了给语义检查看列名。可是语义检查(`Semantic.check`)和查询优化(`relOpCols`)**只看列,一行数据都不碰**,
行数据是白搬的——而且每条语句都搬一次,于是单条语句的开销随库变大而线性增长。
改法:给 `MonadStorage` 加一个 `schema`(只要表结构,不要行),默认实现退化成"快照 + 抹掉行",
`IPCStorage` 覆盖成"只问数据字典要结构"(一次 `list_catalog` 往返,约 0.07 ms),`Engine.runStatementM` 改用它。
1000 × 1000 下的效果:
| 指标 | 改前 | 改后 | 倍率 |
| ---- | ---: | ---: | ---: |
| `INSERT INTO users` | 3.65 ms | **2.67 ms** | 1.4x |
| `INSERT INTO orders` | 5.61 ms | **2.91 ms** | 1.9x |
| `SELECT * FROM users` | 6.7 ms | **2.0 ms** | 3.4x |
| `SELECT ... WHERE age > 90` | 6.6 ms | **1.7 ms** | 3.9x |
| `SELECT ... JOIN ... WHERE` | 11.2 ms | **4.0 ms** | 2.8x |
| `SELECT ... WHERE id = k` | 3.3 ms | **0.2 ms** | 16x |
改完之后单条 INSERT 的耗时和"原始 IPC 插入"持平(2.67 vs 2.68 ms)——
**引擎侧的每条语句开销已经小到量不出来**,剩下全是存储层的落盘成本。
## 查询层:等值连接改成哈希表
`Join` 原来是"两边两两配对再筛":25 万对,每对拼一次行、按列名查两次值,单对约 0.66 µs,
乘起来就是 165 ms 上下——这正是下表"未优化"那一列的全部耗时。
现在条件是 `a = b` 这种两列等值时,先拿右表建哈希表、再拿左表逐行探测,配对次数降到 1000 次哈希查找;
认不出等值键时自动退回嵌套循环,输出顺序与原来完全一致。
数据规模 users / orders 各 500 行(配对 25 万对),中位数,单位 ms:
| 查询 | 结果行数 | 改前 未优化 | 改后 未优化 | 改前 优化后 | 改后 优化后 |
| ------------------------------------------ | -------: | ----------: | ----------: | ----------: | ----------: |
| `JOIN` + `WHERE u.age > 90` | 45 | 110.4 ms | **0.267 ms** | 8.9 ms | **0.30 ms** |
| `JOIN`(无 WHERE,只有投影) | 500 | 135.7 ms | **0.397 ms** | 111.3 ms | **0.549 ms** |
| `JOIN` + `WHERE` + `ORDER BY` + `LIMIT 10` | 10 | 130.9 ms | **0.336 ms** | 9.2 ms | **0.320 ms** |
| 单表 `WHERE age > 90`(对照组) | 45 | 0.095 ms | 0.084 ms | 0.149 ms | 0.13 ms |
**连接类查询约 300 倍;换成 2000 行 / 400 万对时是 500~900 倍**(嵌套循环是 O(左 × 右),哈希连接是线性,规模越大省得越多)。
需要注意:表里"优化器开/关"的比值从原来的 12~17 倍降到了 1.0 倍左右——
**不是优化器变差了,是分母变了**:原来那十几倍几乎全来自"条件把配对次数从 25 万筛到 2.25 万",
而连接本身只要 0.4 ms 之后,下推省下的时间已不占主导。**看性能要看绝对耗时,不是比值。**
## 存储层:写入路径
单条 `INSERT` 的完整流程(清 WAL → 写 WAL → 改数据 → 落盘 → 清 WAL)原来约 **3.8 ms**,现在约 **2.5 ms**:
- **WAL 文件句柄常驻**:原来每条操作要 open/close 三次,而 append 模式下开完再 fsync 比看起来的 open 更贵;
实测单次追加 990 µs → 507 µs。
- **写前那次清空只截断、不单独 fsync**:紧跟其后的 `append` 会 fsync,那一次会把截断一起落下去。
这一项从 522~600 µs 降到 11.6 µs。
写入路径的瓶颈**是 fsync 次数,不是数据量**。剩下的时间基本都是"真落盘",而这是 WAL 语义要求的:
再往下压就要动"每条操作都截断 WAL"这个设计本身(真正的数据库是顺序追加 + 检查点,而不是写一条清一次)。
其余微基准(n = 2000):页顺序写 15~17 µs/页(含 flush)、页顺序读 4.1~4.5 µs/页、
B+ 树插入 17.9 µs/次、点查 14.8~16.1 µs/次、堆表扫描 0.5 µs/行。
## 计时怎么做(四个坑都踩过了)
**坑一:Haskell 是惰性的,不强制求值就等于没测。**
只写 `let r = evalRelOp db plan`,`r` 根本没被算出来,测到的是"定义一个变量"的时间。
所以每次必须把结果算到底(项目里是逐行把列数累加起来当校验和,或者把行数 `evaluate` 出来)。
**坑二:Windows 上 CPU 时钟的精度只有约 15.6 毫秒。**
毫秒级的用例直接测会得到 `0.0 ms` 这种没意义的数字。
查询层基准用**自适应批量**(同一用例连跑 `k` 次,`k` 翻倍到一批超过 0.3 秒再折算成单次),
把时钟误差摊薄;全链路基准因为是 IO 密集、单次就有毫秒级,直接用墙上单调时钟。
**坑三:机器会漂移。** 同一份代码、同一天、前后隔半小时,指标能整体差 15~25%(温度、后台进程都算)。
所以:改前改后必须**同一时段交叉测**;小于 2 倍的差异,没交叉验证之前不要当结论。
**坑四(本项目的专属坑):小心链到旧引擎。** `benchmark/fullchain` 和 `chusql-engine` 在同一个 stack 项目里,
改完引擎源码后 stack 有时不会把依赖库重新拷进基准项目的安装目录——于是你测的是旧引擎,
而且数字看起来"合理"。**改过引擎就先 `stack build --force-dirty`。**
(这一条是实测踩出来的:一次"改动没效果"的排查,最后发现是引擎库的时间戳比源码还旧。)
## 试过但没留下的(别重复踩)
| 想法 | 结果 | 结论 | 为什么 |
| ---- | ---- | ---- | ------ |
| 给 Haskell 库加 `-O2` | 加完 8 项全部慢 15~25%;**去掉后重建再测,一样慢** | **已回退** | 这次"变慢"是机器漂移,不是 `-O2` 造成的——但回退后也没测出 `-O2` 的好处。**结论:没有证据**,要么同轮交叉测再下判断,要么别加 |
| 换掉缓冲池的 `min_by_key` 淘汰 | 池 1024 / 工作集超池:随机读 7.9 µs vs 池 16:5.1 µs | 未做 | 差距是真的(缺页时多扫 1024 个候选页),但只影响"工作集大于池"的随机读,绝对量 ~2.8 µs/次;而写入路径是 2500 µs/次,不在一个量级 |
| 把投影下推进 `Scan`(Scan 自带列清单) | 未做 | 待定 | 能消掉"无 WHERE 的 JOIN"多出来的那趟投影(约 25%,0.1 ms 级),但要改 `RelOp` 类型、`relOpCols`、Eval 和一堆测试 |
| 非唯一索引(一个键多条记录) | 未做 | 待定 | 要 B+ 树允许重复键(键后面带行号)+ 叶子链上的范围扫描;和"复合键"共用同一块地基:**键从 `i64` 定长槽位改成内存可比较的变长字节键** |
| 只给索引加"唯一"检查、不改插入语义 | 未做 | 已否 | 那样建索引时唯一、之后插重复值仍会把老行"挤"出索引,查询结果就和全表扫不一致了——宁可插入时报错 |
## 怎么加用例
- **全链路**:在 `benchmark/fullchain/src/Main.hs` 的 `runScenarios` 里加 `benchSql label sql` 就行;
要验证结果就照着 `[校验]` 那一段写断言(现在的两个校验是 `age > 90` 的行数和 JOIN 的行数)。
- **查询层**:在 `benchmark/chusql-engine/src/Main.hs` 的 `queries` 列表里加一条 SQL 字符串,其余全自动
(自动翻译、优化、跑两遍、比结果、算加速比)。
- **存储层**:在 `benchmark/chusql-storage/benches/storage.rs` 里加一段 `bench(...)` / `bench_once(...)`。
想在文档里引用数字,记得把**数据规模、机器、时间**一起写上——脱离这些谈"快了几倍"没有意义。
> 最近更新:2026-09-25